Redis缓存与数据库双写一致是后端系统设计中的经典问题。当同一份业务数据既存储在MySQL等关系型数据库中,又以副本形式放在Redis里,读取性能可以得到显著提升,但写入路径却从一个变成了两个。数据库更新成功后,如果缓存没有及时删除或删除失败,后续请求就会继续读到旧数据;如果删除缓存的时机不对,并发请求又可能把旧值重新写回缓存。这个问题的本质不是Redis或MySQL自身有什么缺陷,而是两个独立存储系统之间不存在天然的事务边界。

要理解双写一致,首先要接受一个现实:在分布式缓存场景下,绝对的强一致通常需要付出极高的性能代价,甚至很难实现。大多数业务系统追求的是最终一致,即允许短暂的不一致窗口,但最终缓存和数据库会趋向相同的状态。缓存过期时间、删除策略、重试机制以及并发读写时序,共同决定了一致性窗口的长短。下面从几个关键角度拆解这个问题。
一、问题从何而来:两份数据与两条写入路径
在没有缓存的单体数据库中,数据只有一份,事务可以保证写入的原子性和隔离性。引入Redis之后,数据出现了两个副本:数据库是持久化的事实来源,缓存是加速读取的临时副本。写入操作如果同时更新数据库和缓存,就会面临两个操作无法被同一个本地事务包裹的问题。数据库提交成功,缓存更新失败,或者缓存更新成功,数据库提交失败,都会造成两份数据不一致。
更隐蔽的问题是并发时序。假设两个写请求同时修改同一个商品库存,请求A把库存从10改成9,请求B把库存从9改成8。如果两个请求都先更新数据库再更新缓存,但执行顺序交错,最终缓存里可能写入9,而数据库已经是8。即使每个操作单独看都成功,最终结果仍然错误。因此,直接同时更新缓存和数据库并不是一个可靠的方案,反而会放大并发写入的竞态风险。
从一致性要求来看,缓存场景通常不追求数据库级别的强一致。原因在于缓存存在的目的是为了减少数据库访问、提升吞吐量,如果每一次读写都加锁或引入分布式事务,性能优势会迅速消失。工程上普遍采用的策略是:保证数据库永远是准确数据源,缓存的旧值通过删除、过期或异步刷新逐步修正。理解这一点,是选择具体方案的前提。
二、Cache Aside模式:先更新数据库,再删缓存
Cache Aside是目前使用最广泛的缓存读写模式。读请求先访问缓存,如果命中则直接返回;如果未命中,则从数据库读取数据,并回填到缓存中。写请求的核心逻辑并不是更新缓存,而是先更新数据库,再删除缓存。删除缓存比更新缓存更安全,因为缓存原本就是数据库的副本,删除后下一次读取自然会从数据库取到最新值并重建缓存。
为什么推荐先更新数据库、后删除缓存,而不是反过来?先删除缓存再更新数据库存在一个典型问题:写请求删除缓存后,还没完成数据库更新,此时并发读请求发现缓存未命中,就会从数据库读取旧值,并把旧值回填到缓存中。等写请求更新完数据库,缓存里已经躺着旧数据,只有等到下一次缓存过期或下一次删除才会恢复。这个窗口虽然短暂,但在高并发读场景下很容易被触发。
先更新数据库再删除缓存,则可以把旧值回填的概率降到更低。更新数据库成功后,缓存仍然是旧值,此时并发读请求会读到旧缓存,这属于正常情况。删除缓存后,后续读请求会从数据库读取新值并重建缓存。只有当读请求恰好在数据库更新完成之后、缓存删除之前经过,并且缓存刚好失效时,才可能从数据库读到新值并写回缓存,随后写请求删除缓存,导致缓存缺失,但这种结果也只是缓存未命中,不会造成长期脏数据。
下面是一个典型的Cache Aside写操作示例:
public void updateOrderStatus(Long orderId, Integer status) {
// 第一步:更新数据库
orderDao.updateStatus(orderId, status);
// 第二步:删除缓存,让旧缓存立即失效
redisTemplate.delete("order:status:" + orderId);
}这段代码看起来简单,但它没有处理删除失败的情况。如果第二步删除缓存时Redis发生网络抖动或进程重启,缓存中的旧值会一直存在,直到过期时间到达。对于一些过期时间设置较长的缓存,这意味着用户可能在较长时间内读到旧状态。因此,Cache Aside模式虽然解决了大部分并发时序问题,但还需要配套的失败补偿机制来兜底。
三、删除失败、并发回填与延迟双删
删除缓存失败是双写一致中最常见的工程问题。数据库已经更新成功,但缓存删除请求超时或返回异常,如果直接忽略,缓存就会长期保留旧值。解决思路是引入异步重试。可以在删除失败后把缓存key写入消息队列,由消费者重新执行删除操作;也可以使用本地重试表或调度任务定时扫描。只要最终能删除成功,缓存就会在下次读取时从数据库重建,一致性得以恢复。
延迟双删是另一种针对并发回填的补偿方案。它的执行顺序是:先删除缓存,再更新数据库,延迟一段时间后再次删除缓存。第一次删除的目的是让旧缓存立即失效,第二次延迟删除是为了覆盖更新数据库期间可能发生的并发读回填旧值。延迟时间通常设置为略大于业务读数据库并回填缓存的耗时,例如200毫秒到1秒。延迟双删的缺点也很明显:延迟时间难以精确设定,第二次删除也可能失败,而且如果使用线程睡眠实现会阻塞请求线程,实际项目中应交给延迟队列或定时任务执行。
public void updateWithDelayDoubleDelete(Long orderId, Integer status, long delayMillis) {
redisTemplate.delete("order:status:" + orderId);
orderDao.updateStatus(orderId, status);
delayExecutor.schedule(() -> {
redisTemplate.delete("order:status:" + orderId);
}, delayMillis, TimeUnit.MILLISECONDS);
}比延迟双删更可靠的方案是订阅数据库binlog。通过Canal或Debezium等工具监听MySQL的变更日志,当数据库中的数据发生变化时,由消费方异步删除或更新对应的Redis缓存。这样做的好处是删除动作不再依赖应用代码是否记得调用,数据库只要成功写入,binlog就一定会产生事件。即使应用在删除缓存时异常退出,binlog消费者仍然可以根据事件继续清理缓存。这种方案将缓存一致性从应用层手动维护,演变为基于数据变更日志的自动协同,适合对一致性要求较高的业务。
四、读写分离下的主从延迟与一致性边界
在读写分离的数据库架构中,主库负责写入,从库负责读取。写请求更新主库后,从库可能需要几毫秒到几百毫秒才能同步完成。如果应用在更新主库后立即删除缓存,读请求可能打到从库,而这时从库还没有拿到最新数据,读到的仍然是旧值,并把旧值回填到缓存中。这个场景和先更新数据库再删缓存并不矛盾,因为这里的问题来自主从复制延迟,而不是应用写入顺序。
针对主从延迟,短期的做法是增加延迟删除的时间,让删除动作发生在从库完成同步之后。更严格的做法是让关键读请求强制走主库,或者根据binlog位点判断从库是否已经追平,再执行缓存删除。不过这些方案会显著增加复杂度,通常只在库存、余额、订单状态等对一致性敏感的场景中使用。对于一般业务,设置一个合理的缓存过期时间已经能够控制不一致窗口。
如果业务真的需要强一致,可以引入分布式锁或读写锁,让同一资源的读和写在缓存层串行化。例如使用Redisson的读写锁,写操作持有写锁期间,读操作阻塞,直到缓存删除完成后再读取。这样可以从逻辑上消除并发旧值回填,代价是吞吐量下降、锁超时和故障恢复需要额外处理。因此,分布式锁仅适合少量热点数据,并不适合大面积使用。
综合来看,Redis缓存与数据库双写一致并没有一个放之四海皆准的完美方案。最实用的组合是:写操作先更新数据库,再删除缓存;删除失败时通过消息队列或binlog订阅进行异步补偿;给缓存设置合理的过期时间作为最终兜底;在并发冲突较高的场景引入延迟双删或分布式锁。清楚每种方案能解决哪些问题、不能解决哪些问题,才能在设计缓存更新策略时做出符合业务需求的取舍。