详解Redis数据库和缓存不一致的情况及解决方案
数据库与缓存不一致是分布式系统中常见问题,本质是数据在缓存层和存储层出现版本差异。
一、并发写操作导致不一致(最常见)
-
场景描述
-
线程A更新数据库 → 线程B更新数据库 → 线程B更新缓存 → 线程A更新缓存
-
结果:缓存中存储的是线程A的旧数据
-
-
发生条件
-
解决方案
-
分布式锁:对同Key的写操作加锁
-
串行化队列:将写请求放入MQ顺序执行
-
二、先更新数据库后删除缓存失败(Cache-Aside模式)
-
场景描述
-
更新数据库成功
-
删除缓存失败(网络抖动/Redis超时)
-
结果:缓存中残留旧数据
-
-
关键代码风险点
public void updateData(Data data) {db.update(data); // 步骤1:数据库更新成功cache.delete(data.getId()); // 步骤2:缓存删除失败! }
-
解决方案
-
重试机制:
void deleteWithRetry(String key, int maxRetry) {int retry = 0;while (!cache.delete(key) && retry++ < maxRetry) {Thread.sleep(50);} }
- 异步补偿:通过Binlog监听(如Canal)触发二次删除
-
三、主从延迟导致脏读(读写分离架构)
-
场景描述
-
主库更新成功 → 删除缓存
-
读请求从未同步的从库读取旧值 → 回填缓存
-
结果:缓存被旧数据污染
-
-
解决方案
-
延迟双删:
db.update(data); // 更新主库 cache.delete(key); // 首次删除 Thread.sleep(500); // 等待主从同步 cache.delete(key); // 二次删除
-
强制读主库:对一致性要求高的查询直连主库
-
四、缓存过期时高并发读(Cache Miss风暴)
-
场景描述
-
缓存过期瞬间涌入大量读请求
-
请求1查DB → 请求2查DB → ... → 请求N查DB
-
多个线程同时回填缓存(可能乱序)
-
结果:缓存可能被中间状态数据覆盖
-
-
极端案例
-
请求1读取到旧值V1,回填耗时久
-
请求2读取新值V2并先完成回填
-
请求1最终将V1写入缓存(覆盖V2)
-
-
解决方案
-
互斥锁重建:仅允许一个线程重建缓存
-
逻辑过期:物理缓存永不过期,通过逻辑时间控制
-
五、批量操作与部分失效
-
场景描述
-
场景1:批量更新数据库成功,但部分缓存删除失败
-
场景2:分页查询缓存无法感知单条数据变更
-
结果:缓存中存在部分脏数据
-
-
典型案例
-
商品列表页缓存无法感知单个商品价格变更
-
订单列表缓存未失效时,订单状态已更新
-
-
解决方案
-
缓存维度拆分:按查询条件设计缓存Key
-
增量广播:通过消息队列通知相关缓存失效
-
短过期时间:对聚合查询设置更短TTL
-
六、跨服务数据变更
-
场景描述
-
服务A更新数据库 → 服务B的缓存未失效
-
原因:服务间缺乏缓存协同机制
-
结果:跨服务缓存残留旧数据
-
-
微服务常见问题
-
解决方案
-
领域事件通知:通过消息队列(Kafka/RabbitMQ)广播变更
-
统一缓存层:所有服务通过SDK操作缓存,SDK统一处理失效
-
七、终极解决之道:取舍策略
根据业务需求选择合适的一致性级别:
策略 | 一致性强度 | 性能影响 | 适用场景 |
---|---|---|---|
先删缓存再更新DB | 中 | 低 | 写少读多 |
先更新DB再删缓存 | 中 | 低 | 通用方案(需重试) |
双写+事务 | 高 | 高 | 金融交易 |
延迟监听(Binlog) | 最终一致 | 中 | 高并发写场景 |
忽略不一致+短TTL | 弱 | 无 | 允许短暂脏读的业务 |
重要原则:不要尝试绝对强一致,除非接受性能断崖式下降。通常建议采用 "更新DB + 延迟双删 + 重试队列" 的组合策略。