最近在排查线上一个偶发问题:用户下单后详情页价格偶尔会回退到上一秒的状态,刷新几次又正常。翻代码、查日志、看Redis和MySQL里的数据,最终定位到的是缓存与数据库一致性没做到位。这个问题的本质不复杂,但想在生产环境里稳定解决,坑比预想的多。
这篇文章就围绕“缓存与数据库一致性”这个主题,把我自己从Cache Aside到延迟双删,再到binlog异步同步的完整演进过程,以及线上踩过的坑、调过的参数、最终沉淀的解决方案,一起写出来。适合正在做电商、资讯、交易链路,或者自己维护缓存系统的后端开发者参考。内容以实际项目可落地为标准,不写空中楼阁的理论。
1. 先泼盆冷水:你读过的很多一致性方案,在生产里根本跑不起来
我见过太多文章把一致性问题简化成一套固定流程:先更新数据库,再删除缓存,然后延迟双删,就完事了。但当你真正在系统里落地时,会发现一堆隐藏问题:并发请求把旧数据写回缓存怎么办、延迟双删的时间到底设多少、删除失败怎么重试、多实例部署时的误删问题怎么办。这些细节不处理好,方案形同虚设。
1.1 大多数系统要的不是强一致,而是窗口可控的最终一致
明确一件事:对于绝大部分业务系统,你根本不需要强一致,你需要的是“最终一致”加上“可控的脏数据窗口”。这个窗口通常控制在几百毫秒到几秒之间,用户完全感知不到。
为什么?因为缓存和数据之间要达成强一致,最直接的做法是引入分布式事务或全局锁。但分布式事务的代价很高,吞吐量骤降,对于高并发的读多写少场景得不偿失。而且,很多业务本身允许短暂的不一致。
举个例子:用户下单后,订单状态从“待支付”变成“已支付”,如果缓存里短时间内还是“待支付”,最多是用户刷新页面才看到新状态,并不会造成资损。但如果商品库存因为缓存旧数据而多卖了一件,那就是大事故。所以,一致性方案必须先按业务场景分级,而不是一刀切追求强一致。
我在项目里的做法是:把数据分成三类。核心交易数据(余额、库存、订单状态),这是底线,宁可牺牲一点性能也要保证正确;展示类数据(商品详情、用户昵称、文章内容),允许秒级延迟;统计类数据(浏览量、点赞数),允许更长时间的不一致。
针对不同级别,采用不同的缓存策略和兜底机制。这个分类在你动手写代码之前就应该完成,否则后面容易顾此失彼。
1.2 先分清三种业务流量:读多写少、写多读少、读写均衡
很多一致性方案失效,不是因为方案本身有问题,而是它根本不适用你的流量模型。我把常见业务分为三类:
读多写少,典型如商品详情页、文章详情。缓存命中率极高,写入是低频操作,方案重点在于“更新数据库后如何让缓存失效或更新”。
写多读少,典型如秒杀库存、点赞计数。每次写入都伴随缓存更新,如果直接删除缓存,很容易造成缓存击穿;如果更新缓存,又面临频繁写Redis的压力。这类场景通常需要后端异步合并,而不是每笔写入都同步更新缓存。
读写均衡,典型如会话信息、用户配置。读写频率接近,适合用带过期时间的Cache Aside,再加一层消息订阅做兜底。
这不是随便分类的,它直接影响你选择“删缓存”还是“更新缓存”,以及要不要引入消息队列。很多团队把一套方案用在所有业务上,出问题是迟早的事。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从Cache Aside到延迟双删:不同阶段我为什么换方案
业内最常提到的就是Cache Aside,也就是旁路缓存策略。但很多人只知其一,不知其二。在实际项目里,Cache Aside本身是够用的,但前提是你把细节做好,而不是写完就上生产。
2.1 Cache Aside 的正确姿势,以及大多数人写错的第一步
Cache Aside的标准流程是:读请求先读缓存,命不中则读数据库,然后写回缓存;写请求先更新数据库,然后删除缓存。
这里大多数人写错的第一步是“更新数据库后直接更新缓存”,而不是删除缓存。为什么删除更好?因为更新缓存有两个大坑:
一是写操作可能不是最终值。比如库存字段,多个线程同时更新,后写的值不一定是数据库里最终提交的值,你更新缓存可能把新数据覆盖回旧值。二是更新缓存需要额外查一次数据库或拼接数据,多一次IO和计算。删除缓存则简单粗暴,下次读请求再回源填充,天然避开了覆盖问题。
另外,删除缓存要特别注意顺序。正确的做法是先更新数据库,等数据库事务提交成功,再删缓存。如果先删缓存,更新数据库过程中有读请求进来,缓存是空的,会穿透到数据库读取到旧值,然后写回缓存,这就造成了永远不一致。
顺序问题看似简单,但出错率极高。我在代码里专门用AOP切面处理写操作,保证删缓存动作在业务事务提交之后执行,而不是放在事务方法内、事务还没提交就删了缓存,导致事务回滚但缓存已被删除的尴尬局面。
2.2 延迟双删的延迟时长到底怎么定,不是拍脑袋
Cache Aside有一个经典并发问题:A线程更新数据库,B线程读取旧值并写回缓存,A线程随后删除缓存,但B线程在A删除之后再次写回旧值,这个旧值会一直留在缓存里。
业界常用延迟双删来解决:先更新数据库,删除缓存;等待一小段时间,再次删除缓存。目的是等B线程把旧值写回缓存的时间窗口过去,再删一次,确保旧值被清掉。
网上很多文章说延迟时间设500ms,或者1秒,但实际项目里不能这么拍脑袋。延迟时间应该大于“读请求从数据库拉数据到写完缓存”的平均耗时,同时考虑网络抖动。我在项目里的做法是:先统计读请求回源写缓存P99耗时,假设是200ms,那么延迟双删的时间我设置为P99的两倍,也就是400ms,再留一点余量,最终设500ms。
这里要强调一下,延迟双删不是删除一次就完,要配合重试机制。我在延迟队列中放了一个删除任务,如果第二次删除失败,会走后续的兜底逻辑,而不是把失败吞掉。
2.3 删除缓存失败怎么办:本地重试、MQ重试与补偿任务的取舍
缓存删除失败是最常见的问题之一。Redis短暂不可用、网络分区、超时,都可能导致删除失败。如果不处理,缓存里就是旧值,需要等过期时间自然淘汰,这个窗口可能很长。
我的处理分三层:
- 同步删除失败后立即重试一次,且重试时改用同步调用,设置较短超时(比如500ms),避免长时间阻塞业务线程。
- 重试仍失败,则发送一条延迟消息到MQ,由消费端在几秒后再做一次删除。这个方案需要MQ可用性较高。
- 最后一层是定时补偿任务,每隔一段时间扫描最近更新过但未确认删除缓存的记录,重新执行删除操作,并记录日志。
三层机制看起来繁琐,但实际开发中我是通过封装的CacheSyncService实现的,对外只暴露一个sync方法,内部包含重试、MQ、补偿的完整链路。业务方不需要关心这些细节,只需要调用sync(cacheKey)即可。
这里还要提一个容易被忽略的点:删除缓存时应使用带版本号的key,比如product:detail:1001:v2。这样即使删除操作乱序,由于key不同,不会造成新值被旧值覆盖的问题。版本号可以从数据库记录里取,也可以通过更新时间戳生成。
3. 生产环境真正通用的解法:binlog订阅异步更新缓存
延迟双删能解决大部分场景,但它终究是“尽力而为”的方案。只要存在并发写,就存在理论上的覆盖窗口。我在项目里真正解决问题的,是引入binlog订阅,把缓存更新从业务链路里抽离出来,做成异步最终一致。
3.1 为什么我更推荐同步方案取代“删缓存”
推荐binlog订阅方案,不是因为延迟双删不好,而是它在高并发、多实例场景下有一个本质缺陷:业务逻辑里删缓存的动作和数据库事务不在同一个可靠通道里,一旦漏删,没有任何机制保证后续能补上。
binlog订阅方案的核心思路是:把MySQL的binlog作为可靠数据源,通过Canal或模拟slave协议监听binlog事件,解析出数据变更,再异步更新Redis缓存。这样,业务代码只需要关心数据库写入,删缓存这件事由基础设施保证。
这个方案有几个明显优势:
- 数据库binlog是MySQL主从同步的基础设施,本身就保证有序和可靠。
- 缓存更新与业务代码解耦,业务方不需要写任何缓存相关代码。
- 天然支持多实例部署,不会出现A实例删缓存后B实例又写入旧值的问题。
3.2 订阅binlog后的幂等与乱序处理:这是容易翻车的地方
binlog订阅有一个很容易翻车的地方:同一条数据的更新顺序。比如一次update操作被binlog拆成多个事件,或者事务里先删后插,订阅端如果严格按照顺序执行没问题,但一旦引入多线程消费,顺序就乱了。
我在实际处理中使用了两种手段:
一是按主键哈希分桶,保证同一条主键的数据一定落到同一个消费线程。这样可以保持单key的消费顺序。如果使用Canal,可以配置hash模式,按表主键计算hash并路由到固定slot。
二是消费端做幂等校验。每次解析出最新变更后,更新Redis之前,先拿变更事件中的版本号或更新时间戳,与Redis中已存的值做比较。如果Redis里已经是新值,就直接跳过。这样可以防止重复消费或乱序消费造成旧值覆盖新值。
版本号怎么选?我在表里统一加了一个updated_at字段,作为乐观锁版本。binlog事件里会带上这个值,更新缓存时直接比较即可。
3.3 缓存重建时的击穿防护:singleflight还是分布式锁
binlog订阅方案解决了数据库到缓存的更新问题,但缓存重建本身是一个危险环节。同一时间大量请求发现缓存不命中,同时回源数据库,会打爆数据库。尤其在缓存刚被更新或删除的瞬间,最容易出现击穿。
我在工程里用的是singleflight机制,把同一个key的并发回源合并成一次请求。具体实现可以用Go的singleflight包,或者Java里自己实现一个基于ConcurrentHashMap的Future合并。
这里有一个小细节:singleflight只能保证单机内的并发合并,如果是多实例部署,每台机器都会各自发起一次回源。要真正防止击穿,需要配合分布式锁,在回源前先尝试获取分布式锁,拿到锁的实例负责加载缓存,其他实例自旋等待。
但分布式锁也有代价:所有未命中缓存的请求都要先抢锁,增加一次Redis交互。我实际的做法是两级防护:先在本机做singleflight,再加一层带超时的分布式锁,并且锁时间设置得尽量短(比如500ms),避免锁冲突拖长响应时间。
3.4 引出一个更简化的落地方案:本地消息表 + Worker 扫描(附实践)
如果你的系统还没引入Canal这类组件,也不想为了缓存一致性引入新的中间件,还有一个轻量方案:本地消息表加Worker轮询。
具体做法是:在业务数据库里建一张cache_sync_log表,每次写操作在同一个数据库事务里插入一条同步日志,记录目标缓存key、操作类型、状态。事务提交后,由后台任务扫描这张表,把状态为待处理的记录捞出来,执行缓存更新或删除,处理成功后更新状态。
这个方案的好处是利用了数据库事务的原子性,只要业务数据提交成功,sync日志就一定存在,不会出现漏记。同时完全不需要额外中间件,纯靠磁盘和SQL就能实现。
坏处是同步会有延迟,取决于Worker扫描的频率。我实际设置为每100ms扫一次,线上延迟大约在100-200ms,对大部分业务完全够用。如果某个key需要更快的同步,可以额外发一条MQ消息,由MQ消费端立刻执行,Worker扫描只做兜底。
4. 并发与异常场景:线上最容易翻车的三个坑及完整排查链路
前面讲的都是方案选型和设计,但真正让人头疼的是那些在极端场景下才暴露的坑。我挑三个印象最深的,说说它们怎么发生、怎么排查、怎么修复。
4.1 并发覆盖的根因:不是锁不好用,而是锁的粒度不对
有一次线上出现商品价格偶发错误,我用Redis里的值和数据库比对,发现两条记录不一致。排查到最后,发现是并发更新同一个商品时,两个线程都在更新数据库后删缓存,但线程A删完后线程B把旧数据写回了缓存。
这个问题的根因不是删除缓存没做,而是锁的粒度太粗,锁住了整个商品对象,导致线程B在A更新完之前读取了旧数据。如果锁粒度细到版本号级别,或者更新时带上版本条件,就能避免。
我在修复时改成了“条件更新+版本号”的方式:SQL里更新时携带WHERE version = #{oldVersion},影响行数为0则说明版本冲突,走重试逻辑。缓存删除则放在事务提交之后。这样即使两个线程同时更新,也只有一个能成功,另一个会重新拉取最新数据再决策。
这里也提醒一下:不要盲目使用分布式锁。锁是为了保护临界区,但如果临界区本身可以通过版本号或乐观锁规避,就不需要引入锁的复杂度。
4.2 一个真实报警排查过程:从缓存脏数据到定位写库延迟
有一次凌晨收到告警,说某个商品的详情页显示价格和数据库严重不符。初步怀疑是缓存被写入了脏数据。
排查链路是这样的:
- 先直接查Redis,确认key存在且value是旧价格,说明缓存里确实有脏数据。
- 再查数据库,确认数据库里是最新价格,说明数据库侧正确。
- 查看binlog消费端日志,发现该商品的最后一次更新事件没有消费到,原因是消费线程在某个时刻OOM重启了,唯一一条binlog没有被消费。
问题很清楚:binlog订阅的消费端不是高可用的,重启后没有做断点续传。修复方案是给消费端加了基于ZooKeeper或Redis的位移记录,重启后从上次消费位置继续拉取,而不是从头开始。
这个案例说明一个重点:一致性方案不仅要有,还要考虑方案自身的可靠性。消费端挂了怎么办?消息丢了怎么办?这些都需要有监控和恢复机制。
4.3 脏数据自愈:给缓存设置合理的过期时间是最便宜的安全网
无论方案多完善,都难免有漏网之鱼。所以我的个人经验是:永远不要设置“永不过期”的缓存。给缓存设置一个合理的过期时间,比如商品详情设10分钟,库存设1分钟,就算上面的所有机制同时失效,最多脏10分钟或1分钟,不会永久影响用户。
这个“安全网”思想在工程里非常值得推广。很多团队把精力花在追求极致一致性上,忘了最基本的兜底策略。实际上,一个合理的过期时间加上一个定期对账任务,能覆盖掉95%以上的脏数据问题。
我在项目里特意写了一个对账脚本,每天凌晨从数据库里随机抽取几千条核心记录,和Redis比对,不一致的自动修复并告警。虽然听起来很原始,但这道保底防线救过我两次。
5. 一致性监控与度量:怎么知道方案在线上是有效的
方案上线不是说跑通就算完,你还需要知道它到底有没有在正常工作。这需要监控、指标和报警。
5.1 用数据对账脚本兜底:每天校验几万条随机key
我推荐每个涉及缓存一致性的系统,至少要有一个离线对账任务。它的职责是定期从数据库拉取一批数据,与Redis比对,发现不一致就报警并自动修复。
对账任务怎么设计?我实际用的方法是:按照主键ID分片,每天选几个分片,每个分片随机取1000条记录,查询数据库和Redis,比较关键字段。如果每天跑1万分片,一个月基本能覆盖全部核心数据的10%-20%。
这个方案不需要覆盖全部数据,因为重点不是普查,而是通过随机抽样发现系统性问题。比如某个表因为某次上线改字段导致缓存更新逻辑失效,对账任务能在一天内发现,而不是等到用户反馈。
对账脚本我直接用定时任务框架跑,每天凌晨2点执行,输出一份报告发到群里。报告格式很简单:一致率、不一致列表、修复结果。
5.2 核心指标与告警阈值,我实际调出来的参数
监控指标方面,我主要看四个:
缓存命中率:命中率突然下降,可能说明删除缓存的操作太频繁,或者过期时间太短。正常商品详情页命中率应该在95%以上,低于90%就要关注。
回源QPS:数据库回源次数突然飙升,一般是缓存穿透或集中过期。回源QPS和业务高峰期的比值,正常应该在5%以内。
同步延迟:binlog消费端处理的延迟时间。正常情况下应该小于500ms。如果延迟持续超过1秒,说明消费能力不足。
删除失败率:缓存删除操作失败的比例。这个指标异常升高,说明Redis集群可能不稳定或网络有抖动。
告警阈值我会分P0/P1/P2三级。P0是缓存关键数据不一致超过1分钟,直接影响业务;P1是回源QPS暴涨,可能导致数据库过载;P2是命中率下降或同步延迟超时。每次上线新功能或改缓存逻辑,我都会回看这四类指标,确认没有恶化。
5.3 灰度发布与回滚预案
一致性方案改动的风险很大,尤其是在线切换时。我的习惯是分三步走:
- 先在预发环境完整跑一遍,用压测工具模拟高并发读写,观察指标是否符合预期。
- 上线时先切10%流量,观察半小时,确认缓存命中率、同步延迟、错误日志都正常,再逐步扩大。
- 准备一个开关,出现问题时能一键回退到旧方案,比如开关控制是否启用延迟双删或binlog消费,避免线上环境不可控。
这个开关我用配置中心管理,修改后实时生效,不需要发布新代码。回滚预案虽然简单,但在关键时刻能减少故障时长,值得每个团队重视。
最后再分享一个实际经验:不要等到线上出问题才去优化一致性。每次迭代时,顺手评估一下新增的读写点是否走了一致性保障,比事后补方案成本低得多。我在实践中就是靠这个习惯,避免了好几次潜在的脏数据事故。
