秒杀系统高并发优化实战(C++ / Drogon):4.8 应用层锁(mutex / 自旋 / 原子三后端)
4.3 已经靠 uk_user_sku 唯一键保证"不重复下单",但那个兜底发生在 MySQL 行锁之后——每个重复请求都得真打一次 DB 才被拒。这一篇在数据库之前加一道"在途闸门":同一个用户在同一个商品上的并发重复请求,还没进 DB 就被应用层挡掉。我们做了 mutex / 自旋 / 原子三种后端,并实测对比它们的开销与收益。
配套代码:
src/service/InflightGuard.h(三后端闸门);压测:scripts/lock-bench.sh 2000 100 4、GET /api/lock/stats。
一、先拆一个致命误区:锁到底该锁在哪
最直觉的写法是拿 std::mutex 把整个 doSeckill 包起来,锁在 handler 开头、在 DB 回调里解锁。这在 Drogon 里是灾难,两个独立原因:
- 阻塞 IO 线程:Drogon 的 handler 跑在 IO 线程(本项目
threads_num=4)。一个请求持锁等 MySQL 往返(几 ms),就把这 1/4 的请求全卡住,QPS 直接掉到个位数。 - 锁所有权跨了异步边界:锁在 A 上下文加、在 B 上下文解,任何一条异常路径忘了释放,这个 sku 就永久不可买——而且极难复现。
正确的切法:把锁的作用域缩到极致,只保护"在途标记"这个纳秒级临界区。
tryAcquire(key):打 DB 之前,先在同一(userId, skuId)维度标"我正在处理";标记失败说明已有在途请求,直接判重复,连 DB 都不用打。release(key):DB 回调返回时清标记,用shared_ptr的 deleter 自动释放,杜绝漏放。
于是并发重复请求不再去挤 MySQL 行锁,在应用层就被挡掉——真正的收益不是"加锁变快了",而是"把无效请求挡在了数据库之外"。
二、三种后端实现
InflightGuard 把"在途标记"做成可切换后端的闸门,构造函数按 Mode 选实现:
1 | class InflightGuard { |
三种后端的取舍(来自头文件注释,已落盘):
| 后端 | 结构 | 精确性 | 代价 |
|---|---|---|---|
| Mutex | 分片 std::mutex + unordered_set |
精确 | 锁上 sleep/wake 一次上下文切换 ~1–2µs |
| Spin | 分片 SpinLock + unordered_set |
精确 | 临界区仅一次哈希插入(~100ns),自旋占满一核空转 |
| Atomic | 无锁位图(fetch_or) |
不精确(hash 冲突误挡) | 零等待零切换,用精确性换极致延迟 |
SpinLock 靠 std::atomic_flag 实现,临界区是纳秒级的,转几圈就出去,不值得睡眠:
1 | class SpinLock { |
三、RAII 句柄:让"最后一副本析构才释放"
Drogon 的回调链会把 token 拷贝进每一层 lambda,不能靠单一析构点释放。用 shared_ptr 自定义 deleter 实现"最后一个持有者负责释放":
1 | // 可移动可拷贝的 RAII 句柄;最后一个副本析构时自动 release(key) |
makeKey 用 splitmix64 风格的乘法混合 (userId, skuId),而不是简单位拼接——拼接会让"相邻 userId"落同一分片/相邻位,分片锁退化成全局锁、位图冲突率飙升。None 模式只返回空壳 token,让调用方代码保持统一(闸门关闭时逻辑不变)。
四、怎么测:两个场景一起看才出结论
scripts/lock-bench.sh 2000 100 4 的设计关键是分两个场景,只看一个会误判:
- unique 场景:每用户只发一次请求,没有重复。测的是"加了锁,纯开销有多大"。
- dup 场景:每用户并发发 4 次同一请求,大量重复。测的是"加了锁,在 DB 之前挡掉多少"。
只看 unique → 得出"锁是负优化";只看 dup → 得出"锁是银弹"。两个一起看才知道该在什么流量形态下开它。脚本每轮
pkill -f seckill-cpp杀残留进程、用/api/lock/stats确认模式真的切换、跑完trap把config.json的 mode 改回原值,避免"上一轮结论被改坏的配置污染"。
五、实测数据(WSL i7-14650HX + 本地 MySQL,threads_num=4)
2000 请求 = 500 用户 × 4 次同 (userId, skuId):
| 模式 | 场景 | QPS | 耗时s | 200 | 409 | 应用层挡掉 |
|---|---|---|---|---|---|---|
| none | unique | 350.6 | 5.7038 | 2000 | 0 | 0 |
| none | dup | 831.8 | 2.4045 | 500 | 1500 | 0 |
| mutex | unique | 401.0 | 4.9873 | 2000 | 0 | 0 |
| mutex | dup | 924.2 | 2.1641 | 500 | 1500 | 1500 |
| spin | unique | 386.6 | 5.1727 | 2000 | 0 | 0 |
| spin | dup | 1160.2 | 1.7238 | 500 | 1500 | 1500 |
| atomic | unique | 337.1 | 5.9329 | 1999 | 1 | 0 |
| atomic | dup | 1005.2 | 1.9896 | 500 | 1500 | 1501 |
六、结论速读
① unique 场景 · 锁的纯开销(none=350.6 作基准):mutex +14.4%、spin +10.3%、atomic −3.9%。说明"在途标记"本身有成本,机制的目标不是让加锁变快。
② dup 场景 · 锁的收益:none=831.8 但应用层挡掉=0(1500 个重复一路打 DB,靠 uk_user_sku 兜底);开闸门后 mutex/spin/atomic 挡掉≈1500,DB 写压力降约 75%。
③ dup 场景 QPS 提升(相对 none):mutex +11.1% / spin +39.5%(临界区百纳秒、无上下文切换收益最大)/ atomic +20.8%。
④ atomic 的代价——误杀:位图 fetch_or 不精确,dup 挡掉=1501(比真实重复 1500 多 1),unique 场景也误杀 1 个(409)。生产选 atomic 须容忍极低误杀率。
⑤ 选型建议:通用默认 mutex(精确、无误杀);临界区极短且线程数≈核数时上 spin(延迟最低);atomic 仅用于极度延迟敏感、且能容忍偶发误杀的场景。
关键认知:dup 场景 none 的 831.8 是"被 1500 个 409 快速返回拉高"的表面 QPS,DB 仍被 500 个真实请求压着;开闸后 QPS 更高是因为无效请求根本没进 DB。两者口径不同,只看任一个场景都得不到结论。
小结
- 应用层锁的正确位置:只保护纳秒级"在途标记"临界区,绝不可包异步 DB 调用,否则阻塞 IO 线程 + 跨异步边界漏放锁。
- 三后端:
Mutex(分片互斥+集合,精确)、Spin(分片自旋+集合,临界区百纳秒、无切换)、Atomic(无锁位图,最快但 hash 冲突误杀)。 - 实测:dup 场景开闸门挡掉≈1500、DB 压力降~75%;unique 场景纯开销 mutex +14.4% / spin +10.3% / atomic −3.9%。
InflightGuard用shared_ptrRAII 句柄保证"最后一副本析构才 release",Drogon 回调链拷贝安全;makeKey用 splitmix64 混合避免分片退化。🐾

