4.4 用压测证明"不超卖"了,4.3 也讲了我们用的是 UPDATE ... WHERE stock>0。这一篇把防超卖的三条主流路线摆在一起对比,说清为什么阶段一选"SQL 条件判断"而不是乐观锁/悲观锁——这是秒杀系统设计里最经典的一道"功能抉择"。

配套代码:src/service/SeckillService.cc(步骤 1 的原子 UPDATE)、sql/schema.sqlseckill_sku)。

一、三条路线速览

路线 核心思想 代表写法
乐观锁 带版本号 CAS,提交时校验版本没被改 UPDATE ... SET stock=stock-1, version=version+1 WHERE id=? AND version=?
悲观锁 先锁住行,再读再改,事务结束才放 SELECT ... FOR UPDATE 然后 UPDATE
SQL 条件判断(本文采用) 把"是否还有库存"塞进 UPDATE 的 WHERE UPDATE ... SET stock=stock-1 WHERE id=? AND stock>0

二、乐观锁:version 字段 CAS

加一个 version 列,更新时带上"我读到的版本",数据库只在版本没变时才改,否则返回 0(被别人抢先了),应用层重试或判失败。

1
2
UPDATE seckill_sku SET stock = stock - 1, version = version + 1
WHERE id = ? AND version = ?; -- 版本不符 → affectedRows=0 → 重试/失败
  • 优点:不长期持行锁,读多写少、冲突少时吞吐高;无"锁等待"。
  • 缺点要加 version 列 + 改所有写路径 + 处理重试;秒杀是"写多冲突多"场景,重试会放大已经很热的行竞争,反而更慢。版本号管理也是一笔账。

三、悲观锁:SELECT ... FOR UPDATE

事务开始先 SELECT ... FOR UPDATE 把行锁住,再读 stock、判断、UPDATE,事务提交才放锁。

1
2
3
4
5
BEGIN;
SELECT stock FROM seckill_sku WHERE id = ? FOR UPDATE; -- 锁住行
-- 应用层判断 stock>0 后:
UPDATE seckill_sku SET stock = stock - 1 WHERE id = ?;
COMMIT;
  • 优点:语义直白,"先锁再改"谁都懂;冲突处理简单(锁等待而非重试)。
  • 缺点持有行锁的时间 = 整个事务(含网络/应用逻辑),把并发完全串行化,吞吐比"条件判断"更差;且要小心锁范围(锁错索引会升级成表锁)。

四、SQL 条件判断(我们采用):WHERE stock>0

把"是否还有库存"直接作为 UPDATE 的 WHERE 条件,数据库在行锁内逐个判定,库存为 0 时 affectedRows==0 → 回滚判售罄。

1
2
UPDATE seckill_sku SET stock = stock - 1 WHERE id = ? AND stock > 0;
-- affectedRows==0 → 库存已空 → 直接 SOLD_OUT,不进后续逻辑
  • 优点单条语句、零额外列、零版本管理;行锁只在 UPDATE 执行期间持有(比悲观锁的"整个事务"短);天然原子,无需应用层重试。
  • 缺点:本质上仍串行化在单行(所有请求排队过这行锁)——这正是阶段一吞吐被卡的根(基线 ~371 QPS),但这是"DB 直扣"方案的共性代价,不是该写法的特有问题。
图 1:三条防超卖路线的行锁持有区间对比 乐观锁不持锁,CAS冲突→重试放大竞争 悲观锁持锁=整个事务完全串行化,最慢 SQL 条件(本文)持锁=UPDATE 期间单语句,最短 三者都串行化在单行;本文选最短持锁 + 零额外列

五、为什么阶段一选 SQL 条件判断

维度 乐观锁 悲观锁 SQL 条件(选)
额外列 需要 version 不需要 不需要
重试逻辑 需要 不需要 不需要
行锁持有 短(不持) 长(整事务) 短(UPDATE 期间)
阶段一 DB 直扣适配 冲突多,重试放大竞争 完全串行,吞吐最低 单语句,最契合
后续演进 行锁瓶颈由缓存/ MQ/Lua 解决

阶段一就是"Drogon + MySQL 直连"的最朴素形态,目标是先把正确性(不超卖/不重复)和架构骨架立住。SQL 条件判断单语句、零额外列、零版本管理、天然原子,最契合这个阶段;它的"行锁串行化"瓶颈,正是阶段二(Redis 预扣减)、阶段三(MQ 异步)、阶段四(Lua 原子)要逐一解决的——每一阶段都应显著拉高 QPS,而基线是 4.7 实测的 ~371。

功能抉择(本篇核心权衡)

① 为什么不选乐观锁(version CAS)?
秒杀是"写多冲突多"场景,乐观锁的 CAS 在冲突时要重试,而重试会让本就极热的单行竞争被反复放大,吞吐反而比直接串行更差;还要加 version 列、改所有写路径、写重试逻辑——阶段一不值得为"正确性已能保证"的写法付这笔复杂度。

② 为什么不选悲观锁(FOR UPDATE)?
FOR UPDATE 把行锁持有到整个事务结束(含应用层判断/网络),完全串行化,吞吐比"条件判断"更差;且锁范围一旦写错(没走主键)会升级成表锁,风险更大。我们的 UPDATE ... WHERE stock>0 行锁只在 UPDATE 执行期间持有,最短。

③ 为什么接受"行锁串行化"这个代价?
因为阶段一的核心是"DB 直扣"的正确性验证,不是吞吐。串行化在单行是 DB 直扣方案的共性代价,不是该写法的特有问题——它明确标出了优化靶心(行锁),让阶段二/三/四的"缓存预扣 / MQ 异步 / Lua 原子"每一步都有清晰的对比基线(4.7 实测 ~371 QPS)。

小结

  • 防超卖三路线:乐观锁(version CAS) / 悲观锁(FOR UPDATE) / SQL 条件判断(WHERE stock>0)
  • 阶段一选 SQL 条件判断:单语句、零额外列、零版本管理、天然原子,行锁只在 UPDATE 期间持有(最短)。
  • 乐观锁在"写多冲突多"的秒杀场景会因重试放大竞争;悲观锁持锁到整事务、完全串行化,吞吐最差。
  • "行锁串行化"是 DB 直扣的共性代价,正是阶段二/三/四(缓存/MQ/Lua)要解决的靶心,基线 ~371 QPS。🐾