秒杀系统高并发优化实战(C++ / Drogon):4.3 秒杀下单接口开发(基础版,同步落库)
阶段一的核心来了。秒杀下单 POST /api/seckill 要解决两个会要命的问题:超卖(100 件卖成 137 件)和重复下单(同一人刷多单)。这一篇把"阶段一基础版"的打法讲透:单行原子扣减 + 同一事务 + ON DUPLICATE KEY 幂等。
配套代码:
src/service/SeckillService.cc(doSeckill)、src/controllers/SeckillController.cc(seckill)、sql/schema.sql(uk_user_sku)。
一、是什么、坑在哪、本质一句话
是什么:下单 = 扣减库存(若还有)+ 落一条订单,两者要么全成、要么全回滚;同一用户对同一商品只能成一单。
坑在哪:
- 先 SELECT 再 UPDATE:并发下读到的都是旧值,必然超卖——这是秒杀第一大坑。
- 扣库存和落订单不包事务:中途崩溃留下"库存扣了但没订单"的不一致。
- 裸 INSERT 做幂等:唯一键冲突抛异常,外层 catch 当成 DB 错误回滚——但此时库存已经被扣了,且这本该是"重复下单"的业务拒绝。
本质一句话:下单的本质是"在数据库行锁内做原子判定"——扣减下沉成 UPDATE ... WHERE stock>0,写组合包事务,幂等用 ON DUPLICATE KEY 而非裸 INSERT。
二、防超卖:原子 UPDATE ... WHERE stock>0
超卖的根因是"应用层先读后写"——SELECT stock 再 UPDATE 在并发下读到旧值,多个人同时通过判断。必须把"判断是否还有库存"下沉到数据库单行的原子操作,让 WHERE stock>0 在行锁内判定。
1 | -- 扣减与"是否还有库存"在同一行锁内完成,绝不在应用层先 SELECT |
三、事务:扣库存 + 落订单同一事务
扣库存和落订单是两个写,中间崩溃会留下"库存扣了但没订单"的不一致。事务保证原子性——代价是每个请求占用一条 DB 连接并持 seckill_sku 行锁到提交,这正是阶段一 QPS 被卡死的根源(基线 ~371 QPS),也是后续引入 Redis 预扣(阶段二)、MQ 异步(阶段三)的动机。
1 | // src/service/SeckillService.cc(节选,事务内两步) |
四、幂等:ON DUPLICATE KEY UPDATE(uk_user_sku)
seckill_order 上有 uk_user_sku(user_id, sku_id) 唯一键。同一用户重复秒杀时,裸 INSERT 会抛唯一键冲突异常,外层 catch 当成 DB 错误回滚——但库存已被步骤 1 扣掉,事务一回滚库存退回,更糟的是这本来是"重复下单"的业务拒绝,不该报成系统错误。改用 ON DUPLICATE KEY UPDATE id = id(UPDATE 空操作),靠 affectedRows() 区分:
1= 真插入新订单(成功)0= 命中唯一键、UPDATE 没改值 = 重复下单,显式回滚把多扣的库存还回去2= 命中唯一键且真改了值(这里id=id不会触发)
1 | -- 幂等落单:同一(user_id, sku_id) 第二次来 → affectedRows=0 → 判重复、回滚还库存 |
五、Drogon 1.9.10 事务语义(坑)
v1.9.10 的 Transaction 没有 commit() 成员,提交发生在"最后一个 shared_ptr<Transaction> 析构"时自动发 commit,成功才触发 setCommitCallback 注册的回调。所以:
- 提交路径:不手动提交,lambda 返回后
tx在各层拷贝相继析构 → 自动 commit →cb(true,"OK")。 - 回滚路径:显式
tx->rollback()并置标志,析构不再自动 commit,commitCallback不触发——回滚路径自己调cb。 - 流式
<<>>不支持(Result, exception_ptr)合并回调,故每条 SQL 用execSqlAsync(sql, 成功, 异常, 参数...)。
六、完整 doSeckill 时序图
七、返回码与验证
1 | curl -s -X POST localhost:8080/api/seckill -H 'Content-Type: application/json' -d '{"userId":1,"skuId":1}' |
| 场景 | HTTP | body |
|---|---|---|
| 下单成功 | 200 | {"code":0,"msg":"success"} |
| 库存不足 | 409 | {"code":1,"msg":"SOLD_OUT"} |
| 重复下单 | 409 | {"code":1,"msg":"DUPLICATE_ORDER"} |
| 参数缺失/非 JSON | 400 | {"code":400,"msg":"missing or invalid userId/skuId"} |
功能抉择(本篇核心权衡)
① 为什么先读后写会超卖,必须下沉成原子 UPDATE?
应用层 SELECT stock 再 UPDATE 在并发下读到的是旧值——100 个人同时读到 stock=1,都通过判断去扣,卖成 101 件。把"是否还有库存"放进 UPDATE ... WHERE stock>0,行锁内判定,库存为 0 时 affectedRows==0 直接回滚,从根上杜绝超卖。
② 为什么用事务而不是两条独立 SQL?
扣库存和落订单是两个写,中间崩溃(进程 kill / DB 抖动)会留下"库存扣了但没订单"的不一致。事务让它们原子化——代价是每请求占一条连接并持行锁到提交,这是阶段一吞吐瓶颈,也是后续优化的靶子。
③ 为什么幂等用 ON DUPLICATE KEY 而不是裸 INSERT + catch?
裸 INSERT 撞唯一键会抛异常,外层 catch 当成 DB 错误回滚——但此时库存已被扣掉,且这本该是"重复下单"的业务拒绝而非系统故障。ON DUPLICATE 让冲突走 UPDATE 空操作,affectedRows==0 明确判重复、显式回滚还库存,语义与状态码都对。
小结
- 防超卖靠 单行原子
UPDATE ... WHERE stock>0,扣库存与"是否还有"在行锁内判定,绝不先 SELECT 再 UPDATE。 - 扣库存 + 落订单包同一 DB 事务,要么全成要么全回滚;Drogon 1.9.10 无
commit()成员,靠析构自动提交 +setCommitCallback。 - 幂等靠
uk_user_sku+ON DUPLICATE KEY UPDATE id=id,affectedRows区分新单/重复,重复时回滚还库存。 - 阶段一代价:每请求占连接 + 持行锁到提交 → 吞吐被行锁串行化卡死(基线 ~371 QPS),这正是阶段二/三优化的动机。🐾

