缓存与数据库一致性实战:从Cache Aside到延迟双删与最终一致
2026/9/15 10:29:19 网站建设 项目流程

干后端这些年,几乎每个团队都会撞上同一类诡异故障:用户明明支付成功,订单列表却还挂着“待支付”;后台编辑了商品信息,前端怎么刷新都是旧数据;用户改了登录密码,第二天居然还能用旧密码登进系统。排查到最后,大部分问题的根因都指向同一处——缓存和数据库的更新顺序出了问题。缓存、数据库、更新顺序、不一致,这四个词凑到一起,就是一套经典的“线上事故组合拳”。

这篇内容我会把这个坑拆开揉碎讲清楚:先分析为什么会不一致,再给出一套从Cache Aside到延迟双删再到消息队列最终一致性的完整解法,最后补上线上排查的经验。适合正在做后端开发、系统架构,或者被缓存一致性折磨过的同学参考,看完可以直接拿去用。

1. 问题根源:缓存和数据库为什么会“打架”

1.1 两类存储的定位差异:临时笔记本与最终账本

要理解缓存和数据库不一致,得先认清这两类组件的本质差异。以最常见的Redis和MySQL为例:Redis是内存型数据库,读写速度快到毫秒级甚至微秒级,但数据存在内存里,掉电就可能丢;MySQL这类关系型数据库,数据落在磁盘上,有完整的事务保障和持久化机制,但读写速度比Redis慢一个数量级以上。

所以大家通常把Redis当成“临时笔记本”,把MySQL当成“最终账本”。业务查询先翻临时笔记本,翻不到再去账本里查,查完顺手抄一份到笔记本上。这个模型天然就有一个问题:笔记本上的内容和账本上的内容,本质上是两份数据,只要存在副本,就一定存在同步延迟和同步失败的窗口。

很多人一开始写缓存代码时想法很简单:数据库更新了,顺手把缓存也更新一下不就行了?这个“顺手”,恰恰就是一连串事故的开端。因为更新操作一旦涉及两个存储系统,就不再是“一次写操作”,而是“两次写操作”,只要两次写之间有并发请求穿插进来,时序就会被搅乱。

1.2 两条更新路线,两种踩坑方式

先更新数据库,再更新缓存。这是新手最容易采用的方式,也是写写冲突的重灾区。假设有两个线程同时修改同一条数据:线程A想把库存从100改成80,线程B想把库存从100改成70。如果A先更新数据库到80,B再更新数据库到70,最后数据库里的值是70。但缓存这一侧,由于线程调度和网络延迟的不确定性,完全可能出现B先把缓存更新成70,A再把缓存更新成80的结果。最终数据库是70,缓存却是80,两边就对不上了。

先更新缓存,再更新数据库。这个方案更危险。缓存更新成功后,数据库更新却因为事务回滚、网络故障等原因失败了,那缓存里的“新值”就彻底成了无源之水,每次读都会被这个假数据误导。这种方案基本属于自掘坟墓,正经业务里不建议使用。

你可能会说:那我让两个线程串行不就行了?理论上可以,但为了实现“串行”付出的代价——分布式锁、串行化队列——往往会成为新的性能瓶颈。这也是缓存一致性问题的核心矛盾:强一致性和高性能天生就是冲突的。

1.3 并发时机才是真正的元凶

实际上,比起“写写冲突”,线上更常见的脏数据场景是由“读写交错”造成的。我画个经典的时间线你就明白了:

  • 时间点1:读请求到达,发现缓存中没有数据,也就是缓存Miss。
  • 时间点2:读请求去数据库查,查到一条旧值,比如库存100。
  • 时间点3:写请求到达,把数据库更新到了80。
  • 时间点4:读请求拿着查到的旧值100,回写到了缓存。

从结果看,数据库是80,缓存里躺着的却是100。后面所有读请求都会命中这个旧值,直到缓存过期或再次被删除。这个场景之所以隐蔽,是因为整个过程里没有任何一步写缓存是错乱的,每一步看起来都在按顺序执行,但偏偏就产出了不一致的结果。

关键点在于:读请求的“读数据库”和“写缓存”这两个动作之间,被写请求的“更新数据库”插了一脚。这就是为什么单纯调整“更新顺序”并不能彻底解决问题,因为在并发环境下,读者和写者之间永远存在这种可乘之机。

2. 缓存更新的黄金方案:Cache Aside模式

2.1 Cache Aside 的核心思想:数据库为主,缓存为辅

在大量实践之后,业界沉淀出一套相对靠谱的缓存读写模式,叫作Cache Aside Pattern,翻译过来就是“旁路缓存”。它的核心思想是:数据库才是唯一的事实来源,缓存只是旁边的一个加速层,一切以数据库为准。

读操作流程:

  1. 先读缓存。
  2. 缓存命中,直接返回。
  3. 缓存未命中,读数据库。
  4. 把查询结果写入缓存。
  5. 返回结果。

写操作流程:

  1. 更新数据库。
  2. 删除缓存。

注意最后一步,是删除缓存,不是更新缓存。这是整个模式里最关键的决定,我后面会单独解释为什么。这套模式之所以叫“黄金方案”,是因为它在绝大多数业务场景下,能以最小的代价把不一致的概率压到最低。实现简单、理解成本低、不依赖额外组件,新项目直接照抄都不会出大乱子。

2.2 写操作的正确姿势:先更新库,再删缓存

写操作先更新数据库,再删除缓存,这一点很多人第一次听到会觉得反直觉:为什么不更新缓存呢?我们来分析一下这个顺序的合理性。先更新数据库的好处是,数据库作为唯一数据源,无论如何它的值都是最新的。缓存被删除后,下一次读请求会发现缓存Miss,于是去数据库读最新值,再回填缓存,一切恢复正常。

用代码来表示,最基本的写法是这样的:

public void updateOrderStatus(Long orderId, Integer status) { // 1. 更新数据库,这一步失败则整个操作失败,不会动缓存 orderDao.updateStatus(orderId, status); // 2. 删除缓存,让后续读请求回填最新值 String cacheKey = "order:detail:" + orderId; redisTemplate.delete(cacheKey); }

这里有个容易被忽略的细节:更新数据库和删除缓存这两个动作之间,理论上仍然有极短的时间窗口,期间读请求可能读到旧缓存。但相比“更新缓存”的方案,这个窗口已经被压缩得非常小,而且缓存值仍然是“旧值”,不会出现“新值被旧值覆盖”这种更棘手的场景。

另外一个细节是:如果删除缓存这一步失败怎么办?我的建议是,在业务代码里把Redis操作包一层捕获异常,但不要因为缓存删除失败就让数据库事务回滚。数据库是主,缓存是辅,不能因为次要组件让核心数据更新失败。要解决删除失败的问题,应该靠重试机制,这个我在后面会展开讲。

2.3 为什么是“删缓存”而不是“更新缓存”

很多人不理解:删掉再重新查一次,多费一遍事啊,直接把新值更新到缓存里不是更高效吗?这个问题我踩过坑,所以想认真说明白。

更新缓存这个操作存在两个问题。第一,写缓存需要额外的数据转换或计算。如果缓存里存的不是数据库原始字段,而是聚合后的JSON、列表、统计值,那每次更新数据库都需要重新计算整个缓存结构,成本会成倍上升。第二,更新缓存无法区分“这次写入是不是最新版本”。并发环境下,两个线程同时更新缓存,后写的线程不一定对应数据库的最新值,写缓存完全没有顺序保障。而删除缓存是一个非常轻量级的操作,删错了大不了下次读请求再回填一次,天然免疫了“新旧覆盖”的问题。

我在实际项目里还遇到过一种情况:团队里有人把“更新缓存”和“删除缓存”混在一起用,同一个数据有的接口更新、有的接口删除,结果缓存里的数据一会儿是结构A,一会儿是结构B,下游解析直接报错。所以我的建议是,一个数据项统一只采用一种写缓存策略,要么全部用删除,要么全部用更新,千万别混着来。

3. 进阶补全:延迟双删与最终一致性

3.1 延迟双删的原理和适用边界

Cache Aside已经能解决90%的问题,但开头描述的那个“读请求旧值回写缓存”的极端场景还在。延迟双删就是专门针对这个场景设计的补丁。思路很朴素:第一次删除缓存后,等一小段时间,确保所有可能回写旧值的读请求都完成了,再删一次缓存,把可能重新出现的旧值清掉。

完整的延迟双删流程:

  1. 更新数据库。
  2. 删除缓存(第一次删除)。
  3. 等待一段时间,比如500毫秒。
  4. 再次删除缓存(第二次删除)。

对应到代码:

public void updateOrderStatusWithDelayDelete(Long orderId, Integer status) { orderDao.updateStatus(orderId, status); String cacheKey = "order:detail:" + orderId; // 第一次删除 redisTemplate.delete(cacheKey); // 延迟第二次删除 scheduledExecutorService.schedule(() -> { redisTemplate.delete(cacheKey); }, 500, TimeUnit.MILLISECONDS); }

需要注意的是,延迟双删并不是一个“银弹”,它有明显的适用边界。它解决的是“读请求在缓存删除之前miss、之后把旧值写回”的竞态问题。如果并发量极高、写请求极频繁,双删之间的窗口内可能又产生了新的脏数据,那就需要继续权衡。从实践看,绝大多数中低并发业务,用延迟双删已经足够了。

另外提醒一句:第二次删除的间隔不能太短,否则起不到效果;也不能太长,否则在窗口期内缓存一直处于“被删除状态”,流量可能全部打到数据库,造成缓存穿透。这个时间的设置我下面单独讲。

3.2 延迟时间到底怎么定

延迟双删里最让人纠结的就是sleep的时间。设短了,读请求还没把旧值写完,第二次删除就执行了,白删;设长了,缓存长期空窗,数据库压力陡增。

我的经验是:延迟时间必须大于一次读请求“从缓存Miss到回写缓存”所经历的最长时间。这个时间可以这样估算:测量线上读接口的TP99耗时,也就是99%的请求都落在多少毫秒内,再预留50%到100%的余量。假设读接口TP99是200毫秒,那延迟时间取400到500毫秒比较稳妥。

如果不想用sleep这么粗暴的方式,可以用延迟消息队列、定时任务或者Redis的过期回调来替代,把“定时删一次”的逻辑和业务线程解耦。但说实话,绝大多数业务场景下,一个全局的延迟调度线程池就够用了,不需要为了双删专门引入一套新中间件。

还有一点要强调:延迟双删只能“尽可能减少”脏数据的存活时间,不能做到绝对没有脏数据。所以,给所有缓存设置一个合理的过期时间,是最后一道保命符,绝不能省略。

3.3 更可靠的最终一致性:消息队列与重试机制

延迟双删解决不了另一个问题:第一步删除就失败了怎么办?如果缓存删除操作一直失败,缓存里就会长期保留旧值。我的做法是引入消息队列,把“删除缓存”这个动作变成一个可重试的异步任务。

流程大致是:

  1. 业务接口里先更新数据库,更新成功后发送一条“删除指定缓存Key”的消息到MQ。
  2. 消费者收到消息,执行缓存删除。
  3. 如果删除失败,消费者返回重试状态,MQ会按照预设策略重复投递。
  4. 超过最大重试次数后,转入死信队列,由人工或定时任务兜底处理。

伪代码示例:

// 发送端:业务更新后发送消息 public void updateOrderStatusWithMq(Long orderId, Integer status) { orderDao.updateStatus(orderId, status); mqTemplate.send("cache-del-topic", new CacheDeleteEvent("order:detail:" + orderId)); } // 消费端:执行缓存删除,失败则重试 @RabbitListener(queues = "cache-del-queue") public void onCacheDelete(CacheDeleteEvent event) { try { redisTemplate.delete(event.getCacheKey()); } catch (Exception e) { // 抛出异常,让MQ重试 throw new RuntimeException("delete cache failed", e); } }

这套方案的核心优势有两个:一是业务接口的响应时间不再被Redis操作拖累;二是删除操作拥有了重试能力,即使Redis瞬间抖动,消息还可以在队列里等待,等Redis恢复后再删。在实际生产环境中,我还见过更进一步的方案——订阅数据库的Binlog变更,比如用Canal监听MySQL的binlog,一旦订单表发生更新,自动触发对应缓存的删除。这属于“CDC(Change Data Capture)”思路,对业务代码侵入为零,但运维成本也更高。一般业务规模没到那个程度,用MQ方案就足够了。

4. 实操案例:一个订单状态同步的全过程

4.1 需求与方案选型

用一个我最近在做的电商订单场景来完整演示一遍方案选型。需求是这样:用户在商城下单支付后,订单状态从“待支付”变成“已支付”,订单列表页和订单详情页需要实时展示最新状态。首页和列表页的请求量非常大,一旦支付成功,用户会立即刷新页面,如果没有缓存,数据库会被读流量打爆;但缓存更新得太激进,又可能出现没支付的旧状态被读到。

这个场景的约束条件有三点:一是读多写少,订单状态变化频率不高,但查询频率很高;二是对一致性要求偏高,用户支付成功后不能看到“待支付”状态太长时间;三是系统流量集中在整点秒杀等场景,瞬时并发大。

综合评估后,我选择了最朴素的方案组合:Cache Aside模式作为主链路,加一次延迟删除兜底,再加上缓存过期时间兜底。没有直接上MQ,原因是订单状态写入频率不高,用MQ会引入额外的组件维护成本,属于过度设计。

4.2 核心代码落地

订单状态更新的核心代码,我拆成三个部分来看。

第一部分是订单服务里的更新逻辑,采用Cache Aside加延迟双删:

@Service public class OrderService { private static final String ORDER_CACHE_PREFIX = "order:detail:"; @Autowired private OrderDao orderDao; @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private ScheduledExecutorService cacheExecutor; @Transactional public void markOrderPaid(Long orderId, Integer newStatus) { // 1. 更新数据库 orderDao.updateStatus(orderId, newStatus); // 2. 第一次删除缓存 String cacheKey = ORDER_CACHE_PREFIX + orderId; redisTemplate.delete(cacheKey); // 3. 延迟第二次删除,防止读请求旧值回写 cacheExecutor.schedule(() -> redisTemplate.delete(cacheKey), 500, TimeUnit.MILLISECONDS); } }

第二部分是查询逻辑,走典型的Cache Aside读路径:

public OrderVO getOrderDetail(Long orderId) { String cacheKey = ORDER_CACHE_PREFIX + orderId; // 1. 查缓存 Object cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return (OrderVO) cached; } // 2. 缓存未命中,查数据库 OrderVO orderVO = orderDao.selectDetail(orderId); if (orderVO == null) { return null; } // 3. 回填缓存,并设置过期时间,兜底 redisTemplate.opsForValue().set(cacheKey, orderVO, 300, TimeUnit.SECONDS); return orderVO; }

第三个细节是缓存过期时间的设置。我这里给了5分钟,主要考虑是:支付状态这种数据,5分钟的极端延迟在业务上可以接受,同时如果真发生缓存和数据库不一致,5分钟后也会自动恢复。过期时间不要太长,以前我见过有人为了“提高命中率”把过期时间设成24小时,结果线上出了脏数据,用户硬生生看了一整天的旧状态,非常影响口碑。

4.3 验证方式与压测结果

代码写完怎么证明它有效?我一般是三层验证。

第一层,单元测试。模拟一个读线程在读到一个旧值后,模拟写线程更新数据库并执行双删,再模拟读线程回写旧值,最后断言缓存里的值是否已经被清理。这个测试能用很低的成本暴露出双删逻辑是否生效。

第二层,本地并发模拟。用线程池起大约50个读线程和10个写线程,同时操作同一条订单数据,跑一段时间后,定期扫描缓存值和数据库值并做比对。多跑几轮,观察是否有不一致的记录出现。

第三层,压测环境验证。用压测工具把QPS打到日常峰值的1.5倍,观察接口耗时和错误率。注意,延迟双删里那500毫秒的删除动作如果同步执行,会占用业务线程,所以一定要放到独立的调度线程池里,不要阻塞主链路。

我自己实操下来,这套组合在订单场景里的表现是:常规压测下接口写耗时没有明显上升,读缓存命中率维持在95%以上,长时间对账没有发现缓存与数据库不一致的记录。

5. 常见问题与排查技巧实录

5.1 线上排查三板斧

即使做了各种预防,线上偶尔还是会有不一致问题,这是常态。遇到问题别慌,我建议按照三个步骤排查。

第一板斧:看缓存有没有过期时间。很多缓存不一致是“缓存永不过期”造成的,解决办法就是给所有缓存设置合理的过期时间,这是成本最低的保底手段。

第二板斧:看写接口的代码顺序。进代码里确认写操作是不是“先更新库、再删缓存”。经常有人改了业务逻辑后在事务提交前删缓存,导致本次删除只删了旧缓存,事务一旦回滚,新缓存没生成,旧值反而被重新回填空窗,越删越乱。

第三板斧:拉日志看时间线。把读请求的“缓存Miss日志”和写请求的“更新数据库日志”放在一起看,定位是否存在“读请求读库在旧值时刻、写回缓存在新值时刻之后”的交错窗口。只要找到这种窗口,就能确认是不是经典的读写交错问题。

5.2 高频踩坑清单

我把这几年的踩坑经历整理成一张速查表,方便你直接对照排查。

问题场景典型原因参考解法
改完数据页面永远不变缓存没设过期时间,旧值长期存活统一为缓存设置过期时间
偶发脏数据,过一会儿自愈读请求旧值回写缓存延迟双删或MQ异步兜底
删除缓存后数据库被压垮双删间隔过长,或缓存穿透设置合理的双删间隔,加布隆过滤器
缓存删除失败后无感知Redis网络抖动或故障引入消息队列重试,或失败日志告警
同一数据既有更新又有删除多个接口策略不一致统一缓存写策略,禁止混用
事务回滚了但缓存被删了缓存删除在事务提交前执行删除操作移到事务提交后的回调里

5.3 日志打点与对账任务

排查问题除了靠应急三板斧,更可靠的办法是建立事前的监控和主动发现机制。我现在的习惯是给缓存写入和删除的关键动作打点,记录操作时间、缓存Key、操作类型,日志格式尽量统一,方便后续检索。光有日志还不够,最好再写一个定时对账任务,定期扫描一批核心数据的缓存值和数据库值,比对不一致的条目,并上报告警。这个对账任务不用跑得特别频繁,每五分钟扫描一次即可,扫描的Key范围也只需要覆盖订单、库存、用户余额这类高价值数据。

对账扫描的核心代码大概是这样:

@Component public class CacheConsistencyCheckTask { @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private OrderDao orderDao; // 每5分钟执行一次 @Scheduled(fixedDelay = 300_000) public void checkOrderCache() { List<Long> activeOrderIds = orderDao.findActiveOrderIds(); for (Long orderId : activeOrderIds) { String cacheKey = "order:detail:" + orderId; Object cachedOrder = redisTemplate.opsForValue().get(cacheKey); if (cachedOrder == null) { continue; } OrderVO dbOrder = orderDao.selectDetail(orderId); if (!cachedOrder.equals(dbOrder)) { log.warn("cache inconsistency detected, key={}", cacheKey); // 触发删除并发送告警 redisTemplate.delete(cacheKey); } } } }

这个思路本质上就是“兜底兜到最后一层”:即使前面的双删、MQ重试都失效了,定时对账也能把脏数据揪出来清掉。数据一致性从来不是靠某一个方案单独保证的,而是由“缓存过期兜底 + 双删 + 重试 + 对账”这一整套防线共同支撑起来的。

最后再分享一个我个人的经验:讨论缓存和数据库一致性,不要一开始就追求百分百强一致,而是先给业务划分一个“可接受的不一致窗口”。有的数据容忍5秒,有的数据容忍5分钟,这个时间直接决定了你该用双删、MQ还是对账任务。把需求定清楚了,技术选型自然就清晰了。很多时候团队之所以在这个问题上反复出事故,不是因为缺少方案,而是因为所有人都默认自己处理的是一道“非黑即白”的题,忽略了业务本身才是第一约束。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询