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. 自测
- 四种写操作组合里,哪一种在无并发时就已经是错的?
- 为什么「删缓存」比「更新缓存」更适合作为标准做法?
- 延迟双删的第二次删除,究竟在清什么?
- binlog 订阅方案的延迟主要花在哪几段?
- TTL 与删除机制各自解决什么问题,为什么不能互相替代?
- 你会用哪些指标判断「缓存正在不一致」?
S12-X11 · 缓存一致性:双写、失效与延迟双删
https://www.clxhxhhr.top/posts/5866/ 评论
0 条
还没有评论,先写一条吧。