秒杀系统高并发优化实战(C++ / Drogon):5.4 缓存一致性:为什么先删库后删缓存,以及延迟双删
5.2 / 5.3 给读接口加了缓存,但缓存是副本——副本和真相源(MySQL)之间随时可能不一致。这一篇把缓存层最容易被问倒的问题讲透:写操作之后缓存怎么办?为什么顺序错了会出事故?单次删除还留了什么缝?延迟双删补的是哪条缝? 配套代码在 5.4 已落地为可开关的 double_delete_ms(默认关),这篇讲清它到底在防什么、以及为什么默认不开。
本文是「秒杀系统(C++ / Drogon)」系列第五章第四篇。配套代码:
src/service/SkuCache.*(失效策略 +DelayDeleter延迟删除线程)、config.json的cache.double_delete_ms。代码对应阶段二v0.2.x。
一、缓存一致性:是什么、坑在哪、本质一句话
- 是什么:读接口走缓存后,系统里同一份数据存在两个地方——MySQL(真相)和 Redis(副本)。写操作发生的那一刻,两处就不一致了。所谓缓存一致性,就是管理"副本何时作废、何时重建、作废后会不会读到脏值"这一整套时序问题。
- 坑(最常见的两处):① 顺序反了——先删缓存再更新库,中间来的读请求会把旧值回填进缓存,且要等 TTL 到期才自愈;② 顺序对了但仍有窗口——读请求在
DEL之前读库读到旧值、在DEL之后才把旧值写回缓存。 - 本质一句话:缓存的"一致性"不是靠同步(把新值也写一份),而是靠"失效"(删掉旧副本让下次读重建)——因为 DEL 幂等、与时序无关,而写新值要回答"我手上的新值来自哪个事务"。
二、为什么用"删"而不是"写":失效的语义更安全
下单成功后,我们面对的缓存值里含 stock——它是多个并发事务共同修改的字段。假如"更新缓存"而不是"删缓存":
1 | 事务 A:扣减 stock 10→9,提交成功 |
更新缓存必须回答"我手上的值是不是最新",而这需要版本号/时间戳——把事务提交顺序和缓存写入顺序对齐,复杂度瞬间爆炸。删除则完全绕开这个问题:DEL 是幂等的,谁先谁后无所谓,任何一次删除之后到达的读请求都会 miss、回源数据库拿到真相再重建。这正是 Cache-Aside 里 "Aside" 的含义:缓存是旁路,写路径根本不维护它,只负责把它作废。
再叠一层:本项目列表缓存 value 里含全部商品的 stock,想"更新"就得重算整个 JSON,代价比删除大得多。所以 5.2 / 5.3 落地时就把语义定死了:
1 | // src/service/SkuCache.h —— 为什么是"删"不是"改" |
三、最经典的顺序坑:先删缓存再更新数据库
假设把"删缓存"放在事务提交之前,事故时序是:
这个事故的本质:删除是一个"瞬时"动作,而更新数据库是一个"持续"过程——删除和更新之间有一段时间窗,任何读请求落在窗内都会把旧值填回去,把我们的删除"抵消"掉。
解法一句话:顺序绝不能反——先让数据库落定,再删缓存。 本项目把删除挂在事务提交回调里,天然保证顺序:
1 | // src/service/SeckillService.cc :: doSeckill —— DEL 挂在 commit 回调里 |
committed == true 才执行删除——删除时数据库已经提交,读请求即使 miss 回源,读到的也必然是新值。
四、顺序对了,仍有一条缝:旧值回填窗口与延迟双删
先提交 DB 再删缓存,还剩下一条极窄的缝:
等等——按图 2 的时序,读请求在 ② 回源 DB 时,写事务已经提交(① 完成),它为什么会读到旧值?答案是 InnoDB 的快照读 / 事务隔离:读请求自己的事务如果在写提交前就已开启(或走的是较早的 consistent snapshot),仍可能读到旧版本。加上 MySQL 主从架构下"主库已提交、从库延迟"的回源,这个窗口在真实分布式系统里并不罕见。
延迟双删就是为这条缝设计的:第一次 DEL 之后,隔一小段时间(比如 1 秒)再删一次。窗口内如果有旧值回填,第二次删除把它清掉;窗口之后再回填的读请求,读到的已经是新值,没有脏数据可填。它不追求"绝对无窗口",而是把窗口的危害(脏值长期驻留)压缩成多一次 miss(缓存被多删一次,下次读多回源一次——正确性无损)。
五、落地:专用延迟删除线程,而不是 sleep
直觉写法是在 IO 线程里 sleep(1s) 再删——这在 Drogon 里是红线(IO 线程一次 sleep 会卡住同线程所有排队请求,见本系列 ADR-2)。5.4 的落地实现是一个专用后台线程 + 延时任务队列:
1 | // src/service/SkuCache.cc —— 5.4 延迟双删专用执行线程(延时队列实现) |
投递端(IO 线程)只做 push + notify;到点后由后台线程发起 Redis 异步 DEL(execCommandAsync 线程安全,命令投递回 Redis 客户端的事件循环,后台线程也不阻塞)。失效路径统一挂上二次删除:
1 | // SkuCache::invalidateOnOrder → invalidateItem / invalidate / invalidateList |
开关与可观测:
1 | // config.json → cache 段 |
1 | // GET /api/cache/stats |
六、为什么默认关闭:延迟双删是一次显式的交易
| 项 | 单次 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/stats的delayed_delete递增;改回 0 后只有一次。5.4 之后,5.5 讲缓存预热与雪崩——抖动已经有了,缺的是"流量进来之前把缓存填满"。

