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

配套代码:src/service/SeckillService.cc(步骤 1 的原子 UPDATE)、sql/schema.sql(seckill_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。🐾

配套源码

本文对应代码已开源:Hespethorn/seckill-cpp

C++ / Drogon 高并发秒杀系统实战,含完整的压测基线、技术决策记录(ADR)与可复现脚本:

  • 阶段一(直打数据库):QPS≈440,0 超卖
  • 阶段二(Redis 缓存):读接口 list 10557 QPS / detail 8024 QPS

如果对你有帮助,欢迎 star ⭐