缓存更新问题
业务数据更新了,怎么处理数据库数据和缓存数据呢?
先更新DB,再更新Cache
缺点:DB更新完以后,Cache可能更新失败,导致两边数据不一致
先删除Cache,再更新DB
缺点:删除Cache以后,更新DB之前可能存在并发读数据,导致缓存记录的旧数据
上面两个都会有比较严重的问题,当下主流的三种方式是
- Cache Aside策略(旁路缓存模式)
- Read/Write Through(读写穿透)
- Write Behind Caching(异步写回)
Cache Aside 策略
先更新DB,再删除Cache
为什么是删缓存而不是更新缓存
一、根本原因:避免并发写导致的数据错乱
场景复现:并发更新时的缓存脏数据
sequenceDiagram
participant T1 as 线程1
participant T2 as 线程2
participant DB as 数据库
participant Cache as 缓存
T1 ->> DB: 更新数据 A=1 (写操作)
T2 ->> DB: 更新数据 A=2 (写操作)
T2 ->> Cache: 更新缓存 A=2
T1 ->> Cache: 更新缓存 A=1 ← 脏数据!
- 问题本质:
数据库的更新顺序是T1 → T2(最终 A=2),但缓存更新顺序可能是T2 → T1(缓存中 A=1 错误覆盖了 A=2) - 后果:
缓存永久存储旧数据,直到下一次删除或过期
💡 删除缓存的优势:
无论线程执行顺序如何,删除操作具有天然幂等性(多次删除等效于一次),不会产生脏数据覆盖。
二、性能优化:减少无效计算与网络开销
-
写多读少场景的浪费
- 若数据更新后长时间不被读取,更新缓存的操作完全浪费
- 示例:
用户修改个人简介(1小时仅1次写),但可能一天才被读取1次
→ 更新缓存 = 白消耗 CPU + 网络带宽
-
缓存计算成本高昂
- 某些缓存值需复杂计算(如聚合查询结果)
- 反模式:
// 伪代码:错误更新缓存 void updateUser(User user) { db.update(user); // 耗费资源的缓存重建 UserCacheDTO dto = expensiveCalculation(user); cache.set(user.id, dto); } - 删除策略的优势:
将计算延迟到真正需要时(读请求回填),避免无效计算
三、数据一致性:解决多操作原子性难题
数据库与缓存无法保证原子更新
- 致命问题:
若先更新缓存成功,但数据库更新失败 → 缓存与数据库永久不一致 - 删除策略的容错性:graph LR A[更新数据库] --> B{成功?} B -->|是| C[删除缓存] B -->|否| D[放弃删除] D --> F[缓存自动过期后读取正确数据] C -->|失败| E[异步重试]即使删除缓存失败,也有 TTL 兜底,而更新缓存失败则直接污染数据
四、业务场景适配:读多写少的黄金组合
Cache Aside 的适用场景比例
| 操作类型 | 典型比例 | 缓存策略 | 优势体现 |
|---|---|---|---|
| 读操作 | 90%+ | 读时回填 | 避免冷启动问题 |
| 写操作 | <10% | 删缓存(非更新) | 节省写开销,规避并发冲突 |
✅ 真实案例:
电商商品详情页(QPS 10万+):
- 每天商品更新不足千次(删缓存代价低)
- 读请求占99%(回填缓存收益最大化)
五、与更新缓存的策略对比
| 维度 | 更新缓存策略 | 删除缓存策略 (Cache Aside) |
|---|---|---|
| 并发写安全性 | ❌ 易产生脏数据覆盖 | ✅ 天然幂等,无覆盖风险 |
| 计算资源消耗 | ❌ 可能浪费计算(写多读少) | ✅ 按需计算(读时触发) |
| 数据一致性 | ❌ 缓存/DB原子性难保障 | ✅ 依赖异步删除+TTL兜底 |
| 网络开销 | ❌ 每次写都更新缓存(带宽占用高) | ✅ 仅删除(1次轻量操作) |
| 适用场景 | 写密集型 + 数据计算简单 | 读密集型 + 数据计算复杂 (主流) |
总结:设计哲学的核心
-
语义正确性优先
删除操作 = “让数据失效” → 符合缓存作为非权威数据源的定位 -
面向失败设计
假设任何操作都可能失败 → 删除策略的故障影响远小于更新 -
延迟计算思想
不预测需求,仅在真正需要时重建缓存(YAGNI原则)
🔥 何时该用更新缓存?
仅当同时满足:
- 写频率极高 + 读频率也高(如实时计数器)
- 数据计算极其简单(如原子递增)
- 具备事务机制保证 DB 与缓存原子更新
否则一律选择删除缓存。
删除缓存失败怎么办
1. 重试删除机制(简单版)
// 伪代码示例
void updateData(Data newData) {
try {
db.update(newData); // 步骤1:更新数据库
cache.del(key); // 步骤2:尝试删除缓存
} catch (Exception e) {
// 步骤3:同步重试(最多3次)
for (int i = 0; i < 3; i++) {
if (cache.del(key)) break;
Thread.sleep(50);
}
}
}
- 优势:代码简单,适用于低并发场景
- 劣势:
❌ 阻塞主线程,影响接口性能
❌ 重试失败仍会不一致
2. 异步重试队列(推荐)
graph LR
A[业务更新数据库] --> B[发送删除消息到MQ]
B --> C{MQ消费成功?}
C -- 是 --> D[删除缓存]
C -- 否 --> E[重试消费]
E -->|超过阈值| F[告警+人工介入]
- 实现要点:
- 业务更新数据库后,写入消息队列(如 Kafka/RocketMQ)
- 独立消费者异步重试删除缓存(指数退避策略)
- 优势:
✅ 解耦主流程,不影响核心业务性能
✅ 高可靠性(MQ持久化+重试) - 劣势:
❌ 引入消息中间件,架构复杂度增加
3. 监听数据库 Binlog(终极方案)
graph LR
DB[数据库] -->|Binlog| Canal
Canal -->|变更事件| MQ[消息队列]
MQ --> Consumer[缓存删除服务]
Consumer -->|成功| Cache[Redis]
Consumer -->|失败| Retry[重试机制]
- 实现工具:
- MySQL → Canal / Debezium 监听 Binlog
- PostgreSQL → Logical Decoding
- 优势:
✅ 完全解耦业务代码,对应用透明
✅ 支持跨服务缓存清理(如订单更新后清理用户缓存) - 劣势:
❌ 架构复杂,运维成本高
4. 设置缓存过期时间(TTL兜底)
# 更新数据时设置带TTL的缓存
SET key value EX 300 # 即使删除失败,300秒后自动失效
- 适用场景:
- 允许短期不一致(如商品描述更新)
- 兜底防御缓存删除彻底失败
- 关键点:
⚠️ TTL 时间需根据业务容忍度设定(通常 5-60 分钟)
5. 双删延迟队列(应对并发场景)
void updateData(Data newData) {
cache.del(key); // 第一次删除(预删)
db.update(newData); // 更新数据库
mq.sendDelayMsg(key, 1s); // 发送1秒延迟消息
}
// MQ消费者
void handleDelayMsg(key) {
cache.del(key); // 第二次删除(清理并发读产生的脏数据)
}
- 解决痛点:
✅ 缓解“删缓存后、更新DB前”的并发读旧数据回填问题 - 劣势:
❌ 延迟时间难以精确设定
6. 版本号/时间戳控制(强一致场景)
// 写操作
db.update(newData, version+1);
cache.del(key);
// 读操作
Data data = cache.get(key);
if (data == null) {
data = db.query(key);
// 检查版本:仅当缓存中版本≤数据库版本时才写入
if (data.version >= cache.getVersion(key)) {
cache.set(key, data);
}
}
- 本质:通过数据版本号实现写后读的幂等性
- 优势:
✅ 可防御任何阶段的并发冲突 - 代价:
❌ 需改造数据模型,读逻辑变复杂
📊 方案选型决策表
| 方案 | 一致性保障 | 性能影响 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 同步重试 | ⭐️⭐️ | 高 | 低 | 非核心业务 |
| 异步队列(推荐) | ⭐️⭐️⭐️⭐️ | 低 | 中 | 通用业务(平衡型) |
| Binlog监听(强推) | ⭐️⭐️⭐️⭐️⭐️ | 无 | 高 | 大型分布式系统 |
| TTL兜底 | ⭐️⭐️ | 无 | 低 | 允许短期不一致 |
| 双删延迟队列 | ⭐️⭐️⭐️ | 中 | 中 | 高并发写场景 |
| 版本号控制 | ⭐️⭐️⭐️⭐️⭐️ | 中 | 高 | 金融/库存等强一致场景 |
🛡️ 防御性架构设计原则
- 降级开关:
- 缓存删除失败率超阈值时,自动关闭缓存读写(直连DB)
- 监控三板斧:
- 埋点监控:缓存删除成功率 + MQ堆积量
- 日志追踪:记录删除操作的 Key 和错误堆栈
- 实时告警:10分钟内连续失败 > 50 次触发电话告警
- 兜底策略:
- 必须设置缓存 TTL(即使业务层面不需要)
- 定期扫描长时间未更新的热点 Key 强制刷新
💡 终极建议:
- 中小系统:异步重试队列 + TTL 兜底(成本收益比最高)
- 金融级系统:Binlog 监听 + 版本号控制(不计成本保一致)
- 永远假设删除会失败:设计时以“缓存删除必然失败”为前提做容错
Read/Write Through(读写穿透)
Write Behind Caching(异步写回)
三种核心策略对比
| 策略 | 一致性 | 性能 | 复杂度 | 典型场景 |
|---|---|---|---|---|
| Cache Aside | 最终一致 | 读高📈,写中 | 低 | 电商详情页 |
| Read/Write Through | 强一致 | 读中,写低📉 | 高 | 全局配置信息 |
| Write Behind | 最终一致 | 读中,写极高 | 中 | 点赞计数/日志系统 |

浙公网安备 33010602011771号