MySQL与Redis缓存一致性问题详解及解决方案
一、缓存一致性问题的背景
在高并发互联网系统中,数据库往往是系统性能的瓶颈。即使使用 MySQL 的索引优化、读写分离、分库分表等技术,在访问量持续增长时仍然会出现性能压力。因此,在架构设计中通常会引入 Redis 作为缓存层。
典型架构如下:
客户端 → 应用服务 → Redis → MySQL
读取流程:
1. 先查 Redis
2. Redis 未命中再查 MySQL
3. 查询结果写入 Redis
4. 返回数据
写入流程:
1. 更新 MySQL
2. 更新或删除 Redis
这种架构虽然能显著降低数据库压力,但同时带来了新的问题:缓存与数据库数据不一致。
缓存一致性问题本质是:
缓存中的数据与数据库中的数据出现短暂或长期的不一致状态。
在高并发系统中,这个问题不可完全避免,但可以通过合理的架构设计将问题控制在可接受范围。
二、缓存一致性的核心问题
缓存一致性问题主要来源于以下几个原因:
1 数据更新顺序问题
假设存在以下操作顺序:
线程A:更新数据库
线程B:读取数据库
线程A:删除缓存
如果线程B在缓存删除之前读取数据库,并将旧数据写入缓存,就会出现:
缓存 = 旧数据
数据库 = 新数据
这就是经典的 缓存脏数据问题。
2 并发读写问题
高并发场景下常见流程:
1. 查询缓存
2. 缓存不存在
3. 查询数据库
4. 写入缓存
如果多个线程同时查询:
线程1:缓存未命中
线程2:缓存未命中
线程3:缓存未命中
所有线程都会访问数据库。
这会导致:
- 数据库瞬间压力暴增
- 缓存击穿
3 延迟写入问题
当系统采用异步写缓存时,可能出现:
数据库已经更新
缓存还未更新
导致短时间内用户读取到旧数据。
三、缓存一致性的三种常见模式
业界常见的缓存模式主要有三种:
- Cache Aside Pattern(旁路缓存)
- Read Through / Write Through
- Write Behind
其中最常用的是 Cache Aside Pattern。
四、Cache Aside Pattern(旁路缓存)
旁路缓存是互联网系统最常用的模式。
读取流程:
1 查询缓存
2 未命中查询数据库
3 写入缓存
4 返回数据
示例代码(Java):
public User getUser(Long userId) {
String key = "user:" + userId;
String cache = redisTemplate.opsForValue().get(key);
if (cache != null) {
return JSON.parseObject(cache, User.class);
}
User user = userMapper.selectById(userId);
if (user != null) {
redisTemplate.opsForValue().set(key, JSON.toJSONString(user), 10, TimeUnit.MINUTES);
}
return user;
}
更新流程
更新通常采用:
1 更新数据库
2 删除缓存
示例代码:
@Transactional
public void updateUser(User user) {
userMapper.updateById(user);
String key = "user:" + user.getId();
redisTemplate.delete(key);
}
为什么删除缓存而不是更新缓存
更新缓存存在以下问题:
- 业务复杂
- 可能遗漏字段
- 容易产生并发问题
删除缓存更加简单可靠。
因此行业最佳实践:
更新数据库 → 删除缓存
五、双写不一致问题
即使采用 Cache Aside 模式,仍然会存在并发问题。
典型流程:
线程A:更新数据库
线程B:读取数据库
线程A:删除缓存
线程B:写入旧缓存
最终:
Redis = 旧数据
MySQL = 新数据
解决方案一:延迟双删
延迟双删是非常常见的解决方案。
流程:
1 更新数据库
2 删除缓存
3 延迟一段时间
4 再次删除缓存
Java示例:
@Transactional
public void updateUser(User user) {
userMapper.updateById(user);
String key = "user:" + user.getId();
redisTemplate.delete(key);
executorService.submit(() -> {
try {
Thread.sleep(500);
} catch (InterruptedException ignored) {
}
redisTemplate.delete(key);
});
}
延迟时间如何设置
延迟时间需要满足:
数据库查询时间 < 延迟时间
一般推荐:
300ms ~ 1s
优点
- 实现简单
- 成本低
- 对绝大多数业务有效
缺点
- 仍然不是强一致
- 依赖经验配置延迟时间
六、基于消息队列保证一致性
在复杂系统中,通常采用 MQ方案。
更新流程:
1 更新数据库
2 发送消息
3 消费者删除缓存
架构:
应用 → MySQL
→ MQ → Cache Service → Redis
优点
1 解耦业务 2 提高可靠性 3 支持失败重试
示例流程
生产者:
@Transactional
public void updateUser(User user) {
userMapper.updateById(user);
mqProducer.send("cache_delete_topic", user.getId());
}
消费者:
@KafkaListener(topics = "cache_delete_topic")
public void deleteCache(Long userId) {
String key = "user:" + userId;
redisTemplate.delete(key);
}
消息可靠性设计
必须保证:
数据库更新成功 → 消息必须发送成功
解决方案:
事务消息
常见实现:
- RocketMQ事务消息
- Outbox Pattern
七、基于 Binlog 的缓存更新
在大型系统中,更常见的方案是:
监听 MySQL Binlog 同步缓存。
架构:
MySQL → Binlog → Canal → Cache Service → Redis
流程:
1 MySQL数据更新
2 生成Binlog
3 Canal监听Binlog
4 解析数据变化
5 更新或删除Redis
优点
- 完全解耦业务代码
- 数据变化自动同步
- 适用于大型系统
Canal 架构
MySQL
│
Binlog
│
Canal Server
│
消息队列
│
缓存服务
│
Redis
示例数据变更事件
Binlog解析后:
table = user
type = UPDATE
id = 1001
缓存服务执行:
DEL user:1001
八、缓存击穿问题
缓存击穿指:
热点Key突然失效
大量请求直接访问数据库
解决方案:
1 互斥锁
只允许一个线程查询数据库。
示例:
String key = "user:" + userId;
String value = redis.get(key);
if (value == null) {
String lockKey = "lock:user:" + userId;
if (redis.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS)) {
User user = userMapper.selectById(userId);
redis.set(key, JSON.toJSONString(user), 10, TimeUnit.MINUTES);
redis.delete(lockKey);
return user;
} else {
Thread.sleep(50);
return getUser(userId);
}
}
2 热点数据永不过期
热点数据可以设置:
TTL = -1
由后台任务定期更新。
九、缓存穿透问题
缓存穿透是指:
查询一个不存在的数据
缓存和数据库都不存在
每次请求都会访问数据库
攻击场景:
userId = -1
解决方案一:缓存空值
key = user:-1
value = null
ttl = 1分钟
解决方案二:布隆过滤器
布隆过滤器可以在访问数据库之前判断:
数据是否可能存在
结构:
请求 → BloomFilter → Redis → MySQL
RedisBloom示例
BF.ADD user_filter 1001
BF.EXISTS user_filter 1001
十、缓存雪崩问题
缓存雪崩指:
大量Key同时过期
大量请求直接访问数据库
解决方案
1 随机过期时间
TTL = base + random
示例:
int ttl = 600 + new Random().nextInt(300);
2 多级缓存
本地缓存 (Caffeine)
↓
Redis
↓
MySQL
架构:
Client
↓
Application
↓
Caffeine
↓
Redis
↓
MySQL
十一、示例数据库结构
用户表示例:
CREATE TABLE user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(64) NOT NULL,
email VARCHAR(128),
status TINYINT DEFAULT 1,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
KEY idx_username(username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
十二、缓存一致性方案对比
| 方案 | 一致性 | 复杂度 | 适用场景 |
|---|---|---|---|
| Cache Aside | 最终一致 | 低 | 中小系统 |
| 延迟双删 | 较高 | 低 | 常规互联网业务 |
| MQ删除缓存 | 高 | 中 | 高并发系统 |
| Binlog同步 | 很高 | 高 | 大型分布式系统 |
十三、最佳实践总结
高并发系统推荐架构:
+----------------+
| BloomFilter |
+--------+-------+
|
v
Client → Application → Redis
|
v
MySQL
写入流程:
1 更新数据库
2 删除缓存
3 延迟双删
复杂系统:
MySQL → Binlog → Canal → MQ → Cache Service → Redis
核心原则:
- 优先保证数据库正确
- 缓存只作为加速层
- 允许短暂不一致
互联网系统中很少追求强一致缓存,因为成本极高。
合理的目标是:
在性能与一致性之间取得平衡,实现最终一致性。