拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

缓存与数据库双写一致性:从延迟双删到binlog消息队列的生产级架构

缓存与数据库双写一致性:从延迟双删到binlog消息队列的生产级架构 缓存与数据库双写一致性这七个字大概能劝退一半刚入行的后端开发。面试的时候人人都会背“先更新数据库再删缓存”可真到了线上高并发场景下缓存和数据库分分钟给你演一出“罗生门”。我从用缓存的第一天起就在跟这个问题较劲从最初的加过期时间到延迟双删再到痛定思痛上 binlog 订阅 消息队列每一步都是拿线上事故换来的。这篇不打算讲什么高深理论就想把我从“伪解法”一路折腾到生产级架构的真实过程包括那些坑和取舍完整摊开给你看。如果你是做后端、做架构设计或者正为缓存不一致头疼这篇应该能帮你少走至少半年的弯路。1. 双写一致性问题的本质为什么缓存和数据库总是打架1.1 从 Cache Aside 模式说起在讲一致性之前得先搞清楚我们平常写缓存的方式。绝大多数业务系统用的都是 Cache Aside旁路缓存模式读的时候先读缓存缓存没有就去读数据库然后把数据写回缓存写的时候先更新数据库再删除缓存或者更新缓存。它的核心逻辑是让缓存只作为加速层数据库才是唯一真相源。这个模式本身是没问题的问题出在“写的时候”这个环节。很多人会想既然读的时候要回填缓存那写的时候直接更新缓存不就行了何必删掉这里有个朴素的原因缓存里的数据往往不是数据库表的原始行而是经过多表 join、字段裁剪、甚至通过 RPC 组装出来的视图。你更新了数据库的一个字段对应的缓存对象可能有几十个字段需要联动修改直接更新缓存的成本极高而且很容易更新出“半新半旧”的脏视图。所以成熟的方案大多是删缓存让下次读的时候自然重建。这是 Cache Aside 相对优雅的地方但也是各种并发问题的起点。1.2 不一致是怎么产生的并发时序分析不一致的核心原因是“读重建”和“写删除”两个动作在并发时产生了时序交叉。最经典的场景是这样的线程 A 要读 key缓存未命中于是去数据库读到了旧值这时候线程 B 更新了数据库并删除了缓存线程 A 接着把刚才读到的旧值写回缓存。结果就是数据库是最新的缓存是旧的。只要缓存不失效之后的读都会命中这个脏数据。注意这个问题的根源在于“缓存重建”和“缓存删除”不是原子的也没有在时间上形成正确顺序。有人会抬杠说如果线程 A 在写回缓存之前先检查一下 key 是否还存在如果已经被删了就丢弃不写行不行这个“检查-删除”本身仍然是非原子的中间依然有竞争窗口。你必须在更底层保证这两个动作要么同时发生要么有明确的版本依赖否则就是伪解法。理解了这一点后面看各种方案就很容易辨别它到底能不能打。2. 那些年我们用过的伪解法看起来很对上线就翻车2.1 先更新数据库再删除缓存最常见的陷阱很多团队进化到的最初版本就是“先更新数据库再删除缓存”理由是删除缓存只涉及一次 Redis 操作比更新缓存快得多哪怕删失败了缓存过期也能兜底。但这里有个概率很小、后果却极其严重的时序问题线程 A 更新完数据库还没来得及删缓存线程 B 读到了旧缓存这件事发生在更新提交之后、删除之前那么 B 就会拿到旧数据。更麻烦的是如果删除缓存的动作失败了比如 Redis 超时、网络抖动、连接池耗尽那么旧缓存会一直存活到过期时间中间所有读都是脏数据。你以为加个重试就能解决重试只是提高了最终成功的概率但没有解决“删除动作期间读缓存”的窗口期。这个窗口通常在毫秒级平时流量不大时几乎不会出现可一旦碰到热点数据、大促流量窗口内请求量累积脏数据命中次数就会显著上升。线上故障就是这么来的你压测时测不出问题因为并发模型和流量分布跟真实场景差太远。我当时第一次遇到这个问题就是某个商品详情页在秒杀开始后的前两分钟大量用户看到的价格和下单页对不上排查了半天才发现是更新数据库后删缓存失败Redis 里躺着几分钟前的旧价格。2.2 延迟双删为什么治标不治本延迟双删的思路是先删除缓存再更新数据库然后 sleep 几百毫秒再次删除缓存。设计者希望通过第二次删除清掉那些在更新期间把旧值写回的并发请求。听起来像那么回事但有两个很硬的问题。第一sleep 的时间完全靠拍脑袋设短了清不掉延迟写回设长了又拖慢接口响应而且这个延迟跟业务查询耗时强相关不同接口差异很大。第二第二次删除如果失败你依然要依赖重试或过期兜底整个方案的可靠性并没有从机制上得到提升。更隐蔽的问题是延迟双删引入了“先删缓存”这个动作这会让缓存命中率在更新瞬间急剧下降本来能扛住的流量突然全部打到数据库相当于人为制造了一次缓存击穿。在大促场景下这不是修复问题而是制造更大问题。所以延迟双删在社区里流传很广但真正能在生产环境稳定运行的我几乎没有见过。我自己也试过把 sleep 时间设为 500ms结果在慢查询接口上完全不生效旧值写回发生得比第二次删除还晚反而成了新的脏数据来源。2.3 缓存过期时间兜底只是心理安慰还有个常见做法是给所有缓存设置一个较短的 TTL比如 5 分钟然后告诉自己“就算不一致最多 5 分钟也就自愈了”。这种思路不能说错但它把一致性风险从“不可接受”降级成了“可短暂忍受”。问题是很多业务对短暂的不一致是敏感的比如库存、余额、订单状态你让用户看到“已支付”变成“未支付”哪怕只有 1 秒也是严重事故。而且 TTL 兜底本身还有一个副作用它会让清理和修复都变得迟钝。真正出问题时你很难判断一个 key 是刚写进去的脏数据还是该过期还没过期的普通数据。监控告警也会因此变得很吵。TTL 应该作为最后一道防线而不是主要修复手段。把它当成“方案”本质上是不愿意投入精力做一致性保障。当然我这里得补充一句上面这些“伪解法”不能说完全没用在某些读多写少、容忍度高的业务里它们也能跑得很稳。但如果你追求的是生产级架构期望在高并发下依然能保证一致那这些解法就都不够了。3. 生产级架构的正确姿势最终一致性与幂等保障3.1 基于 binlog 订阅的异步更新机制生产级方案的核心思想是把“更新缓存”这个动作从业务请求链路中抽离出来改成监听数据库的 binlog在数据真正落库之后异步地完成缓存更新或删除。这样做的好处是显而易见的不管业务代码怎么写只要数据提交成功了binlog 就一定会有记录你只需要保证“binlog 被可靠消费”就能做到最终一致。在 MySQL 生态里最成熟的媒介就是 Canal它把自己伪装成 MySQL 的从节点拿到 binlog 事件后提供给消费者。Canal 的消费端可以接 Kafka、RocketMQ 等消息队列也可以直接写一个 TCP 客户端。我推荐走 MQ原因有两个一是 Canal 本身不负责消息堆积和重试一旦下游挂掉事件就丢了需要 MQ 来兜底二是后面要做的版本校验、幂等处理、失败重试在 MQ 的消费端做会更容易。3.2 消息队列 重试 版本号控制用 MQ 之后核心链路变成这样数据库 binlog 变动 - Canal 解析成消息 - 写入 MQ - 消费者拿到消息 - 判断当前缓存是否应该被更新或删除 - 执行 Redis 操作 - 失败则重试。这里有一个关键设计版本号控制。如果直接“删缓存”那么在高并发下删完缓存立刻又被读流量回填成旧数据依然可能出问题。所以生产环境我一般不在消费端裸删 key而是把缓存里的 value 包装成一个带有更新版本号的对象消费者解析 binlog 里的数据版本比如利用数据库行数据的 update_time 字段或者自增版本列和缓存里已有的版本号对比如果 binlog 版本更新就写缓存否则就跳过。这样即使读流量先回填了一个旧值后续消费者看到 binlog 版本后仍然会覆盖成新值最终收敛到一致。但这里要注意版本号必须来自数据库本身不能依赖 Redis 里自己维护的计数器。因为只有数据库的更新时序是唯一可信的。我在项目里就直接用的业务表上的一个version字段每次 update 时version version 1binlog 里会带上这个字段消费者拿它跟缓存里的版本比较简洁且没有歧义。3.3 本地消息表或事务消息保证消息不丢既然依赖 MQ那“消息不丢”就是一个必须解决的问题。Canal 跟 MySQL 之间通过 binlog 位点来记录消费进度这本身是可靠的。但如果 Canal 解析后写入 MQ 时网络闪断或者 MQ 因为某种原因拒绝消息消息就丢了。解决方式通常有两种一种是让 Canal 把消息落到本地文件或表然后再异步投递到 MQ投递失败就重试直到成功另一种是把“业务更新数据库”和“写消息表”放在同一个事务里这被称为本地消息表方案。我在实践中更偏向第二种因为它不依赖 Canal 对业务数据的解析消息表里的状态可以非常明确。流程是业务事务里更新数据库同时插入一条“待发送”的消息记录事务提交后后台任务扫描消息表把消息发给 MQ收到 MQ 的确认后把消息状态改成“已发送”。如果中途挂了重启后继续扫描“待发送”和“发送中超时”的消息配合消费端的幂等处理就能保证消息不丢、不重。要注意的是本地消息表会让业务代码多一层侵入但换来的是极高的可靠性。如果你真的追求生产级这一层是值得的。也可以在中间件层面使用事务消息比如 RocketMQ 的事务消息本质上是把本地消息表搬到了 Broker 内部代码更简洁但要求你对中间件有足够掌控力。3.4 为什么强一致很难CAP 与性能取舍聊到这里肯定有人会问那有没有办法做到强一致让应用在读缓存的时候永远不会拿到旧值答案是有但要付出性能代价。最典型的办法是“缓存更新和数据库更新放在同一个分布式事务里”这就意味着每次写操作都要经历两阶段提交延迟一下子从毫秒级涨到秒级高并发系统根本吃不消。另一条路是读写都走同一个代理由代理保证缓存和数据库操作的原子性但这本质上把系统变成了一个单点扩展性大打折扣。所以绝大多数互联网业务选择的是最终一致性允许在极短时间窗口内读到旧值但在确定的时间范围内一定收敛。生产级架构的价值就是把这个时间窗口压缩到最小同时保证系统在故障和并发下依然稳定。理解这一点你才能在工作中做合理的取舍。不是所有业务都需要强一致也不是所有数据都用同样的策略把握住“一致性等级”和“可用性、性能”之间的平衡是架构师的基本功。4. 核心环节实现一个可落地的缓存更新方案4.1 架构总览与组件选型下面给你一套我实际用过的组合MySQL 8.0 Redis 6.x Canal 1.1.7 RocketMQ 4.9 Spring Boot 2.x。选择 Canal 是因为它对 MySQL 版本兼容性不错配置相对简单RocketMQ 是因为事务消息能力成熟而且顺序消息、重试队列都很好用。当然你用 Kafka 也完全可以只要保证 binlog 消费的幂等性即可。整个链路我用一个简单流程描述一下应用更新 MySQLMySQL 产生 binlogCanal 伪装成从库解析 binlog过滤出需要监听的数据库表把变更事件发送到 RocketMQ 的某个 topic一个独立的消费服务订阅这个 topic拿到变更事件后解析出主键、版本号、操作类型insert/update/delete然后组装新的缓存值或者直接删除对应 key如果组装或写 Redis 失败进入 RocketMQ 的重试队列最多重试 N 次还是失败就落库到一张补偿表由定时任务兜底处理。4.2 关键配置与代码示例Canal 的配置文件instance.properties里最核心的是这几行canal.instance.master.address127.0.0.1:3306canal.instance.dbUsernamecanalcanal.instance.dbPasswordcanalcanal.instance.filter.regexyour_db\\..*我这里把监听范围设为your_db下的所有表。如果你只想监听某张表就写your_db\\.t_user。注意反斜杠转义Canal 用的是 Java 正则网上很多人在这里配错导致一条 binlog 都收不到然后怀疑人生。Canal 对应的 MQ 客户端模板大致长这样CanalRocketMQListener // 伪代码示意 public class BinlogConsumer { Autowired private CacheService cacheService; public void onMessage(Message message) { BinlogEvent event parse(message.getBody()); if (event.getTable().equals(t_user)) { Long id event.getPrimaryKey(); if (event.getType().equals(UPDATE)) { Long newVersion event.getVersion(); Long cachedVersion cacheService.getVersion(user: id); if (newVersion cachedVersion) { User user loadFromDb(id); cacheService.setWithVersion(user: id, user, newVersion); } } } } }当然真实环境里你最好不要裸用canal-spring-boot-starter因为它版本更新经常有坑。我更推荐自己封装一个消费容器手动管理 Ack这样你能清楚地知道每条消息到底消费成功了没有。如果删除缓存失败RocketMQ 会自动重试但你要注意重试次数和间隔。默认的重试次数对 Redis 偶发抖动是够用的如果超过次数还失败务必把消息投递到一个死信 topic或者落库到本地补偿表。我踩过的一个大坑是当时只配置了重试没做死信处理结果 Redis 短暂不可用期间积压了一大批重试消息恢复后消费端被重试流量打爆反而拖垮了整个服务。后来我改成有限重试 补偿表兜底才彻底稳下来。4.3 缓存击穿、穿透与雪崩的预防生产级架构不能只解决一致性问题还要解决缓存本身可能引发的故障。这里我简单说三个最典型的。击穿是指一个热点 key 刚好过期瞬间大量请求全部打到数据库。解决办法是加互斥锁重建缓存时只允许一个线程去查库其他线程等待缓存重建完成。穿透是指请求的 key 在数据库里不存在缓存也没法缓存导致每次都要查库。解决办法是缓存空值并设置较短 TTL或者用布隆过滤器拦截。雪崩是指大量 key 在同一时间过期导致数据库压力骤增。解决办法是给 TTL 加上随机抖动同时设置多级缓存比如本地缓存 Redis 组合让热点数据即使 Redis 抖动也有一层兜底。这些措施和一致性是配套的。因为如果你的缓存经常被击穿或雪崩数据库压力一大binlog 消费延迟就会变高最终一致性的收敛时间就会被无限拉长。所以生产级架构不是单点优化而是一整套组合拳。5. 常见问题与排查技巧实录5.1 监控与告警如何发现不一致数据不一致最可怕的地方在于它不会主动汇报你只能从业务异常中感知。所以必须建立监控。我的做法是在缓存里额外存一个“最近更新时间”在服务端读缓存时以一定的采样率对缓存里的数据做一个“影子校验”拿同一个主键去数据库查一遍跟缓存内容做对比如果发现不一致或版本落后就记录一条日志同时触发告警。这个采样率不用太高1% 就够因为一致性问题的触发通常是并发故障采样率太高反而影响性能。另一个指标是 binlog 消费延迟。Canal 会暴露延迟时间消费服务的 MQ 积压数也要监控。一旦发现积压上千条基本就意味着有一段时间的数据变更没有更新到缓存这时候告警一定要足够响亮。我有一次就是靠这个指标发现了一个隐蔽的 bug某张表的更新频率极高导致消费端完全跟不上缓存里全是旧数据。5.2 人工补偿方案数据库和缓存对比工具就算监控做得再好也难免有漏网之鱼。我建议开发一套定时对比修复工具每天晚上低峰期扫描业务表中的主键和版本号然后批量读取缓存的版本号找出两边不一致的记录再按版本高低修复。这里要小心的是对比修复不能把“合法的新值”被当成不一致覆盖掉。我遇到过的情况是某个字段更新频率很高扫描时读到数据库的最新值但缓存在扫描结束后才被异步更新结果我们误判了一次不一致反而把新缓存给删了。解决办法是在做修复之前再查一次 binlog 消费位点和 MQ 积压情况确认所有变更都已经消费完了再开始对比。如果消费积压不为零就先等积压清空。这个细节看起来很简单但真的能救你。5.3 容易被忽略的细节key 设计与序列化最后分享几个实际工作中容易被坑到的点。第一个是 key 的粒度。缓存 key 最好不要把整张表塞进去尽量按主键或者维度拆分否则一条数据变动整个大缓存对象全部失效命中率很难看。第二个是序列化方式。之前我遇到过 Redis 存了 JSON后续增加了字段老的缓存反序列化直接报错导致所有读请求都打到数据库。解决办法是给缓存对象加版本号或者用向前兼容的序列化方案比如 Protobuf 或者带ignoreUnknown的 JSON 配置。第三个是过期时间的设置。不要所有 key 都一个 TTL要根据业务特性区分。比如热点商品可以长一点订单状态可以短一点。而且 TTL 最好在配置中心可动态调整因为不同业务阶段对一致性和命中率的要求是变的。5.4 伪解法代码的清除与重构在从伪解法迁移到生产级架构时还有一件必须做的事彻底把代码里那些“先删缓存”“延迟双删”的逻辑清理掉。如果你只是把 binlog 消费加了上去但业务代码里还在删除缓存就会有两个写缓存途径互相竞争反而更容易出问题。我记得当时重构时团队花了一周时间梳理所有更新数据库的 Service 方法把里面跟缓存相关的代码全部移除只保留纯数据库操作。刚开始很多人不习惯觉得缓存不操作了心里没底但跑了一段时间后就发现其实一致性更好了代码也更干净了。这个经验可能比架构本身更重要一致性是一个整体性设计而不是某个环节的一厢情愿。最后说点实在的。缓存与数据库双写一致性很多团队从“伪解法”走到“生产级架构”差的往往不是一两个技术点而是对整个系统数据流和数据生命周期的理解。我个人在经历了删缓存踩坑、延迟双删被流量教训、binlog 消费链路故障之后最大的体会是不要试图用某个“灵巧的小技巧”去解决系统性的问题要把一致性作为一种架构约束来设计。如果你现在还在用延迟双删或者只看 TTL 兜底不妨先建立一个监控和对比修复机制把你的“不一致窗口”量化出来再逐步引入 binlog 消费。这个路径看起来很慢但每一步都会让系统更稳。我自己现在遇到缓存问题第一反应不是加代码而是先去问数据库的变更能否有一个独立、可靠、可重放的来源想清楚这个问题方案基本就出来了。
返回列表