做后端的人,多少都跟Redis打过交道。凡是把它当缓存中间件用的,几乎都会踩到“Redis与数据库(DB)数据一致性”这个坑。表面上看是“缓存更新失败”或者“读到了旧数据”,但根子其实是双写场景下,缓存和数据库之间缺乏原子性保证的天然矛盾。这篇文章不绕弯子,直接把常见方案、原理、代码骨架、选型逻辑和排查技巧讲透,适合做订单、库存、用户中心这类强数据校验场景的同学参考。不是说要保证绝对强一致,而是要搞清楚在什么场景下能接受哪种程度的不一致,然后选对手段。
1. 问题到底出在哪:缓存与DB的“双写”困局
1.1 一致性问题的核心矛盾
Redis和数据库之间的一致性问题,本质上源于两个系统之间的状态独立演进,缺少事务性的原子保证。你更新了MySQL里的商品库存,Redis里可能还有一份旧的库存快照,用户下单时看到的数字就错了。这跟CAP理论有关,更跟架构设计有关,但最直接的矛盾是:数据库写入和缓存更新,是两个独立的操作,它们没法像同一个数据库事务那样做到“要么都成功,要么都失败”。
有人会说,那我在代码里先更新数据库,再更新Redis,不就行了?道理是这个道理,但实际执行中,Redis更新失败、网络闪断、代码抛异常、进程重启,任何一步出问题都会导致两边数据不同步。更隐蔽的是并发问题:一个线程正在更新DB,另一个线程在读缓存并回填旧值,这时候即使你顺序是对的,也可能被并发读写串了线。
所以,讨论一致性方案,先要分清两个概念:强一致性和最终一致性。强一致意味着任何时刻读到的都是最新值,这在分布式环境下通常要付出很大代价,Redis做缓存时一般不追求这个。最终一致性则是允许短暂的不一致,但经过一定时间后,缓存和DB会趋同。后面讲的所有方案,基本都是最终一致性的实现思路。
1.2 你可能踩到的“脏数据”场景
先说几个我在项目里真实遇到过的场景,把问题具象化。第一个是更新数据库成功,但删除Redis缓存失败。这种最直接,缓存里永远是旧值,而且如果没设过期时间,可能要用很久才被察觉。第二个是读请求在写请求完成前读了旧值,然后回填到缓存,导致新数据被旧数据覆盖。这种更隐蔽,因为这次读操作本身没问题,只是发生在错误的时间窗口里。第三个是批量操作时,部分key更新漏掉,比如分页缓存里的列表数据没有整体失效,导致数据张冠李戴。第四个是缓存中的对象为序列化旧版本,数据库字段都加了,Redis里反序列化却报错或丢失字段。
这几个场景对应着不同的解决思路:删除失败需要重试和补偿;并发窗口需要缩短或消除;漏更新需要更强的一致性控制手段;序列化问题属于工程细节,但同样会导致线上故障。搞清楚自己最可能触发的是哪一种,再选方案,才不至于盲目套模板。
2. 最基础的解法:缓存旁路(Cache Aside)模式的正确姿势
2.1 读流程:先查缓存,还是先查库?
业界最通用的缓存读写模式叫Cache Aside,中文常翻译成缓存旁路。读流程是标准的:先查Redis,命中就直接返回;未命中就去查数据库,然后把查到的数据回填到缓存,设置过期时间。关键点在于,回填的时候要考虑并发。两个线程同时发现缓存未命中,同时查库得到相同结果,同时回填,其实问题不大,数据是一致的。但如果查库期间另一个写请求已经改了数据库,这两个线程读到的可能还是旧值,回填后就把旧值固化在缓存里了。
对于读多写少的场景,这种并发窗口问题还可以接受,因为旧值被读到的概率和影响范围都有限。但一定要给缓存设过期时间,并且要根据业务容忍度选择合理的TTL。TTL过长,一旦发生不一致就要等很久才能自愈;TTL过短,则缓存命中率下降,数据库压力增大。常用的经验值是热点商品、库存类数据可以设几分钟到十几分钟,基础配置类、字典类的数据可以半小时到几小时,具体要看业务容错能力。还有个小细节,回填缓存时最好加上“如果存在则不覆盖”的控制,或者用SETNX,避免并发回填把新数据覆盖掉。
2.2 写流程:先更新库,还是先删缓存?
写流程是Cache Aside的核心争议点。有两种做法:先更新数据库,再删除缓存(通常叫先更后删);先删除缓存,再更新数据库(先删后更)。业界主流推荐先更后删,原因可以从失败后果来分析。
先更后删的问题是:如果删缓存失败,缓存中残留旧值,下次读会继续用旧值。但这个可以通过重试、告警、订阅日志来兜底,而且旧值往往还在,不会导致数据彻底损坏。先删后更的问题是:删缓存成功后、更新DB之前,如果有读请求进来,发现缓存未命中就去查库,查到的是还是旧值,然后回填缓存,等到DB更新完成后,缓存里存的还是旧值。也就是说,这个方案必须配合“延迟双删”才能解决窗口期问题,否则反而更容易生成脏数据。所以,不管哪个方案,更新DB后的缓存删除动作是绕不开的。
2.3 为什么“先删缓存再更新库”容易翻车
我把这个坑单独拎出来讲,是因为很多同学面试时背过“先删再更,再延迟删”的组合,但实际落地时仍然翻车。根本原因在于,延迟双删的“延迟时间”很难定准。它要延迟到所有旧读请求结束,可你又不知道这些请求到底要多久才完成。定得太短,窗口没关掉,旧值还是会回填;定得太长,这段时间缓存一直是空的,数据库压力陡增。
这里可以做一个粗略估算:如果一次业务请求的完整耗时目标是50毫秒,那延迟时间至少要留在200毫秒以上,而且一定要配合异步重试,不能只删一次就结束。我个人的建议是,如果不是对一致性要求极高,不建议单独依赖先删后更+延迟双删,更稳妥的做法是用先更后删+删除失败重试。延迟双删只作为最后一道保险。
所以说,先删后更这个方案在设计上是“主动清缓存,再更新DB”,思路是让缓存先失效,避免读到更新期间的中间状态,但实际操作中,更新DB期间的旧读请求会把旧值回填回来,这是这个方案永远躲不开的硬伤。工程上能用,但是要用得谨慎。
3. 进阶方案:延迟双删、分布式锁与订阅Binlog
3.1 延迟双删:用时间窗换一致性
延迟双删的本质是:在“先删缓存、更新DB、再删缓存”这个链路里,多一次延迟删除,把旧读请求回填的旧值再次清掉。操作序列变为:第一次删缓存 → 更新DB → 休眠N毫秒 → 第二次删缓存。这个N毫秒就是给那些在第一次删除后、更新完成前发生的读请求留出完成回填的时间。等它们都完成了,再删一次,把旧值清掉。
这种方案的关键参数是延迟时间N,它必须大于“读请求查DB的时间 + 回填缓存的时间 + 一点冗余”。你可以通过日志或者监控,观察线上实际读请求的P99耗时,通常取P99耗时的2倍或加几百毫秒作为N。但要注意,这个方案无法处理极端慢请求,比如某次查询执行了2秒,而N只设了500毫秒,这个慢请求回填的旧值还是活下来了。所以,延迟双删只能降低不一致的概率,不能根除。工程上建议把它作为辅助手段,与后续的补偿任务搭配使用。
3.2 分布式锁:把并发变成串行,牺牲一点吞吐
如果你实在无法容忍并发窗口期,最直接的办法是上分布式锁把所有读改写操作串行化。具体来说,在更新DB之前先获取一把针对这个缓存key的锁,比如Redis里用SETNX或者Redisson的Lock,然后所有读操作在发现缓存未命中、准备回填之前,也要先尝试获取同一把锁;拿不到锁的读请求,要么等待锁释放后重新读取缓存,要么直接查DB不回填。这样做的效果是,同一时间只有一个线程在操作这个key,读写互斥,从源头上消灭了并发覆盖问题。
好处显而易见,逻辑简单、一致性最强,几乎可以达到“读最新值”的效果。缺点也非常直观:所有针对同一key的访问都要经过锁,热点key的QPS会直接被打折,锁竞争严重时,请求耗时会上升不少。而且分布式锁本身也有不少细节需要处理,比如加锁超时时间、锁自动续期(常见的是Redisson的看门狗机制)、重入问题、以及锁粒度控制。锁粒度太粗会把无关的key也串行化,太细又保证不了跨key的一致性。所以我的经验是,分布式锁更适合“写多、且写操作会导致数据变化较大”的场景,比如库存扣减、账户余额变更;而读多写少又追求性能的场景,用分布式锁就有点得不偿失了。
补充一点:Redis分布式锁本身也是基于Redis的,如果Redis主从切换期间锁丢失,仍然有极小概率产生并发,这是使用Redis锁的固有局限。对这种极端一致性有要求的场景,可以考虑Redisson的多节点红锁(RedLock)方案,但代价是要部署多个独立Redis节点,绝大多数业务场景并不需要做到这一步。
3.3 订阅Binlog:把“删缓存”变成异步必然动作
前面几个方案都是在应用代码层面做协调,有一个绕不开的共同问题:如果应用系统在更新DB成功之后突然宕机,还没来得及删缓存,那这条脏数据就没人管了。解决这一问题的终极思路,是从“应用主动删缓存”改成“数据库变更驱动的异步删缓存”,最常用的手段就是订阅数据库的Binlog。
思路是:应用更新DB之后,MySQL通过Binlog记录这次变更;通过Canal(阿里开源的CDC工具)订阅Binlog,解析出变更的库表、主键ID,触发删除对应Redis key。这样,删缓存这个动作从“同步调用”变成了“事件驱动的异步必达”,即使应用进程崩溃,Binlog还在,Canal还能继续消费,缓存最终也会被删掉。这就是真正的最终一致性方案:数据库里有什么变化,缓存迟早会跟着变。
这个方案工程复杂度高一些,要先搭建Canal服务、维护Binlog消费端,还要把业务表的变更转换成都明白的缓存key。但它的好处的确明显:和应用代码解耦,删除动作有保障,特别适合核心交易链路、数据一致性要求很高的场景。我见过不少互联网公司的缓存架构,就是Cache Aside+Binlog订阅双轨并行:正常请求走Cache Aside,Binlog作为兜底,确保缓存最终一定失效。
3.4 各方案对比与最终选型建议
把刚才讲的几种方案放在一张表里对比,方便你选型的时候心里有数。这是我在架构选型时经常会做的一步,做完之后就不会再纠结了。
| 方案 | 实现难度 | 一致性强度 | 对DB压力 | 典型场景 | | --- |--- |--- |--- | | Cache Aside(先更后删) | 低 | 最终一致,有窗口 | 低,仅在未命中时查库 | 读多写少,通用场景 | | 先删后更+延迟双删 | 中 | 最终一致,窗口减小 | 中,延迟期间缓存空窗 | 写操作偶发、容忍短时DB压力 | | 更新时加分布式锁 | 中高 | 强一致(互斥) | 中 | 写多、热点key、强一致性要求 | | 订阅Binlog异步删缓存 | 高 | 最终一致,删除可靠 | 低,由事件驱动 | 数据一致性要求高、核心链路 |
选型逻辑很简单:绝大多数业务用Cache Aside就够了,配好过期时间和删除重试;如果对并发窗口不满意,再加一个分布式锁兜底热点key;如果连“应用宕机导致缓存未删”都无法接受,那就上Binlog订阅。没有哪个方案是万能的,关键看业务接受多少不一致时间和运维复杂度。我个人的原则是:能不引入新组件就不要引入,能异步兜底就不要同步强搞。缓存本来就为了性能,过度保护反而失去意义。
4. 实操落地:Java代码示例与关键参数调优
4.1 一个可运行的Cache Aside代码骨架
说了这么多理论,上点能直接落地的代码。我用Java+Spring Boot写一个最基础的Cache Aside读改写骨架,然后在此基础上扩展其他方案。这里不追求完整代码库,只把核心链路讲透。
@Service public class ProductService { @Autowired private StringRedisTemplate redisTemplate; @Autowired private JdbcTemplate jdbcTemplate; private static final String KEY_PREFIX = "product:"; // 读流程:先缓存,后DB,再回填 public Product getProduct(Long id) { String key = KEY_PREFIX + id; String json = redisTemplate.opsForValue().get(key); if (json != null) { return JSON.parseObject(json, Product.class); } // 未命中,查数据库 Product product = queryFromDB(id); if (product != null) { // 回填缓存,设置过期时间,比如10分钟 redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 10, TimeUnit.MINUTES); } return product; } // 写流程:先更新DB,再删除缓存 public void updateProduct(Product product) { jdbcTemplate.update("update product set name=? where id=?", product.getName(), product.getId()); String key = KEY_PREFIX + product.getId(); redisTemplate.delete(key); // 删除缓存 // 关键:删除失败要重试并告警,不能静默忽略 } }这里有个容易被忽略的点:回填缓存的时候一定要设置合理的过期时间,不要让key永久存在。一旦永久存在,任何一次删除失败都意味着脏数据永续存在,后面所有补偿机制都起不了作用。而设置了过期时间之后,即使最坏情况发生,缓存也能在过期后自愈。所以过期时间是保障数据一致性的最后一道防线,绝对不是可有可无的参数。另一方面,删除缓存的时候,如果删除失败,需要立刻进入重试逻辑,最简单的做法是用Spring的Retryable注解,或者把删除失败的key记录到一个本地队列里,由后台线程轮询重试。
4.2 延迟双删和分布式锁的代码实现
再来看延迟双删和分布式锁的代码样子。延迟双删不复杂,就是多一行sleep和一次delete,但要注意第二删必须异步,否则会阻塞写请求。示例代码如下:
public void updateProductWithDelayDelete(Product product) { // 第一次删除 redisTemplate.delete(KEY_PREFIX + product.getId()); // 更新DB jdbcTemplate.update("update product set name=? where id=?", product.getName(), product.getId()); // 延迟第二次删除,用线程池异步执行 asyncExecutor.execute(() -> { try { Thread.sleep(300); redisTemplate.delete(KEY_PREFIX + product.getId()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); }分布式锁部分用Redisson实现,加锁和释放锁的姿势如下。这里一定注意,锁的释放要放在finally里,否则异常时锁会一直持有到超时。同时要用Redisson的看门狗机制,让锁在业务执行期间自动续期,避免业务耗时超过锁超时时间导致锁提前释放,其他线程乘虚而入。代码是简化的,重点是展示思路:
public void updateProductWithLock(Product product) { RLock lock = redissonClient.getLock("product:lock:" + product.getId()); boolean locked = false; try { locked = lock.tryLock(2, TimeUnit.SECONDS); if (!locked) { throw new RuntimeException("获取锁超时"); } jdbcTemplate.update("update product set name=? where id=?", product.getName(), product.getId()); redisTemplate.delete(KEY_PREFIX + product.getId()); } finally { if (locked && lock.isHeldByCurrentThread()) { lock.unlock(); } } }锁超时时间(例子里的2秒)要根据业务实际耗时设置。太短,稍慢一点的DB操作就会导致锁提前失效;太长,锁竞争期间其他线程会长时间等待。可以用压测来确定P99等待时间,然后往上加一点余量。这里也顺带提一下Redisson的看门狗:默认情况下,Redisson会给锁自动续期到30秒,业务没执行完锁不会超时释放,非常实用,但前提是你设置的leaseTime是-1(即不手动指定锁超时时间)。如果手动指定了leaseTime,看门狗机制就不会生效。很多人踩过这个坑,锁超时时间设了10秒,结果业务跑了20秒,锁早就没了。
4.3 过期时间与补偿任务:最后的兜底
缓存过期时间和补偿任务,是我一再强调的兜底手段。过期时间的选择要结合实际业务数据的变化频率。对库存类这种变化非常频繁的数据,建议设置2到5分钟甚至更短;对配置类数据,可以设置1小时以上。但还要注意一个反直觉的现象:过期时间过长,数据不一致的窗口也会变长,这对用户来说很难忍受;过期时间过短,缓存命中率下降,数据库压力就上来了。可以采取一个折中策略:回填缓存时设置一个相对合理的TTL,同时依赖补偿任务来提前刷新,而不是单纯依赖过期来纠正。
补偿任务指的是:定期对热点key做全量或者增量的一致性校验,或者在高频写操作完成后的若干秒内,比对DB和Redis中的值,发现不一致就强制覆盖缓存。具体做法可以是写一个定时任务,扫描业务表中更新时间在最近N分钟内的记录,把对应的Redis key全部删除,让它们下一次读取时重新从DB回填。这种方式简单有效,能兜底绝大多数漏删和回填覆盖问题。我上一个项目里就是这么做的:延迟双删+定时全量清缓存,效果很明显,脏数据投诉大幅减少。
5. 常见问题与排查技巧实录
5.1 击穿、穿透、雪崩和一致性有什么关系
很多同学容易把缓存击穿、穿透、雪崩和数据一致性混为一谈,其实它们不是一回事。击穿是一个热点key在失效瞬间,大量请求同时打向DB,导致DB压力骤增。穿透是查询一个不存在的key,缓存里永远没有,请求直接打到DB。雪崩是大批量key在同一时间失效,导致DB瞬间承受巨大流量。这三个问题主要围绕的是“缓存失效带来的性能风险”,而数据一致性围绕的是“缓存和DB内容不一致”。
不过它们之间有联动:如果一个热点key因为缓存删除(一致性方案里的删除动作)而短暂失效,很有可能触发击穿问题。所以当你通过“先删缓存再更新DB”的方式做一致性时,要格外小心热点key:删掉缓存后,DB更新完成前这段时间,所有读请求都会涌向DB。我的建议是,对热点key使用逻辑过期或互斥回填策略,比如在回填缓存时加分布式锁,只有一个线程查库并回填,其余线程短暂等待后重试读缓存。这样既保证缓存不会反复失效,又能保护DB。
5.2 线上脏数据到底怎么定位
上线了各种方案之后,还是会遇到“缓存里的值感觉不对”的反馈。这时候怎么定位?我的排查顺序是:第一步,确认Redis里的真实值,用redis-cli查一下key,看当前缓存的是什么;第二步,对比DB里的值,确认DB是否已经变更;第三步,看缓存值的时间特征,比如Redis里还可以通过DEBUG OBJECT命令查看key的空闲时间,粗略判断这个key是什么时候被写入的,跟业务操作时间做对照;第四步,查应用日志,看看最近有没有写请求、删缓存请求、以及删除是否成功。如果发现删缓存失败但没有重试日志,基本就能定位到问题源头。
如果问题不是个例而是批量出现的,那就要考虑缓存key的生成规则是否被变更了,比如加了新参数但缓存key没包含它;或者序列化方式改了,导致旧的缓存值反序列化失败。还有一个高频坑:团队里有人为了临时排查问题,直接在Redis里改了值,或者手动加了key,导致后续缓存刷新逻辑被覆盖。对于这种问题,除了规范操作,还要在Redis前面加一层key治理能力,比如对核心数据key做前缀约定、设置生产环境命令审计。
5.3 避坑清单:这些细节决定了方案成败
根据我踩过的坑,整理了一份避坑清单,希望对你有实际帮助。第一条,删除缓存必须设置失败重试和告警,最忌讳的就是写一个delete操作然后不管结果。很多一致性问题,本质上不是方案不对,而是删除动作静默失败了,还没人知道。第二条,不要给缓存key设置随机过期时间,除非你想模拟雪崩。第三条,缓存对象要注意序列化兼容性,尤其是字段增删时,建议用兼容性好的序列化方式,或在反序列化时加版本控制。第四条,分布式锁要注意锁粒度、超时时间和可重入问题,锁的释放一定要放在finally里。第五条,监控不可少。至少要知道核心key的写次数、删除次数、删除失败次数、回填次数,有了这些指标,才能提前发现一致性问题而不是等问题爆发。
6. 最后分享一个实践技巧:消息队列兜底删除
如果你不想引入Canal这类重组件,又想弥补“应用宕机导致缓存没删”的风险,我还有一个轻量级的技巧:在更新DB之后发送一条MQ消息,消费端收到消息后去执行Redis删除。逻辑上等同于异步重试,但比同步重试更可靠,因为MQ自带重试机制和持久化,能扛住应用进程崩溃。代码写起来也不复杂,写过MQ消费程序的团队很快就能上手。这个方案是Cache Aside+MQ删除,实现成本和维护成本都比较低,适合大部分中小业务。
实际项目中,我通常会把Cache Aside作为主流程,MQ异步删除作为兜底,再配合缓存的过期时间形成三道防线。等业务量和一致性要求进一步提升,才会考虑引入Binlog订阅机制。数据一致性本来就不是一道简单的数学题,而是工程权衡的艺术。根据业务容量、成本预算和运维能力,选择适合自己的那套组合,比盲目追求最强方案要明智得多。这也是我今天最想跟你分享的一点。