“小树,你的系统又被投诉数据不一致了!”
凌晨2点,运维的夺命连环call把你惊醒。
问题出在哪里
小树赶紧梳理了一下他缓存数据的流程:
- 用户请求过来之后,先查缓存有没有数据,如果有则直接返回。
- 如果缓存没数据,再继续查数据库。
- 如果数据库有数据,则将查询出来的数据,放入缓存中,然后返回该数据。
- 如果数据库也没数据,则直接返回空。
具体流程如下图:
这时公司老大哥帮忙一看,就说如果刚查完数据就放到缓存中了,这条数据又被更新了,你再进来查询,还是可以命中缓存,可是缓存里的数据已经和数据库的值不一样了。小树一听就明白了。
原来是小树没有在数据库更新时同步更新缓存,小树又说那我在更新数据库后同时更新一下缓存是不是就可以。老大哥笑着摇了摇头,可没有那么简单。
更新数据库和更新缓存是两个操作,先更新哪一个呢?咱们挨个分析一下 - 先更新缓存,再更新数据库
- 先更新数据库,再更新缓存
先更新缓存,再更新数据库
我们都知道,网络是容易出问题的,如果在更新数据库的时候,操作失败了,那么缓存中存在,数据库中不存在,缓存中的数据就是脏数据。这种问题已经很严重了,相当于MySQL 事务里提到的脏读,一般都是要尽量避免的
先更新数据库,再更新缓存
先更新数据库的话,可以保证缓存里的数据都是数据库里存在过的,就是更新缓存失败了,缓存里的数据也只是旧数据,而不是不存在于数据库里的。相关于上一个好一点。
再有一个,一般用到缓存,代表访问数据频率比较高,有一定的并发,再高并发的情况下,又有许多问题
- 请求a先过来,刚写完了数据库。但由于网络原因,卡顿了一下,还没来得及写缓存。
- 这时候请求b过来了,先写了数据库。
- 接下来,请求b顺利写了缓存。
- 此时,请求a卡顿结束,也写了缓存。

按照业务时间来说最后的A应该为2,但由于网络的原因导致更新缓存慢了,最后的缓存中的数据和数据库中的对不上
如果高并发且写多读少的情况下,代表没更新一次数据都要去更新缓存,可能修改了10次,实际只读了一次,这样的话也会造成CPU和内存资源的浪费。实际开发中,这种方案也很少用。
再想想我们的目的,我们为了是数据库更新的时候缓存也能够同时更新,既然更新会浪费资源,那我是不是可以更新数据库时把缓存删掉。这种思想我们给他取了一个名字旁路缓存
方案一:Cache-Aside Pattern(旁路缓存)
通过延迟删除策略,优先保证数据库正确性,接受短暂不一致。
根据上面的思路,我们了解到旁路缓存有两步操作,读操作和写操作
同样删除缓存也会有两种情况
- 先删缓存,再写数据库
- 先更新数据库,后删缓存
先删缓存,再写数据库
- 请求a先过来,把缓存删除了。但由于网络原因,卡顿了一下,还没来得及写数据库。
- 这时请求b过来了,先查缓存发现没数据,再查数据库,有数据,但是旧值。
- 请求b将数据库中的旧值,更新到缓存中。
- 此时,请求a卡顿结束,把新值写入数据库

此时则会出现缓存和数据库不一致的情况
先更新数据库,后删缓存
我们再回到上一个场景中,
- 请求a先过来,先更新数据库。但由于网络原因,没来得及删缓存。
- 这时请求b过来了,命中了缓存,有数据,但是旧值。
- 此时,请求a卡顿结束,把缓存更新
我们会发现,也会出现数据不一致的情况,但是只有一次,之后的数据就被更新为正常值了。再不要求完全一致的情况下,好像也可以接受隐藏风险
那这种方案是最多只会出现一次不一致的情况吗?我们先看下面这种场景:
缓存失效或第一次访问:
- A线程读取 → 缓存未命中 → 从数据库读到数据
- B线程更新数据库 → 删除缓存
- A线程回填缓存
结果:数据库是新数据,缓存却是旧的!

删除缓存失败:
在写策略时,如果由于网络原因,删除缓存失败了。那在读策略查询缓存的时候仍然会命中缓存,但是数据是旧数据。依然出现了缓存不一致的情况
代码实现
// 读操作
public Product getProduct(Long id) {
Product product = cache.get(id);
if (product == null) {
product = productRepository.findById(id).orElse(null);
if (product != null) {
cache.put(id, product);
}
}
return product;
}
// 写操作
public void updateProduct(Product product) {
productRepository.save(product);
cache.delete(product.getId()); // 删除而非更新
}
小结
说到这里,我们先总结一个这个方案的优缺点
| 优点 | 缺点 | 适用场景 |
|---|---|---|
| 实现简单 | 短暂不一致(删除失败时) | 读多写少(如商品详情) |
| 避免并发写问题 | 需处理缓存穿透 | 容忍秒级不一致 |
大部分读多写少且对一致性没有太高要求的场景下可以使用,注意设置一下缓存过期时间,避免旧数据一直无法删除即可。
方案二:双删策略(Double Delete)
通过两次删除 + 延迟补偿,解决并发场景下的脏数据问题。
上面提到了旁路删除仍然会有两个问题,小树想到,缓存失效或者第一次访问的时候出现缓存不一致,就是更新完数据库马上就删除了缓存,那我稍微慢一点删除,等并发的读都回填完了在删除,不就可以保证最终一致性了吗,为了减少缓存不一致的可能性,我在开始再删一次缓存,按照这个想法,小树写了下面的代码
进阶版双删代码
public void updateWithDoubleDelete(Product product) {
// 第一次删除
cache.delete(product.getId());
// 更新数据库
productRepository.save(product);
// 延迟二次删除
scheduler.schedule(() -> {
cache.delete(product.getId());
}, 500, TimeUnit.MILLISECONDS); // 延迟时间=主从延迟+缓冲
}
为什么是500ms?
延迟时间 = 主从同步延迟 + 业务处理时间 + 安全缓冲(建议100-200ms)
- 数据库主从同步通常需要200-300ms
- 留出缓冲时间,确保其他线程已处理完旧数据
老大哥看了看说,不错,这种方案叫做这样做延时双删,确实可以减少缓存不一致的风险,在允许短暂数据不一致的情况下这种风险是可以接受的。但是删除缓存失败了呢?你如何处理,
小树想了两种方法
- 可以添加重试机制,失败了重新删除缓存。
- 同时给缓存添加过期时间,避免删除缓存失败,一直读到错误的缓存数据
老大哥点了点头,说:想法不错,那你重试机制如何实现呢,是同步还是异步呢?
小树说:这我还没想好,你给我讲讲呗
老大哥说:不早了,我要睡觉了。明天再跟你讲吧,你先消化一下今天说的方案
小树总结了一下双删策略的优缺点
优缺点
| 优点 | 缺点 |
|---|---|
| 解决并发脏读 | 延迟时间难设定 |
| 成本低 | 仍可能不一致 |
适用边界:
- 适合写后立刻读少的场景(如后台管理系统)
- 不适用秒杀场景(延迟期间仍可能读到脏数据)
方案三:消息队列补偿
通过异步解耦,确保缓存删除操作最终成功。
架构图
Java实现
// 生产者
public void updateOrder(Order order) {
orderRepository.save(order);
rocketMQTemplate.send("cache-delete-topic",
MessageBuilder.withPayload(order.getId()).build());
}
// 消费者
@RocketMQMessageListener(topic = "cache-delete-topic", consumerGroup = "cache-group")
public class CacheDeleteListener implements RocketMQListener<String> {
@Override
public void onMessage(String orderId) {
cache.delete("order::" + orderId); // 支持重试机制
}
}
优缺点
| 优点 | 缺点 | 适用场景 |
|---|---|---|
| 高可靠性 | 系统复杂度高 | 分布式系统 |
| 解耦业务 | 消息可能堆积 | 订单/支付系统 |
选型建议:
- 低并发场景:用Redis Stream实现轻量级队列
- 高并发场景:RocketMQ + 死信队列监控
方案四:Binlog监听(Canal)
通过数据库日志驱动缓存更新,实现业务零侵入。
Canal部署架构
MySQL → Canal Server → Kafka → 缓存服务优势:
- 完全解耦业务代码
- 实时性达到毫秒级
Java实现(Canal客户端)
CanalConnector connector = CanalConnectors.newClusterConnector(
"127.0.0.1:2181", "example", "", "");
connector.subscribe(".*\\..*");
while (running) {
Message message = connector.getWithoutAck(100);
for (CanalEntry.Entry entry : message.getEntries()) {
if (entry.getEntryType() == CanalEntry.EntryType.ROWDATA) {
// 解析变更并删除缓存
String tableName = entry.getHeader().getTableName();
String id = parseId(entry.getStoreValue());
cache.delete(tableName + "::" + id);
}
}
}
优缺点
| 优点 | 缺点 | 适用场景 |
|---|---|---|
| 业务零侵入 | 部署复杂 | 微服务架构 |
| 毫秒级同步 | 需处理DDL事件 | 数据异构同步 |
适用场景:
- 微服务架构中多个服务共享缓存
- 数据异构场景(如ES索引同步)
方案五:版本号控制
通过数据版本校验,实现精准更新。
数据表设计
CREATE TABLE products (
id BIGINT PRIMARY KEY,
data JSON,
version INT DEFAULT 0 -- 新增版本号字段
);
缓存结构:
{
"value": "{...}",
"version": 123
}
Java实现(JPA乐观锁)
@Entity
public class Product {
@Id private Long id;
@Version private Integer version; // 乐观锁字段
// 其他字段...
}
@Transactional
public void updateProduct(Product product) {
Product dbProduct = productRepo.findById(product.getId())
.orElseThrow();
if (!dbProduct.getVersion().equals(product.getVersion())) {
throw new OptimisticLockException();
}
productRepo.save(product);
cache.put(product.getId(), product); // 更新缓存
}
额外成本:
- 存储开销增加5%-10%
- 所有读写操作需校验版本号
回报:
- 缓存命中率提升40%
- 彻底解决并发更新导致的脏读
终极大招:分布式锁该不该上?
通过互斥锁确保同一时刻只有一个写操作。
Redlock实现方案
public void updateWithLock(Product product) { String lockKey = "lock:product:" + product.getId(); // 获取锁(最多等待500ms,锁持有30s) boolean locked = redisLock.lock(lockKey, 500, 30000); if (locked) { try { db.update(product); cache.delete(product.getId()); } finally { redisLock.unlock(lockKey); } } }性能数据:
- 吞吐量下降60%(实测)
- 平均响应时间从20ms → 80ms
使用铁律:
- 仅用于库存扣减等核心场景
- 必须设置锁超时时间!
**你的业务真的需要强一致性吗?
任何技术方案的选择都都基于业务的,不要看网上吹的某个方案有多么好就无脑选择,适合自己业务的方案才是最好的
场景测试:
- 用户修改昵称后,5秒内看到旧名字 → 能接受 ✅
- 支付成功后,订单状态延迟1分钟更新 → 灾难 ❌
结论:
- 90%的业务只需最终一致性
- 剩下10%的金融/交易场景才需要强一致
最后抉择:一张图帮你选型
| 场景特征 | 推荐方案 | 一致性强度 | 性能影响 |
|---|---|---|---|
| 读多写少 | Cache-Aside | 最终一致 | 无影响 |
| 高并发写入 | 双删+消息队列 | 最终一致+ | 中等 |
| 金融/交易场景 | 分布式锁+版本号 | 强一致 | 高 |
结语:没有完美方案,只有合适的选择
记住这个黄金公式:
一致性成本 = 业务损失 × 发生概率 - 技术投入成本
下次当老板质问“为什么又出问题”时,你可以从容反问:
“您愿意为数据一致性支付多少成本?”
记住:技术方案的选择永远是在性能、一致性和复杂度之间寻找平衡点的艺术!