4.3 已经靠 uk_user_sku 唯一键保证"不重复下单",但那个兜底发生在 MySQL 行锁之后——每个重复请求都得真打一次 DB 才被拒。这一篇在数据库之前加一道"在途闸门":同一个用户在同一个商品上的并发重复请求,还没进 DB 就被应用层挡掉。我们做了 mutex / 自旋 / 原子三种后端,并实测对比它们的开销与收益。

配套代码:src/service/InflightGuard.h(三后端闸门);压测:scripts/lock-bench.sh 2000 100 4GET /api/lock/stats

一、先拆一个致命误区:锁到底该锁在哪

最直觉的写法是拿 std::mutex 把整个 doSeckill 包起来,锁在 handler 开头、在 DB 回调里解锁。这在 Drogon 里是灾难,两个独立原因:

  1. 阻塞 IO 线程:Drogon 的 handler 跑在 IO 线程(本项目 threads_num=4)。一个请求持锁等 MySQL 往返(几 ms),就把这 1/4 的请求全卡住,QPS 直接掉到个位数。
  2. 锁所有权跨了异步边界:锁在 A 上下文加、在 B 上下文解,任何一条异常路径忘了释放,这个 sku 就永久不可买——而且极难复现。

正确的切法:把锁的作用域缩到极致,只保护"在途标记"这个纳秒级临界区

  • tryAcquire(key):打 DB 之前,先在同一 (userId, skuId) 维度标"我正在处理";标记失败说明已有在途请求,直接判重复,连 DB 都不用打
  • release(key):DB 回调返回时清标记,用 shared_ptr 的 deleter 自动释放,杜绝漏放。

于是并发重复请求不再去挤 MySQL 行锁,在应用层就被挡掉——真正的收益不是"加锁变快了",而是"把无效请求挡在了数据库之外"。

二、三种后端实现

InflightGuard 把"在途标记"做成可切换后端的闸门,构造函数按 Mode 选实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
class InflightGuard {
public:
enum class Mode { None, Mutex, Spin, Atomic };
explicit InflightGuard(Mode mode, std::size_t shards = 64, std::size_t bits = 1u << 16)
: mode_(mode), shards_(shards), bits_(bits) {
if (mode_ == Mode::Mutex) { /* 分片 mutex + 哈希集合 */ }
else if (mode_ == Mode::Spin) { /* 分片 SpinLock + 哈希集合 */ }
else if (mode_ == Mode::Atomic) { /* 无锁位图 bitmap_ */ }
}

// 标记在途,返回 valid()==false 表示已有同键请求在处理 → 调用方判重复
InflightToken tryAcquire(std::uint64_t key);
};

三种后端的取舍(来自头文件注释,已落盘):

后端 结构 精确性 代价
Mutex 分片 std::mutex + unordered_set 精确 锁上 sleep/wake 一次上下文切换 ~1–2µs
Spin 分片 SpinLock + unordered_set 精确 临界区仅一次哈希插入(~100ns),自旋占满一核空转
Atomic 无锁位图(fetch_or 不精确(hash 冲突误挡) 零等待零切换,用精确性换极致延迟

SpinLockstd::atomic_flag 实现,临界区是纳秒级的,转几圈就出去,不值得睡眠:

1
2
3
4
5
6
7
8
9
class SpinLock {
public:
void lock() {
while (flag_.test_and_set(std::memory_order_acquire)) { /* 空转 */ }
}
void unlock() { flag_.clear(std::memory_order_release); }
private:
std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
};

三、RAII 句柄:让"最后一副本析构才释放"

Drogon 的回调链会把 token 拷贝进每一层 lambda,不能靠单一析构点释放。用 shared_ptr 自定义 deleter 实现"最后一个持有者负责释放":

1
2
3
4
5
6
7
8
9
10
11
12
// 可移动可拷贝的 RAII 句柄;最后一个副本析构时自动 release(key)
class InflightToken {
bool valid() const { return static_cast<bool>(owner_); }
private:
std::shared_ptr<void> owner_; // deleter 在最后一个副本析构时跑
};

// tryAcquire 成功后构造 owner,deleter 绑定 this + key:
std::shared_ptr<void> owner(static_cast<void *>(this), [this, key](void *) {
release(key);
});
return InflightToken(std::move(owner));

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 确认模式真的切换、跑完 trapconfig.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
图 1:dup 场景「应用层挡掉」与 QPS(none 闸门关闭作对照) 831.8none 924.2mutex 1160spin 1005atomic 挡掉=0 挡掉1500 挡掉1500 挡掉1501 开了闸门后,1500 个重复请求被挡在 DB 前,QPS 反而更高(无效请求不再占行锁)

六、结论速读

① 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%。
  • InflightGuardshared_ptr RAII 句柄保证"最后一副本析构才 release",Drogon 回调链拷贝安全;makeKey 用 splitmix64 混合避免分片退化。🐾