MySQL 实战精通系列 · 第11篇:Redis 缓存与 MySQL 一致性实战
本篇目标:缓存和数据库怎么配合、数据不一致怎么解决、缓存击穿/穿透/雪崩怎么防------从缓存策略到分布式锁,系统化掌握缓存一致性方案。
文章目录
- [MySQL 实战精通系列 · 第11篇:Redis 缓存与 MySQL 一致性实战](#MySQL 实战精通系列 · 第11篇:Redis 缓存与 MySQL 一致性实战)
-
- 一、为什么需要缓存
-
- [1.1 缓存的核心价值](#1.1 缓存的核心价值)
- [1.2 一张图看懂缓存位置](#1.2 一张图看懂缓存位置)
- 二、缓存更新策略
-
- [2.1 四种更新策略对比](#2.1 四种更新策略对比)
- [2.2 Cache Aside:推荐方案](#2.2 Cache Aside:推荐方案)
- [2.3 先删缓存还是先更新 DB](#2.3 先删缓存还是先更新 DB)
- 三、缓存一致性深入
-
- [3.1 不一致的根本原因](#3.1 不一致的根本原因)
- [3.2 强一致方案](#3.2 强一致方案)
- [3.3 缓存一致性实战代码](#3.3 缓存一致性实战代码)
- 四、缓存三大问题
-
- [4.1 缓存穿透](#4.1 缓存穿透)
- [4.2 缓存击穿](#4.2 缓存击穿)
- [4.3 缓存雪崩](#4.3 缓存雪崩)
- 五、分布式锁
-
- [5.1 为什么需要分布式锁](#5.1 为什么需要分布式锁)
- [5.2 Redis 分布式锁实现](#5.2 Redis 分布式锁实现)
- [5.3 Redisson:生产级分布式锁](#5.3 Redisson:生产级分布式锁)
- [5.4 分布式锁对比](#5.4 分布式锁对比)
- [六、Redis 高可用](#六、Redis 高可用)
-
- [6.1 三种架构对比](#6.1 三种架构对比)
- [6.2 哨兵模式](#6.2 哨兵模式)
- [6.3 集群模式](#6.3 集群模式)
- 七、实战任务
- 八、本篇小结
一、为什么需要缓存
1.1 缓存的核心价值
缓存的价值
│
├── ① 降低数据库压力 ← 读请求走缓存,MySQL 只扛写
├── ② 提升响应速度 ← Redis 内存操作,微秒级
├── ③ 扛住高并发 ← 缓存层水平扩展容易
└── ④ 保护后端服务 ← 限流、降级的基础
原则:缓存是性能优化的第一手段,但缓存引入了一致性问题,必须提前设计。
1.2 一张图看懂缓存位置
用户请求
│
↓
① 本地缓存(Caffeine / Guava)
│ 未命中
↓
② 分布式缓存(Redis)
│ 未命中
↓
③ MySQL 数据库
│
↓
回填缓存
多级缓存原则:
| 层级 | 存储 | 速度 | 容量 | 一致性 |
|---|---|---|---|---|
| 本地缓存 | JVM 内存 | 纳秒级 | 小 | 差 |
| Redis | 内存 | 微秒级 | 大 | 较好 |
| MySQL | 磁盘 | 毫秒级 | 最大 | 最好 |
二、缓存更新策略
2.1 四种更新策略对比
| 策略 | 原理 | 一致性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| Cache Aside | 先更新 DB,再删缓存 | 较好 | 低 | 通用首选 |
| Read/Write Through | 缓存层代理 DB 读写 | 好 | 中 | 缓存中间件 |
| Write Behind | 只写缓存,异步刷 DB | 差 | 中 | 写多读少 |
| 双写 | 同时写 DB 和缓存 | 差 | 低 | 不推荐 |
2.2 Cache Aside:推荐方案
读流程:
① 读缓存
② 命中 → 返回
③ 未命中 → 读 DB
④ 写入缓存
⑤ 返回
写流程:
① 更新 DB
② 删除缓存
为什么是删除缓存而不是更新缓存:
更新缓存的问题:
├── 并发写时,缓存值可能被旧数据覆盖
├── 缓存值可能是复杂计算结果,更新成本高
└── 如果缓存值很少被读,更新是浪费
删除缓存的优势:
├── 简单,下次读时自然回填
├── 避免并发写覆盖问题
└── 懒加载,只缓存热数据
2.3 先删缓存还是先更新 DB
方案 A:先删缓存,再更新 DB
问题:并发读时,读线程在删缓存后、更新 DB 前读到旧数据,回填旧值
方案 B:先更新 DB,再删缓存(推荐)
问题:极小概率下,读线程在更新 DB 前读缓存未命中,读到旧 DB 值,
然后写线程更新 DB 并删缓存,读线程回填旧值
→ 概率极低,且可通过延迟双删兜底
延迟双删:
java
// 更新 DB
updateDB(data);
// 删除缓存
redis.delete(key);
// 延迟 500ms 再删一次
Thread.sleep(500);
redis.delete(key);
三、缓存一致性深入
3.1 不一致的根本原因
并发场景:
时刻 线程A(写) 线程B(读)
T1 更新 DB = 2
T2 读缓存 miss
T3 读 DB = 2(读到新值)
T4 写缓存 = 2
T5 删除缓存
→ 缓存 = 2,DB = 2,一致 ✓
时刻 线程A(写) 线程B(读)
T1 读缓存 miss
T2 读 DB = 1(旧值)
T3 更新 DB = 2
T4 删除缓存
T5 写缓存 = 1(旧值)
→ 缓存 = 1,DB = 2,不一致 ✗
结论:先更新 DB 再删缓存,在极端并发下仍可能不一致,但概率极低。
3.2 强一致方案
| 方案 | 原理 | 性能 | 适用场景 |
|---|---|---|---|
| 分布式锁 | 读写都加锁 | 差 | 强一致要求 |
| 订阅 binlog | Canal 监听,异步删缓存 | 好 | 最终一致 |
| 延迟双删 | 删两次缓存 | 中 | 兜底方案 |
Canal 方案:
MySQL binlog → Canal → MQ → 缓存删除服务 → 删除 Redis
优势:
① 业务代码无侵入
② 保证最终一致
③ 异步解耦
3.3 缓存一致性实战代码
java
public Product getProduct(Long id) {
String key = "product:" + id;
// ① 读缓存
String cached = redis.get(key);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
// ② 读 DB
Product product = productMapper.selectById(id);
if (product == null) {
// 防穿透:缓存空值
redis.setex(key, 60, "");
return null;
}
// ③ 写缓存
redis.setex(key, 3600, JSON.toJSONString(product));
return product;
}
@Transactional
public void updateProduct(Product product) {
// ① 更新 DB
productMapper.updateById(product);
// ② 删除缓存
redis.delete("product:" + product.getId());
// ③ 延迟双删(可选)
delayDelete("product:" + product.getId(), 500);
}
四、缓存三大问题
4.1 缓存穿透
问题:查询不存在的数据,缓存和 DB 都没有
→ 每次请求都打到 DB
场景:恶意攻击,用不存在的 ID 刷接口
解决方案:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 缓存空值 | 不存在也缓存,设短过期 | 简单 | 占内存 |
| 布隆过滤器 | 预判 key 是否存在 | 内存小 | 有误判 |
| 参数校验 | 拦截非法请求 | 从源头 | 不通用 |
布隆过滤器实现:
java
// 初始化布隆过滤器
BloomFilter<Long> bloomFilter = BloomFilter.create(
Funnels.longFunnel(),
1000000, // 预期元素数量
0.01 // 误判率
);
// 写入时加入
bloomFilter.put(productId);
// 查询前判断
if (!bloomFilter.mightContain(productId)) {
return null; // 一定不存在
}
4.2 缓存击穿
问题:热点 key 过期瞬间,大量请求同时打到 DB
→ DB 压力骤增
场景:秒杀商品、热门新闻
解决方案:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 互斥锁 | 只让一个线程重建缓存 | 简单 | 有等待 |
| 逻辑过期 | 不设物理过期,异步更新 | 无等待 | 实现复杂 |
| 永不过期 | 热点 key 不设过期 | 简单 | 需手动更新 |
互斥锁实现:
java
public Product getProductWithLock(Long id) {
String key = "product:" + id;
String cached = redis.get(key);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
// 获取分布式锁
String lockKey = "lock:product:" + id;
try {
boolean locked = redis.setnx(lockKey, "1", 10);
if (locked) {
// 双重检查
cached = redis.get(key);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
// 查 DB 并回填
Product product = productMapper.selectById(id);
redis.setex(key, 3600, JSON.toJSONString(product));
return product;
} else {
// 未获取锁,等待后重试
Thread.sleep(50);
return getProductWithLock(id);
}
} finally {
redis.delete(lockKey);
}
}
4.3 缓存雪崩
问题:大量 key 同时过期,或 Redis 宕机
→ 所有请求打到 DB,DB 崩溃
场景:凌晨批量刷新缓存、Redis 集群故障
解决方案:
| 方案 | 原理 |
|---|---|
| 过期时间加随机值 | 避免同时过期 |
| 多级缓存 | 本地缓存兜底 |
| 熔断降级 | DB 压力大时拒绝请求 |
| Redis 高可用 | 主从 + 哨兵 + 集群 |
过期时间加随机值:
java
// 错误:所有 key 都是 3600 秒
redis.setex(key, 3600, value);
// 正确:加随机值,避免同时过期
int expire = 3600 + RandomUtils.nextInt(0, 600);
redis.setex(key, expire, value);
五、分布式锁
5.1 为什么需要分布式锁
场景:秒杀扣库存
① 读库存 = 10
② 判断 > 0
③ 扣减库存 = 9
问题:并发时,多个线程同时读到 10,都扣减 → 超卖
5.2 Redis 分布式锁实现
基础版(有缺陷):
java
// 加锁
Boolean locked = redis.setnx("lock:stock", "1");
if (locked) {
try {
// 业务逻辑
} finally {
redis.delete("lock:stock"); // 问题:可能删别人的锁
}
}
改进版(SET NX EX):
java
// 加锁:原子操作,设置过期时间
String requestId = UUID.randomUUID().toString();
Boolean locked = redis.set("lock:stock", requestId, "NX", "EX", 10);
if (locked) {
try {
// 业务逻辑
} finally {
// 释放锁:判断是自己的锁才删(Lua 脚本保证原子性)
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end";
redis.eval(script, Collections.singletonList("lock:stock"),
Collections.singletonList(requestId));
}
}
5.3 Redisson:生产级分布式锁
java
// 加锁
RLock lock = redisson.getLock("lock:stock");
try {
// 尝试加锁,最多等待 10 秒,锁自动续期
boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (locked) {
// 业务逻辑
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
Redisson 的优势:
① 自动续期(看门狗机制)
→ 业务未执行完,锁不会过期
② 可重入
→ 同一线程可多次加锁
③ 支持多种锁
→ 公平锁、读写锁、联锁
④ 高可用
→ RedLock 算法,多节点容错
5.4 分布式锁对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Redis SET NX | 简单、性能好 | 需自行处理续期 | 一般场景 |
| Redisson | 功能全、自动续期 | 依赖 Redis | 生产首选 |
| ZooKeeper | 强一致、临时节点 | 性能差 | 强一致要求 |
| etcd | 强一致、租约 | 生态弱 | K8s 环境 |
六、Redis 高可用
6.1 三种架构对比
| 架构 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 主从 | 一主多从,读写分离 | 简单 | 故障需手动切换 |
| 哨兵 | 主从 + 自动故障转移 | 自动切换 | 写能力受限 |
| 集群 | 分片 + 主从 | 水平扩展 | 复杂度高 |
6.2 哨兵模式
哨兵架构
│
├── Sentinel 1 ──┐
├── Sentinel 2 ──┼── 监控 Master
├── Sentinel 3 ──┘
│
├── Master(写)
├── Slave 1(读)
└── Slave 2(读)
故障转移:
① Sentinel 发现 Master 宕机
② 多数派确认
③ 选举新 Master
④ 其他 Slave 指向新 Master
⑤ 通知客户端
6.3 集群模式
Redis Cluster
│
├── 分片:16384 个槽位
├── 每个 Master 负责一部分槽
├── 每个 Master 有多个 Slave
└── 客户端根据 key 计算槽位,路由到对应节点
槽位计算:
slot = CRC16(key) % 16384
七、实战任务
任务清单
- 实现 Cache Aside 模式的读写逻辑
- 用布隆过滤器解决缓存穿透
- 用互斥锁解决缓存击穿
- 用随机过期时间解决缓存雪崩
- 用 Redisson 实现分布式锁
- 搭建 Redis 哨兵模式
- 用 Canal 实现缓存自动删除
自检问题
- 缓存更新有哪四种策略?为什么推荐 Cache Aside?
- 为什么是删除缓存而不是更新缓存?
- 先删缓存还是先更新 DB?为什么?
- 什么是延迟双删?解决什么问题?
- 缓存穿透、击穿、雪崩的区别?各自的解决方案?
- Redis 分布式锁的 SET NX EX 为什么比 SETNX 好?
- Redisson 的看门狗机制是什么?
- Redis 哨兵和集群的区别?
八、本篇小结
第11篇 核心收获
│
├── 缓存价值
│ ├── 降低 DB 压力
│ ├── 提升响应速度
│ └── 扛住高并发
│
├── 更新策略
│ ├── Cache Aside:先更新 DB,再删缓存
│ ├── Read/Write Through:缓存代理
│ ├── Write Behind:异步刷 DB
│ └── 双写:不推荐
│
├── 一致性
│ ├── 先更新 DB 再删缓存(推荐)
│ ├── 延迟双删(兜底)
│ ├── 分布式锁(强一致)
│ └── Canal + binlog(最终一致)
│
├── 三大问题
│ ├── 穿透:缓存空值 / 布隆过滤器
│ ├── 击穿:互斥锁 / 逻辑过期
│ └── 雪崩:随机过期 / 多级缓存
│
├── 分布式锁
│ ├── SET NX EX:基础版
│ ├── Lua 脚本:安全释放
│ └── Redisson:生产级
│
└── Redis 高可用
├── 主从:读写分离
├── 哨兵:自动切换
└── 集群:水平扩展
下一篇:第12篇《综合实战:电商数据库全流程》