Redis 与 MySQL 缓存一致性:从 Cache Aside 到 Outbox 与 CDC
在高并发系统中,MySQL 通常保存完整、持久的数据,Redis 则保存一份用于加速读取的副本。
问题在于:MySQL 和 Redis 是两个独立系统,无法天然组成一个原子事务。 数据更新过程中,只要其中一步失败、超时或乱序,就可能出现数据库已经是新值、缓存仍然是旧值的情况。
多数业务并不追求绝对强一致,而是接受一个很短的不一致窗口,并要求系统最终恢复正确。这就是缓存设计中最常见的目标:最终一致性。
一、先理解几个专业名词
缓存本质上是一份可丢弃的副本。关键数据的正确性必须由数据库、事务和约束保证,不能交给缓存兜底。
二、常见缓存读写模式
Redis + MySQL 场景中,最常用的是 Cache Aside。
三、Cache Aside 的标准流程

1. 读取流程
查询 Redis。
命中则直接返回。
未命中则查询 MySQL。
将查询结果写入 Redis,并设置 TTL。
返回结果。
public Product getProduct(long id) {
String key = "product:" + id;
Product cached = redis.get(key);
if (cached != null) {
return cached;
}
Product product = productRepository.findById(id);
if (product != null) {
redis.set(key, product, randomTtl());
}
return product;
}
这里的 randomTtl() 表示在基础过期时间上增加少量随机值,避免大量缓存同时过期。
2. 写入流程
正确的默认顺序是:
更新并提交 MySQL 事务。
数据库提交成功后,删除 Redis 缓存。
下一次读取时,从 MySQL 获取新值并重新回填。
public void updateProduct(Product product) {
transactionTemplate.executeWithoutResult(status -> {
productRepository.update(product);
}); // executeWithoutResult 正常返回时,事务已经提交
redis.delete("product:" + product.getId());
}
真实项目中要确保删除发生在事务提交成功之后,而不是 SQL 执行之后、事务提交之前。Spring 项目可以使用事务同步回调、领域事件或 Outbox 等方式处理。
四、为什么通常删除缓存,而不是直接更新缓存
1. 避免并发写乱序
假设请求 A 先把数据库更新为 v1,请求 B 后把数据库更新为 v2。如果两个请求都直接更新缓存,网络延迟可能导致 B 先写缓存、A 后写缓存,最终缓存反而停留在旧版本 v1。
删除缓存不会把某个业务值主动写进去,下一次读取会从权威数据源重新构建,乱序风险更小。
2. 避免无效计算
复杂缓存对象可能需要关联查询、聚合和序列化。数据更新后未必马上有人读取,立即重建缓存可能白白消耗资源。
3. 降低业务耦合
同一条数据库记录可能对应多个缓存结构。业务代码如果每次都负责精确更新所有缓存,后续很容易遗漏。删除缓存通常比同步维护缓存对象简单。
但“只删不更新”并不是绝对规则。对于极热点数据,如果缓存未命中成本很高,可以采用 Write Through、Refresh-ahead 或异步预热,只是需要额外解决版本、失败和并发问题。
五、两种更新顺序分别有什么问题
1. 先删缓存,再更新数据库:不推荐作为默认方案
可能出现以下过程:
请求 A 删除缓存。
请求 B 读取缓存未命中。
请求 B 从 MySQL 读到旧值,并写回缓存。
请求 A 才完成数据库更新。
最终数据库是新值,缓存却是旧值,而且会一直持续到下一次删除或 TTL 到期。
因此默认应选择:先提交数据库,再删除缓存。
2. 先更新数据库,再删除缓存:仍存在小概率竞态

即使采用推荐顺序,也可能发生:
读请求 A 缓存未命中,从数据库读取到旧值
v1。写请求 B 更新数据库为
v2,随后删除缓存。请求 A 在删除完成后,才把旧值
v1写回缓存。
这不是顺序写错,而是 Cache Aside 本身存在的并发窗口。
多数业务通过以下措施接受并限制风险:
所有缓存必须设置 TTL。
热点 Key 使用 Singleflight 或互斥回源,减少并发回填。
对更严格的场景增加版本号、Generation 或 Lease,阻止旧请求回填。
对写后立即读取的请求,短时间内读取主库或直接绕过缓存。
六、延迟双删能不能解决问题
所谓延迟双删,通常是在一次更新前后执行两次缓存删除,中间等待一段时间。常见写法包括:
常见顺序之一是:删除缓存 → 更新数据库 → 延迟 → 再删缓存。
另一种写法是:更新数据库 → 删除缓存 → 延迟 → 再删缓存。
第二次删除的目标,是清理并发读取在更新窗口内重新写回的旧值。
它能降低部分竞态概率,但有明显局限:
延迟时间很难准确设置,数据库查询、网络和主从延迟都不是固定值。
等待时间过短可能删不到旧回填,过长会扩大不一致窗口。
在请求线程中
sleep会占用线程,影响吞吐量。第二次删除本身仍可能失败。
它不能解决消息丢失、服务崩溃和跨系统双写问题。
因此,延迟双删更适合作为一种补丁型措施,而不是生产系统的最终答案。需要使用时,应通过延迟队列或定时任务异步执行,不要阻塞业务线程。
七、真正需要重点处理的是“删除缓存失败”
最常见且影响更直接的故障是:
MySQL 已经提交成功。
应用删除 Redis 时超时、断网或进程崩溃。
旧缓存继续存在。
TTL 可以让旧数据最终消失,但在 TTL 很长时,不一致可能持续很久。因此生产系统通常会增加可靠重试。
方案一:应用内有限重试
适合非核心、小规模系统:
删除失败后快速重试 2~3 次。
使用指数退避,避免 Redis 故障时形成重试风暴。
重试仍失败则记录日志并报警。
缺点是进程崩溃后任务会丢失。
方案二:发送可靠消息异步重试
数据库提交后发送“删除某个缓存 Key”的消息,由消费者不断重试。
需要注意:如果“更新数据库”和“发送消息”是两个独立步骤,仍然存在双写问题:
数据库提交成功,但消息没发出去。
消息发出去了,但数据库事务回滚。
因此,仅仅“加一个 MQ”并不自动可靠。
方案三:Transactional Outbox + CDC
这是更完整的工程方案。

业务事务同时完成两件事:
更新业务表。
向 Outbox 事件表插入一条缓存失效事件。
两条 SQL 位于同一个 MySQL 本地事务中,要么一起提交,要么一起回滚。
CREATE TABLE outbox_event (
event_id VARCHAR(64) PRIMARY KEY,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
created_at DATETIME(3) NOT NULL
);
随后由 Canal、Debezium 等 CDC 工具订阅 binlog,把 Outbox 事件发送到 Kafka 或 RocketMQ,缓存消费者再执行删除。
这套方案解决的是:业务数据已经提交,但失效消息没有可靠产生的问题。
消费者还必须处理以下问题:
至少一次投递(At-least-once):消息可能重复,消费者必须幂等。
顺序问题:同一业务实体的事件应按实体 ID 分区,或使用版本号判断新旧。
重试与死信队列:反复失败的消息进入 DLQ,等待人工或补偿任务处理。
事件积压:监控 CDC 延迟、MQ 消费延迟和重试队列长度。
模式变更:表结构或事件格式变化时要考虑兼容性。
如果消费者只执行 DEL key,重复消费通常没有副作用;如果消费者直接更新缓存,则应携带版本号,避免旧事件覆盖新值。
八、直接订阅业务表 binlog,还是使用 Outbox
两种方式都属于 CDC,但语义不同。
1. 直接订阅业务表
Canal 或 Debezium 直接解析业务表变更,再推导需要删除的缓存 Key。
优点:
业务代码侵入较小。
能捕获所有写入来源,包括脚本、后台任务和其他服务。
缺点:
数据库字段变化不等于业务事件,消费者需要自行理解表结构。
一条 SQL 可能影响多个缓存,映射关系复杂。
聚合缓存、列表缓存和搜索索引很难仅靠单表变化精确推导。
2. 使用 Outbox 记录业务事件
业务代码显式写入类似 ProductPriceChanged、UserProfileUpdated 的事件。
优点:
事件语义清楚,不依赖消费者猜测表结构。
容易同时驱动 Redis、搜索索引和其他下游系统。
业务数据与事件在同一事务中提交。
缺点:
需要设计事件模型和 Outbox 表。
需要清理历史事件,治理事件版本。
仍需部署 CDC、MQ 和消费者链路。
中小系统通常不必一开始就上完整 CDC 平台。只有当缓存失效已经跨越多个服务、多个数据视图,或可靠性要求明显提高时,Outbox + CDC 才更有价值。
九、读写分离会放大缓存不一致
MySQL 复制通常默认是异步的。主库提交后,从库可能还没有回放到最新事务,这段时间称为复制延迟(Replica Lag)。
假设写入流程为:
更新主库。
删除缓存。
下一个读请求缓存未命中。
请求被路由到从库。
从库返回旧值,并重新写入缓存。
此时即使缓存删除完全成功,旧数据仍会从从库“复活”。
常见解决方式:
写入后的短时间内,相关请求强制读主库。
对同一会话使用写后读粘滞策略(Read-your-writes Stickiness)。
监控从库延迟,延迟过高时暂停把关键读取路由到从库。
对必须立即读到新值的接口绕过缓存和从库。
在缓存值中携带数据版本,拒绝较旧版本覆盖较新版本。
需要特别注意:Canal 看到了主库 binlog,不代表某个从库已经完成事务回放。 CDC 与从库同步是两条不同链路。
十、版本号、Generation 与 Lease
当业务不能接受旧请求在删除后重新回填,可以增加“新旧判断”。
1. 版本号
数据库记录增加单调递增的 version:
value = 商品数据
version = 42
写缓存时,通过 Lua 脚本原子比较:只有新版本不小于缓存版本时才允许覆盖。
这可以解决乱序更新和旧事件覆盖,但前提是所有写入路径都正确维护版本号。
2. Generation / 命名空间版本
把缓存 Key 设计为:
product:123:v42
数据更新时只需增加 Generation,后续读取直接使用新版本 Key。旧 Key 不再被访问,等待 TTL 自动清理。
这种方式适合:
一个实体对应多个缓存 Key。
列表、聚合结果或标签缓存难以逐个删除。
希望失效操作接近 O(1)。
代价是短时间内会保留多份旧缓存,占用更多内存。
3. Lease / 回填令牌
缓存未命中时,系统向回源请求发放一个短期令牌。只有令牌仍然有效,请求才允许写回缓存。数据更新或失效时撤销旧令牌,从而阻止慢请求回填旧值。
Lease 比简单锁更适合解决“旧回填晚于新失效到达”的问题,但实现明显更复杂,通常只用于高价值热点数据。
十一、热点 Key 还要处理缓存击穿
缓存一致性和缓存击穿不是同一个问题,但经常同时出现。
缓存击穿(Cache Stampede / Thundering Herd)
某个热点 Key 失效时,大量请求同时未命中,一起查询数据库,可能瞬间压垮 MySQL。
常见措施:
Singleflight / 请求合并:同一 Key 同时只允许一个请求回源,其余请求等待结果。
互斥锁:抢到锁的请求查询数据库并回填,其他请求等待或返回旧值。
Refresh-ahead:缓存接近过期时提前异步刷新。
逻辑过期:缓存中保存业务过期时间,过期后先返回旧值,再异步刷新。
热点预热:发布或流量高峰前提前加载缓存。
这些方法主要保护数据库和延迟,并不能自动保证数据库与缓存一致。
缓存穿透
查询一个数据库中根本不存在的对象,导致每次请求都绕过缓存访问数据库。
可使用:缓存空值、布隆过滤器、参数校验和限流。
缓存雪崩
大量 Key 同时过期或 Redis 整体不可用,导致请求集中回源。
可使用:TTL 随机抖动、多级缓存、限流、熔断、降级和 Redis 高可用。
十二、多级缓存需要额外失效机制
很多系统并不是只有 Redis,而是:
典型结构是:L1 本地缓存(Caffeine)→ L2 Redis → MySQL。
Redis 被删除后,各应用实例的本地缓存可能仍然保存旧值。因此还需要向所有实例广播失效事件。
常见方式:
通过 Kafka、RocketMQ 或 Redis Streams 广播可重放的失效消息。
使用 Redis Client-side Caching Tracking 接收失效通知。
本地缓存设置更短 TTL 作为兜底。
应用重连后清空本地缓存,避免错过断线期间的失效消息。
普通 Redis Pub/Sub 不保存历史消息,订阅者断线时可能错过通知。对可靠性要求高的场景,不应只依赖无重放能力的广播。
十三、Write Through 也不是天然强一致
Write Through 通常表示一次写请求同步更新数据库和缓存,两个步骤都成功后才返回。
它能缩短写后读的旧数据窗口,但 MySQL 和 Redis 仍然是两个独立系统:
数据库成功、缓存失败。
缓存成功、数据库失败。
客户端超时,不知道服务端到底完成到哪一步。
除非缓存中间件和存储系统提供明确的一致性协议,否则 Write Through 仍需要补偿、重试、幂等和版本控制。它是更积极地维护缓存,不是自动获得分布式事务。
对于支付余额、账本、库存最终扣减等正确性优先的路径,通常应让数据库或专门的事务型存储负责最终判断。缓存只能用于展示、预估或加速非权威读取。
十四、生产方案如何选择

推荐采用分级演进,而不是一开始把所有机制全部堆上去:
基础级
Cache Aside + 数据库提交后删除缓存 + TTL + 删除失败日志与告警。
可靠级
在基础级上增加:异步重试、重试队列、死信处理和消费幂等。
平台级
进一步采用:Transactional Outbox + CDC + MQ + 统一缓存失效服务 + 版本控制 + 全链路监控。
十五、生产检查清单
上线前至少确认以下问题:
MySQL 是否是明确的权威数据源。
缓存删除是否发生在数据库事务提交之后。
所有缓存是否设置合理 TTL,并增加随机抖动。
删除失败后是否有重试、告警和人工补偿入口。
MQ 消费者是否幂等,重复消息是否安全。
是否监控 CDC 延迟、消费积压、删除失败率和死信数量。
读写分离场景是否处理了写后读主从延迟。
热点 Key 失效时是否会发生大量并发回源。
多级缓存是否能让所有应用实例收到失效通知。
聚合缓存和列表缓存是否有明确的依赖关系、标签或版本策略。
业务能容忍多长时间的旧数据,是否定义了可量化的 SLO。
SLO(Service Level Objective) 是可衡量的服务目标。例如:“99.99% 的缓存失效事件在 2 秒内完成”“陈旧读取比例低于百万分之一”。没有指标,就很难判断一致性方案是否真的有效。
十六、总结
Redis 与 MySQL 缓存一致性没有一个能覆盖所有业务的“完美答案”。
对大多数读多写少、允许短暂旧数据的系统,最实用的默认方案仍然是:
Cache Aside = 先提交 MySQL + 再删除 Redis + 设置 TTL + 删除失败重试。
需要记住的关键点是:
延迟双删只能降低部分竞态概率,不是强一致方案。
TTL 只能限制脏数据的最长寿命,不能阻止脏数据产生。
Canal 读取到主库 binlog,不代表从库已经同步完成。
只加 MQ 仍可能存在数据库与消息的双写问题。
Outbox + CDC 解决的是事件可靠产生和传播问题。
消息链路通常是至少一次投递,消费者必须幂等。
强一致业务不应依赖缓存承担正确性责任。
缓存设计的目标不是消灭一切理论上的不一致,而是根据业务容忍度,控制不一致窗口、保证最终恢复,并让异常能够被监控和补偿。
参考资料
整理日期:2026-07-14
Redis 与 MySQL 缓存一致性:从 Cache Aside 到 Outbox 与 CDC
https://lautung.com/archives/yT4q56fF
评论