ARTICLE DETAIL

资讯详情

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

分布式缓存一致性实战:从Cache Aside到延迟双删与binlog订阅

分布式缓存一致性实战:从Cache Aside到延迟双删与binlog订阅 分布式缓存一致性这个题目做后端的人几乎躲不开。我经历过好几个从“缓存用得挺好”到“半夜被报警叫醒”的项目核心问题往往不是缓存本身而是对一致性边界的认知不到位。这篇我不打算讲教科书式的理论会把我在实际项目中验证过的方案、踩过的坑、以及一套从选型到排查的完整链路整理出来希望能给正在做缓存设计或者已经在处理脏数据的同学一些直接能用的参考。1. 缓存一致性问题的本质不是“过期时间”能兜住的1.1 为什么会有不一致多副本加并发写再加故障分布式缓存一致性本质上是一个多副本一致性问题。数据库里有一份数据Redis里有一份数据两者都是同一份业务数据的副本。只要存在多个副本写入发生在不同时刻就一定会出现短暂的、甚至长时间的“两边对不上”。这个问题在计算机体系结构里早就有过——CPU的L1/L2缓存和主存之间也是一致性问题硬件通过缓存一致性协议比如MESI解决了。但分布式场景没有这种共享总线和统一协议Redis和MySQL是两个独立组件它们之间靠网络通信而网络本身有延迟、有超时、有不可靠。于是问题被放大了一个数据被修改时DB可能成功了但缓存删除命令超时了、丢失了、或者被一个并发请求覆盖了。这些都不是“缓存过期时间设得短一点”能解决的因为过期时间只影响缓存最终会被清掉但在这段时间内用户读到的可能是旧值而很多业务恰恰在这个窗口内做出了错误决策。我见过一个很典型的场景库存扣减。扣减库存时先更新DB再删缓存。如果删除命令因为Redis集群抖动超时了缓存里还是旧库存前端继续展示有货用户下单后才发现无货。这是实打实的资损问题不是“最终一致就无所谓了”的轻描淡写。1.2 三个最常见的错误认知误区一设了TTL就能自愈。很多团队上线缓存时下意识给key加上5分钟过期时间觉得就算不一致过期之后也会恢复。但问题在于TTL生效之前你读到的就是脏数据。如果这个期间发生了超卖、展示了错误价格、暴露了不该公开的信息损失已经产生了TTL不能挽回。更麻烦的是不少业务为了命中率给热点key设置的TTL是24小时甚至更长脏数据窗口被拉到了不可接受的水平。误区二更新缓存比删除缓存好。这是理解偏差比较大的一点。缓存的设计初衷是让“读多写少”的请求命中在内存里。如果你每次写操作都去更新缓存里对应的key那你在写路径上做了两件事更新DB、更新缓存。问题来了缓存里存的往往是经过计算、序列化、甚至聚合后的数据更新成本高并且如果并发写同一个key两个线程按不同顺序写DB和缓存很容易出现缓存里是旧版本、DB里是新版本的情况。删除缓存就不一样它只是让下一次读miss然后回源相当于把“写”的代价延迟到“读”那一刻符合缓存的本质。误区三DB永远是对的所以最终一致就行。这个想法不能算错但要看业务。对账、报表类系统最终一致完全没问题但用户端实时查询比如订单状态、钱包余额、商品价格只要有一段窗口看到的是旧值投诉和资损就来了。最终一致是结果描述不是设计目标你的目标应该是控制“不一致窗口的长度和影响范围”。错误认知实际情况设了TTL就能自愈脏数据窗口期内损失已发生更新缓存比删除缓存好更新放大写流量并发下覆盖更乱DB最终一致就够了用户端实时查询不接受长时间旧值1.3 先定一致性等级再谈方案做缓存设计第一步不是选技术是定指标。我一般把一致性需求分成三档严格强一致读永远返回最新写入值。这种场景下缓存意义不大不如直接读DB或者用分布式事务配合读写锁硬扛。有限窗口最终一致允许在几十毫秒到数百毫秒内读到旧值但必须收敛。绝大多数业务属于这一档缓存可以用。任意延迟最终一致只要求数据最终一致中间读旧值多久都行。这种适合Feed流、计数、排行榜这类场景。大多数团队的缓存系统做的其实是第二档尽量缩小不一致窗口并通过机制保证最终收敛。把一致性等级明确了后面所有技术选型才有判断标准。2. 三种经典缓存模式逐个拆解为什么Cache Aside成了默认选项2.1 Cache Aside模式的实际运作逻辑Cache Aside是业界用得最多的机制它的读写在调用方的代码里显式控制读请求先查缓存命中直接返回未命中则查DB把结果回填缓存再返回。写请求先更新DB再删除缓存注意是删除不是更新。为什么是这种方式核心原因有两个第一删除比更新在写放大上更优。更新缓存意味着你写路径上要处理缓存数据的结构、序列化、可能存在的多级关联而删除只需要一行Redis命令。下次读的时候miss了读取线程会做一次回填代价只是多一次DB查询。第二删除缓存天然规避了并发覆盖。两个线程同时写同一个key时更新缓存可能把新值覆盖成旧值但删除缓存不存在“写入顺序”问题——删掉了就是没有下次读永远从DB取最新的。public String get(String key) { String value redis.get(key); if (value ! null) { return value; } // 缓存未命中从DB加载 value db.get(key); if (value ! null) { redis.setex(key, TTL_SECONDS, value); } return value; } public void update(String key, String newValue) { db.update(key, newValue); redis.del(key); }这段代码看起来简单但它隐含了一个前提DB的更新和缓存的删除之间只要有一段时间差就会有小概率的旧值窗口。Cache Aside没有把这个窗口消掉而是把它压到最小并配上重试机制兜底。2.2 Read Through与Write Through把一致性逻辑收进缓存中间件Read Through模式下业务代码不直接和DB打交道。缓存组件自己负责当缓存miss时由它去加载DB数据并回填。Write Through模式下写入也是先到缓存缓存组件同步把数据写入DB确认DB写成功后缓存才返回成功。这两个模式的最大好处是让数据访问逻辑收敛在缓存中间件内部业务侧只需要操作缓存API天然避免了业务代码里各写一套的混乱。坏处也很明显组件需要同时管理缓存生命周期和DB数据源连接复杂度高开源产品里很少能开箱即用。我建议只有在团队能掌控缓存中间件源码、且多业务线复用场景很强的情况下才考虑Read/Write Through否则维护成本会把一致性红利吃掉。2.3 Write Behind模式高性能背后的数据风险Write Behind也叫Write Back会把缓存作为主存储写入请求只更新缓存然后异步批量把脏数据刷到DB。性能最好因为写路径完全打在内存上。但如果缓存节点在异步刷新DB之前宕机或重启这部分已“成功”写入的数据就丢了。所以Write Behind适合日志、计数、点赞数、浏览数这类可以容忍少量丢失的场景订单、库存、余额这种资金链路绝对不能碰。我在一个签到系统里用过Write BehindRedis里存用户连续签到天数后台每十分钟批量落一次库。对一致性要求确实不高就算丢了几分钟数据用户也不至于闹。但这类方案上线前一定要和产品对齐损失边界。2.4 三种模式的对比与选型建议模式一致性强度读性能写性能实现复杂度适用场景Cache Aside最终一致窗口可控高中低绝大多数业务Read Through / Write Through最终一致逻辑内聚高中低高多业务复用、组件自研Write Behind弱一致可能有数据丢失高最高中日志、计数、非关键数据我的选型建议很直接多数项目从Cache Aside开始就够了别一上来就搞花活。先把基础模式跑稳真的遇到“写路径无法可靠删除缓存”的瓶颈时再按下一章讲的进阶机制去收敛。3. 并发边界下的硬骨头删缓存失败、回填旧值与双删策略3.1 “先更库后删缓存”在并发下为什么会翻车Cache Aside在低并发下很稳定但一旦出现读写并发隐藏的竞态就开始冒头。我先把问题场景拆给你看线程A更新DB把数据改成新值。线程B在此时发起读缓存未命中于是查DB。B查到的是A更新之前的旧值因为A的更新还没提交或者B的查询走在了更新提交之后但回填发生在A删除缓存之前。B把旧值回填到缓存。A执行删除缓存命令——但此时B已经把旧值写进去了A的删除删的是“空”或者“已经存在的旧值”结果缓存中残留了旧值。问题的根源是删除操作和回填操作之间没有做任何串行化约束。两个操作各自执行谁先谁后完全取决于线程调度和网络延迟。3.2 先删缓存再更新库顺序换了问题还在既然“先更库后删缓存”有竞态那“先删缓存再更新库”呢我把执行序列列出来线程A删除缓存。线程B发起读缓存未命中查DB取到旧值。B把旧值回填到缓存。A更新DB为新值。你会发现缓存里又留下了旧值而且这一次是B在A更新DB之前回填的后续没有删除操作再帮你清理。等于旧值一直躺到TTL过期。这比“先更库后删缓存”更糟因为它把不一致窗口拉长了。所以结论很明确不能靠简单调整顺序解决问题必须引入一个机制让“旧值回填”这个操作在时间维度上被后置的清理动作覆盖掉。3.3 延迟双删给竞态窗口补一枪延迟双删的思路是第一次删除缓存、更新DB过一小段延迟后再删除一次缓存。第二次删除的目的就是清掉在“更新DB之后、第一次删除之前”这段窗口里被并发读回填的旧值。操作顺序如下删除缓存。更新DB。延迟一段时间按业务耗时估算。再次删除缓存。这里的核心参数是延迟时间。它必须大于“读请求从缓存miss到回填完成”的最大耗时否则第二次删除发生在回填动作完成之前等于白删。工程上怎么定这个值我一般做法是取线上读请求P99耗时减去缓存命中耗时再乘以两倍作为缓冲通常落在200ms到500ms之间。具体数值一定要从链路追踪数据里算不能拍脑袋定100ms然后上线。延迟删除本身也是一个耗时动作不应该阻塞主线程。我习惯放进线程池异步执行同时记录重试次数删除失败时投递到一个可靠队列里。延迟双删的缺点也比较明显如果写并发极高回填动作可能反复发生第二次删除也可能被第三次回填覆盖双删只是概率性降低问题不是根除。它适合“并发度中等、热点key少、领导要求快速止血”的场景。3.4 版本号方案让并发写收敛成有序序列比双删更扎实的做法是在缓存的数据上带上一个版本号。思路很简单每次更新DB时把业务数据的版本号自增写入缓存时把版本号一并带上。读请求回填缓存前对比当前缓存里的版本号和DB里的版本号只有新版本才能覆盖旧版本。-- 伪代码写入缓存前校验版本号 local currentVersion redis.call(GET, versionKey) if currentVersion and tonumber(currentVersion) tonumber(ARGV[1]) then return 0 end redis.call(SET, dataKey, ARGV[2]) redis.call(SET, versionKey, ARGV[1]) return 1用Redis的Lua脚本把“版本号校验数据写入”合在一个原子操作里旧版本回填就会被直接拒绝。这个方案相当于把并发随机时序收敛成了一个按版本号排序的确定性序列——这才是往“正则化”方向走了一大步。版本号方案的代价是附加设计成本版本号怎么生成、存哪里、要不要随接口返回给客户端系统里每个涉及缓存的地方都得处理好。但如果你的业务有强竞争场景比如一个复杂对象的多个字段被不同服务并发更新那这笔成本是值得的。3.5 锁方案兜底手段不是首选对热点key加分布式锁让写操作串行化读操作在锁外执行也能显著降低一致性风险。也就是说写写互斥读写之间仍有短窗口。我一般建议把这个方案限制在特定热点key上而不是全局所有key加锁。原因很简单锁的引入会带来额外的Redis通信成本和线程阻塞如果全局加锁读性能直接崩掉。实际项目里我会配合热点探测组件动态识别哪些key正在被高并发写只对这部分key加锁普通key还是走正常的Cache Aside。4. 从最终一致走向工程收敛binlog订阅、事务消息与一致性正则化4.1 用binlog订阅接管缓存删除Canal到MQ的经典链路业务代码里发删除缓存命令最大的隐忧是“DB更新成功、但删除命令没发出去”这件事。不管是因为事务提交后代码抛异常还是因为工程师在某条灵光一现的代码路径里忘了删缓存这类遗漏很难通过代码审查全部挡住。更可靠的方案是把缓存删除从业务代码里剥离出来交给binlog订阅组件。MySQL输出的binlog记录每一条真实的数据变更订阅到变更后投递到MQ由MQ消费端执行缓存删除。这个链路的优点很突出只要DB真的提交了事务binlog就一定会生成不存在“忘了发”的问题。binlog在单库单表上是严格有序的消费端按顺序删除天然规避了乱序覆盖。业务代码彻底不需要关心缓存删除新同学写代码时也可以少踩一个坑。代价是组件链路变长部署Canal、维护MQ、处理消费幂等和积压。我把这套方案用在订单、价格这类强一致敏感的核心链路上其它非核心链路继续用Cache Aside。4.2 事务消息和本地消息表不引入Canal也能保证可靠删除如果团队不想运维Canal这套重链路另一个选择是事务消息。以本地消息表为例业务侧在一个数据库事务里同时做两件事——更新业务数据、插入一条“待删除缓存”的消息记录。事务提交后后台任务扫描未处理的消息记录发送MQ或者直接调删除缓存的接口删除成功后把消息标记为已完成。这里的核心价值是DB更新和消息记录在同一事务里要么都成功要么都失败。哪怕后续删除缓存的动作失败消息记录还躺在表里可以重试也就不会出现“DB更新了但缓存一直没人删”的经典事故。4.3 聊一下我理解的“一致性正则化机制”最近热词里有一个“一致性正则化机制”我按工程语义尝试翻译一下它本质上是在把不可控的并发竞态通过一套固定范式收敛成可控的流程。正则表达式的核心逻辑是用模式去匹配任意输入把“看似混乱的东西”归纳成规则。一致性正则化也是这样让“缓存删除失败”“并发回填旧值”“消息乱序”这些随机事件被归纳到一套固定的处理流程里。这套流程必须有四个组成部分固定的操作顺序比如“DB提交 - 消息入队 - 消费删除 - 确认完成”顺序一旦确定不允许有人绕过去。幂等删除缓存这个动作天然幂等删不存在的key也合法消息消费端要按业务唯一键做去重。失败重试任何一步失败都要进入重试通道最终落进死信队列或对账表。对账兜底定期扫描发现DB和缓存长期不一致的数据自动修复。你对照一下双删是正则化但只做了前两点版本号方案是正则化做了顺序和幂等加上可靠消息和定期对账才是完整闭环。4.4 对账脚本一致性机制再完善也得有人站岗不管用哪种机制我都会留一条后路对账。对账的方法并不复杂。对于核心热点key写一个全量比对任务扫描DB数据对比Redis中同一key的值发现不一致就报警并触发修复。对于普通key做抽样对比比如按key的hash值取模每天抽查一部分控制整体覆盖率。计算层面不需要逐条JM比较可以取数据的关键字段做哈希比如CRC32把DB哈希值和缓存哈希值比对不一致再做细粒度确认。对账产生的不一致记录统一走修复通道重新从DB拉取并回填缓存同时检查这次不一致的根因看是写路径漏删、还是清理任务没有覆盖到。5. 生产环境排查实录一次缓存脏数据从报警到修复的完整链路5.1 问题表象和第一反应有一次线上活动页价格展示出了问题部分用户看到的商品价格和真实下单价格不一致。这种问题从表象上看具备所有缓存脏数据的特征——访问量一上来一部分用户命中旧缓存一部分用户拿到新值界面混乱。我们第一反应是检查这些key的TTL是否太长结果发现所有价格缓存都是5分钟过期理论上最多脏5分钟。但实际现象持续了快半小时说明问题不是TTL能解释的一定是有其它路径在持续制造脏缓存。5.2 排查的完整推理过程第一步对比DB和缓存中的实际值。把用户报错的那几个商品抽出来查MySQL里的价格再查Redis里的价格发现两边确实有一部分不一致而且Redis里的值不是纯旧的有些key里居然是更早版本的数据。第二步检查key的最近写入记录。通过Redis的MONITOR和慢查询日志定位到这些key最近一次写入来源是一个批量任务进程。第三步审查代码后我们发现链路里藏了一个历史遗留逻辑批量价格调整任务会先更新Redis价格再异步更新DB。这个写路径完全绕开了Cache Aside规范。为什么当时这么写因为批量任务量很大直接同步删缓存再更新DB担心DB压力太高。结果是任务先把新价格写进缓存DB还没更新此时用户已经看到新价格随后DB更新完成但缓存里因为存在后续读命中一直保留着任务写入的值看起来反而是缓存比DB新。一旦缓存过期或被清理读请求回源DB又把旧价格带回缓存看起来又是缓存比DB旧。两边反复横跳乱成一团。第四步通过日志确认并发时序。批量任务和用户读请求确实在同一时间窗口内交叉发生读miss后回填了DB旧值而批量任务稍后又写入了新值但另一个节点的缓存删除命令覆盖了时间窗口最终残留了旧值。5.3 修复方案不是我预期的最优解但足够稳健我们没有在这套历史遗留代码上继续打补丁而是直接统一写路径所有价格变更必须走统一服务内部按Cache Aside规范执行先更新DB再删除缓存。批量任务不再直写Redis改为只更新DB通过binlog订阅消费删除缓存。对价格缓存引入版本号读回填时低版本不能覆盖高版本。事后启动对账任务把存量不一致的key全部修复。这套组合下来一致性问题彻底收敛了。过程中我发现最该警惕的不是技术方案难选而是项目里还藏着多少条不可见的“直写缓存”路径——这是排查和治理都要面对的核心难题。5.4 从这个case里提炼的排查与治理清单遇到脏数据先分两类是“写路径没删缓存”还是“读路径回填了旧值”。两类问题的排查方向完全不同前者查代码写链路后者查并发时序。用Redis的key过期时间做第一道判断如果脏数据存活明显超过TTL说明有永不过期的key或者直写路径如果脏数据在TTL内反复出现说明是并发覆盖。排查这类问题时别只盯着缓存代码把整个数据写路径画出来。每一条不经过标准流程的写入都是一颗定时炸弹。上线新缓存方案时先问三个问题这个key允许不一致多久不一致由谁修复修复的时效性怎么保证答不上来就不要上线。最后说两句实在的现在我做缓存设计时不会先想用什么中间件而是先画一张“读写时序图”把所有可能的并发执行顺序列一遍再盯着每个交叉点问自己这里不一致怎么办这个问题想清楚了方案其实自己会浮出来。再分享一个小技巧不管你打算用什么方案先把对账脚本写好。它不一定是线上最靓的功能但它可能是唯一一个能在凌晨三点帮你兜底的东西。分布式缓存一致性没有银弹无非是尽量缩小窗口、尽量收敛冲突、并确保不管发生什么都有人能发现并修复。
返回列表