第 194 章:Redis 缓存模式与一致性
学习目标
- 掌握 4 种缓存读写模式
- 理解缓存一致性问题
- 学会 Cache Aside Pattern
- 了解 Read/Write Through、Write Behind
一、缓存的四种经典模式
| 模式 | 读 | 写 |
|---|---|---|
| Cache-Aside | 应用控制 | 应用控制 |
| Read-Through | 缓存层控制 | 应用控制 |
| Write-Through | 缓存层控制 | 缓存层同步 |
| Write-Behind | 缓存层控制 | 缓存层异步 |
二、Cache-Aside(旁路缓存,最常用)
2.1 流程
2.2 实现
java
@Service
public class UserService {
@Autowired
private RedisTemplate<String, User> redisTemplate;
@Autowired
private UserMapper userMapper;
public User findById(Long id) {
String key = "user:" + id;
// 1. 查缓存
User user = redisTemplate.opsForValue().get(key);
if (user != null) {
return user;
}
// 2. 查 DB
user = userMapper.selectById(id);
if (user == null) {
return null;
}
// 3. 写缓存(设过期时间防永久脏)
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
return user;
}
public void update(User user) {
// 1. 更新 DB
userMapper.updateById(user);
// 2. 删除缓存(不是更新!)
redisTemplate.delete("user:" + user.getId());
}
public void delete(Long id) {
userMapper.deleteById(id);
redisTemplate.delete("user:" + id);
}
}2.3 为什么删除而不是更新
java
// ❌ 更新缓存:两个并发写会出现脏数据
// 线程 A: update DB = 1, set cache = 1
// 线程 B: update DB = 2, set cache = 2
// 如果 A 后写缓存,cache=1 但 DB=2,脏数据
// ✅ 删除缓存:下次读才回源,且能容忍并发
// 线程 A: update DB = 1, del cache
// 线程 B: update DB = 2, del cache (幂等)
// 下次读:cache miss → 从 DB=2 加载,正确三、Read-Through
应用只跟缓存交互,缓存自己读 DB。
java
public class ReadThroughCache {
private final Cache cache;
private final DB db;
public User find(Long id) {
return cache.get(id, () -> db.findById(id)); // 缓存层自动回源
}
}Spring Cache 的 @Cacheable 模式类似,只是回源是直接调用方法。
四、Write-Through
写操作同步写 DB 和缓存,缓存层代理。
java
public void save(User user) {
cache.put(user.getId(), user); // 先写缓存
cache.getWriter().write(user); // 缓存层同步写 DB
}特点:一致性高,但写慢。
五、Write-Behind(异步写)
写缓存,缓存异步批量写 DB。
特点:
- ✅ 写极快
- ❌ 数据可能丢失(宕机)
- 场景:点赞数、阅读量(允许少量丢失)
六、缓存一致性问题
6.1 三大来源
6.2 双写不一致(经典场景)
java
// ❌ 先写 DB 再写缓存,可能不一致
public void update(User user) {
userMapper.updateById(user); // DB = 2
redisTemplate.opsForValue().set("user:" + user.getId(), user); // 缓存 = 2
}
// 假设:DB 写完,缓存写之前,另一个读线程读到 DB=2,写入缓存=2
// 没事啊?问题在写过程中...场景 1:写完 DB 后,还没来得及删/写缓存,进程挂了 → 永久脏数据。
场景 2:并发读写导致脏数据(删除缓存方案也可能):
T1: 写 DB = 1
T2: 删缓存
T3: 读 cache miss → 查 DB = 1 → 写 cache = 1 ✅
T1: 写 DB = 2
T2: 删缓存
T3: 读 cache miss → 查 DB = 1(还没到 T1 提交?)→ 写 cache = 1 ❌6.3 解决方案
方案 1:延迟双删
java
public void update(User user) {
userMapper.updateById(user);
// 第一次删
redisTemplate.delete("user:" + user.getId());
// 延迟再删(异步)
CompletableFuture.runAsync(() -> {
try { Thread.sleep(500); } catch (InterruptedException e) {}
redisTemplate.delete("user:" + user.getId());
});
}方案 2:异步监听 binlog(Canal)
java
// Canal 订阅
canal.subscribe("user", event -> {
if (event.isUpdate() || event.isInsert()) {
redisTemplate.delete("user:" + event.getId());
}
});方案 3:版本号 / 分布式锁
java
public void updateWithLock(User user) {
String lockKey = "lock:user:" + user.getId();
RLock lock = redissonClient.getLock(lockKey);
try {
lock.lock();
userMapper.updateById(user);
redisTemplate.delete("user:" + user.getId());
} finally {
lock.unlock();
}
}方案 4:短 TTL + 最终一致
最实用:接受秒级不一致,靠短 TTL 自动过期。
java
redisTemplate.opsForValue().set(key, value, 30, TimeUnit.SECONDS);七、缓存粒度
7.1 全量 vs 字段
java
// 全量缓存:整个 User
User user = userMapper.selectById(id);
redisTemplate.opsForValue().set("user:" + id, user);
// 字段缓存:单独 name
String name = userMapper.selectName(id);
redisTemplate.opsForValue().set("user:" + id + ":name", name);| 粒度 | 优点 | 缺点 |
|---|---|---|
| 全量 | 简单 | 浪费内存,字段更新全失效 |
| 字段 | 灵活 | key 多,代码复杂 |
7.2 推荐:全量 + 短 TTL
大多数业务场景,简单胜过优化。
八、缓存预热
java
@Component
public class CacheWarmup implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
List<User> hotUsers = userMapper.selectHotUsers();
hotUsers.forEach(user ->
redisTemplate.opsForValue().set("user:" + user.getId(), user, 1, TimeUnit.HOURS)
);
}
}九、本章小结
| 模式 | 一致性 | 性能 | 复杂度 |
|---|---|---|---|
| Cache-Aside | 最终 | 高 | 低 |
| Read-Through | 最终 | 高 | 中 |
| Write-Through | 强 | 中 | 中 |
| Write-Behind | 弱 | 极高 | 高 |
推荐
大多数场景用 Cache-Aside + 延迟双删 + 短 TTL。
动手练习
- 实现 Cache-Aside 模式的用户服务
- 用延迟双删解决双写不一致
- 实现启动时缓存预热
- 用 Lua 脚本保证删除缓存的原子性
推荐阅读
- 📖 缓存更新套路
下一章:第 195 章:Redis 分布式锁 →