Redis 延迟双删还是读到脏数据?一次缓存一致性事故
更新数据库后删缓存。
怕并发读写不一致,又加了一次延迟删除。
结果线上还是读到了旧数据。
问题不在"有没有双删",而在你有没有搞清楚:缓存一致性从来不是一个
delete就能彻底解决的。
一、事故现场
线上有个商品详情接口。
读流程很常见:
java
public Product getProduct(Long productId) {
String key = "product:" + productId;
Product cache = redisTemplate.opsForValue().get(key);
if (cache != null) {
return cache;
}
Product product = productMapper.selectById(productId);
redisTemplate.opsForValue().set(key, product, 10, TimeUnit.MINUTES);
return product;
}
更新商品时:
java
@Transactional(rollbackFor = Exception.class)
public void updateProduct(ProductUpdateCommand command) {
productMapper.update(command);
redisTemplate.delete("product:" + command.getProductId());
}
看起来没问题。
更新数据库后删除缓存。
下次读缓存 miss,再查数据库回填。
但线上偶尔出现:
text
后台已经改了价格
用户端还是看到旧价格
过一会儿又恢复正常
第一反应是:
Redis 删除失败了?
查日志发现删除成功。
后来又加了延迟双删:
java
productMapper.update(command);
redisTemplate.delete(key);
executor.schedule(() -> redisTemplate.delete(key), 500, TimeUnit.MILLISECONDS);
问题少了,但没有完全消失。
二、为什么删了缓存还会读到旧数据?
先看一个经典并发时序。
假设缓存里原来是旧价格:
text
product:1001 = 99 元
现在后台把价格改成 89 元。
时间线可能是这样:
text
T1:更新线程开始更新数据库
T2:读线程发现缓存 miss
T3:读线程查数据库,读到旧值 99
T4:更新线程提交数据库,新值 89
T5:更新线程删除缓存
T6:读线程把旧值 99 写回缓存
最后缓存里又变成了:
text
product:1001 = 99 元
这就是缓存不一致。
注意:更新线程确实删过缓存。
但读线程在错误的时间点,把旧数据又写回去了。
三、延迟双删解决的是什么?
延迟双删的思路是:
text
更新数据库前后删一次
过一小段时间再删一次
常见写法:
java
public void updateProduct(ProductUpdateCommand command) {
String key = "product:" + command.getProductId();
redisTemplate.delete(key);
productMapper.update(command);
sleep(500);
redisTemplate.delete(key);
}
或者异步延迟删:
java
productMapper.update(command);
redisTemplate.delete(key);
scheduledExecutor.schedule(
() -> redisTemplate.delete(key),
500,
TimeUnit.MILLISECONDS
);
第二次删除是为了清理这类脏回填:
text
读线程在更新期间查到旧数据
并把旧数据重新写进缓存
所以延迟双删不是魔法。
它只是赌一件事:
脏数据回填会在延迟时间内发生,第二次删除能把它删掉。
四、为什么延迟双删还是可能失败?
1. 延迟时间不好定
如果你延迟 500ms 删除。
但某次读请求特别慢:
text
T1:读线程查数据库
T2:更新线程删除缓存
T3:500ms 后第二次删除
T4:读线程终于返回旧数据
T5:读线程把旧数据写回缓存
第二次删除已经过去了。
旧数据还是被写回缓存。
延迟太短,兜不住慢查询。
延迟太长,又会增加实现复杂度和不确定性。
2. 删除任务可能丢
如果第二次删除靠本地线程池:
java
scheduledExecutor.schedule(() -> redisTemplate.delete(key), 500, TimeUnit.MILLISECONDS);
那它不是可靠任务。
服务重启、线程池满、进程崩溃,都可能导致第二次删除没执行。
3. 主从延迟也会影响
有些项目读写分离。
更新写主库。
读线程查从库。
如果从库延迟,读线程可能在更新提交后仍然读到旧值。
然后把旧值回填缓存。
这时你只盯着 Redis 删没删,很难排查。
因为根因在数据库读写链路。
4. 缓存没有版本
如果缓存对象没有版本号或更新时间,旧值和新值在 Redis 里看起来一样"合法"。
读线程把旧对象写回去时,系统不知道它旧。
这也是很多缓存一致性问题难排查的原因。
五、正确做法 1:先接受"最终一致"
大多数缓存场景,不应该追求强一致。
缓存本质上是性能层。
它和数据库之间更适合做最终一致。
所以你要先问业务:
text
价格能不能短暂旧?
库存能不能短暂旧?
用户权限能不能短暂旧?
账户余额能不能短暂旧?
订单状态能不能短暂旧?
不同业务答案不一样。
比如商品描述短暂旧,可能能接受。
但账户余额、权限状态、支付结果短暂旧,就可能出大问题。
不要所有数据都套同一套缓存策略。
六、正确做法 2:更新后删缓存,不要先删后改
一般推荐:
text
先更新数据库
再删除缓存
而不是:
text
先删除缓存
再更新数据库
因为先删缓存后更新数据库,中间窗口更容易让读请求查到旧数据库并回填旧缓存。
推荐写法:
java
@Transactional(rollbackFor = Exception.class)
public void updateProduct(ProductUpdateCommand command) {
productMapper.update(command);
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
redisTemplate.delete("product:" + command.getProductId());
}
}
);
}
注意这里是 afterCommit。
不要在事务还没提交时就删缓存。
否则读线程可能缓存 miss 后读到旧数据库。
七、正确做法 3:删除失败要能重试
最怕的是:
text
数据库更新成功
缓存删除失败
这时旧缓存会一直存在,直到 TTL 过期。
所以删除缓存最好不是"打个日志就算了"。
可以把删除任务写入本地消息表:
sql
CREATE TABLE cache_delete_task (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
cache_key VARCHAR(256) NOT NULL,
status VARCHAR(20) NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL
);
事务提交后写任务:
java
@Transactional(rollbackFor = Exception.class)
public void updateProduct(ProductUpdateCommand command) {
productMapper.update(command);
cacheTaskMapper.insert("product:" + command.getProductId(), "NEW");
}
后台任务删除:
java
@Scheduled(fixedDelay = 1000)
public void deleteCacheTasks() {
List<CacheDeleteTask> tasks = cacheTaskMapper.selectNewTasks();
for (CacheDeleteTask task : tasks) {
try {
redisTemplate.delete(task.getCacheKey());
cacheTaskMapper.markSuccess(task.getId());
} catch (Exception e) {
cacheTaskMapper.markRetry(task.getId(), e.getMessage());
}
}
}
这样至少能知道:
text
哪些缓存删除失败
失败了几次
什么时候重试
是否需要告警
八、正确做法 4:关键数据加版本
如果缓存对象里有版本号或更新时间,就能减少旧值覆盖新值。
比如:
java
public class ProductCache {
private Long productId;
private BigDecimal price;
private Long version;
private LocalDateTime updateTime;
}
回填缓存时,不要无脑 set:
java
redisTemplate.opsForValue().set(key, product);
可以做版本判断。
思路是:
text
如果 Redis 里已有更新版本,不允许旧版本覆盖
具体实现可以用 Lua 保证原子性。
伪代码:
lua
local old = redis.call('GET', KEYS[1])
if old == false then
redis.call('SET', KEYS[1], ARGV[1])
return 1
end
local oldVersion = cjson.decode(old)['version']
local newVersion = cjson.decode(ARGV[1])['version']
if newVersion >= oldVersion then
redis.call('SET', KEYS[1], ARGV[1])
return 1
end
return 0
这不是所有业务都必须做。
但对价格、权限、状态这类敏感数据很有用。
九、正确做法 5:设置合理 TTL
缓存一定要有 TTL。
不要让旧缓存永久存在。
比如:
java
redisTemplate.opsForValue().set(key, product, 10, TimeUnit.MINUTES);
TTL 的作用不是解决所有一致性问题。
它是最后兜底。
即使删除失败,旧数据也不会无限期存在。
不同数据 TTL 要分开设计:
| 数据类型 | TTL 建议 |
|---|---|
| 商品描述 | 可以长一点 |
| 商品价格 | 短一点,或版本控制 |
| 用户权限 | 短一点,变更后强制删除 |
| 首页配置 | 可较长,但要支持主动刷新 |
| 账户余额 | 尽量不要只依赖缓存 |
十、上线前 checklist
看到缓存更新逻辑,建议过一遍:
| 检查项 | 风险 | 建议 |
|---|---|---|
| 是否先删缓存再改库 | 容易回填旧值 | 优先改库后删缓存 |
| 删除是否在事务提交前 | 读到旧数据库 | 用 afterCommit |
| 删除失败是否可重试 | 旧缓存长期存在 | 任务表/MQ 重试 |
| 是否读写分离 | 从库延迟回填旧值 | 关键读走主库 |
| 是否有 TTL | 旧值永久存在 | 必须设置过期时间 |
| 是否有版本字段 | 旧值覆盖新值 | 版本判断 |
| 是否强一致业务 | 缓存短暂旧也出事 | 不要简单缓存 |
| 是否监控缓存删除失败 | 出事后难定位 | 记录和告警 |
十一、总结
延迟双删不是不能用。
但它不是银弹。
它只是尽量清理"读线程把旧值回填缓存"的窗口。
真正要稳,需要组合拳:
数据库提交后删缓存。
删除失败要能重试。
关键数据要有版本。
缓存必须有 TTL。
强一致数据别只靠缓存兜。
最后记住一句话:
缓存一致性没有绝对完美,只有在业务可接受范围内,把不一致窗口压到足够小。
下一篇准备写:MQ 消息重复消费,为什么你的幂等还是挡不住?
如果你也在排查 Java 后端线上问题,可以关注我。这个系列会持续更新 Spring 事务、线程池、JVM、MySQL、Redis、MQ 的真实踩坑复现。