秒杀系统高并发优化实战(C++ / Drogon):4.6 解决超卖:SQL 条件判断(行锁内 WHERE stock>0)
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 | UPDATE seckill_sku SET stock = stock - 1, version = version + 1 |
- 优点:不长期持行锁,读多写少、冲突少时吞吐高;无"锁等待"。
- 缺点:要加
version列 + 改所有写路径 + 处理重试;秒杀是"写多冲突多"场景,重试会放大已经很热的行竞争,反而更慢。版本号管理也是一笔账。
三、悲观锁:SELECT ... FOR UPDATE
事务开始先 SELECT ... FOR UPDATE 把行锁住,再读 stock、判断、UPDATE,事务提交才放锁。
1 | BEGIN; |
- 优点:语义直白,"先锁再改"谁都懂;冲突处理简单(锁等待而非重试)。
- 缺点:持有行锁的时间 = 整个事务(含网络/应用逻辑),把并发完全串行化,吞吐比"条件判断"更差;且要小心锁范围(锁错索引会升级成表锁)。
四、SQL 条件判断(我们采用):WHERE stock>0
把"是否还有库存"直接作为 UPDATE 的 WHERE 条件,数据库在行锁内逐个判定,库存为 0 时 affectedRows==0 → 回滚判售罄。
1 | UPDATE seckill_sku SET stock = stock - 1 WHERE id = ? AND stock > 0; |
- 优点:单条语句、零额外列、零版本管理;行锁只在 UPDATE 执行期间持有(比悲观锁的"整个事务"短);天然原子,无需应用层重试。
- 缺点:本质上仍串行化在单行(所有请求排队过这行锁)——这正是阶段一吞吐被卡的根(基线 ~371 QPS),但这是"DB 直扣"方案的共性代价,不是该写法的特有问题。
五、为什么阶段一选 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。🐾

