Redis 延迟双删还是读到脏数据?一次缓存一致性事故

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 的真实踩坑复现。

相关推荐
薛定谔的悦1 小时前
光伏-储能-负荷联合预测:给 EMS 装上"预知能力"
后端
网易云信1 小时前
从“合规”到“信任”:网易智企专属云打造强监管下的通信新范式
人工智能·后端
吃糖的小孩1 小时前
从“让聊天模型顺便选表情”到独立分类器:我给 QQ AI Chatbot 做了一套本地动态表情包系统
后端
weedsfly1 小时前
前端开发中的代理模式——从 Vue 3 响应式到日常开发
前端·javascript·面试
无责任此方_修行中1 小时前
面对 300+ 仓库的重复劳动,我用 Guideline 优化了 Vibe Coding 工作流
后端·ai编程·vibecoding
咖啡八杯1 小时前
GoF设计模式——状态模式
java·面试·架构
蛋挞大王0011 小时前
精简版 MyBatis
后端
952362 小时前
RabbitMQ - 高级特性
java·spring boot·分布式·后端·rabbitmq
尘世中一位迷途小书童2 小时前
Cesium 涟漪扩散点:自定义材质,参数调对了才好看
前端·面试