引言
缓存是提升系统性能的法宝,但缓存与数据库的双写问题一直是开发者面临的经典难题:是先更新数据库还是先删除缓存?要不要加锁?如何保证最终一致性?
在高并发系统中,Redis作为缓存几乎成了标配。然而,缓存与数据库的一致性问题却让许多开发者头疼不已。本文将带你深入剖析三种最常用的缓存读写策略——Cache Aside、Read/Write Through、Write Behind,结合实际场景分析其优劣与选型建议,帮你彻底告别缓存与数据库不一致的烦恼。
一、Cache Aside(旁路缓存)策略
1.1 核心流程
Cache Aside 是实际应用中最广泛的策略,它将缓存与数据库的交互完全交给应用层:
读流程:
- 应用先查询缓存(Redis)
- 如果命中,直接返回数据
- 如果未命中,则查询数据库
- 将查询结果写入缓存,并返回
写流程:
- 应用先更新数据库
- 删除对应的缓存(而不是更新缓存)
为什么是删除而不是更新?
因为更新操作可能涉及复杂逻辑,且并发更新可能导致缓存脏数据。删除后,下次读取时自然会重建缓存,简单可靠。
1.2 并发问题与解决方案
在写操作中,先更新数据库后删除缓存,可能遇到以下并发问题:
场景A:线程1更新数据库,但还未删除缓存;线程2读取缓存,命中了旧数据,返回了脏数据。
解决方案:
此场景的概率极低(需要更新数据库和删除缓存之间恰好有读请求),通常可接受。若业务对一致性要求极高,可采用延迟双删:
public void update(String key, Object data) {
// 1. 更新数据库
db.update(data);
// 2. 删除缓存
redis.del(key);
// 3. 延迟后再次删除(可选)
Thread.sleep(500);
redis.del(key);
}
场景B:先删除缓存,后更新数据库。若删除后、更新前有读请求,会将旧数据写入缓存,导致缓存长期为脏数据。
结论:业界普遍采用先更新数据库再删除缓存的顺序。
1.3 适用场景
- 绝大多数通用业务场景
- 读多写少,且对一致性要求不是极端严苛
- 缓存失效后可容忍短暂的数据不一致
1.4 代码示例(Java + Spring Data Redis)
@Service
public class UserService {
@Autowired
private UserDao userDao;
@Autowired
private RedisTemplate<String, User> redisTemplate;
public User getUser(Long id) {
String key = "user:" + id;
User user = redisTemplate.opsForValue().get(key);
if (user != null) {
return user;
}
// 缓存未命中,查数据库
user = userDao.getById(id);
if (user != null) {
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
}
return user;
}
public void updateUser(User user) {
// 更新数据库
userDao.update(user);
// 删除缓存
redisTemplate.delete("user:" + user.getId());
}
}
二、Read/Write Through(读写穿透)策略
2.1 核心流程
Read/Write Through 策略将缓存作为主要数据源,应用只与缓存交互,由缓存服务负责与数据库同步:
读流程:
- 应用请求缓存
- 若缓存命中,直接返回
- 若缓存未命中,缓存服务从数据库加载数据,写入缓存后返回
写流程:
- 应用将数据写入缓存
- 缓存服务同步将数据写入数据库,并返回结果
注意:Redis原生不支持此策略,需要应用层封装或使用代理层实现。
2.2 优点与缺点
| 优点 | 缺点 |
|---|---|
| 应用层逻辑简单,只需与缓存交互 | 缓存层实现复杂,需要处理数据加载、写入、事务等 |
| 缓存与数据库之间的一致性由缓存层保证 | 如果缓存层宕机,整个系统不可用 |
| 减少了应用层的复杂度 | 需要高可用部署 |
2.3 适用场景
- 业务逻辑简单,希望将数据访问统一收敛到缓存层
- 对一致性要求较高,且愿意接受额外的开发成本
- 适用于构建在缓存之上的数据服务
2.4 代码示例(模拟封装层)
public class RedisThroughCache {
private RedisTemplate redisTemplate;
private UserDao userDao;
public User get(Long id) {
String key = "user:" + id;
User user = redisTemplate.opsForValue().get(key);
if (user != null) {
return user;
}
// 缓存未命中,从数据库加载并写入缓存
user = userDao.getById(id);
if (user != null) {
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
}
return user;
}
public void put(User user) {
String key = "user:" + user.getId();
// 先写缓存
redisTemplate.opsForValue().set(key, user);
// 再同步写数据库
userDao.update(user);
}
}
三、Write Behind(写回)策略
3.1 核心流程
Write Behind 策略也称 Write Back,是异步写入的典型代表:
读流程:与 Read/Write Through 类似,若未命中则从数据库加载
写流程:
- 应用直接更新缓存
- 缓存返回成功
- 后台有一个异步任务(或消息队列)将缓存中的变更批量、异步地同步到数据库
3.2 优点与缺点
| 优点 | 缺点 |
|---|---|
| 写性能极高,因为只操作内存缓存 | 数据一致性窗口较大 |
| 可以合并多次写操作,减少数据库压力 | 如果缓存宕机,未同步的数据可能丢失 |
| 适合高并发写入场景 | 实现复杂,需要设计可靠的同步机制 |
3.3 适用场景
- 对数据一致性要求不高,但要求极高写入吞吐量的场景(如日志收集、点赞数、访问计数)
- 可接受短时间不一致的业务
- 结合消息队列实现最终一致性
3.4 代码示例(基于消息队列)
@Service
public class WriteBehindService {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private KafkaTemplate kafkaTemplate;
// 写操作:直接更新缓存,并发送异步消息
public void updateUser(User user) {
String key = "user:" + user.getId();
redisTemplate.opsForValue().set(key, user);
// 发送消息到Kafka,消费者异步更新数据库
kafkaTemplate.send("user-update-topic", user);
}
}
四、三种策略对比与选型建议
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Cache Aside | 简单,灵活,主流 | 并发时可能短暂不一致 | 绝大多数业务系统 |
| Read/Write Through | 应用层无感知,缓存与数据库统一管理 | 实现复杂,缓存层成为瓶颈 | 对一致性要求较高,可接受封装 |
| Write Behind | 写入性能极高,可合并操作 | 数据可能丢失,一致性差 | 高吞吐写入,可容忍最终一致 |
选型建议:
- 如果你正在使用Redis作为缓存,Cache Aside 是最自然、最易于维护的选择
- 如果业务需要更高的一致性保障,且愿意在缓存层投入开发,可以考虑 Read/Write Through
- 对于日志、计数、监控等写密集型且允许丢数据的场景,Write Behind 可以极大提升吞吐量
五、总结
缓存读写策略的选择,本质上是性能、一致性、复杂度三者之间的权衡。
- Cache Aside 以其简洁和普适性成为大多数系统的首选
- Read/Write Through 将复杂性封装在缓存层,使应用更加简洁
- Write Behind 则牺牲强一致性换取极致写入性能
在实际项目中,你还可以结合多种策略:例如热点数据使用 Cache Aside,写密集型数据使用 Write Behind。
最重要的是,根据业务特性选择最合适的方案,并在设计时充分考虑并发场景下的数据一致性问题。
希望本文能帮助你理清缓存读写策略的脉络,在实际架构设计中做出更明智的决策。
本文为原创内容,转载请注明出处。如果你在Redis应用中遇到问题,欢迎在评论区留言讨论!
关注「卷毛的技术笔记」微信公众号,获取Redis深度解析与实战技巧,告别缓存陷阱,让系统性能飙升!