---
title: 缓存一致性
type: concept
domain: redis
tags: [redis, cache, consistency, cache-aside, canal, binlog, dual-delete]
status: learning
created: 2026-09-20
last_reviewed: 2026-09-20
method: feynman
related_questions:
  - "什么情况下会出现数据库和缓存不一致的问题？"
related_knowledge:
  - ./cache-three-failures.md
  - ./redis-design-dimensions.md
visualizations: []
anki_cards: 5
interview_rounds:
  - "2026-09-20-round-1-Q7"
---

# 缓存一致性

> DB 与缓存不一致的根因是**非原子性 + 并发**，具体有 6 类场景。

## TL;DR（30 秒扫完）

- **6 大不一致场景**：更新后删失败 / 删后更新失败 / 并发 race / 先删后更 / 先更后删 / 主从延迟
- **推荐模式 Cache-Aside**：读时回填 + 写时删缓存（**删除比更新安全**）
- **延迟双删**：更新 DB → 删缓存 → 延迟 500ms-1s → 再删一次（清并发读回填的脏值）
- **强一致方案**：Binlog 订阅（Canal）+ MQ 重试 / CDC / 事务性消息
- **兜底**：TTL 是最终一致性的安全网

## 关键结论

- **结论 A**：删除缓存比更新缓存安全——并发写时更新缓存会有旧值覆盖新值的 race
- **结论 B**：延迟双删的延迟值应覆盖主从同步周期（经验 500ms-1s）
- **结论 C**：MULTI/EXEC 无回滚无隔离，生产环境推荐用 Lua 脚本替代
- **结论 D**：Redis 原子性是架构级保证（单线程），MySQL 是协议级保证（undo log）

## 完整讲解（费曼四步）

### STEP 1 · 概念

**缓存一致性**：DB 与缓存数据保持同步的机制。由于非原子性 + 并发，两者必然存在不一致窗口，需要选择**强一致**或**最终一致**方案。

### STEP 2 · 大白话

**外卖比喻**：
- **不一致**：外卖 App 显示"有货"，但后厨其实已经卖完了
- **Cache-Aside**：用户点菜时先查 App（缓存），App 没有就跑去问后厨（DB），拿到后更新 App
- **延迟双删**：后厨更新库存后先清空 App，过 1 秒再清空一次（防止有人刚好在更新时查到旧值又写回 App）

### STEP 3 · 底层

#### 不一致的 6 大场景

| # | 场景 | 现象 |
|---|------|------|
| 1 | 更新 DB 后删除缓存**失败** | 脏数据长期驻留到 TTL |
| 2 | 删除缓存后更新 DB **失败** | 下次读回填旧值 |
| 3 | **并发 race**：读线程读旧值 → 写线程更新 DB 删缓存 → 读线程把旧值写回缓存 | 脏数据驻留 |
| 4 | 先删缓存再更新 DB | 删除后并发读回填旧值 |
| 5 | 先更新缓存再更新 DB | DB 更新失败，缓存是新值 DB 是旧值 |
| 6 | **主从延迟** | 主库写完从库未同步，读走从库拿旧值 |

#### 四大经典模式对比

| 模式 | 读流程 | 写流程 | 一致性 | 适用 |
|------|--------|--------|--------|------|
| **Cache-Aside**（推荐） | 先读缓存，miss 读 DB 回填 | 更新 DB → 删缓存 | 最终 | 通用 |
| **Read-Through** | 缓存 miss 由缓存层回填 | 更新缓存 → 缓存层写 DB | 较强 | 高一致性需求 |
| **Write-Through** | 直接读缓存 | 同步写缓存 + DB | 强 | 写少读多 |
| **Write-Behind** | 直接读缓存 | 异步批量写 DB | 弱 | 允许丢失 |

#### 为什么"删除缓存"优于"更新缓存"

- **更新缓存**：先算旧值可能覆盖新值，写放大且难收敛
- **删除缓存**：让下次读**自然回填最新值**，天然正确
- **并发写场景下更新缓存必然有 race**，删除没有

#### 延迟双删的正确姿势

```
更新 DB → 删缓存 → 延迟 N ms → 再删一次
```

- **N 值经验**：500ms-1s（覆盖一次主从同步周期）
- **为什么是删两次**：第一次删后可能并发读回填旧值，第二次删清掉这个脏数据
- **实现**：线程池延迟任务 / 消息队列延迟消息 / 定时任务

#### Redis 原子性三层次

| 层级 | 机制 | 说明 |
|------|------|------|
| 命令层 | 单命令（INCR/SETNX/LPUSH） | 事件循环中串行执行 |
| 脚本层 | Lua 一次性编译+执行 | 执行期间不接受其他命令 |
| 事务层 | MULTI/EXEC 批量入队 | EXEC 时顺序串行（**无回滚**） |

**MULTI/EXEC 的坑**：
- **无回滚**：中间一条失败，其余继续执行
- **无隔离**：MULTI 后其他客户端命令仍可插入队列
- **语法检查早，运行时错误晚**：SYNTAX 错在入队时报错，类型错在 EXEC 时报错
- 生产环境**推荐用 Lua 脚本**替代 MULTI

#### Redis vs MySQL 原子性对比

| 维度 | Redis SETNX 原子性 | MySQL 事务原子性 |
|------|-------------------|-----------------|
| 来源 | 架构（单线程） | 协议（undo log） |
| 失败处理 | 无回滚 | 回滚到快照 |
| 粒度 | 单命令 / 脚本 / MULTI | 事务内所有 SQL |
| 隔离性 | 天然串行 | 靠锁 + MVCC |

#### 强一致性方案（按侵入性从小到大）

1. **Binlog 订阅（Canal）**：消费 MySQL binlog → MQ → 删缓存；**最推荐**，业务无侵入
2. **CDC 工具**：Debezium / Flink CDC，同上思路
3. **事务性消息**：RocketMQ 半消息，DB 事务与消息发送原子
4. **分布式事务**：Seata AT/TCC/最终事务；侵入大，不推荐
5. **锁住整个流程**：读锁 + 写锁，性能差

#### 工程实践清单

| 数据类型 | 推荐方案 |
|---------|---------|
| 关键数据（订单、余额） | Binlog + MQ 强一致 |
| 一般数据（商品、配置） | Cache-Aside + 延迟双删 + TTL |
| 只读数据（静态内容） | Read-Through + 长 TTL |
| 高频写场景 | Write-Through 减少 DB 压力 |

#### 兜底策略

- **TTL 是最终一致性的安全网**：即使前面都失败了，TTL 到期后自然恢复
- **TTL 设多长**：业务可容忍的脏数据时间窗（订单 5min，商品详情 1h）
- **监控不一致率**：定时任务抽样对比 DB 与缓存，超阈值告警

### STEP 4 · 简化

**一句话总结**：不一致根因是非原子性+并发（6 大场景），Cache-Aside 删除比更新安全，延迟双删覆盖主从周期，强一致用 Canal+MQ，TTL 兜底。

**记忆口诀**：
- 6 场景：更新后删失败 / 删后更新失败 / 并发 race / 先删后更 / 先更后删 / 主从延迟
- Cache-Aside：读回填 + 写删缓存
- 延迟双删：500ms-1s 覆盖主从周期
- 强一致：Canal + MQ > 事务消息 > Seata
- 兜底：TTL 是安全网

## 常见误区

- **误区 1**：说"更新缓存更安全" → 正确：删除比更新安全，并发写时更新缓存会 race
- **误区 2**：说"MULTI/EXEC 有回滚" → 正确：无回滚无隔离，推荐 Lua 脚本
- **误区 3**：说"延迟双删延迟随便设" → 正确：应覆盖主从同步周期（500ms-1s）
- **误区 4**：说"Redis 和 MySQL 原子性一样" → 正确：Redis 是架构级保证，MySQL 是协议级保证
- **误区 5**：说"TTL 不需要，前面方案够" → 正确：TTL 是最终一致性的安全网，必须设

## 延伸追问

1. **Canal 如何保证不丢消息？**
   - offset 落盘 + 断点续传 + MQ 至少一次投递
2. **延迟双删的 N 值怎么定？**
   - 主从延迟监控 P99 + 业务读放大系数；经验 500ms-1s
3. **读写分离下如何避免读到旧值？**
   - 强制走主库读（有写会话）；session sticky；读前等待 binlog 同步
4. **Seata AT 模式原理？为什么侵入小？**
   - undo_log + 全局锁 + 一阶段本地事务二阶段补偿
5. **如何监控缓存不一致率？**
   - 定时抽样对比 DB 与缓存；埋点上报不一致次数；阈值告警
6. **库存扣减场景如何保证一致性？**
   - Redis Lua 原子扣减 + MQ 事务消息 + DB 最终落库 + 对账补偿

## 速查表

```
6 场景: 更新后删失败/删后更新失败/并发race/先删后更/先更后删/主从延迟
Cache-Aside: 读回填 + 写删缓存（推荐）
延迟双删: 500ms-1s 覆盖主从周期
强一致: Canal+MQ > 事务消息 > Seata（侵入大）
Redis 原子: 命令层/脚本层/事务层（MULTI 无回滚）
MySQL 原子: undo log + 锁 + MVCC
兜底: TTL 是安全网（订单 5min, 商品 1h）
```

## 关联题目（题库）

- 《什么情况下会出现数据库和缓存不一致的问题？》— 2026-09-20 Round 1 Q7, ⭐⭐⭐⭐☆（根因抓得准，缺 6 大场景系统列举）

## 关联知识

- [缓存三失效（穿透/击穿/雪崩）](./cache-three-failures.md)
- [Redis 设计 12 大维度](./redis-design-dimensions.md)
- [Redis 为什么快 + 为什么单线程](./redis-single-thread.md)

## Anki 候选卡片

1. **正**：DB 与缓存不一致的 6 大场景？**反**：更新后删失败、删后更新失败、并发 race、先删后更、先更后删、主从延迟
2. **正**：为什么删除缓存优于更新缓存？**反**：并发写时更新缓存会有旧值覆盖新值的 race，删除让下次读自然回填
3. **正**：延迟双删的延迟值设多少？**反**：500ms-1s，覆盖一次主从同步周期
4. **正**：Redis MULTI/EXEC 事务有回滚吗？**反**：无回滚无隔离，推荐用 Lua 脚本替代
5. **正**：强一致缓存的推荐方案？**反**：Binlog 订阅（Canal）+ MQ 重试，业务无侵入

---

*最后更新：2026-09-20*
