在高并发系统中,MySQL 通常保存完整、持久的数据,Redis 则保存一份用于加速读取的副本。

问题在于:MySQL 和 Redis 是两个独立系统,无法天然组成一个原子事务。 数据更新过程中,只要其中一步失败、超时或乱序,就可能出现数据库已经是新值、缓存仍然是旧值的情况。

多数业务并不追求绝对强一致,而是接受一个很短的不一致窗口,并要求系统最终恢复正确。这就是缓存设计中最常见的目标:最终一致性


一、先理解几个专业名词

名词

含义

权威数据源(System of Record)

业务最终认定为真实数据的存储。本文默认是 MySQL。

缓存失效(Invalidation)

数据变化后,删除或标记旧缓存不可用。

脏数据 / 陈旧数据(Stale Data)

缓存中的值落后于数据库。

TTL(Time To Live)

缓存键的存活时间,到期后 Redis 自动删除。

最终一致性

允许短暂不一致,但要求经过重试、过期或同步后最终一致。

写后读一致性(Read-after-write)

一次写入成功后,同一用户或后续请求立刻能读到新值。

双写(Dual Write)

一次业务操作需要同时修改两个独立系统,例如 MySQL 和 Redis。

幂等(Idempotency)

同一操作重复执行多次,结果与执行一次相同。DEL key 通常天然幂等。

CDC(Change Data Capture)

捕获数据库变更并将其转换为事件,常见来源是 MySQL binlog。

binlog

MySQL 记录数据变更的二进制日志,可用于复制、恢复和增量订阅。

Outbox

在业务数据库中增加事件表,让业务数据与待发送事件在同一事务中提交。

缓存本质上是一份可丢弃的副本。关键数据的正确性必须由数据库、事务和约束保证,不能交给缓存兜底。


二、常见缓存读写模式

模式

读路径

写路径

适用特点

Cache Aside(旁路缓存)

应用先查缓存,未命中再查数据库并回填

应用更新数据库后删除缓存

最常见,简单、灵活

Read Through

应用只访问缓存层,由缓存层负责回源

通常配合其他写策略

应用代码更简单,但依赖中间件能力

Write Through

读取通常走缓存

写操作同步更新数据库和缓存

写后读更及时,但写延迟和协调复杂度更高

Write Behind / Write Back

通常直接读缓存

先写缓存,再异步刷数据库

写性能高,但存在丢数据和恢复复杂度

Write Around

未命中时回源

只写数据库,不主动写缓存

避免无效缓存写入,下一次读再加载

Redis + MySQL 场景中,最常用的是 Cache Aside


三、Cache Aside 的标准流程

Cache Aside 读写流程

1. 读取流程

  1. 查询 Redis。

  2. 命中则直接返回。

  3. 未命中则查询 MySQL。

  4. 将查询结果写入 Redis,并设置 TTL。

  5. 返回结果。

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. 写入流程

正确的默认顺序是:

  1. 更新并提交 MySQL 事务。

  2. 数据库提交成功后,删除 Redis 缓存。

  3. 下一次读取时,从 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. 先删缓存,再更新数据库:不推荐作为默认方案

可能出现以下过程:

  1. 请求 A 删除缓存。

  2. 请求 B 读取缓存未命中。

  3. 请求 B 从 MySQL 读到旧值,并写回缓存。

  4. 请求 A 才完成数据库更新。

最终数据库是新值,缓存却是旧值,而且会一直持续到下一次删除或 TTL 到期。

因此默认应选择:先提交数据库,再删除缓存。

2. 先更新数据库,再删除缓存:仍存在小概率竞态

旧值回填竞态

即使采用推荐顺序,也可能发生:

  1. 读请求 A 缓存未命中,从数据库读取到旧值 v1

  2. 写请求 B 更新数据库为 v2,随后删除缓存。

  3. 请求 A 在删除完成后,才把旧值 v1 写回缓存。

这不是顺序写错,而是 Cache Aside 本身存在的并发窗口。

多数业务通过以下措施接受并限制风险:

  • 所有缓存必须设置 TTL。

  • 热点 Key 使用 Singleflight 或互斥回源,减少并发回填。

  • 对更严格的场景增加版本号、Generation 或 Lease,阻止旧请求回填。

  • 对写后立即读取的请求,短时间内读取主库或直接绕过缓存。


六、延迟双删能不能解决问题

所谓延迟双删,通常是在一次更新前后执行两次缓存删除,中间等待一段时间。常见写法包括:

常见顺序之一是:删除缓存 → 更新数据库 → 延迟 → 再删缓存

另一种写法是:更新数据库 → 删除缓存 → 延迟 → 再删缓存

第二次删除的目标,是清理并发读取在更新窗口内重新写回的旧值。

它能降低部分竞态概率,但有明显局限:

  • 延迟时间很难准确设置,数据库查询、网络和主从延迟都不是固定值。

  • 等待时间过短可能删不到旧回填,过长会扩大不一致窗口。

  • 在请求线程中 sleep 会占用线程,影响吞吐量。

  • 第二次删除本身仍可能失败。

  • 它不能解决消息丢失、服务崩溃和跨系统双写问题。

因此,延迟双删更适合作为一种补丁型措施,而不是生产系统的最终答案。需要使用时,应通过延迟队列或定时任务异步执行,不要阻塞业务线程。


七、真正需要重点处理的是“删除缓存失败”

最常见且影响更直接的故障是:

  1. MySQL 已经提交成功。

  2. 应用删除 Redis 时超时、断网或进程崩溃。

  3. 旧缓存继续存在。

TTL 可以让旧数据最终消失,但在 TTL 很长时,不一致可能持续很久。因此生产系统通常会增加可靠重试。

方案一:应用内有限重试

适合非核心、小规模系统:

  • 删除失败后快速重试 2~3 次。

  • 使用指数退避,避免 Redis 故障时形成重试风暴。

  • 重试仍失败则记录日志并报警。

缺点是进程崩溃后任务会丢失。

方案二:发送可靠消息异步重试

数据库提交后发送“删除某个缓存 Key”的消息,由消费者不断重试。

需要注意:如果“更新数据库”和“发送消息”是两个独立步骤,仍然存在双写问题:

  • 数据库提交成功,但消息没发出去。

  • 消息发出去了,但数据库事务回滚。

因此,仅仅“加一个 MQ”并不自动可靠。

方案三:Transactional Outbox + CDC

这是更完整的工程方案。

Outbox 与 CDC 缓存失效流程

业务事务同时完成两件事:

  1. 更新业务表。

  2. 向 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 记录业务事件

业务代码显式写入类似 ProductPriceChangedUserProfileUpdated 的事件。

优点:

  • 事件语义清楚,不依赖消费者猜测表结构。

  • 容易同时驱动 Redis、搜索索引和其他下游系统。

  • 业务数据与事件在同一事务中提交。

缺点:

  • 需要设计事件模型和 Outbox 表。

  • 需要清理历史事件,治理事件版本。

  • 仍需部署 CDC、MQ 和消费者链路。

中小系统通常不必一开始就上完整 CDC 平台。只有当缓存失效已经跨越多个服务、多个数据视图,或可靠性要求明显提高时,Outbox + CDC 才更有价值。


九、读写分离会放大缓存不一致

MySQL 复制通常默认是异步的。主库提交后,从库可能还没有回放到最新事务,这段时间称为复制延迟(Replica Lag)

假设写入流程为:

  1. 更新主库。

  2. 删除缓存。

  3. 下一个读请求缓存未命中。

  4. 请求被路由到从库。

  5. 从库返回旧值,并重新写入缓存。

此时即使缓存删除完全成功,旧数据仍会从从库“复活”。

常见解决方式:

  • 写入后的短时间内,相关请求强制读主库。

  • 对同一会话使用写后读粘滞策略(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

删除失败不能长期存在

增加异步重试、告警和补偿任务

多服务、多写入来源

binlog CDC 统一捕获变化

需要明确业务事件

Transactional Outbox + CDC + MQ

读写分离且要求写后立刻可见

写后读主库、会话粘滞或绕过缓存

热点 Key

Singleflight、互斥回源、预热、Refresh-ahead

需要阻止旧版本覆盖

版本号、Generation 或 Lease

L1 + L2 多级缓存

可靠失效广播 + 本地短 TTL

强一致关键路径

不依赖缓存做正确性判断,直接读取权威数据源

推荐采用分级演进,而不是一开始把所有机制全部堆上去:

基础级

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 解决的是事件可靠产生和传播问题。

  • 消息链路通常是至少一次投递,消费者必须幂等。

  • 强一致业务不应依赖缓存承担正确性责任。

缓存设计的目标不是消灭一切理论上的不一致,而是根据业务容忍度,控制不一致窗口、保证最终恢复,并让异常能够被监控和补偿。


参考资料

  1. Microsoft Azure Architecture Center:Cache-Aside Pattern

  2. Microsoft Azure Architecture Center:Caching Guidance

  3. Redis 官方文档:EXPIRE

  4. Redis 官方文档:Client-side Caching

  5. MySQL Reference Manual:Replication

  6. MySQL Reference Manual:Semisynchronous Replication

  7. Alibaba Canal Wiki:Introduction

  8. Debezium Documentation:Outbox Event Router

  9. Microservices.io:Transactional Outbox Pattern

  10. Meta Engineering:Cache Made Consistent

整理日期:2026-07-14