08 MySQL 与缓存的协作模式——Cache Aside 到数据一致性
摘要: MySQL 与缓存(通常是 Redis)的组合是互联网系统中最常见的架构之一。但这个组合也是数据不一致 bug 的重灾区——不是”缓存了什么”的问题,而是”什么时候更新缓存、更新顺序是什么、失败了怎么处理”的工程问题。本文从四种缓存读写策略(Cache Aside、Read Through、Write Through、Write Behind)的本质差异出发,深入分析 Cache Aside 模式下”先删缓存后更新数据库”与”先更新数据库后删缓存”的不一致场景,以及双删策略、消息队列异步删除、Canal 订阅 Binlog 等不同方案的适用边界。最后给出缓存穿透、缓存击穿、缓存雪崩三种故障模式的根因分析与防御手段。
第 1 章 为什么要在 MySQL 前加缓存
1.1 MySQL 的性能上限与缓存的定位
一台配置良好的 MySQL 服务器,在 OLTP 场景下的读 QPS 通常在 10000-50000 之间(取决于查询复杂度、索引命中率、Buffer Pool 大小等因素)。对于大多数业务来说,这已经足够。
但有些场景下,读取的数据是高度重复的——例如,一个热门商品的详情页,每秒可能被数万人请求,但商品信息(价格、描述、图片)的变化频率很低(可能几分钟甚至几小时才更新一次)。每次请求都去 MySQL 读取相同的数据,既浪费了 MySQL 的查询处理能力,也浪费了网络和磁盘 I/O。
缓存(如 Redis)的定位是:将高频读取、低频变化的数据存储在更快的介质(内存)中,用于吸收大量重复读请求,从而降低 MySQL 的负载。Redis 的单机读 QPS 可以达到 10 万以上,延迟在微秒到毫秒级别——比 MySQL 快 1-2 个数量级。
缓存不是万能的,它解决的是读热点问题,而不是写入压力。对于写密集型场景(如计数器、实时排行榜),缓存的策略与读热点场景完全不同。
1.2 缓存带来的复杂性
引入缓存后,系统从”只有 MySQL 一个数据源”变成了”MySQL + Redis 两个数据源”。任何时候两个数据源的数据都可能不一致:
- MySQL 更新了,Redis 还是旧值
- Redis 的键已经过期,MySQL 还没被查询到
一致性的程度和实现成本是反比的:要求越强的一致性,实现越复杂,性能开销越大。实践中需要根据业务对一致性的容忍程度来选择合适的策略。
第 2 章 四种缓存读写策略
2.1 Cache Aside(旁路缓存)——最常用
读取流程:
- 先查 Redis,命中则直接返回
- 未命中则查 MySQL,将结果写入 Redis(设置过期时间),返回结果
写入流程:
- 更新 MySQL
- 删除(或更新)Redis 中对应的缓存
特点:应用层直接管理 Redis 和 MySQL,逻辑清晰,是互联网应用最常用的模式。
2.2 Read Through(读穿透)
读取流程:应用只与缓存交互,缓存未命中时,由缓存组件自动去 MySQL 加载数据。
与 Cache Aside 的区别:Cache Aside 中应用层主动负责加载;Read Through 中缓存组件封装了”未命中则从数据库加载”的逻辑,应用层只看到缓存。
适合用缓存中间件(如 Ehcache + Hibernate 的二级缓存)封装的场景,互联网应用中直接使用 Redis 时不常用。
2.3 Write Through(写穿透)
写入流程:应用向缓存写入,缓存同步将写操作穿透到 MySQL。
优点:写操作保证 MySQL 和缓存同步,一致性较强。
缺点:每次写都需要更新两个存储,写入延迟增加;如果写入的数据不会被读取(“冷写”),缓存被无效数据填满。
2.4 Write Behind(异步写回)
写入流程:应用只写缓存,由异步任务将缓存中的修改批量回写到 MySQL。
优点:写入极快(只写内存),可以合并多次对同一键的修改(如计数器 +1 多次,只需最后写一次),大幅减少 MySQL 写入次数。
缺点:数据丢失风险——Redis 崩溃时,未回写的数据会丢失;实现复杂,需要处理回写失败、顺序保证等问题。
适合对实时持久化要求不高的场景,如游戏的游戏状态、会话数据、实时计数器等。
核心概念
四种策略的本质差异在于:谁负责填充缓存、谁负责更新 MySQL、同步还是异步。Cache Aside 把决策权完全交给应用层,灵活但需要应用层处理一致性逻辑;Write Through/Read Through 将逻辑封装在缓存层;Write Behind 追求极致写性能,牺牲一致性保证。
第 3 章 Cache Aside 的一致性问题深度分析
3.1 两种写入顺序的选择
Cache Aside 的写入流程有两种实现方式,两种都有问题:
方式一:先删缓存,后更新 MySQL
T1: 删除 Redis 中的 key
T2: 更新 MySQL(将值从 100 改为 200)
问题场景:
线程 A(写操作) 线程 B(读操作)
T1: 删除 Redis key
T2: 查 Redis,未命中
T3: 查 MySQL,读到旧值 100
T4: 更新 MySQL 为 200
T5: 将旧值 100 写入 Redis(过期时间 30 分钟)
结果:Redis 中存入了旧值 100,在缓存过期前,所有读请求都会得到错误的旧值 100,而 MySQL 中已经是 200。
方式二:先更新 MySQL,后删缓存(推荐)
T1: 更新 MySQL(将值从 100 改为 200)
T2: 删除 Redis 中的 key
问题场景(理论上存在,实践中概率极低):
线程 A(读操作,此时缓存恰好过期) 线程 B(写操作)
T1: 查 Redis,未命中(缓存刚好过期)
T2: 查 MySQL,读到旧值 100
T3: 更新 MySQL 为 200
T4: 删除 Redis key(key 不存在,删除是幂等的)
T5: 将旧值 100 写入 Redis(过期时间 30 分钟)
结果:Redis 中存入了旧值 100。
这个问题场景需要同时满足:
- 缓存恰好在 T1 和 T5 之间过期(概率已经较低)
- 写操作(T3-T4)在 T1(读 MySQL 之前)和 T5(写 Redis 之后)之间完成(需要写操作比读 MySQL + 写 Redis 的时间还短,而写操作通常更慢)
在实际工程中,这种并发窗口极短,概率非常低。这就是”先更新 MySQL,后删缓存”比”先删缓存,后更新 MySQL”更推荐的原因。
3.2 延迟双删策略
对于”先删缓存,后更新 MySQL”的情况,有一种被广泛采用的改进方案叫延迟双删(Delay Double Delete):
// 第一次删除(清除旧缓存)
redis.delete(key);
// 更新数据库
db.update(record);
// 延迟 500ms 后第二次删除(清除在数据库更新期间可能被写入的旧值)
Thread.sleep(500);
redis.delete(key);第二次删除的目的是:清除在”第一次删除”和”数据库更新完成”之间,可能被其他线程将旧数据写入缓存的”污染值”。
延迟双删的问题:
Thread.sleep(500)会阻塞当前线程,通常需要改为异步执行(提交到线程池)- 500ms 的延迟是经验值,不够精确——如果数据库更新超过 500ms,仍然可能有窗口
- 第二次删除之后,如果仍有读请求在第二次删除之前读了旧数据写入缓存,问题仍然存在(不过此时窗口极小)
3.3 消息队列异步删除
一种更健壮的方案是:将缓存删除操作发送到消息队列,由消费者异步执行。
写流程:
1. 更新 MySQL
2. 发送"删除缓存"消息到 MQ(仅发消息,不等结果)
消费者:
1. 消费消息,执行 redis.delete(key)
2. 如果删除失败,消息重试(MQ 的重试机制)
优点:
- 数据库更新和缓存删除解耦,主流程不阻塞在缓存操作上
- 消息队列的重试机制保证最终删除成功(最终一致性)
- 业务代码更简洁
缺点:
- 引入了 MQ 依赖
- 消息消费有延迟(通常几毫秒到几百毫秒),期间缓存中可能是旧值
3.4 Canal + Binlog 订阅:最优雅的方案
最彻底的解耦方案是:业务代码只更新 MySQL,由专门的组件监听 MySQL 的 Binlog 变更,自动更新 Redis。
Canal 是阿里开源的 MySQL Binlog 订阅工具,伪装成 MySQL Slave 来接收主库的 Binlog,然后解析后推送给下游消费者。
架构图:
MySQL Master → Binlog → Canal Server → 消费者(更新 Redis)
工作流程:
- MySQL 事务提交,将修改写入 Binlog
- Canal 接收到 Binlog 事件,解析出”哪张表的哪行数据发生了什么变化”
- Canal 消费者根据变更内容,决定删除或更新 Redis 中的对应缓存
优点:
- 业务代码零侵入:业务代码不需要任何与缓存相关的逻辑,只需正常更新数据库
- 强一致语义:Binlog 中的变更是已提交事务的变更,不会有”数据库更新失败但缓存已删除”的问题(因为只有数据库真正提交了,Canal 才会收到 Binlog)
- 天然顺序保证:Binlog 是有序的,Canal 消费顺序与数据库写入顺序一致
缺点:
- 架构更复杂,需要维护 Canal 集群
- 有延迟(Binlog 写入 → Canal 读取 → 消费 → 更新 Redis,总延迟可能在几十毫秒到秒级)
设计哲学
这几种方案体现了一个从紧耦合到松耦合的演进:直接在业务代码中操作缓存(最紧耦合,控制精确但代码复杂)→ 消息队列异步删除(中间层解耦)→ Canal 订阅 Binlog(完全解耦,业务代码无感知)。耦合度越低,架构越灵活,但复杂度也越高。选择哪种方案取决于团队的运维能力和业务对一致性延迟的容忍度。
第 4 章 缓存失效的三大故障模式
4.1 缓存穿透(Cache Penetration)
现象:大量请求查询的 key 在 Redis 和 MySQL 中都不存在,每次都穿透缓存直接打到 MySQL,且由于 MySQL 也没有结果,无法写入缓存(写入了什么?)——每次请求都会打到 MySQL。
根因:请求的 key 对应的数据根本不存在,缓存无法起到屏蔽作用。典型的攻击场景:用大量不存在的 user_id 请求接口,绕过缓存直接打垮 MySQL。
防御方案一:缓存空值
即使 MySQL 中没有数据,也在 Redis 中缓存一个空值(如 "null" 或空对象),设置较短的过期时间(如 60 秒):
String value = redis.get(key);
if (value == null) {
Record record = db.get(key);
if (record == null) {
redis.setex(key, 60, "NULL"); // 缓存空值,60秒过期
return null;
}
redis.setex(key, 3600, serialize(record));
return record;
}
return "NULL".equals(value) ? null : deserialize(value);缺点:如果攻击者使用大量不同的不存在 key,仍然会导致 Redis 被大量空值填满。
防御方案二:布隆过滤器(Bloom Filter)
在缓存前面加一层布隆过滤器:将所有合法的 key 提前存入布隆过滤器,请求到来时先判断 key 是否在过滤器中,不在则直接返回空,不访问缓存和数据库。
布隆过滤器是一种概率性数据结构,有很小的误判率(可能将不存在的 key 判断为存在,但不会将存在的 key 判断为不存在)。这个方向的误判是可接受的:最坏情况是偶尔放过一个不存在的 key 到缓存层,缓存空值即可。
4.2 缓存击穿(Cache Breakdown / Hotspot)
现象:某个热点 key 在缓存中过期的瞬间,大量并发请求同时到来,全部穿透缓存打到 MySQL,导致 MySQL 瞬间压力剧增。
根因:热点 key 的过期导致大量请求同时回源,形成”惊群效应”。
防御方案一:互斥锁(Mutex)
缓存未命中时,先尝试获取分布式锁(如 Redis 的 SET NX),获取到锁的线程去 MySQL 查询并写入缓存,其他未获取到锁的线程等待或短暂 sleep 后重试:
String value = redis.get(key);
if (value == null) {
String lockKey = "lock:" + key;
boolean locked = redis.setnx(lockKey, "1", 10); // 10秒锁超时
if (locked) {
try {
value = db.get(key);
redis.setex(key, 3600, value);
} finally {
redis.delete(lockKey);
}
} else {
Thread.sleep(50); // 未获取锁,等待后重试
return get(key); // 递归重试
}
}
return value;防御方案二:逻辑过期(Logical Expiration)
不设置 Redis 的物理过期时间,而是在 value 中存储一个”逻辑过期时间”。读取时检查逻辑时间是否已过期:
- 未过期:直接返回旧值(永远不会因物理过期而缓存击穿)
- 已过期:返回旧值(不影响当前请求),同时异步启动一个后台线程去 MySQL 查询并更新缓存
这种方案牺牲了一定的数据新鲜度(过期后仍然返回旧值,直到后台线程更新),换取了零缓存击穿的可用性保证。适合允许短暂数据延迟的热点场景。
4.3 缓存雪崩(Cache Avalanche)
现象:大量缓存 key 同时过期(或 Redis 实例崩溃),大量请求同时涌向 MySQL,导致 MySQL 过载甚至宕机。
根因:
- 同时过期:大批 key 设置了相同的过期时间(如批量写入时统一设置
EXPIRE 3600),在 3600 秒后同时失效 - Redis 宕机:Redis 实例崩溃,全部缓存失效,所有请求涌向 MySQL
防御方案一:过期时间加随机抖动
在基础过期时间上加一个随机偏移,避免大量 key 同时过期:
// 不要这样做(所有 key 同一时间过期)
redis.setex(key, 3600, value);
// 应该这样做(过期时间在 3400-3800 秒之间随机分布)
int ttl = 3600 + ThreadLocalRandom.current().nextInt(-200, 200);
redis.setex(key, ttl, value);防御方案二:Redis 高可用
通过 Redis Cluster 或 Sentinel 保证 Redis 的高可用,避免单点故障导致全量缓存失效。
防御方案三:熔断与限流
在 MySQL 前加熔断器(如 Hystrix/Resilience4j),当 MySQL 的响应时间超过阈值时,触发熔断,直接返回降级结果(如”服务繁忙,请稍后重试”),避免 MySQL 被雪崩压垮。
防御方案四:多级缓存
引入本地缓存(Caffeine、Guava Cache)作为 Redis 之前的第一级缓存。即使 Redis 全部失效,本地缓存仍然可以吸收部分请求。缺点是本地缓存的数据一致性更难保证(各实例的本地缓存可能不同步)。
第 5 章 特殊场景:读写分离下的缓存策略
5.1 主从延迟带来的缓存污染
在使用 MySQL 主从复制 + 读写分离的架构中,写操作走主库,读操作走从库。由于主从延迟(通常几毫秒到几秒),从库的数据可能滞后于主库。
如果缓存策略是”更新数据库后删除缓存”,可能发生:
T1: 更新主库(值改为 200)
T2: 删除 Redis 缓存
T3: 读请求到来,Redis 未命中,查从库(从库尚未同步,读到旧值 100)
T4: 将旧值 100 写入 Redis
结果:缓存中存了从库的旧值 100,而不是主库的新值 200。
解决方案:
- 写后强制路由主库:数据更新后,使用同一事务标识或”主库路由标记”,在接下来的一段时间内(如 500ms)将对该 key 的读请求路由到主库,而不是从库
- 缓存中存储版本号:在缓存 value 中附加版本号,写入时对比版本号,低版本不覆盖高版本(防止从库旧值覆盖主库新值)
第 6 章 小结
本文的核心知识框架:
- 四种缓存策略定位:Cache Aside(应用主控,最灵活)、Read Through(缓存封装加载逻辑)、Write Through(同步穿透写)、Write Behind(异步批量回写,最高性能,最低一致性)
- Cache Aside 的一致性选择:先更新 MySQL 后删缓存的不一致窗口极短(实践中可接受),优于先删缓存后更新 MySQL
- 缓存删除的三种实现:直接删除(简单,偶有不一致)→ 消息队列异步删除(解耦,最终一致)→ Canal 订阅 Binlog(零侵入,架构更复杂)
- 三大故障模式:
- 穿透(key 不存在)→ 缓存空值 / 布隆过滤器
- 击穿(热点 key 过期瞬间)→ 互斥锁 / 逻辑过期
- 雪崩(大量 key 同时过期 / Redis 宕机)→ 随机 TTL 抖动 / Redis 高可用 / 熔断限流
- 主从延迟下的特殊处理:写后强制路由主库或版本号保护,避免从库旧值污染缓存
思考题
- MySQL 的
utf8字符集实际上只支持最多 3 字节的 UTF-8 字符——不能存储 emoji(4 字节)。utf8mb4是真正的 UTF-8。很多旧系统使用utf8导致无法存储 emoji。从utf8迁移到utf8mb4需要修改表和列的字符集——在大表(亿级行)上ALTER TABLE可能需要数小时。你如何在不停机的情况下完成字符集迁移?- 排序规则(Collation)决定了字符串的比较和排序方式。
utf8mb4_general_ci(不区分大小写,不区分重音)和utf8mb4_unicode_ci(更精确的 Unicode 排序)有什么区别?MySQL 8.0 默认的utf8mb4_0900_ai_ci相比前两者有什么改进?在什么场景下排序规则的选择会影响查询结果(如德语的 ß 和 ss 是否相等)?- 字符集在连接层面也需要一致——
character_set_connection、character_set_client、character_set_results必须与应用的编码一致。如果 JDBC 连接参数中未指定characterEncoding=utf8mb4,可能导致中文乱码。你如何排查字符集相关的乱码问题?SHOW VARIABLES LIKE 'character_set%'中哪些变量最关键?