缓存更新策略

一、前言:为什么"先更新 DB 还是先删缓存"成了经典面试题?

你是否遇到过这些场景?

  • 用户修改了头像,但页面刷新后还是旧图
  • 商品价格已调低,购物车仍显示原价
  • 后台改了配置,前端却没生效

根本原因缓存与数据库数据不一致

在高并发系统中,缓存(如 Redis)和数据库(如 MySQL)是两个独立的存储系统。一旦更新操作设计不当,就会导致短暂甚至长期的数据不一致

本文将深入剖析四大主流缓存更新策略,并给出生产环境推荐方案,助你彻底解决"缓存一致性"难题。


二、核心矛盾:CAP 理论下的取舍

在分布式系统中,强一致性(Consistency)与高可用(Availability)难以兼得

  • 缓存追求 高性能 + 高可用
  • 数据库追求 持久性 + 一致性

因此,缓存一致性通常是"最终一致性" ------ 允许短暂不一致,但必须在合理时间内收敛。

📌 目标在可接受的时间窗口内,让缓存与数据库保持一致


三、四大缓存更新策略详解

策略 1:Cache Aside(旁路缓存)------ ✅ 最常用

读流程

  1. 先读缓存
  2. 命中 → 返回
  3. 未命中 → 查数据库 → 写入缓存 → 返回

写流程

  1. 先更新数据库
  2. 再删除缓存
java 复制代码
public void updateUser(User user) {
    // 1. 更新 DB
    userMapper.updateById(user);
    // 2. 删除缓存(下次读时自动重建)
    redisTemplate.delete("user:" + user.getId());
}
✅ 优点:
  • 简单、直观、易于实现
  • 读写分离,符合大多数业务场景
⚠️ 潜在问题:
  • 并发场景下可能短暂不一致(见下文分析)

策略 2:Read/Write Through(读写穿透)

  • 应用只与缓存层交互
  • 缓存层负责同步读写数据库(类似代理)

写流程

  1. 应用 → 缓存层
  2. 缓存层 → 更新自身 + 同步更新 DB

🔧 通常由中间件实现(如 ORM 框架),应用无感知。

✅ 优点:
  • 应用逻辑简单
  • 一致性由缓存层保证
❌ 缺点:
  • 实现复杂
  • 缓存层成为单点瓶颈

📌 实际项目中较少手动实现,多用于框架内部


策略 3:Write Behind(写回/异步写)

  • 先更新缓存
  • 异步批量刷入数据库

写流程

  1. 应用 → 更新缓存(立即返回)
  2. 后台线程定期将缓存变更写入 DB
✅ 优点:
  • 写性能极高(如日志、计数器场景)
❌ 缺点:
  • 数据可能丢失(缓存宕机)
  • 一致性差

📌 仅适用于允许丢失、非核心数据


策略 4:Double Delete(延迟双删)------ 高并发兜底方案

针对 Cache Aside 在高并发下的不一致问题 的增强方案。

写流程

  1. 删除缓存
  2. 更新数据库
  3. 延迟 N 毫秒后,再次删除缓存
java 复制代码
public void updateUser(User user) {
    // 1. 先删缓存(防旧数据残留)
    redisTemplate.delete("user:" + user.getId());
    
    // 2. 更新 DB
    userMapper.updateById(user);
    
    // 3. 延迟双删(兜底)
    Thread.sleep(500); // 或用 MQ 异步延迟
    redisTemplate.delete("user:" + user.getId());
}
✅ 作用:
  • 解决"旧缓存重建"问题(见下文并发分析)
⚠️ 注意:
  • sleep 会阻塞线程,建议用 RocketMQ / RabbitMQ 延迟消息 替代

四、深度剖析:为什么"先更新 DB 再删缓存"也可能不一致?

场景:高并发下的"旧缓存重建"

复制代码
时间线:
T1: 线程A 查询 user:1001 → 缓存 miss → 查 DB(值=张三)
T2: 线程B 更新 user:1001 → DB 改为"李四" → 删除缓存
T3: 线程A 将"张三"写入缓存 ← ❌ 旧数据覆盖!

💥 结果:缓存中是"张三",DB 中是"李四" → 不一致持续到缓存过期

解决方案:

  1. 延迟双删(如上)
  2. 给缓存设置较短 TTL(如 5~10 分钟),限制不一致窗口
  3. 使用 Canal / Binlog 监听 DB 变更,主动失效缓存(最终一致性强)

五、生产环境推荐方案

场景 推荐策略
普通 Web 应用 Cache Aside + 短 TTL(如 10 分钟)
高并发核心业务 Cache Aside + 延迟双删(MQ 实现)
强一致性要求 不使用缓存,或采用 本地缓存 + 分布式锁
数据变更通知 监听 MySQL Binlog(Canal / Debezium)→ 自动删缓存

📌 终极建议
80% 场景用 Cache Aside + 合理 TTL 即可
20% 极端场景再上延迟双删或 Binlog 方案


六、Spring Boot 实战:优雅实现 Cache Aside

java 复制代码
@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;
    
    @Autowired
    private StringRedisTemplate redisTemplate;

    private static final String USER_KEY = "user:%d";

    // 读:自动填充缓存
    public User getUser(Long id) {
        String key = String.format(USER_KEY, id);
        String json = redisTemplate.opsForValue().get(key);
        if (json != null) {
            return JSON.parseObject(json, User.class);
        }

        User user = userMapper.selectById(id);
        if (user != null) {
            redisTemplate.opsForValue().set(key, JSON.toJSONString(user), 10, TimeUnit.MINUTES);
        }
        return user;
    }

    // 写:先更新 DB,再删缓存
    @Transactional
    public void updateUser(User user) {
        userMapper.updateById(user);
        redisTemplate.delete(String.format(USER_KEY, user.getId()));
        
        // 【可选】发送 MQ 延迟消息,500ms 后再次删除
        // mqProducer.sendDelayMessage("cache.delete", key, 500);
    }
}

七、避坑指南:常见误区

❌ 误区 1:先删缓存,再更新 DB

风险

  • 删缓存成功
  • 更新 DB 失败
  • 缓存空,后续请求查到旧 DB 数据 → 永久不一致
    正解先更新 DB,成功后再删缓存

❌ 误区 2:更新缓存而不是删除

问题

  • 需要序列化/反序列化,成本高
  • 若缓存结构复杂(如 Hash),易出错
    正解删除缓存,让下次读自动重建

❌ 误区 3:追求强一致性而放弃缓存

后果 :系统性能下降,得不偿失
正解接受短暂不一致,用 TTL + 监控兜底


八、结语

感谢您的阅读!如果你有任何疑问或想要分享的经验,请在评论区留言交流!

相关推荐
難釋懷2 小时前
什么是缓存
缓存
wWYy.11 小时前
详解redis(16):缓存击穿
数据库·redis·缓存
潇凝子潇17 小时前
Java 设计支持动态调整的LFU缓存: 需包含热度衰减曲线和淘汰策略监控
java·spring·缓存
派大鑫wink18 小时前
【Day57】SpringBoot 整合 Redis:吃透缓存配置与 API 实战
spring boot·redis·缓存
heartbeat..20 小时前
Redis 常用命令全解析:基础、进阶与场景化实战
java·数据库·redis·缓存
optimistic_chen1 天前
【Redis系列】分布式锁
linux·数据库·redis·分布式·缓存
不想写bug呀1 天前
Redis总结
数据库·redis·缓存
予枫的编程笔记1 天前
【Redis核心原理篇3】Redis 主从复制:数据同步的底层逻辑与实践
数据库·redis·缓存
关于不上作者榜就原神启动那件事1 天前
多级缓存必要性
缓存