
在电商、社交、内容平台等高频读写场景中缓存与数据库双写不一致几乎是每个后端工程师都会踩的坑。典型症状是数据库里商品价格已经改成 99 元缓存里却还是 199 元用户下单时看到旧价格引发投诉。要解决这个问题单纯依靠“先删缓存再更新数据库”或“先更新数据库再删缓存”都力不从心。今天要聊的“延迟双删 消息队列”组合拳是实践中被验证过的有效方案。为什么双写会不一致先看一个经典并发场景。线程 A 更新数据库线程 B 读取缓存。如果采用“先更新数据库再删除缓存”的策略线程 A 将数据库价格更新为 99 元。线程 B 读缓存发现缓存未命中。线程 B 读数据库此时 A 的事务尚未提交B 读到旧值 199 元。线程 A 提交事务删除缓存。线程 B 将旧值 199 元写入缓存。结果就是数据库是 99 元缓存是 199 元不一致就此产生。如果采用“先删缓存再更新数据库”同样存在并发窗口删完缓存后读请求把旧数据加载进缓存随后数据库才更新缓存同样变脏。延迟双删给缓存二次清理的机会延迟双删的核心思路是在更新数据库后先删除一次缓存然后延迟一段时间例如 500ms再删除一次缓存。第一次删除是为了让后续读请求尽量从数据库加载新数据第二次删除则是为了清除在“数据库更新完成前”被其他线程意外写入的旧缓存。伪代码大致如下java复制下载public void updateProduct(Product product) { // 1. 更新数据库 productMapper.update(product); // 2. 第一次删除缓存 redis.delete(product: product.getId()); // 3. 延迟一段时间后再次删除 scheduledExecutor.schedule(() - { redis.delete(product: product.getId()); }, 500, TimeUnit.MILLISECONDS); }延迟时间需要根据业务读操作的耗时来估算通常设置为“读请求从数据库加载数据并回写缓存”的最大耗时。但延迟双删有两个明显短板一是延迟时间难以精准设定二是第二次删除可能失败一旦失败脏数据仍然会残留。消息队列让第二次删除可靠且可重试引入消息队列后延迟双删的可靠性大幅提升。具体做法是更新数据库后向 MQ 发送一条延迟消息消息内容为“删除某个缓存 key”。消费者收到消息后执行删除如果删除失败可以利用 MQ 的 ACK 机制进行重试直到成功或达到最大重试次数。以 RocketMQ 为例它原生支持延迟消息java复制下载// 更新数据库后发送延迟消息 public void updateProduct(Product product) { productMapper.update(product); Message message new Message( cache-delete-topic, (product: product.getId()).getBytes() ); // 延迟 500ms 投递 message.setDelayTimeLevel(1); rocketMQTemplate.send(message); } // 消费者 RocketMQMessageListener(topic cache-delete-topic, consumerGroup cache-group) public class CacheDeleteConsumer implements RocketMQListenerString { Override public void onMessage(String cacheKey) { redis.delete(cacheKey); // 如果删除抛出异常MQ 会自动重试 } }这样第一次删除可以立即执行也可由业务代码直接删除第二次删除则交给 MQ 延迟投递并保证可靠性。如果担心消息发送失败还可以引入本地消息表或事务消息确保“数据库更新”与“消息发送”的原子性。更进一步如果不想在业务代码里侵入消息发送逻辑可以借助 Canal 订阅 MySQL binlog将数据变更事件投递到 MQ再由消费者统一删除缓存。这种方式对业务代码零侵入但架构复杂度更高。注意事项与边界延迟双删 消息队列能显著降低不一致窗口但它实现的是最终一致性而非强一致性。在延迟期间缓存中可能仍是旧数据因此对一致性要求极高的场景如金融交易应考虑加锁或直接读数据库。此外延迟时间要结合业务 RT 调整MQ 的重复消费需要消费者具备幂等性缓存 key 的过期时间也应作为兜底手段保留。最后任何方案都离不开监控。对“数据库与缓存不一致”的告警、MQ 堆积监控、删除失败重试次数等指标应当纳入日常运维体系。架构没有银弹延迟双删加消息队列是在一致性、性能与复杂度之间做出的合理权衡。