阶段一的核心来了。秒杀下单 POST /api/seckill 要解决两个会要命的问题:超卖(100 件卖成 137 件)和重复下单(同一人刷多单)。这一篇把"阶段一基础版"的打法讲透:单行原子扣减 + 同一事务 + ON DUPLICATE KEY 幂等。

配套代码:src/service/SeckillService.ccdoSeckill)、src/controllers/SeckillController.ccseckill)、sql/schema.sqluk_user_sku)。

一、是什么、坑在哪、本质一句话

是什么:下单 = 扣减库存(若还有)+ 落一条订单,两者要么全成、要么全回滚;同一用户对同一商品只能成一单。

坑在哪

  • 先 SELECT 再 UPDATE:并发下读到的都是旧值,必然超卖——这是秒杀第一大坑。
  • 扣库存和落订单不包事务:中途崩溃留下"库存扣了但没订单"的不一致。
  • 裸 INSERT 做幂等:唯一键冲突抛异常,外层 catch 当成 DB 错误回滚——但此时库存已经被扣了,且这本该是"重复下单"的业务拒绝。

本质一句话:下单的本质是"在数据库行锁内做原子判定"——扣减下沉成 UPDATE ... WHERE stock>0,写组合包事务,幂等用 ON DUPLICATE KEY 而非裸 INSERT

二、防超卖:原子 UPDATE ... WHERE stock>0

超卖的根因是"应用层先读后写"——SELECT stockUPDATE 在并发下读到旧值,多个人同时通过判断。必须把"判断是否还有库存"下沉到数据库单行的原子操作,让 WHERE stock>0 在行锁内判定。

1
2
3
-- 扣减与"是否还有库存"在同一行锁内完成,绝不在应用层先 SELECT
UPDATE seckill_sku SET stock = stock - 1 WHERE id = ? AND stock > 0
-- affectedRows == 0 → 库存已为 0(或行不存在)→ 售罄,回滚

三、事务:扣库存 + 落订单同一事务

扣库存和落订单是两个写,中间崩溃会留下"库存扣了但没订单"的不一致。事务保证原子性——代价是每个请求占用一条 DB 连接并持 seckill_sku 行锁到提交,这正是阶段一 QPS 被卡死的根源(基线 ~371 QPS),也是后续引入 Redis 预扣(阶段二)、MQ 异步(阶段三)的动机。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// src/service/SeckillService.cc(节选,事务内两步)
db_->newTransactionAsync([userId, skuId, cb, token](const auto &tx) {
tx->setCommitCallback([cb, token](bool committed) {
if (committed) cb(true, "OK"); else cb(false, "DB_ERROR: commit failed");
});
// 步骤 1:原子扣减
tx->execSqlAsync(
"UPDATE seckill_sku SET stock = stock - 1 WHERE id = ? AND stock > 0",
[tx, userId, skuId, cb, token](const drogon::orm::Result &r) {
if (r.affectedRows() == 0) { tx->rollback(); cb(false, "SOLD_OUT"); return; }
// 步骤 2:扣减成功才落订单(同事务)
tx->execSqlAsync(
"INSERT INTO seckill_order (user_id, sku_id, status, create_time) "
"VALUES (?, ?, 1, NOW()) ON DUPLICATE KEY UPDATE id = id",
[tx, userId, skuId, cb, token](const drogon::orm::Result &r2) {
if (r2.affectedRows() == 0) { // 命中唯一键=重复下单
tx->rollback(); // 把多扣的库存还回去
cb(false, "DUPLICATE_ORDER"); return;
}
// 成功:返回后 tx 各层拷贝析构 → 自动 commit → cb(true,"OK")
},
[tx, userId, skuId, cb, token](const std::exception_ptr &e) { tx->rollback(); /*...*/ },
userId, skuId);
},
[tx, userId, skuId, cb, token](const std::exception_ptr &e) { tx->rollback(); /*...*/ },
skuId);
});

四、幂等: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
2
3
4
-- 幂等落单:同一(user_id, sku_id) 第二次来 → affectedRows=0 → 判重复、回滚还库存
INSERT INTO seckill_order (user_id, sku_id, status, create_time)
VALUES (?, ?, 1, NOW())
ON DUPLICATE KEY UPDATE id = id

五、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:秒杀下单(原子扣减 + 事务 + ON DUPLICATE 幂等) Controller Service DB 事务 MySQL doSeckill(u,s) newTransaction UPDATE stock-1 WHERE>0 affected=0→SOLD_OUT 有库存 INSERT ... ON DUP aff=1 新单 / 0 重复 rollback还库存 DUPLICATE_ORDER 409 {code:1,msg}

七、返回码与验证

1
2
3
4
curl -s -X POST localhost:8080/api/seckill -H 'Content-Type: application/json' -d '{"userId":1,"skuId":1}'
# 首次 → {"code":0,"msg":"success"}
# 重复 → {"code":1,"msg":"DUPLICATE_ORDER"}(409)
# 售罄 → {"code":1,"msg":"SOLD_OUT"}(409)
场景 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 stockUPDATE 在并发下读到的是旧值——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=idaffectedRows 区分新单/重复,重复时回滚还库存。
  • 阶段一代价:每请求占连接 + 持行锁到提交 → 吞吐被行锁串行化卡死(基线 ~371 QPS),这正是阶段二/三优化的动机。🐾