惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

IT之家
IT之家
C
CXSECURITY Database RSS Feed - CXSecurity.com
V
Visual Studio Blog
D
Darknet – Hacking Tools, Hacker News & Cyber Security
小众软件
小众软件
L
LangChain Blog
Cyberwarzone
Cyberwarzone
美团技术团队
The Register - Security
The Register - Security
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
T
Tor Project blog
V
V2EX
Security Archives - TechRepublic
Security Archives - TechRepublic
Hacker News: Ask HN
Hacker News: Ask HN
L
LINUX DO - 最新话题
Recent Announcements
Recent Announcements
H
Hackread – Cybersecurity News, Data Breaches, AI and More
酷 壳 – CoolShell
酷 壳 – CoolShell
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
aimingoo的专栏
aimingoo的专栏
人人都是产品经理
人人都是产品经理
F
Full Disclosure
V2EX - 技术
V2EX - 技术
The Cloudflare Blog
博客园 - 叶小钗
T
Threat Research - Cisco Blogs
阮一峰的网络日志
阮一峰的网络日志
G
GRAHAM CLULEY
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Latest news
Latest news
S
Security @ Cisco Blogs
Spread Privacy
Spread Privacy
Project Zero
Project Zero
K
Kaspersky official blog
MyScale Blog
MyScale Blog
Attack and Defense Labs
Attack and Defense Labs
云风的 BLOG
云风的 BLOG
博客园 - 【当耐特】
Hacker News - Newest:
Hacker News - Newest: "LLM"
大猫的无限游戏
大猫的无限游戏
P
Privacy International News Feed
Google DeepMind News
Google DeepMind News
WordPress大学
WordPress大学
C
Cybersecurity and Infrastructure Security Agency CISA
Webroot Blog
Webroot Blog
罗磊的独立博客
Vercel News
Vercel News
N
News and Events Feed by Topic
A
Arctic Wolf
C
CERT Recently Published Vulnerability Notes

博客园 - 纪莫

技术面:如何让你的系统抗住高并发的流量? 技术面:MySQL篇(InnoDB的锁机制) 技术面:MySQL篇(InnoDB事务执行过程、事务隔离级别、事务并发异常) 技术面:MySQL(一条SQL在MySQL的执行过程?、MyISAM和InnoDB的区别?数据库事务机制?) 技术面:MySQL篇(为啥会有非关系型数据库?MySQL的数据存储一定在磁盘吗?) 技术面:SpringCloud(SpringCloud有哪些组件,SpringCloud与Dubbo的区别) 技术面:SpringBoot(springboot的类加载和传统的双亲委派有什么区别、如何按顺序实例化Bean) 技术面:SpringBoot(启动流程、如何优雅停机) 技术面:Spring(循环依赖,spring与springboot的区别) 技术面:Spring (事务传播机制、事务失效的原因、BeanFactory和FactoryBean的关系) 阿里一面:类加载的过程是怎么样的? 双亲委派的优点和缺点? 产生fullGC的情况有哪些? spring的动态代理有哪些?区别是什么? 如何排查CPU使用率过高? 技术面:Spring (bean的生命周期、创建方式、注入方式、作用域) 技术面:Java并发(线程同步、死锁、多线程编排) 技术面:Java并发(线程池、ForkJoinPool) 技术面:Java并发(上下文切换、线程安全、并发与并行、守护线程、虚拟线程) DDD领域驱动设计的理解 Kafka如何保证「消息不丢失」,「顺序传输」,「不重复消费」,以及为什么会发生重平衡(reblanace) Kafka为什么吞吐量大,速度快? Elasticsearch写入数据的过程是什么样的?以及是如何快速更新索引数据的?
技术面:如何解决缓存和数据库一致性的问题?
纪莫 · 2025-12-04 · via 博客园 - 纪莫

前言

缓存(例如:Redis)和数据库的数据一致性问题,也是一个经典的问题,无论是在面试还是在日常工作当中,遇到的概率非常大。尤其是在高并发的场景下,这个问题会变得更加严重。

业内常见的解决方案

  1. 先更新数据库,再删缓存。
  2. 延迟双删:先删缓存,再更新数据库,延时一段时间,再删一次缓存。
  3. Canal/Maxwell订阅 binlog,更新数据库,基于中间件监听binlog后删除缓存。

这三种方案基本上就能解决市面上大部分的业务场景中的缓存不一致的问题了。

方案 优点 缺点 适用场景
先更新数据库,再删缓存 实现简单,对业务代码侵入小 删缓存失败,存在数据不一致的问题 95%一般场景都适合,尤其是并发量不大,或者对一致性要求不太高的。
延迟双删 数据一致性保证更好 第二次删失败要重试;延迟 时长要靠压测估 对数据一致性要求高,并发量大的场景。
更新数据库,基于中间件监听binlog后删除缓存 与业务代码解耦,一致性有保障 实现复杂,需要引入中间件 适合基础建设完善,且并发高对一致性要求高的场景

先写数据库,再删缓存

先来说一下,为什么是删除缓存,而不是更新缓存?

并发写冲突风险高

在高并发场景下,多个线程/服务可能同时更新同一份数据如果采用更新缓存的策略,容易出现竞态条件,导致缓存中的数据是旧值或错误值

假设有两个线程 A 和 B,同时更新数据库和缓存:

时间 线程 A 线程 B
T1 更新数据库为 100
T2 更新数据库为 200
T3 更新缓存为 100(A 的旧值)
T4 更新缓存为 200(B 的新值)

最终缓存中是 200,看起来没问题,但如果 T3 发生在 T4 之后(由于网络延迟、线程调度等),缓存就会被A的旧值覆盖,变成 100,导致缓存和数据库不一致

时间 线程 A 线程 B
T1 更新数据库为 100
T2 更新数据库为 200
T3 更新缓存为 200(B 的新值)
T4 更新缓存为 100(A 的旧值)

但是删除缓存就不存在这个问题,删除缓存操作具有幂等性,多次执行不会产生副作用,相比更新缓存更适合在高并发场景下使用。

缓存更新逻辑复杂,容易出错

更新缓存时,通常需要:

  1. 从数据库读取最新值
  2. 构造缓存对象(可能涉及序列化、字段拼接等)
  3. 写入 Redis

这个过程中,任何一个环节出错,都可能导致缓存中的数据是不完整或错误的。

删除缓存逻辑简单,只要一个 DEL 操作,出错概率低,恢复快。

延迟加载(Lazy Loading)更自然

删除缓存后,下一次读取时会触发缓存未命中,然后从数据库加载最新数据并回填缓存。
这种延迟加载机制:

  • 保证了缓存中的数据总是从数据库加载的最新值
  • 避免了提前写入缓存时可能用到的旧数据

删除缓存配合“先更新数据库,再删除缓存”策略更可靠

先更新数据库,再删除缓存,这个策略在大多数情况下能保证最终一致性,即使出现极端情况(如删除缓存失败),也可以通过消息队列重试或定时任务补偿

但是有时候删除缓存还要考虑一些极端场景,例如:删除缓存后,大量请求打到数据库,造成缓存击穿。通常的解决方案就是
使用互斥锁(如 Redis 分布式锁)保证只有一个线程去加载数据

在一致性优先的场景下,删除缓存比更新缓存更稳妥、更简单、更容易兜底的选择。

先写数据库还是先删缓存?

上面通过比较,我们知道了,删缓存比更新缓存更好,所以为了降低数据不一致的情况产生,选择删缓存。那么是先更新数据库再删缓存呢,还是说,先删缓存再更新数据库呢?

先删缓存,再更新数据库

举个例子来说明:
还是有两个线程 A 和 B,线程A是写操作,线程B是读操作。

时刻 线程 A(写) 线程 B(读)
T1 删除缓存成功
T2 缓存 miss,去库读旧值 100
T3 把 100 回填缓存
T4 数据库更新为 200(还没提交或同步完成)

也有可能T3发生在T4之后

时刻 线程 A(写) 线程 B(读)
T1 删除缓存成功
T2 缓存 miss,去库读旧值 100
T3 数据库更新为 200(还没提交或同步完成)
T4 把 100 回填缓存

如果先删缓存,就有可能导致缓存里在很长一段时间内都是 旧值 100,直到下一次失效或更新。

先更新数据库,再删缓存

如果我们先更新数据库,再删除缓存,有一个好处,那就是缓存删除失败的概率还是比较低的,除非是网络问题或者缓存服务器宕机的问题,否则大部分情况都是可以成功的。

并且这个方案还有一个好处,那就是数据库是作为持久层存储的,先更新数据库就能确保数据先写入持久层可以保证数据的可靠性和一致性,即使在删除缓存失败的情况下,数据库中已有最新数据。

还是两个线程A和B,线程A写,线程B读。

时刻 线程 A(写) 线程 B(读)
T1 更新数据库为 200(事务已提交)
T2 删除缓存(DEL)
T3 缓存 miss,去库读 200
T4 把 200 回填缓存

延迟双删

这种先更新数据库,再删缓存,也有一定的缓存不一致的概率,例如:在更新完数据库,还未删除缓存的这段时间。还有就是更新完数据库,如果删除缓存失败了的情况。
那么怎么解决这些问题呢?可以使用更复杂的另一个方案延迟双删

延迟双删”是为了解决高并发场景下 缓存与数据库数据不一致 的一种折中策略,它并不能保证强一致性,但可以缩小不一致的时间窗口。之所以要执行两次删除,是为了应对并发读写过程中可能出现的脏数据回写问题。

基本流程

  1. 第一次删除缓存
    在更新数据库前或后,先删除缓存,防止旧数据被读取。
  2. 更新数据库
  3. 延迟一段时间后,再次删除缓存
    这是关键步骤,目的是清除在并发过程中可能被错误写入的旧数据。

image

为什么需要两次删除?

  1. 第一次删除:防止读取旧缓存
  • 如果不删缓存,用户可能一直读到旧数据。
  • 删除缓存后,新的读请求会“穿透”到数据库,理论上能拿到最新数据。
  1. 第二次删除(延迟删):防止并发写入脏数据
    在第一次删除缓存后,如果有并发读请求进来,它可能:
  • 发现缓存为空;
  • 去数据库读数据;
  • 但此时数据库还没更新完,或者主从同步延迟,读到了旧数据;
  • 然后把旧数据写回缓存,造成“脏数据回写”。

👉 延迟一段时间后再次删除缓存,就是为了清除这种被误写入的旧数据,尽可能保证缓存与数据库的一致性

延迟双删需要注意事项

  • 延迟时间设置是个经验值, 应大于主从同步最大延迟 + 一次完整读请求耗时 + 网络抖动缓冲时间,建议通过监控主从延迟动态调整。
  • 延迟双删不能100%避免不一致,只是降低概率 。
  • 如果第二次删除失败,仍可能导致不一致,因此通常会配合消息队列重试机制作为兜底方案。

这是一种最终一致性策略,适用于对一致性要求较高、但无法承受强一致性开销的业务场景

Cache-Aside+监听binlog删缓存

Cache-Aside + 监听 binlog(或异步消息)删除缓存,是目前主流的最终一致性方案,把“业务代码”与“缓存失效”彻底解耦,既保留 Cache-Aside 的简单性,又通过异步机制弥补“删缓存失败”或“并发脏读”带来的不一致窗口。

基本流程

image

这种流程的好处在于

  • 写路径缩短到一次 DB 事务,应用不直接碰 Redis,P99 延迟低。
  • 即使 Redis 宕机、MQ 堆积,binlog 仍在,具备天然的重放机制,可用于故障恢复。

代码示例

// 1. 业务代码——只写库
@Transactional
public void updateOrder(Order order) {
// 写操作不直接更新缓存,由binlog监听异步删除缓存,读操作仍通过Cache-Aside机制回填缓存。
    orderMapper.updateById(order); 
}

// 2. Canal 适配器——把 binlog 转成简单事件
@CanalEventListener
public class OrderHandler {
    @KafkaSender(topic = "order_binlog")
    public void onUpdate(CanalEntry.Entry entry) {
        // 解析出主键 orderId
        String orderId = parseOrderId(entry);
        return new BinlogEvent("UPDATE", orderId, entry.getHeader().getExecuteTime());
    }
}

// 3. 缓存失效服务——消费并删除
@KafkaListener(topics = "order_binlog", groupId = "cache_clean")
public void cleanCache(BinlogEvent event) {
    try {
        redisTemplate.del("order:" + event.getOrderId());
    } catch (Exception e) {
        // 失败抛异常,让 MQ 重试
        sendRetryMsg(event.getOrderId());
        throw new RuntimeException("DEL failed", e);
    }
}

上面代码示例,我是分开处理的,先处理canal的消息,再处理删除缓存的消息。其实理论上也可以合并到一起,并且如果删除缓存失败,就不提交MQ的offset,只有消费端 ACK 只有 DEL 成功才提交 offset。

Cache-Aside 负责“让缓存可旁路”,binlog 监听负责“让缓存一定失效”;两者结合,写性能不衰减,一致性可兜底,架构可水平扩展,在大多数读多写少、对一致性要求较高的业务场景中,是一种均衡且可扩展的方案。

总结

前面介绍了几种情况的具体问题和解决方案,那么实际工作中应该如何选择呢?

  1. 没 MQ、没 DBA、团队小 → 先更新库再删缓存,极限情况下脏读 1 s 能忍就忍(例如:电商商品详情、用户资料)。

  2. 读 QPS 爆表(10w+)且同一条 key 被反复热读 → 延迟双删把“刚写立刻被读脏”概率再压一个量级(例如:秒杀库存、热点配置)。

  3. 已经上了 Canal/RocketMQ,或者团队有“消息驱动”规范 → 业务代码最干净,后续谁改库都不用管缓存;同时做好“消息失败可回查 DB 修复”的兜底脚本(订单、支付、账务等“不能错钱”场景)。

任何的技术方案,都是一个权衡的过程,要权衡的问题有很多,业务的具体情况,实现的复杂度、实现的成本,团队成员的接受度、可维护性、容易理解的程度等等。

所以,没有一个"完美"的方案,只有"适合"的方案。
但是,如何能选出一个更适合的方案,这里面就需要有更多的输入参考来做支撑了。