ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

高并发流量治理实战(7):缓存一致性工程:延迟双删与 Binlog 对账的实现

高并发流量治理实战(7):缓存一致性工程:延迟双删与 Binlog 对账的实现 发完号之后回到写这件麻烦事上一篇解决了ID 从哪来这一篇处理紧接着的那一步数据写进 DB 之后缓存怎么办。场景还是电商这次看库存服务Redis 挡在前面吸收读流量MySQL 是权威库存展示与超卖纠纷的每一次对质最后都要回到缓存里那个数和 DB 差了多少毫秒。先立一个谱系从强一致锁事务贯穿缓存与库“到最终一致TTL 到期自然收敛”工程上几乎没人买强一致的账——那等于让每个读都去摸一次 DB。主流选择是Cache-Aside旁路缓存 接受一个有界的不一致窗口。所以问题从要不要一致变成两问窗口多大、由谁保证它有界。为什么是删缓存而不是写缓存Cache-Aside 的写路径是更新 DB → 删除缓存不是更新 DB → 更新缓存。这不是风格偏好并发两个写 W1、W2 若都直接写缓存落缓存的顺序和落 DB 的顺序可能交错W1 先提交 DB、W2 后提交但 W2 的缓存值反而后到缓存永久卡在中间态而删除是幂等的收敛动作——最坏情况是缓存空一次、下一个读回源填回最新值。但删缓存自身仍有一个竞态窗口这就是延迟双删存在的原因。实验一逐毫秒推演旧值回填竞态经典坏 interleaving读者在写者提交之前从 DB 读到了旧值却因为慢查询在写者删缓存之后才回填——缓存里躺回旧值。延迟双删用第二次删除兜底第二次删得够不够晚直接决定成败。用事件调度器按虚拟时钟逐毫秒推演 delay10ms 与 delay60ms 两个剧本# 库存服务: DB 权威 Redis 缓存(60s TTL). 写路径 更新DB - 删缓存 - 延迟D毫秒二次删# 读路径 miss - 慢查询(42ms) - 回填. 按虚拟毫秒时钟排序推演旧值回填竞态.defrun(delay):s{db:100,cache:100}# cacheNone 表示未缓存d2113delay events[(0,稳态(缓存已热),lambda:None),(100,读R: TTL到期, miss,lambda:s.update(cacheNone)),(112,写W: DB 提交 100-99,lambda:s.update(db99)),(113,写W: 第1次删缓存,lambda:s.update(cacheNone)),(150,读R: 慢查询返回(拿到的是旧值),lambda:None),(155,读R: 回填旧值 (竞态落地!),lambda:s.update(cache100)),(d2,写W: 延迟双删 (delay%d)%delay,lambda:s.update(cacheNone)),]cleanedd2155ifcleaned:events.append((d27,后续读: 回源拿到新值并回填,lambda:s.update(caches[db])))verdict脏数据只存活了 %d ms 就被双删抹掉%(d27-155)else:events.append((60155,缓存 60s TTL 到期被动失效,lambda:s.update(cacheNone)))events.append((60162,后续读: 回源拿到新值并回填,lambda:s.update(caches[db])))verdict双删抢在回填前执行, 旧值仍挂满 60s TTL 才收敛print( delay%dms %delay)fort,msg,fninsorted(events,keylambdae:e[0]):fn()print(t%5dms %-30s db%-4s cache%s%(t,msg,s[db],s[cache]))print(结论: %s\n%verdict)run(10)run(60)运行输出 delay10ms t 0ms 稳态(缓存已热) db100 cache100 t 100ms 读R: TTL到期, miss db100 cacheNone t 112ms 写W: DB 提交 100-99 db99 cacheNone t 113ms 写W: 第1次删缓存 db99 cacheNone t 123ms 写W: 延迟双删 (delay10) db99 cacheNone t 150ms 读R: 慢查询返回(拿到的是旧值) db99 cacheNone t 155ms 读R: 回填旧值 (竞态落地!) db99 cache100 t60155ms 缓存 60s TTL 到期被动失效 db99 cacheNone t60162ms 后续读: 回源拿到新值并回填 db99 cache99 结论: 双删抢在回填前执行, 旧值仍挂满 60s TTL 才收敛 delay60ms t 0ms 稳态(缓存已热) db100 cache100 t 100ms 读R: TTL到期, miss db100 cacheNone t 112ms 写W: DB 提交 100-99 db99 cacheNone t 113ms 写W: 第1次删缓存 db99 cacheNone t 150ms 读R: 慢查询返回(拿到的是旧值) db99 cacheNone t 155ms 读R: 回填旧值 (竞态落地!) db99 cache100 t 173ms 写W: 延迟双删 (delay60) db99 cacheNone t 180ms 后续读: 回源拿到新值并回填 db99 cache99 结论: 脏数据只存活了 25 ms 就被双删抹掉两个剧本里读者都精确地踩中了竞态t155 旧值回填缓存 db99/cache100 的背离清晰可见命运的分岔只在第二次删除的落点delay10 时它在 t123 就执行完彼时回填还没发生“删了个寂寞”脏值一挂就是 60 秒 TTLdelay60 时它落在 t173把脏值带走不一致窗口收缩到 25 毫秒。于是延迟双删的参数学只有一条公式delay 必须大于慢读者的读取起点到回填终点的最坏总时长DB 慢查询 P99 网络 回填耗时而不是拍脑袋的1 秒保平安——过大 delay 期间第二次删除还可能有新写者进场删错别人的回填。第二次删除本身也要可靠发进延迟队列MQ而不是进程内 sleep进程重启不能把它弄丢。还有一点冷静的延迟双删治的是读竞态回填治不了第一次删缓存就失败——那要靠下面这层兜底。实验二Binlog 对账——把漏网的不一致兜住既然应用侧双写可能失败连接抖动、超时、代码路径漏删最后一道防线是把缓存的更新权外包给变更日志订阅 MySQL binlog按位点把变更重放到缓存或对账修复。模拟 200 次写操作、8% 双写失败率、消费者滞后 300ms 的场景importrandom rndrandom.Random(7)N_KEYS50db,cache,binlog{},{},[]missed0t0for_inrange(200):# 200 次写操作trnd.randrange(5,50)ksku_%02d%rnd.randrange(N_KEYS)vrnd.randrange(1000,9999)db[k]v binlog.append((t,k,v))ifrnd.random()0.08:# 应用侧更新DB刷缓存双写有 8% 失败率missed1continuecache[k]vprint(写操作 %d 次 | 涉及键 %d | 双写失败 %d 次(缓存未刷)%(len(binlog),len(db),missed))print(缓存与 DB 最终值当前不一致的键: %d 个%sum(1forkindbifcache.get(k)!db[k]))# Binlog 对账消费者: 有 300ms 复制/消费延迟, 只按已消费到的位点对账LAG300consumed[(ts,k,v)forts,k,vinbinlogiftst-LAG]expected{}forts,k,vinconsumed:expected[k]v driftsorted(kforkinexpectedifcache.get(k)!expected[k])print(对账位点覆盖 %d 个键, 检出漂移 %d 个: %s%(len(expected),len(drift),drift[:6]))forkindrift:# 以 binlog 为准单向修复cache[k]expected[k]aftersum(1forkinexpectedifcache.get(k)!expected[k])print(修复 %d 个键, 修复后位点内漂移 %d%(len(drift),after))tailsum(1forts,k,vinbinlogiftst-LAG)print(未覆盖的尾部写入 %d 条 - 下一轮对账(位点前移)后收敛%tail)运行输出写操作 200 次 | 涉及键 49 | 双写失败 15 次(缓存未刷) 缓存与 DB 最终值当前不一致的键: 5 个 对账位点覆盖 49 个键, 检出漂移 12 个: [sku_00, sku_02, sku_05, sku_08, sku_12, sku_13] 修复 12 个键, 修复后位点内漂移 0 未覆盖的尾部写入 12 条 - 下一轮对账(位点前移)后收敛三个数对不上恰恰是重点。15 次双写失败只造成 5 个键的最终不一致——多数漏刷被同键的后续成功写顺手覆盖了这就是漂移是瞬态、监控要按秒看的原因对账却检出 12 个漂移——比 5 多因为对账按滞后 300ms 的位点比位点之外还有 12 条尾部写部分已刷进缓存这些缓存比位点新的键在盲比对里也是差异。所以真实系统的对账消费者必须带版本比较缓存值里存 DB 行版本更新时间的微秒数或 GTID 序号只有缓存版本 位点版本才修反向缓存更新跳过——否则每次对账都在把刚写对的数据改回旧的这就是对账把缓存写坏的经典事故。修复方向永远单向binlog → 缓存绝不反向补库。常见陷阱与落地清单拿延迟双删当银弹它只治读竞态回填删缓存 RPC 失败、写 DB 成功前进程崩溃都要靠 binlog 通道或 TTL 收敛兜底。delay 写死常数应来自读 P99 的实测慢 SQL 上线后忘了回头调双删参数是常见二次事故。对账不带版本比较见上文位点滞后期内缓存新于位点的键会被改旧。把缓存一致性外溢到业务语义秒杀扣减这种要求读己之写的路径直接读 DB或用 Redis 原子扣减为权威别让任何毫秒级窗口承诺替你接住超卖责任。落地顺序Cache-Aside 删除不是更新→ 删失败进重试队列 → 热点读路径加延迟双删delay 由读 P99 定→ binlog 通道canal/Debezium 类消费者带版本对账 → 全量兜底任务 不一致率指标上看板。缓存与 DB 的双人舞讲完了但实验里那句读 DB其实暗藏一个没展开的世界库存的读走的到底是主库还是从库上一篇库存服务若把读压到从库300ms 主从延迟会让刚刚改完的库存在详情页显示旧数——下一篇《高并发流量治理实战8读写分离与主从延迟治理从路由到业务兜底》直面这个一致性的另一条裂缝。参考来源MySQL 8.0 Reference Manual: Replicationbinlog 与复制原理: https://dev.mysql.com/doc/refman/8.0/en/replication.htmlWikipedia: Cache coherence: https://en.wikipedia.org/wiki/Cache_coherenceAWS 最佳实践: Caching best practicesCache-Aside 与失效策略: https://aws.amazon.com/caching/best-practices/Debezium 文档: Outbox Event Router变更日志驱动的缓存同步: https://debezium.io/documentation/reference/stable/routines/outbox.htmltags: 缓存一致性, 延迟双删, Binlog, 对账本系列已结集为免费专栏《高并发流量治理实战从限流到全链路压测》 https://blog.csdn.net/weixin_67153745/category_13213830.html 系统性进阶推荐付费专栏《提示词工程实战从入门到生产级 Prompt 设计》限时 ¥19.9首篇免费试读https://blog.csdn.net/weixin_67153745/category_13213600.html
返回列表