Redis缓存与数据库的一致性的原理与最佳实践
作者:倚肆
还在为Redis与MySQL数据不一致头疼?本文通俗讲解缓存一致性原理,手把手教你Cache-Aside最佳实践、延迟双删防并发,甚至分享Binlog异步删除大杀器,无论你是普通后台还是高并发系统,都能找到合适方案,让数据最终一致不再难,需要的朋友可以参考下
1. 为什么会出现不一致?(通俗原理)
缓存(Redis)和数据库(MySQL)是两个独立的存储。更新数据时,就像同时给两座房子送信。
- 如果先给 Redis 送信(删/改),再给 MySQL 送信,但 MySQL 送信慢了,别人来 Redis 查发现没数据,去 MySQL 查到了旧数据,又写回 Redis,导致 Redis 变成旧数据。
- 如果先给 MySQL 送信,再给 Redis 送信(删缓存),中间有极短时间(毫秒级)Redis 还是旧数据,但一删掉就没了,别人再来只能查 MySQL 的新数据。
核心结论:我们只能追求“最终一致”(短暂允许不一样,但很快修复),无法低成本实现绝对强一致。
2. 为什么是“删除缓存”而不是“更新缓存”?
假设两个线程同时更新数据:
- 线程A 把 age 改成 18,更新缓存为 18。
- 线程B 把 age 改成 20,更新缓存为 20(后执行)。
由于网络延迟,如果 B 先到缓存,A 后到缓存,缓存最终变成 18(旧值)。
而“删除缓存”则没有这个烦恼:管你谁先谁后,删掉就没了。下次读取时,读库再回填,保证回填的是最新的 DB 值。
3. 最佳实践核心流程(Cache-Aside 模式)
- 读数据:先查 Redis,命中则返回;未命中则查 MySQL,回填 Redis(设置过期时间 TTL)。
- 写数据:先更新 MySQL(事务提交),再删除 Redis。
4. 并发终极坑(延迟双删的由来)
极罕见情况下:
- 线程A 更新 MySQL 为 100,删除了 Redis。
- 线程B 读 Redis 未命中,去 MySQL 读到 100,准备回填 Redis。
- 线程C 更新 MySQL 为 200,删除了 Redis(此时 Redis 是空的)。
- 线程B 因为网络慢,此时才把 100 回填到 Redis。导致 Redis 变成 100(旧值)。
解决办法:延迟双删。即在更新 MySQL 并删除 Redis 后,等待 1~2 秒(等待可能的并发读回填完成),再删除一次 Redis,把旧值清理掉。
5. 最佳实践示例代码(Java Spring Boot + Redis)
5.1 基础版(最常用,适合大多数场景)
先更新 DB,事务提交后删除缓存,并设置 TTL 兜底。
代码示例:
@Service
@Slf4j
public class UserService {
@Autowired
private UserDao userDao;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final String CACHE_KEY = "user:";
// 查询:旁路缓存
public User getById(Long id) {
String key = CACHE_KEY + id;
// 1. 查缓存
User user = (User) redisTemplate.opsForValue().get(key);
if (user != null) {
return user;
}
// 2. 查数据库
user = userDao.selectById(id);
if (user != null) {
// 3. 回填缓存,必须设置过期时间(兜底)
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
}
return user;
}
// 更新:先更新DB,后删缓存
@Transactional
public void updateUser(User user) {
// 1. 更新数据库
userDao.updateById(user);
// 2. 删除缓存(注意:必须确保事务提交后再删,避免DB回滚导致缓存被误删)
String key = CACHE_KEY + user.getId();
redisTemplate.delete(key);
log.info("更新用户 id={}, 缓存已删除", user.getId());
}
}5.2 严谨版(事务提交后删除 + 延迟双删)
利用事务同步器确保在 DB 真正提交后才删缓存,并开启延迟二次删除。
代码示例:
@Service
@Slf4j
public class UserServiceStrict {
@Autowired
private UserDao userDao;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 用于延迟任务的线程池
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(4);
@Transactional
public void updateUser(User user) {
// 1. 更新数据库
userDao.updateById(user);
String key = CACHE_KEY + user.getId();
// 2. 注册事务同步器,确保事务提交后再操作缓存
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
// 2.1 立即删除缓存
redisTemplate.delete(key);
log.info("事务提交后立即删除缓存 key={}", key);
// 2.2 延迟 1.5 秒后再次删除(防止并发读回填旧数据)
scheduler.schedule(() -> {
try {
redisTemplate.delete(key);
log.info("延迟删除缓存 key={}", key);
} catch (Exception e) {
log.error("延迟删除缓存失败", e);
}
}, 1500, TimeUnit.MILLISECONDS);
}
}
);
}
}5.3 高并发终极兜底(订阅 MySQL Binlog)
如果业务要求极高的最终一致性,引入 Canal 监听 binlog,异步删除缓存。应用层只负责更新 DB,Canal 解析变更事件后自动删除 Redis。
代码示例(此为 Canal 消费端伪代码,由框架回调触发):
@Component
@Slf4j
public class CanalCacheListener {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public void onUserChange(Long userId, String eventType) {
if ("UPDATE".equals(eventType) || "DELETE".equals(eventType)) {
String key = "user:" + userId;
redisTemplate.delete(key);
log.info("Binlog 异步清理缓存 userId={}", userId);
}
}
}6. 最佳实践总结(必记口诀)
- 读:先缓存,后 DB,回填设 TTL。
- 写:先 DB,后删缓存(务必事务提交后删)。
- 防并发:加上延迟双删(1~2 秒)。
- 保命符:缓存必须设过期时间(如 30 分钟),就算删失败了,过一会也自动没了。
- 大杀器:如果要求极高,上 Canal 订阅 Binlog,异步解耦删除。
- 禁区:绝对不要先删缓存再更新 DB,绝对不要用更新缓存代替删除缓存。
7. 各场景选型推荐
| 你的业务场景 | 推荐采用方案 |
|---|---|
| 普通管理后台,并发低 | 基础版(5.1)+ TTL 即可 |
| 用户端高并发读,要求数据较新 | 严谨版(5.2)延迟双删 |
| 金融/库存强校验,绝不能错 | 不使用缓存,直接读数据库 |
| 分布式微服务,解耦优先 | Binlog 异步删除(5.3) |
以上就是Redis缓存与数据库的一致性的原理与最佳实践的详细内容,更多关于Redis缓存与数据库一致性的资料请关注脚本之家其它相关文章!
