问题
如何保证Redis缓存与数据库的数据一致性?
答案
1. 核心概念
缓存一致性问题是指在使用Redis作为数据库缓存时,如何保证缓存中的数据与数据库中的数据保持同步。核心矛盾在于:数据库和缓存的更新不是原子操作,并发场景下容易产生数据不一致。
2. 常见的缓存更新策略
2.1 Cache Aside(旁路缓存)- 最常用
读流程:
- 先读缓存,命中则直接返回
- 缓存未命中,读数据库
- 将数据库结果写入缓存并返回
写流程:
- 先更新数据库
- 再删除缓存
/**
* Cache Aside模式示例
*/
public class CacheAsideService {
@Autowired
private UserMapper userMapper;
@Autowired
private StringRedisTemplate redisTemplate;
// 读操作
public User getUser(Long userId) {
String cacheKey = "user:" + userId;
// 1. 查缓存
String userJson = redisTemplate.opsForValue().get(cacheKey);
if (userJson != null) {
return JSON.parseObject(userJson, User.class);
}
// 2. 缓存未命中,查数据库
User user = userMapper.selectById(userId);
if (user != null) {
// 3. 写入缓存
redisTemplate.opsForValue().set(
cacheKey,
JSON.toJSONString(user),
30, TimeUnit.MINUTES
);
}
return user;
}
// 写操作
public void updateUser(User user) {
String cacheKey = "user:" + user.getId();
// 1. 先更新数据库
userMapper.updateById(user);
// 2. 再删除缓存(下次读取时重新加载)
redisTemplate.delete(cacheKey);
}
}
2.2 其他策略(了解即可)
- Read/Write Through:应用程序只操作缓存,由缓存层负责同步数据库(类似MyBatis二级缓存)
- Write Behind(异步写回):先写缓存,异步批量刷新数据库(性能最高,但可能丢数据)
3. 数据不一致的典型场景与解决方案
3.1 为什么选择”先更新数据库,再删缓存”?
反例1:先删缓存,再更新数据库
- 线程A删除缓存 → 线程B读取(缓存未命中,读到旧数据并写入缓存)→ 线程A更新数据库
- 结果:缓存中是旧数据,数据库是新数据
反例2:先更新数据库,再更新缓存
- 并发更新时,缓存可能被旧值覆盖
- 写多读少场景下,缓存频繁更新但很少使用,浪费资源
最佳实践:先更新数据库,再删缓存
- 不一致窗口期更短(只有极端情况才出现)
- 删除操作比更新操作更轻量
3.2 极端不一致场景:数据库主从延迟
场景:
- 线程A更新主库并删除缓存
- 线程B立即读取,缓存未命中,查询从库(此时从库数据尚未同步,读到旧值)
- 线程B将旧值写入缓存
解决方案1:延迟双删
public void updateUserWithDelayedDoubleDelete(User user) {
String cacheKey = "user:" + user.getId();
// 1. 删除缓存
redisTemplate.delete(cacheKey);
// 2. 更新数据库
userMapper.updateById(user);
// 3. 延迟N毫秒后再次删除缓存
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(500); // 延迟时间需大于主从同步延迟
redisTemplate.delete(cacheKey);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
延迟时间设置:需大于主从同步时间(通常100-300ms),可通过监控确定。
解决方案2:订阅数据库binlog
使用Canal、Maxwell等中间件监听MySQL binlog,异步更新缓存:
/**
* 基于Canal的缓存更新(伪代码)
*/
@Component
public class CanalCacheUpdater {
@CanalListener(destination = "example", schema = "test", table = "user")
public void onUserChange(CanalEntry.EventType eventType, CanalEntry.RowData rowData) {
if (eventType == CanalEntry.EventType.UPDATE ||
eventType == CanalEntry.EventType.DELETE) {
String userId = rowData.getAfterColumnsList().stream()
.filter(col -> "id".equals(col.getName()))
.findFirst()
.map(CanalEntry.Column::getValue)
.orElse(null);
if (userId != null) {
redisTemplate.delete("user:" + userId);
}
}
}
}
优势:
- 解耦业务代码和缓存更新逻辑
- 天然处理主从延迟(binlog同步后才触发)
- 支持异构数据源同步(如同步到ES、MongoDB)
3.3 并发写场景:分布式锁保证
对于热点数据的并发更新,可用分布式锁串行化:
public void updateUserWithLock(User user) {
String lockKey = "lock:user:" + user.getId();
String cacheKey = "user:" + user.getId();
RLock lock = redissonClient.getLock(lockKey);
try {
lock.lock(5, TimeUnit.SECONDS);
// 更新数据库
userMapper.updateById(user);
// 删除缓存
redisTemplate.delete(cacheKey);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
4. 强一致性 vs 最终一致性权衡
4.1 最终一致性(推荐)
- 适用场景:大多数互联网业务(如商品详情、用户信息)
- 方案:Cache Aside + 合理的缓存过期时间(如5-30分钟)
- 优势:性能高,实现简单
- 不一致窗口:毫秒级到秒级(可接受)
4.2 强一致性
- 适用场景:金融交易、库存扣减等强一致性需求
- 方案:
- 不使用缓存,直接查数据库
- 使用分布式锁 + 缓存
- 订阅binlog + 重试机制
- 代价:性能下降,复杂度提升
5. 面试答题总结
标准回答模板:
缓存一致性的核心挑战是数据库和缓存更新不是原子操作。常用的 Cache Aside 模式采用:
- 读:先查缓存,未命中再查数据库并回写缓存
- 写:先更新数据库,再删除缓存
不一致场景及解决方案:
- 主从延迟导致脏数据:延迟双删(更新后延迟N毫秒再删一次缓存)
- 解耦与可靠性:订阅binlog(Canal)异步更新缓存
- 并发热点:分布式锁串行化更新
工程实践:
- 设置合理的缓存过期时间作为兜底
- 大多数场景接受最终一致性(秒级)
- 强一致性场景考虑不用缓存或引入分布式事务
常见追问:
- 为什么不先删缓存再更新数据库? → 并发时更容易产生脏数据
- 延迟双删的延迟时间如何确定? → 需大于主从同步时间,通过监控测试确定(通常100-500ms)
- Canal的原理是什么? → 伪装成MySQL从库,订阅并解析binlog事件