缓存更新问题

业务数据更新了,怎么处理数据库数据和缓存数据呢?

先更新DB,再更新Cache
缺点:DB更新完以后,Cache可能更新失败,导致两边数据不一致

先删除Cache,再更新DB
缺点:删除Cache以后,更新DB之前可能存在并发读数据,导致缓存记录的旧数据

上面两个都会有比较严重的问题,当下主流的三种方式是

  1. Cache Aside策略(旁路缓存模式)
  2. Read/Write Through(读写穿透)
  3. 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次写),但可能一天才被读取1次
      → 更新缓存 = 白消耗 CPU + 网络带宽
  2. 缓存计算成本高昂

    • 某些缓存值需复杂计算(如聚合查询结果)
    • 反模式
      // 伪代码:错误更新缓存
      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次轻量操作)
适用场景 写密集型 + 数据计算简单 读密集型 + 数据计算复杂 (主流)

总结:设计哲学的核心

  1. 语义正确性优先
    删除操作 = “让数据失效” → 符合缓存作为非权威数据源的定位

  2. 面向失败设计
    假设任何操作都可能失败 → 删除策略的故障影响远小于更新

  3. 延迟计算思想
    不预测需求,仅在真正需要时重建缓存(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兜底 ⭐️⭐️ 允许短期不一致
双删延迟队列 ⭐️⭐️⭐️ 高并发写场景
版本号控制 ⭐️⭐️⭐️⭐️⭐️ 金融/库存等强一致场景

🛡️ 防御性架构设计原则

  1. 降级开关
    • 缓存删除失败率超阈值时,自动关闭缓存读写(直连DB)
  2. 监控三板斧
    • 埋点监控:缓存删除成功率 + MQ堆积量
    • 日志追踪:记录删除操作的 Key 和错误堆栈
    • 实时告警:10分钟内连续失败 > 50 次触发电话告警
  3. 兜底策略
    • 必须设置缓存 TTL(即使业务层面不需要)
    • 定期扫描长时间未更新的热点 Key 强制刷新

💡 终极建议

  • 中小系统:异步重试队列 + TTL 兜底(成本收益比最高)
  • 金融级系统:Binlog 监听 + 版本号控制(不计成本保一致)
  • 永远假设删除会失败:设计时以“缓存删除必然失败”为前提做容错

Read/Write Through(读写穿透)

Write Behind Caching(异步写回)

三种核心策略对比

策略 一致性 性能 复杂度 典型场景
Cache Aside 最终一致 读高📈,写中 电商详情页
Read/Write Through 强一致 读中,写低📉 全局配置信息
Write Behind 最终一致 读中,写极高 点赞计数/日志系统
posted @ 2025-08-08 21:48  一只盐桔鸡  阅读(39)  评论(0)    收藏  举报