05.2 - 缓存一致性方案
定位: 缓存与数据库之间的数据同步策略——从强一致到最终一致的权衡 面试高频度: ⭐⭐⭐⭐⭐ 考查方式: 更新缓存的策略选择、先删缓存还是后删、延迟双删
一、这是什么?为什么需要它?
是什么
缓存一致性是指 Redis 缓存中的数据与数据库(MySQL)中的数据保持一致的程度。
为什么需要解决一致性问题?
二、方案对比
2.1 四种更新策略
策略 1:先更新数据库,再删除缓存(Cache Aside ★推荐)写操作: ① UPDATE MySQL(先写库)→ ② DEL Redis(再删缓存)
读操作: ① GET Redis → 未命中 → ② SELECT MySQL → ③ SET Redis
为什么是删缓存不是更新缓存?
- 删除简单(不需计算)
- 下次读时自然构建新缓存
- 避免"写-写"竞争(同一 key 两次写)
策略 2:先删除缓存,再更新数据库写操作: ① DEL Redis(先删缓存)→ ② UPDATE MySQL(后写库)
问题(并发读导致的脏数据): 线程A: DEL Redis 线程B: 读 → Redis 无 → MySQL(旧) → 设缓存 线程A: UPDATE MySQL(新) → 缓存中是旧数据!
策略 3:延迟双删写操作: ① DEL Redis(第一次删) ② UPDATE MySQL ③ sleep(500ms) # 等读请求可能设旧缓存 ④ DEL Redis(第二次删)
双删的目的: 第一次删 → 让读请求查 DB;等读请求完成 → 第二次删 → 清掉可能的旧缓存
问题: sleep 时间不好确定(50ms? 500ms? 5s?)
策略 4:异步更新(Canal ★高可靠) 应用只操作数据库,缓存由 Canal 同步更新: ① 应用 UPDATE MySQL → ② MySQL 记录 binlog → ③ Canal 监听 binlog 变更 → ④ Canal 更新 Redis 缓存
✓ 应用不需要关注缓存逻辑 ✓ binlog 保证不会丢 ✗ 引入消息队列 / Canal 额外组件
2.2 方案对比
| 方案 | 一致性 | 复杂度 | 延迟 | 适用场景 |
|---|---|---|---|---|
| 先删缓存后写库 | 低(脏数据风险) | 低 | 低 | 容忍不一致 |
| 先写库后删缓存 | 中(短暂不一致) | 低 | 低 | 大部分场景 ★ |
| 延迟双删 | 中高 | 中 | 中(sleep) | 对一致性要求略高 |
| Canal 同步 | 高(最终一致) | 高 | 低(秒级) | 高一致性要求 |
| 读写锁 | 高(准强一致) | 高 | 高 | 关键数据 |
三、实战验证
3.1 Cache Aside 模式代码
import redis
import time
r = redis.Redis()
def get_user(id):
"""读:Cache Aside"""
key = f"user:{id}"
# 1. 查缓存
user = r.get(key)
if user is not None:
return user
# 2. 缓存未命中 → 查数据库
user = db.query(f"SELECT * FROM user WHERE id={id}")
if user is None:
return None
# 3. 写入缓存(带过期时间)
r.setex(key, 3600, user)
return user
def update_user(id, data):
"""写:先写库 → 后删缓存"""
key = f"user:{id}"
# 1. 更新数据库
db.execute("UPDATE user SET name=%s WHERE id=%s", (data['name'], id))
# 2. 删除缓存(下次读重新构建)
r.delete(key)
def update_user_delayed_delete(id, data):
"""写:延迟双删版本"""
key = f"user:{id}"
# 1. 第一次删缓存
r.delete(key)
# 2. 更新数据库
db.execute("UPDATE user SET name=%s WHERE id=%s", (data['name'], id))
# 3. 延迟(确保并发读已完成)
time.sleep(0.5)
# 4. 第二次删缓存
r.delete(key)3.2 验证一致性方案效果
# 模拟:先写库后删缓存
# 终端 1:监控 key
redis-cli MONITOR
# 终端 2:写入测试
redis-cli SET user:1 "old_value"
# 执行写流程
echo "模拟先写库后删缓存"
redis-cli DEL user:1 # 真实的业务这里在写入 DB 之后执行
# 验证缓存已删除
redis-cli GET user:1
# (nil) ← 缓存已清空,下次读会重新构建
# 模拟并发读引起的脏数据
echo "模拟:先删缓存后写库的问题"
# 步骤 1:删缓存
redis-cli DEL user:1
# 步骤 2(模拟并发读):缓存没命中→读 DB→写旧值到缓存
redis-cli SET user:1 "old_db_value" # 并发读操作
# 步骤 3:写库(更新为最新值)
redis-cli SET user:1 "new_db_value" # 这里应该是 DB 操作
# → 缓存中是 "old_db_value"(脏数据),DB 中是 "new_db_value"
redis-cli GET user:1
# "old_db_value" ← 不一致!3.3 使用 Lua 保证原子性
-- 保证"查缓存+设缓存"原子性
local key = KEYS[1]
local ttl = ARGV[1]
local value = redis.call('GET', key)
if value then
return value -- 返回缓存值
end
-- 未命中,返回 nil(让调用者查 DB)
return nil四、面试视角
| 追问 | 答案要点 |
|---|---|
| 为什么 Cache Aside 建议删缓存而不是更新? | 删除更简单(原子操作),不会出现"写-写"竞争。更新需要知道缓存结构(如 Hash 对象) |
| 先写库后删缓存,删缓存失败怎么办? | 重试机制(异步队列重试)、延迟双删(第二次覆盖)、Canal(不依赖删缓存) |
| 先删缓存后写库有什么问题? | 并发读可能写旧值到缓存(脏数据),延迟双删可以缓解但不能根治 |
| 延迟双删的 sleep 时间怎么确定? | 取决于"读请求从查 DB 到设缓存"的时间,需要在业务高峰期实测(通常 500ms-1s) |
| Canal 同步方案有什么缺点? | 引入 Canal + 消息队列,运维复杂度增加;binlog 可能会有较大延迟(秒级) |
| 最终一致性和强一致性的选择? | 大部分场景最终一致性足够(Cache Aside);关键数据(库存/余额)需额外保障 |
| 能不能做到强一致? | Redis + MySQL 不能(无分布式事务)。方案:读写全部走 DB(不用缓存)或分布式锁 |
📚 相关链接
- **缓存三大问题** — 缓存的其他常见问题
- **Lua脚本** — 用 Lua 保证缓存操作的原子性
- ← 返回 **缓存设计与应用索引**