缓存最难的部分不是"怎么放进去",而是"数据变了以后怎么让它失效"。绝大多数线上脏数据事故都出自同一个模式:更新数据库成功、删除缓存失败,或者删完缓存又被一个慢查询把旧值写回去了。以下按 Redis 7 与 MySQL 8.0 的常见组合核对。
1. Cache-Aside:两种顺序
| 顺序 | 动作 | 脏数据窗口 | 适用 |
|---|---|---|---|
| 先更库,再删缓存 | UPDATE db → DEL cache |
极小(删除失败或并发送达顺序异常) | 默认选择 |
| 先删缓存,再更库 | DEL cache → UPDATE db |
较大(更新前的读会把旧值回填) | 只适合写少读多且能容忍 |
// 先更库再删缓存(Cache-Aside 标准写法)
@Transactional
public void updatePrice(long skuId, BigDecimal price) {
priceMapper.update(skuId, price); // 1. 数据库为准
redis.del("sku:price:" + skuId); // 2. 删除,不是更新
}
注意 @Transactional 与缓存删除的顺序:删除动作最好放在事务提交之后(TransactionSynchronizationManager.registerSynchronization 或 @TransactionalEventListener(AFTER_COMMIT)),否则事务回滚了,缓存却已经删了,读请求会把旧值重新填回来。
2. 为什么是"删除"而不是"更新"
| 做法 | 问题 |
|---|---|
| 更新缓存 | 两个并发写会以不可控顺序写入两个值,可能留下旧值 |
| 更新缓存 | 缓存值常是聚合结果(多表 join、计数),更新代价高于删除 |
| 更新缓存 | 写多读少时,每次写都更新缓存纯属浪费 |
| 删除缓存 | 下次读按需重建,天然幂等,重复删无副作用 |
一句话:删除是幂等的,更新不是。在并发下,"删除"最多让下一次读多一次回源;"更新"可能永久留下一个错误值。
3. 延迟双删
先删缓存、更库、再延迟删一次,用来覆盖"更新期间有读把旧值回填"的窗口。
public void update(Sku sku) {
redis.del(key); // 第一次删
db.update(sku); // 更库
executor.schedule(() -> redis.del(key), 500, TimeUnit.MILLISECONDS); // 二次删
}
| 前提 | 失效场景 |
|---|---|
| 回源读的耗时 < 延迟时间 | 慢查询超过 500ms,旧值在第二次删之后才回填 → 依旧脏 |
| 延迟删除任务可靠执行 | 进程重启、线程池满、消息丢失 → 第二次删没发生 |
| 主从复制延迟被考虑 | 更库后立刻读从库拿到旧值并回填 → 二次删也救不了 |
延迟双删是"提高概率"的手段,不是正确性保证。它真正适用的场景是:更新频率低、回源慢、又不想引入 binlog 订阅。
4. Read/Write-Through 与 Write-Behind
| 模式 | 写路径 | 一致性 | 风险 |
|---|---|---|---|
| Cache-Aside | 应用自己管库和缓存 | 最终一致 | 应用逻辑分散,易漏删 |
| Read-Through | 读穿透:缓存未命中时由缓存组件回源 | 最终一致 | 回源逻辑集中在 loader |
| Write-Through | 写缓存,缓存组件同步写库 | 较强 | 写延迟变高,缓存成为写路径单点 |
| Write-Behind | 写缓存即返回,异步批量落库 | 弱 | 宕机丢数据,需持久化队列兜底 |
Write-Behind 的吞吐最高,代价是"缓存里已经是新值、库里还是旧值"的窗口可能长达数秒。金融、订单类业务基本不会选它。
5. 并发时序:删缓存 + 旧值回填
这是 Cache-Aside 唯一真正的漏洞,发生时序如下:
| 时刻 | 线程 A(写) | 线程 B(读) | 缓存 | 数据库 |
|---|---|---|---|---|
| T1 | 读缓存 miss | 空 | 旧值 100 | |
| T2 | 查库得到 100 | 空 | 旧值 100 | |
| T3 | 更库为 200 | 空 | 新值 200 | |
| T4 | 删除缓存 | 空 | 新值 200 | |
| T5 | 把 100 写回缓存 | 100(脏) | 新值 200 | |
| T6 | 事务提交 | 脏值长期存在 | 新值 |
触发条件是"B 的读库发生在 T3 之前、写缓存发生在 T4 之后"。窗口长度约等于一次查询耗时,概率不高但必然发生。
| 缓解手段 | 效果 |
|---|---|
| 更新后延迟再删一次 | 覆盖大部分窗口,不保证 |
| 缓存值带版本号,回填时比对 | 能挡住旧版本回填 |
| binlog 订阅异步删 | 窗口缩短到消费延迟,仍非零 |
| 读时校验库版本(读多写少才可行) | 一致性最好,成本最高 |
// 版本号防回填:缓存里存 {value, version},回填前比对 DB 版本
if (cached != null && cached.version >= dbVersion) {
return cached; // 缓存更新,丢弃这次回填
}
6. 订阅 binlog 异步失效
把"删缓存"从业务代码里摘出来,交给 CDC 组件消费 binlog。业务只管写库,缓存由下游保证最终失效。
# Debezium 的 MySQL connector 关键配置
connector.class: io.debezium.connector.mysql.MySqlConnector
database.hostname: mysql-primary
database.include.list: shop
table.include.list: shop.sku
topic.prefix: cdc
snapshot.mode: schema_only
| 组件 | 特点 | 注意 |
|---|---|---|
| Canal | 阿里系,伪装从库拉 binlog | 需要 ROW 格式与 log_slave_updates |
| Debezium | Kafka Connect 生态,事件结构规范 | 运维重,依赖 Kafka |
| Maxwell | 轻量,输出 JSON | 功能相对单一 |
优点:业务与缓存解耦、漏删概率低、可按表订阅。缺点:引入延迟(毫秒到秒级)、多一套要运维的组件、binlog 保留期必须大于消费滞后。
7. 强一致做不到的根因
| 环节 | 为什么破坏强一致 |
|---|---|
| 网络分区 | 更新库成功、删缓存请求丢失,两副本状态不同 |
| 主从复制 | 写主库、读从库,从库滞后期间读到旧值 |
| 缓存与库是两个系统 | 没有跨系统事务,只能"尽力而为 + 补偿" |
| CAP | 分区发生时必须在一致性(C)与可用性(A)二选一 |
想在分区时保 C,就得让写请求在缓存不可达时失败——这在电商详情页上等于自杀。所以工程上的目标不是强一致,而是把不一致窗口压到可接受,并保证最终收敛。
8. TTL 是最后的兜底
无论删除逻辑多严,TTL 都必须有。它是"所有失效机制都失败时"的唯一保险。
| 数据类型 | 建议 TTL | 理由 |
|---|---|---|
| 商品详情 | 5–30 分钟 | 允许短时陈旧 |
| 库存 | 1–5 秒 + 版本校验 | 陈旧代价高 |
| 用户会话 | 30 分钟(滑动) | 与登录态绑定 |
| 配置/字典 | 10 分钟 + 主动刷新 | 变更需秒级生效则配广播 |
| 计数/排行 | 1 分钟 | 可重算,允许抖动 |
TTL 加随机抖动(ttl = base + random(0, base*0.1))能避免同一批 key 同时过期造成雪崩。
9. 击穿、穿透、雪崩与更新策略的关系
| 问题 | 触发 | 与失效策略的关系 | 解法 |
|---|---|---|---|
| 击穿 | 单个热点 key 过期,瞬间大量回源 | TTL 集中 + 删除后立即回源 | 逻辑过期 / 互斥重建 / 单飞 |
| 穿透 | 查不存在的数据,每次都打到库 | 删除策略无关 | 空值缓存 / 布隆过滤器 |
| 雪崩 | 大量 key 同时失效或缓存整体宕机 | TTL 未抖动、集群故障 | 抖动 TTL / 多级缓存 / 限流降级 |
// 单飞:同一 key 只允许一个线程回源,其余等待
public String load(String key) {
String v = redis.get(key);
if (v != null) return v;
String lockKey = "lock:" + key;
if (redis.set(lockKey, "1", SetParams.setParams().nx().ex(10))) {
try {
v = db.query(key);
redis.set(key, v, SetParams.setParams().ex(jitter(600)));
return v;
} finally {
redis.del(lockKey);
}
}
sleep(50);
return redis.get(key); // 等重建完成后再读
}
注意"逻辑过期"与"物理 TTL"的区别:逻辑过期把过期时间放在 value 里,key 本身不过期,重建时异步进行,读请求永远不阻塞——代价是必然读到一次旧值。
10. 排错:五种脏数据现场
| 现象 | 常见原因 | 处理 |
|---|---|---|
| 改了库,页面还是旧值 | 删除失败被吞异常 | 删除失败必须重试或落补偿队列 |
| 偶发旧值,重放后又好了 | 旧值回填(第 5 节时序) | 版本号校验 + 延迟双删 |
| 主库新、从库旧,缓存跟着旧 | 回源读从库 | 强一致读走主库,或缓存写入带版本 |
| 大促期间缓存与库差很多 | 写多读少,删除风暴 | 改为写时只删一次 + 异步重建 |
| 缓存 key 越来越多 | 无 TTL 或 TTL 过长 | 全量 key 必须有 TTL 上限 |
排查顺序永远是:先看缓存里到底是不是旧值(GET 一下)→ 再看删除有没有执行(慢日志/审计)→ 最后看时序(是否被回填)。
11. 边界:什么时候别用缓存
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 强一致账务/余额 | 直读库 + 行锁/乐观锁 | 缓存一致性成本高于收益 |
| 写多读少 | 只做本地小缓存或不做 | 每次写都要失效,命中率低 |
| 结果集巨大 | 分页 + 按需查 | 大 value 拖垮 Redis 与网络 |
| 需要事务性读 | 读库 | 缓存无法参与事务 |
| 数据可重算且便宜 | 不加缓存 | 缓存本身也是故障源 |
一句话总结:先更库再删缓存是默认答案;删除优于更新;延迟双删只是概率优化;TTL 是必须存在的最后一道保险;强一致要么不做缓存,要么在读路径上付代价。
12. 自测
- 为什么更新缓存不如删除缓存?举一个并发反例。
- 延迟双删在什么情况下会失效?
- 第 5 节的时序里,最关键的两次跨线程操作是哪两步?
- 用 binlog 订阅做失效,延迟从哪来,怎么控制?
- 缓存与数据库为什么做不到强一致?用 CAP 解释。
- TTL 抖动解决的是击穿、穿透还是雪崩?