@Cacheable 不防脏读,因其仅执行“查缓存→查DB→写缓存”三步非原子操作,不加锁、不校验版本、不感知并发更新,导致旧值覆盖新数据。

Spring Boot集成Redis后,脏读问题无法靠单一注解或配置自动解决,必须结合业务场景主动控制读写时序和并发访问。
为什么 @Cacheable 本身不防脏读
@Cacheable 只负责“缓存有就返回、没就查库再存”,它不感知数据库是否正在被其他线程修改,也不加锁、不校验版本。当一个线程刚查完DB、还没写入缓存时,另一个线程已更新DB并删了缓存,此时第一个线程把旧值写进缓存,就形成脏数据。
常见错误现象:Cacheable 方法返回过期数据,但数据库里已是新值;多次刷新接口看到数据来回跳变。
- 它不提供原子性:查DB → 写缓存 是两步,中间可能被插入更新
- 它不处理并发:多个请求同时触发未命中,可能各自查到不同版本的DB数据
- 它不带条件写入:缓存覆盖无前提校验,比如“仅当DB中version=5时才允许写入缓存”这类逻辑需手动编码
用 CacheEvict + 条件删除防缓存污染
当业务修改数据(如 updateUser())时,不能只依赖 @CacheEvict 简单删 key,要确保删得及时、删得准确。
推荐做法:
- 在更新数据库成功后立即执行
@CacheEvict(key = "#user.id", cacheNames = "users"),而不是在事务开始前删 - 如果更新涉及多级缓存(如用户 + 用户权限 + 用户统计),用
@Caching(evict = {...})批量清除 - 避免使用
allEntries = true,除非你确定当前缓存名下所有条目都该失效——否则会误伤其他用户的热点数据 - 若更新失败回滚,
@CacheEvict不会自动撤销(因为它是基于方法执行结果触发的),所以必须保证 DB 更新成功是缓存失效的前提
用 Redis 事务 or Lua 脚本保读写原子性
当需要“读取 → 计算 → 写入”三步强一致时(比如库存扣减、余额变更),不能依赖 Spring Cache 抽象层,得下沉到 redisTemplate 或 stringRedisTemplate。
关键点:
-
WATCH+MULTI/EXEC组合可防并发覆盖,但 Spring 的redisTemplate.exec()返回null表示事务被放弃,需手动重试 - 更稳的方式是用 Lua 脚本:把判断和写入封装成一个原子操作,例如
EVAL "if redis.call('GET', KEYS[1]) >= tonumber(ARGV[1]) then redis.call('DECRBY', KEYS[1], ARGV[1]) return 1 else return 0 end" 1 stock 5 - 注意:Lua 脚本内不能调用非确定性命令(如
TIME、RANDOMKEY),否则集群模式下会报错 - 不要在脚本里做耗时计算,Redis 是单线程,脚本卡住等于整个实例卡住
延时双删不是银弹,得配监控和兜底
所谓“先删缓存 → 更新DB → 延时再删缓存”,本质是用时间换一致性,但这个“延时”很难精准设定。
实际落地要注意:
- 延时值不能拍脑袋定,建议参考 DB 主从同步延迟 + 应用本地缓存刷新周期,一般设为 100–500ms
- 必须配合监控:记录每次双删的耗时、二次删除是否成功(可用
redisTemplate.delete()返回布尔值判断) - 要有兜底机制:比如给缓存加短 TTL(如 2s),即使双删失败,脏数据最多存活 2 秒
- 该策略对“更新快、读多”的场景有效,但对“读写频率接近”的场景(如秒杀下单)依然可能漏掉,此时必须上分布式锁或 CAS
真正容易被忽略的是:所有这些手段都建立在「应用层能识别出哪些操作会引发脏读」的基础上。如果你连哪几个接口读、哪几个接口写、它们的 key 命名规则是否统一都说不清,加再多机制也白搭。


















