5.2 / 5.3 给读接口加了缓存,但缓存是副本——副本和真相源(MySQL)之间随时可能不一致。这一篇把缓存层最容易被问倒的问题讲透:写操作之后缓存怎么办?为什么顺序错了会出事故?单次删除还留了什么缝?延迟双删补的是哪条缝? 配套代码在 5.4 已落地为可开关的 double_delete_ms(默认关),这篇讲清它到底在防什么、以及为什么默认不开。

本文是「秒杀系统(C++ / Drogon)」系列第五章第四篇。配套代码:src/service/SkuCache.*(失效策略 + DelayDeleter 延迟删除线程)、config.jsoncache.double_delete_ms。代码对应阶段二 v0.2.x

一、缓存一致性:是什么、坑在哪、本质一句话

  • 是什么:读接口走缓存后,系统里同一份数据存在两个地方——MySQL(真相)和 Redis(副本)。写操作发生的那一刻,两处就不一致了。所谓缓存一致性,就是管理"副本何时作废、何时重建、作废后会不会读到脏值"这一整套时序问题。
  • 坑(最常见的两处):① 顺序反了——先删缓存再更新库,中间来的读请求会把旧值回填进缓存,且要等 TTL 到期才自愈;② 顺序对了但仍有窗口——读请求在 DEL 之前读库读到旧值、在 DEL 之后才把旧值写回缓存。
  • 本质一句话缓存的"一致性"不是靠同步(把新值也写一份),而是靠"失效"(删掉旧副本让下次读重建)——因为 DEL 幂等、与时序无关,而写新值要回答"我手上的新值来自哪个事务"。

二、为什么用"删"而不是"写":失效的语义更安全

下单成功后,我们面对的缓存值里含 stock——它是多个并发事务共同修改的字段。假如"更新缓存"而不是"删缓存":

1
2
3
4
事务 A:扣减 stock 10→9,提交成功
事务 B:扣减 stock 9→8,提交成功
问题:A、B 都试图"把新 stock 写进缓存"。若 B 先写(8)、A 后写(9),
缓存里就是 9 —— 比真实值 8 还多 1,而且这个错值要等 TTL 才消失。

更新缓存必须回答"我手上的值是不是最新",而这需要版本号/时间戳——把事务提交顺序和缓存写入顺序对齐,复杂度瞬间爆炸。删除则完全绕开这个问题DEL 是幂等的,谁先谁后无所谓,任何一次删除之后到达的读请求都会 miss、回源数据库拿到真相再重建。这正是 Cache-Aside 里 "Aside" 的含义:缓存是旁路,写路径根本不维护它,只负责把它作废。

再叠一层:本项目列表缓存 value 里含全部商品的 stock,想"更新"就得重算整个 JSON,代价比删除大得多。所以 5.2 / 5.3 落地时就把语义定死了:

1
2
3
4
// src/service/SkuCache.h —— 为什么是"删"不是"改"
// 1) 列表 value 里含全部 sku 的 stock,要改就得重算整个 JSON,不如交给下次读重建;
// 2) 改值要回答"我手上的新库存来自哪个事务",时序一乱就把旧值盖回去;
// DEL 是幂等的,且与时序无关 —— 任意一次 DEL 之后到达的读请求必然 miss 重建。

三、最经典的顺序坑:先删缓存再更新数据库

假设把"删缓存"放在事务提交之前,事故时序是:

图 1:顺序反了(先 DEL 后更新 DB)——旧值回填,TTL 内全是脏数据 写请求(下单) 读请求(查详情) ① DEL 缓存 ② 更新 DB stock=9 (事务提交中…) ③ GET 缓存 → miss ④ 回源 DB,读到【旧 stock=10】 ⑤ 把旧值写回缓存 ⑥ DB 更新完成 stock=9 结果:缓存 = 旧值 stock=10 所有读请求在 TTL 内都看到 10, 明明已经卖到 9 了。

这个事故的本质:删除是一个"瞬时"动作,而更新数据库是一个"持续"过程——删除和更新之间有一段时间窗,任何读请求落在窗内都会把旧值填回去,把我们的删除"抵消"掉。

解法一句话:顺序绝不能反——先让数据库落定,再删缓存。 本项目把删除挂在事务提交回调里,天然保证顺序:

1
2
3
4
5
6
7
8
9
10
11
12
// src/service/SeckillService.cc :: doSeckill —— DEL 挂在 commit 回调里
tx->setCommitCallback(
[cb, token, this, skuId](bool committed) {
if (committed) {
// 5.2 / 5.3 写侧:committed 之后才删缓存 ——
// 保证"数据库已落定"先于"缓存作废"。
if (cache_) cache_->invalidateOnOrder(skuId);
cb(true, "OK");
} else {
cb(false, "DB_ERROR: commit failed");
}
});

committed == true 才执行删除——删除时数据库已经提交,读请求即使 miss 回源,读到的也必然是新值

四、顺序对了,仍有一条缝:旧值回填窗口与延迟双删

先提交 DB 再删缓存,还剩下一条极窄的缝:

图 2:顺序正确(先提交后 DEL)下的残留窗口 —— 延迟双删要补的缝 写请求(下单) 读请求(查详情) ① 更新 DB stock=10→9,提交成功 ② 回源 DB —— 恰好在这条缝里 读到的是【旧 stock=10】? ③ DEL 缓存 ④ 旧值回填缓存 stock=10 缓存又被旧值占了,等 TTL 自愈 —— 单次删除留下的不一致窗口

等等——按图 2 的时序,读请求在 ② 回源 DB 时,写事务已经提交(① 完成),它为什么会读到旧值?答案是 InnoDB 的快照读 / 事务隔离:读请求自己的事务如果在写提交前就已开启(或走的是较早的 consistent snapshot),仍可能读到旧版本。加上 MySQL 主从架构下"主库已提交、从库延迟"的回源,这个窗口在真实分布式系统里并不罕见。

延迟双删就是为这条缝设计的:第一次 DEL 之后,隔一小段时间(比如 1 秒)再删一次。窗口内如果有旧值回填,第二次删除把它清掉;窗口之后再回填的读请求,读到的已经是新值,没有脏数据可填。它不追求"绝对无窗口",而是把窗口的危害(脏值长期驻留)压缩成多一次 miss(缓存被多删一次,下次读多回源一次——正确性无损)。

五、落地:专用延迟删除线程,而不是 sleep

直觉写法是在 IO 线程里 sleep(1s) 再删——这在 Drogon 里是红线(IO 线程一次 sleep 会卡住同线程所有排队请求,见本系列 ADR-2)。5.4 的落地实现是一个专用后台线程 + 延时任务队列

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
28
29
// src/service/SkuCache.cc —— 5.4 延迟双删专用执行线程(延时队列实现)
class DelayDeleter {
public:
void schedule(std::vector<std::string> keys) {
{
std::lock_guard<std::mutex> lk(m_);
queue_.push_back({std::chrono::steady_clock::now() + delay_,
std::move(keys)}); // 投递 = push + notify,纳秒级
}
cv_.notify_one();
}
private:
void run() {
std::unique_lock<std::mutex> lk(m_);
while (!stop_) {
if (queue_.empty()) { cv_.wait(lk); continue; }
auto &next = queue_.front();
if (std::chrono::steady_clock::now() < next.fireAt) {
cv_.wait_until(lk, next.fireAt); // 睡到最早任务的到点时刻
continue;
}
auto keys = std::move(next.keys);
queue_.pop_front();
lk.unlock(); // 执行 DEL 期间不持锁
fire_(std::move(keys)); // 回调里发起异步 DEL
lk.lock();
}
}
};

投递端(IO 线程)只做 push + notify;到点后由后台线程发起 Redis 异步 DELexecCommandAsync 线程安全,命令投递回 Redis 客户端的事件循环,后台线程也不阻塞)。失效路径统一挂上二次删除:

1
2
3
4
5
6
7
8
// SkuCache::invalidateOnOrder → invalidateItem / invalidate / invalidateList
// 在第一次 DEL 之后:把同一组 key 投进延时队列,到点再删一次
void SkuCache::invalidateItem(int64_t skuId) {
if (!cfg_.enabled || !redis_) return;
const std::string key = keys_.item(skuId);
del(key); // 第一次删除(挂在 commit 回调里)
scheduleDelayedDelete({key}); // 5.4:延迟 doubleDeleteMs 再删一次
}

开关与可观测:

1
2
// config.json → cache 段
"double_delete_ms": 0 // 0 = 关闭(默认);>0(如 500/1000)= 开启延迟双删(毫秒)
1
2
// GET /api/cache/stats
"delayed_delete": 12 // 二次删除累计执行次数(0 = 没开)

六、为什么默认关闭:延迟双删是一次显式的交易

单次 DEL(默认) 延迟双删(开启)
不一致窗口 一次"旧值回填"窗口(极窄) 理论上仍有,但窗口内回填的旧值会被第二次 DEL 清掉
代价 多删一次"可能已被新值重建"的缓存 → 多一次 miss 回源
适用 读多写少、写后立即 DEL、窗口本来就窄(本项目) 对 stock 新鲜度极敏感、或回源链路有主从延迟(从库读旧)的场景

本项目秒杀下单 QPS 远小于读 QPS,且写后立即失效、TTL 兜底,那条缝的实际危害很低;而多删一次会让每次下单后紧接着的读都白 miss 一次。所以 5.4 把能力做出来、默认关掉,需要更严一致性时再开——这符合本项目一贯的原则:先量清楚再优化,把取舍做成显式开关而不是悄悄写死

七、小结

护栏 作用 在代码哪里
只删不写 DEL 幂等、与时序无关,绕开"新值来自哪个事务"的难题 SkuCache 只有 del/invalidate*,没有"更新缓存"
先提交后删除 杜绝"删了之后读请求回填旧值"的最常见事故 DEL 挂在 setCommitCallback(committed 才删)
删哪些 key 可配 item / all / none 三档,新鲜度↔命中率显式交换 invalidate_on_order
延迟双删(默认关) 补"先读旧值后回填"的残留窗口 double_delete_ms + DelayDeleter 专用线程
计数可观测 删除行为可以被量化验证,不是黑盒 stats delayed_delete

🐾 核心三句话:缓存一致性靠"失效"不靠"同步";删除必须在数据库落定之后;删除后仍有极窄回填窗口,延迟双删多花一次删除把它清掉——值不值,取决于你的不一致窗口有多宽。

验证:config.json"double_delete_ms": 1000 → 重启 → 下单一个 sku → redis-cli MONITOR 能看到同一 key 两次 DEL(间隔约 1s),/api/cache/statsdelayed_delete 递增;改回 0 后只有一次。5.4 之后,5.5 讲缓存预热与雪崩——抖动已经有了,缺的是"流量进来之前把缓存填满"。