2626 字
约 8 分钟
14
S12-X11 · 缓存一致性:双写、失效与延迟双删

缓存不一致不是「偶发脏读」,而是并发窗口 + 失败丢失 + 过期兜底三件事叠加的结果。绝大多数事故不是算法错,而是写路径分散在多个服务里、删缓存失败没人重试、TTL 被设成几天。这篇把操作组合、并发窗口、补偿机制拆开讲清楚,最后说清哪些场景根本不该用缓存抗一致。


1. 只有四种基本操作组合

缓存与数据库之间,写操作只有两类:改库、改缓存(或删缓存)。组合起来是四种,逐一分析无并发时的正确性:

# 顺序 无并发是否正确 主要问题
1 更新库 → 删缓存 正确 删失败则缓存长期是旧值
2 删缓存 → 更新库 并发时错 删除后、更新前的读会回填旧值
3 更新库 → 更新缓存 并发时错 两次写缓存可能乱序,旧值覆盖新值
4 更新缓存 → 更新库 并发时错 缓存先变了库没变,读到的值与库不一致

真实的线上环境里,只有「更新库 → 删缓存」在工程上可维护。原因有三:

  • 删是幂等的,重复删没有副作用;更新缓存不是(乱序会留下旧值);
  • 删不要求缓存里有数据,未命中场景天然安全;
  • 缓存值往往由多个来源拼装,更新缓存等于要求在写路径复刻整条读逻辑。

2. 并发下的三种不一致

类型 触发条件 表现 持续时长
读到旧值(短窗口) 删缓存后、库提交前有读请求回填旧值 个别请求拿到旧数据 毫秒到秒级
缓存长期是旧值 删缓存失败且未重试,TTL 又很长 大量请求读到旧数据 到 TTL 到期为止
删缓存失败无人知 异常被吞、消息丢失、跨服务漏删 同上,且难以定位 不可预期

第一种是「概率性短暂不一致」,工程上普遍接受;第二种和第三种是「确定性故障」,必须靠重试与兜底解决。


3. Cache Aside 的标准做法

读:未命中回填;写:删而不是更新。

public OrderDetail get(Long orderId) {
    String key = "order:detail:" + orderId;
    String cached = redis.opsForValue().get(key);
    if (cached != null) {
        return Json.decode(cached, OrderDetail.class);
    }
    OrderDetail detail = orderMapper.selectDetail(orderId);   // 回源
    if (detail == null) {
        redis.opsForValue().set(key, NULL_SENTINEL, Duration.ofMinutes(1)); // 防穿透
        return null;
    }
    redis.opsForValue().set(key, Json.encode(detail), Duration.ofMinutes(30)); // TTL 兜底
    return detail;
}
@Transactional
public void update(UpdateOrderCommand cmd) {
    orderMapper.update(cmd);                    // 先提交库
    afterCommit(() -> cacheEvict(cmd.orderId()));  // 提交后再删
}

private void cacheEvict(Long orderId) {
    try {
        redis.delete("order:detail:" + orderId);
    } catch (Exception e) {
        retryQueue.push(orderId);               // 失败进重试队列,不能吞
    }
}

三个细节决定成败:

细节 做法 不做会怎样
删除时机 事务提交后(afterCommit) 事务回滚但缓存已删,或删除发生在提交前被回填
失败处理 进重试队列 / 记待删表 缓存长期旧值
空值处理 短 TTL 的空值哨兵 缓存穿透打到库

4. 延迟双删:真实作用与局限

流程是:先删缓存 → 更新库 → 延迟一段时间再删一次。第二次删的作用是清掉「在更新库期间被别人回填的旧值」。

public void updateWithDoubleDelete(UpdateOrderCommand cmd) {
    String key = "order:detail:" + cmd.orderId();
    redis.delete(key);                                  // 第一次删
    orderMapper.update(cmd);                            // 更新库(事务内)
    scheduler.schedule(() -> redis.delete(key),         // 第二次删(延迟)
            DELAY_MS, TimeUnit.MILLISECONDS);
}
问题 事实
它消除不一致吗 不。它把窗口从「读回填的瞬间」压缩到「延迟时长内」
为什么两次 第一次清理旧值,第二次清理窗口期被回填的旧值
延迟多久 要大于「一次读请求的回源 + 回填」耗时,典型量级为百毫秒到 1 秒;具体按实际 RT 核对,不能拍脑袋
延迟任务可靠吗 单机 ScheduledExecutor 重启即丢;生产应放延迟队列或待删表
多实例写入 每个实例都要删同一 key,第二次删必须落在「最后一次写之后」

延迟双删常被当成「银弹」,实际上它只是把概率压低:延迟期内仍有读、延迟任务仍可能失败。它的定位是降低概率的补充手段,不是一致性保证。


5. 订阅 binlog 异步失效

更彻底的做法是让缓存失效跟着数据库变更走:解析 binlog(Debezium / Canal),投递到消息队列,由缓存侧消费删除。

方案 延迟量级 复杂度 适用
应用内双写删除 毫秒级 低 写路径集中、单服务
binlog 订阅 百毫秒级(含解析 + 投递 + 消费) 中高 多写入方、跨服务
定时比对修正 分钟级 中 兜底,不作为主路径
{
  "name": "order-cdc",
  "config": {
    "connector.class": "io.debezium.connector.mysql.MySqlConnector",
    "database.hostname": "mysql",
    "database.include.list": "trade",
    "table.include.list": "trade.orders",
    "topic.prefix": "cdc",
    "snapshot.mode": "schema_only"
  }
}
@KafkaListener(topics = "cdc.trade.orders")
public void onOrderChange(ConsumerRecord<String, String> record) {
    JsonNode after = Json.readTree(record.value()).path("after");
    if (after.isMissingNode()) {
        return;   // delete 事件,交由 TTL 处理
    }
    String key = "order:detail:" + after.path("id").asLong();
    redis.delete(key);
}

binlog 方案的三条前提:表必须有主键、消费必须幂等(重复投递就是重复删,删本身幂等)、顺序不保证时要能容忍乱序删除(删是幂等的,所以乱序无害——这正是删优于更新缓存的另一个理由)。


6. TTL 是最后一道防线

TTL 不能当一致性方案,但没有 TTL 的缓存是不可运维的。

设置 作用 反面
普通数据 30 分钟 限制脏数据存活上限 设成 7 天等于放弃兜底
热点数据 1–5 分钟 减少脏读窗口 过短会打穿到库
空值哨兵 30 秒–1 分钟 防穿透 过长会掩盖真实写入延迟
随机抖动 ±10% 防雪崩 无抖动时大批 key 同时过期

判断口诀:TTL 决定「最坏情况下脏多久」,删除机制决定「正常情况下脏不脏」。两者不能互相替代。


7. 强一致场景怎么办

场景 做法 代价
余额、库存扣减 不用缓存读,直接读主库 + 行锁/乐观锁 QPS 受限于数据库
配置类强一致读 本地缓存 + 版本号校验,变更推送 需要推送通道
秒杀库存 Redis 作为唯一权威源,异步落库 需要幂等与对账
订单状态查询 读主库;缓存只用于列表页等弱一致场景 读放大

结论:强一致的地方不要用「数据库 + 缓存」双写,直接把 Redis 当权威源,或者干脆不缓存。


8. 不一致的观测

// 定时抽样比对:库与缓存的关键字段是否一致
@Scheduled(fixedDelay = 60_000)
public void sampleCompare() {
    for (Long id : orderMapper.sampleIds(200)) {
        OrderDetail db = orderMapper.selectDetail(id);
        String cached = redis.opsForValue().get("order:detail:" + id);
        if (cached == null) continue;
        OrderDetail c = Json.decode(cached, OrderDetail.class);
        if (db != null && c != null && !db.getStatus().equals(c.getStatus())) {
            metrics.counter("cache.inconsistent").increment();
            redis.delete("order:detail:" + id);   // 顺手修正
        }
    }
}
观测项 异常信号 含义
缓存命中率骤降 从 90% 掉到 60% 大量 key 被删或过期,可能删缓存风暴
cache.inconsistent 计数 持续大于 0 写路径漏删
删缓存重试队列长度 单调上涨 Redis 或网络异常,旧值在堆积
业务侧「刚改完看不到」工单 集中出现 一致性窗口变长

9. 排错:六种典型现象

现象 根因 处理
改完数据刷新页面还是旧值 写库成功但删缓存失败且被 catch 吞掉 加失败重试与待删表,记录告警
高并发下偶发旧值 删缓存发生在读回填之前 延迟双删 + 提交后删
某个 key 永远不更新 双写分散在两个服务,其中一处漏删 收口写路径,或改 binlog 订阅
Redis 内存暴涨 无 TTL 或 TTL 过长 统一 TTL 策略 + 淘汰策略核对
大批 key 同时失效打穿数据库 TTL 无抖动 加随机抖动,热点预热
缓存里是 null 但库里有值 空值哨兵写入后未在写路径清理 写路径必须删空值哨兵

10. 边界:这些情况本就不该追求「缓存一致」

场景 建议
商品详情、文章正文 接受秒级不一致,TTL + 删除即可
用户余额、优惠券核销 不缓存或 Redis 作权威源
后台报表 直接查库或走读模型,不套缓存
跨服务共享缓存 写路径必然分散,优先 binlog 方案
缓存作为「降级兜底」 明确它是旧数据,业务侧要能接受

一句话:缓存的正确目标不是「一致」,而是「把不一致控制在可接受、可观测、可自愈的范围内」。


11. 自测

  1. 四种写操作组合里,哪一种在无并发时就已经是错的?
  2. 为什么「删缓存」比「更新缓存」更适合作为标准做法?
  3. 延迟双删的第二次删除,究竟在清什么?
  4. binlog 订阅方案的延迟主要花在哪几段?
  5. TTL 与删除机制各自解决什么问题,为什么不能互相替代?
  6. 你会用哪些指标判断「缓存正在不一致」?
S12-X11 · 缓存一致性:双写、失效与延迟双删
https://www.clxhxhhr.top/posts/5866/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。