写路径才是修罗场
Cache Aside 的读路径上一篇已经讲完:先读缓存,未命中回源查库再回填。真正的分歧全在写路径上——数据变了,缓存怎么办?两个问题浮出水面:改缓存还是删缓存?先动库还是先动缓存?四个组合里有两个是明显错的,剩下两个的选择,决定了系统的不一致窗口有多宽。
先把丑话说在前面:只要缓存和数据库是两个独立系统,就没有强一致的免费午餐。缓存一致性追求的不是「永不不一致」,而是把不一致的窗口压短、把出现旧值的概率压低,压到业务可接受的范围。
为什么是删,不是改
写库之后顺手把缓存更新成新值,看起来更「及时」,实际埋着两颗雷。并发写覆盖:两个写请求 A、B 同时到达,A 先写库、B 后写库,但缓存的更新顺序可能反过来——B 先把缓存写成自己的值,A 再把缓存覆盖成旧值,从此库里是新值、缓存里是 A 的旧值,再也不会有人来纠正。删除就没有这个问题,删完靠下次读回源,谁后写库谁说了算。
第二颗雷是算新值的成本:很多缓存值不是单表字段的搬运,而是多表聚合、远程调用的加工结果,写库的那个线程未必算得出来。而「删缓存、下次读时回源重建」是天然的懒加载——不更新的数据不占内存,重建逻辑和读路径复用一份。所以结论明确:写路径只删缓存,不做缓存更新。
为什么先库后缓存
剩下的选择:先删缓存再更新库,还是先更新库再删缓存?先删缓存的时序是这样的:缓存删掉、库还没更新,这个空档里一个读请求进来,回源查库——查到的是旧值,顺手把旧值回填进缓存。等写请求把库更新完,缓存里躺着的是旧值,而且没有 TTL 之外的力量再来纠正它。旧值驻留到下次过期,窗口有多长,取决于 TTL 有多长。
先删缓存再写库(危险时序):
写: DEL cache ──┐
读: GET miss ──查库(旧值)──回填 ──┐
写: UPDATE db ──┴── 缓存=旧值,库=新值先更新库再删缓存呢?反过来想它最坏的情况:读请求在库更新前查到了旧值,又在缓存删除后才回填——旧值又留下去了。这个竞态同样存在,但概率天差地别:它要求「读」跨越整个写事务的耗时(读比写慢得多,还通常带事务),本身是低概率事件,回填又恰好排在删除之后,概率再乘一次。先更新库再删缓存,是低概率竞态;先删缓存再写库,是高概率事故——工程选择不言自明。
删失败的兜底
先库后缓存还有一块没讲完:库更新成功、删缓存失败怎么办?旧值继续在缓存里服务到 TTL 到期。兜底思路有三层:重试——删失败进消息队列异步重试,直到删掉为止;兜底 TTL——所有缓存必须有过期时间,删失败的最坏后果被 TTL 封顶;订阅补偿——用 binlog 订阅做第二道删缓存保险,下一篇展开。三层叠上,「删失败」从事故降级成一次短暂的不一致。
到这里,Cache Aside 的写路径定型:更新库、删缓存、失败重试、TTL 兜底。但主从延迟和并发竞态还留着尾巴——延迟双删和 binlog 订阅,就是把这尾巴剪干净的两种工程手段,下一篇见。
评论 (0)