Redis缓存与数据库的数据一致性是分布式系统中的一个经典挑战。没有完美的通用方案,只有针对特定场景的权衡选择。以下是主要的策略、场景分析和实践建议:
一、核心策略模式
1. Cache-Aside(旁路缓存)
最常见的模式,由应用层控制缓存:
// 读流程
1. 读缓存,命中则返回
2. 未命中则读数据库
3. 写入缓存后返回
// 写流程
1. 更新数据库
2. 删除缓存(非更新)
优点:简单、可控
风险:并发写时可能短暂不一致
2. Write-Through(直写)
所有写操作同步更新缓存和数据库:
写请求 → 更新缓存 → 同步更新数据库 → 返回
优点:强一致性保证
缺点:写延迟高,缓存污染可能
3. Write-Behind(后写)
异步批量更新,先更新缓存,异步持久化到数据库:
写请求 → 更新缓存 → 异步队列 → 批量写数据库
优点:高性能
缺点:可能丢失数据,实现复杂
二、高并发场景下的经典问题与解决方案
1. 缓存删除延迟导致的不一致
问题场景:
线程A更新数据库 → 线程B读缓存(旧值) → 线程A删除缓存
解决方案:延迟双删
public void updateData(Data data) {
// 1. 先删除缓存
redis.del(key);
// 2. 更新数据库
db.update(data);
// 3. 延迟再次删除
Thread.sleep(delayTime);
redis.del(key);
}
2. 先更新数据库还是先删除缓存?
- 先删缓存,再更新数据库(更常用)
- 风险:删缓存后、更新DB前,其他线程可能读到旧DB数据并重建旧缓存
- 缓解:延迟双删
- 先更新数据库,再删缓存
- 风险:更新DB后、删缓存前,缓存仍是旧数据
- 概率较低(通常删缓存很快)
三、强一致性方案
1. 订阅数据库变更日志(推荐)
通过Canal、Debezium等工具监听MySQL binlog:
数据库变更 → 解析binlog → 消息队列 → 更新/删除缓存
优点:解耦、可靠
缺点:架构复杂,有延迟
2. 分布式事务
使用Seata等框架,但性能代价高:
BEGIN;
UPDATE db_table ...;
DELETE FROM redis_cache ...;
COMMIT;
四、工程实践建议
分级策略
| 场景 | 一致性要求 | 推荐方案 |
|---|---|---|
| 用户会话、配置 | 中等 | Cache-Aside + 较短TTL |
| 商品详情页 | 中等 | 延迟双删 + 版本号控制 |
| 库存、余额 | 高 | 直接读库 或 分布式锁+Cache-Aside |
| 计数、统计 | 低 | Write-Behind + 容忍延迟 |
关键实践
- 设置合理的过期时间
SET key value EX 300 # 5分钟自动过期即使更新失败,数据最终也会一致 - 使用版本号或时间戳
{ "data": {...}, "version": 1672531200, "timestamp": 1672531200 } - 降级与熔断
- 缓存失败时直接读库
- 数据库压力大时返回缓存旧数据
- 监控与告警
- 缓存命中率监控
- 数据不一致告警(通过对比采样)
五、选择建议
- 优先考虑最终一致性:大部分业务场景可接受秒级不一致
- 读多写少场景:Cache-Aside + 较短过期时间
- 写多读少场景:考虑不缓存或Write-Through
- 强一致性要求:直接读数据库,或使用分布式锁+Cache-Aside
- 架构演进:从简单开始,先使用Cache-Aside,随着业务复杂再引入binlog同步
六、常见误区
- ❌ 先更新缓存,再更新数据库(崩溃时数据永久不一致)
- ❌ 过度追求强一致性,牺牲可用性
- ❌ 忽略缓存雪崩、穿透、击穿问题
- ❌ 没有设置缓存过期时间
最终建议:根据业务容忍度选择方案。对于电商详情页,500ms内一致通常可接受;对于金融余额,可能需要强一致或短暂读库。在实际工程中,”Cache-Aside + 合理TTL + 延迟双删”组合能满足80%的场景。