一、为什么要缓存?为什么会有一致性问题?
引入缓存的根本原因:数据库扛不住读 QPS。Redis 单机 10 万 QPS,MySQL 单机几千 QPS,差两个数量级。
但凡数据有两份副本(DB + Cache),就必然存在不一致窗口。一致性问题的本质:
写操作要更新两个存储,无法做到原子。 不论先后顺序如何,中间都会有读到旧值的可能。
二、缓存读写的四种基本模式
2.1 Cache-Aside(旁路缓存,最常用)
读:先查缓存 → 命中返回;未命中 → 查 DB → 写缓存 → 返回
写:先更新 DB → 删除缓存
def get_user(user_id: int) -> dict:
key = f"user:{user_id}"
data = redis.get(key)
if data:
return json.loads(data)
data = db.query("SELECT * FROM users WHERE id = %s", user_id)
redis.setex(key, 300, json.dumps(data)) # TTL 5 分钟兜底
return data
def update_user(user_id: int, **fields):
db.execute("UPDATE users SET ... WHERE id = %s", user_id)
redis.delete(f"user:{user_id}") # 删除而非更新
为什么是「删除」而不是「更新」?
- 删除是幂等的,更新需要计算最新值(可能涉及聚合,开销大)
- 写多读少时,频繁更新缓存浪费(很多更新的值根本没人读)
- 删除后,下次读时按需重建,是 lazy 策略
2.2 Read/Write-Through(缓存代理 DB)
应用只与缓存交互,缓存层负责同步到 DB。一致性更强但实现复杂,常见于本地缓存框架(Caffeine、Guava)。
2.3 Write-Behind(异步写回)
写缓存后,异步批量刷入 DB。性能最高,但宕机会丢数据。日志、计数器场景适用。
2.4 对比
| 模式 | 一致性 | 性能 | 复杂度 | 典型场景 |
|---|---|---|---|---|
| Cache-Aside | 中 | 中 | 低 | 通用 |
| Read-Through | 中 | 中 | 中 | 本地缓存 |
| Write-Through | 强 | 低 | 中 | 强一致需求 |
| Write-Behind | 弱 | 高 | 高 | 计数、日志 |
三、Cache-Aside 的经典竞态
3.1 「先删缓存,再更新 DB」的问题
时序:
T1 写线程:删缓存
T2 读线程:查缓存(miss)→ 查 DB(拿到旧值)
T1 写线程:更新 DB
T2 读线程:把旧值写回缓存 ❌ 缓存中是旧值,且长期不一致
3.2 「先更新 DB,再删缓存」的问题(更小,业界主流)
时序:
T1 读线程:查缓存(miss)→ 查 DB(拿到旧值 v1)
T2 写线程:更新 DB(v2)→ 删缓存
T1 读线程:把 v1 写回缓存 ❌
但概率极低,因为这要求读 DB 比写 DB 还慢(通常读快写慢)。
3.3 解决方案:延迟双删
def update_user(user_id: int, **fields):
redis.delete(f"user:{user_id}") # 第一次删
db.execute("UPDATE ...")
time.sleep(0.5) # 等可能的脏读重建完成
redis.delete(f"user:{user_id}") # 第二次删
生产中第二次删常通过消息队列异步完成,避免阻塞写请求。
3.4 终极方案:订阅 binlog(Canal / Debezium)
DB → binlog → Canal → MQ → 缓存清理服务
优点:业务代码无侵入、可靠(binlog 不丢)、解耦 缺点:架构复杂、有秒级延迟
四、缓存三大经典问题
4.1 缓存穿透:查询不存在的 key
症状:恶意请求 user_id = -1、-2 …,缓存永远 miss,DB 被打挂。
方案 A:空值缓存
data = db.query(...)
if not data:
redis.setex(key, 60, "NULL") # 短 TTL 避免堆积
return None
方案 B:布隆过滤器(推荐高 QPS 场景)
from pybloom_live import ScalableBloomFilter
bf = ScalableBloomFilter()
# 启动时加载所有合法 ID
for uid in db.query("SELECT id FROM users"):
bf.add(uid)
def get_user(user_id):
if user_id not in bf:
return None # 直接拦截
# 正常查缓存/DB
特点:有误判(少数不存在的会放行),无漏判,刚好契合本场景。
4.2 缓存击穿:热 key 失效瞬间
症状:某个超热 key(如首页 banner)TTL 到期,瞬时大量请求同时打到 DB。
方案 A:互斥锁重建
def get_with_lock(key):
data = redis.get(key)
if data:
return data
lock_key = f"lock:{key}"
if redis.set(lock_key, "1", nx=True, ex=10): # 抢锁
try:
data = db.query(...)
redis.setex(key, 300, data)
return data
finally:
redis.delete(lock_key)
else:
time.sleep(0.05)
return get_with_lock(key) # 重试
方案 B:逻辑过期(不设置 TTL,值里带过期时间)
{
"data": {...},
"expire_at": 1714500000
}
读到过期值时,返回旧值 + 异步刷新,永远不让请求穿透到 DB。秒杀、首页强烈推荐。
4.3 缓存雪崩:大量 key 同时失效
症状:批量预热的缓存 TTL 设成同一个值,到期瞬间 DB 被打挂。
方案:
- TTL 加随机抖动:
expire = 300 + random.randint(0, 60) - 多级缓存:本地缓存(Caffeine)+ Redis
- 熔断降级:DB 压力过大时返回兜底值
五、分布式锁
5.1 为什么需要?
单机 threading.Lock 只在进程内有效。多实例部署后,需要一个所有节点都认可的"裁判"。典型场景:
- 防止超卖(库存扣减)
- 定时任务防重复执行
- 缓存重建避免击穿
- 幂等控制
5.2 Redis 实现:从错误到正确
错误版本 1:SETNX + EXPIRE 分两步
if redis.setnx(lock, "1"):
redis.expire(lock, 10) # ❌ 中间宕机就死锁了
do_business()
redis.delete(lock)
错误版本 2:SET NX EX,但删锁不校验
if redis.set(lock, "1", nx=True, ex=10):
do_business() # 业务跑了 11 秒,锁已过期
redis.delete(lock) # ❌ 删了别人的锁
正确版本:唯一标识 + Lua 原子删
import uuid
def acquire_lock(key: str, ttl: int = 10) -> str | None:
token = str(uuid.uuid4())
if redis.set(key, token, nx=True, ex=ttl):
return token
return None
RELEASE_LUA = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
"""
def release_lock(key: str, token: str):
redis.eval(RELEASE_LUA, 1, key, token)
# 使用
token = acquire_lock("order:create:user_123")
if token:
try:
do_business()
finally:
release_lock("order:create:user_123", token)
关键点:
- 唯一 token:避免误删别人的锁
- Lua 原子:判断 + 删除必须原子(否则 GET 后到 DEL 前也可能过期)
- NX EX 一条命令:避免分两步的死锁
5.3 看门狗(Watchdog):解决业务超时
业务执行时间不可预测,TTL 设短了会被强制解锁,设长了宕机后久不释放。
Redisson 的方案:加锁后启动后台线程,每 1/3 TTL 续期一次。
# 简化版 Python 实现
import threading
class RedisLock:
def __init__(self, key, ttl=30):
self.key, self.ttl = key, ttl
self.token = str(uuid.uuid4())
self._stop = threading.Event()
def acquire(self):
if redis.set(self.key, self.token, nx=True, ex=self.ttl):
threading.Thread(target=self._watchdog, daemon=True).start()
return True
return False
def _watchdog(self):
while not self._stop.wait(self.ttl / 3):
redis.eval(
"if redis.call('get',KEYS[1])==ARGV[1] then "
"return redis.call('expire',KEYS[1],ARGV[2]) else return 0 end",
1, self.key, self.token, self.ttl
)
def release(self):
self._stop.set()
redis.eval(RELEASE_LUA, 1, self.key, self.token)
5.4 Redlock 争议
Redis 作者 Antirez 提出 Redlock:在 N 个独立 Redis 节点上,过半成功即获锁。
但分布式系统专家 Martin Kleppmann 反对,核心论点:
- GC 暂停或网络延迟会导致客户端持锁后超时,但自己不知道,仍然写数据
- 任何依赖时钟的算法都不安全
工程实践:
- 大多数业务用单 Redis + 看门狗已经够用(金融除外)
- 真正强一致用 ZooKeeper / etcd(基于 Raft,无时钟依赖)
5.5 ZooKeeper 实现(顺序临时节点)
/lock/
├── order_0000000001 ← client A
├── order_0000000002 ← client B
└── order_0000000003 ← client C
- 创建临时顺序节点
- 检查自己是否最小,是则获锁
- 否则监听前一个节点(避免羊群效应)
- 客户端宕机 → 临时节点自动删除 → 锁自动释放
优点:无时钟依赖、宕机自动释放、公平锁 缺点:性能弱于 Redis(写操作走 Raft),复杂度高
5.6 选型决策
| 场景 | 推荐 |
|---|---|
| 普通幂等、防并发 | Redis + Lua |
| 业务时间不可控 | Redis + 看门狗(Redisson) |
| 金融、强一致 | ZooKeeper / etcd |
| 高并发秒杀 | Redis + Lua(性能优先) |
六、实战:库存扣减的演化
版本 1:纯 DB(性能差)
def deduct():
with db.transaction():
stock = db.query("SELECT stock FROM goods WHERE id=1 FOR UPDATE")
if stock > 0:
db.execute("UPDATE goods SET stock=stock-1 WHERE id=1")
行锁串行,QPS 几百到顶。
版本 2:Redis 预扣 + 异步落库
DEDUCT_LUA = """
local stock = tonumber(redis.call('get', KEYS[1]))
if stock and stock > 0 then
redis.call('decr', KEYS[1])
return 1
end
return 0
"""
def deduct(goods_id):
if redis.eval(DEDUCT_LUA, 1, f"stock:{goods_id}"):
mq.send("stock_deduct", {"goods_id": goods_id}) # 异步落 DB
return True
return False
QPS 可达 5 万+,秒杀场景标配。
版本 3:分桶(避免单 key 热点)
buckets = 10
bucket = random.randint(0, buckets - 1)
key = f"stock:{goods_id}:{bucket}"
# 每个桶独立扣减,总库存 = sum(buckets)
破解 Redis 单 key 热点(10 万 QPS 上限)。
七、避坑清单
- 缓存 key 一定加业务前缀:
user:profile:123而非123 - TTL 必加随机抖动:防雪崩
- 大 key 拆分:单 value > 10KB 就要警惕,> 1MB 必拆
- 热 key 监控:Redis
--hotkeys、阿里云有专门工具 - 不要把 Redis 当数据库:开 AOF + RDB 也只是降低丢失概率
- 本地缓存 + Redis 多级:减少 Redis 网络开销,但要解决多实例本地缓存一致(监听 Redis Pub/Sub 或 binlog)
- 缓存预热:服务启动时主动加载热数据,避免冷启动雪崩
- 降级开关:缓存挂了能切到 DB(限流保护)
- 锁的粒度尽量小:
order:create:user_123而非order:create - 锁不要嵌套:容易死锁,必要时用可重入锁
八、面试高频题速记
- Q:为什么删缓存不更新? A:幂等、节省计算、避免无效更新
- Q:删缓存失败怎么办? A:消息队列重试 / 订阅 binlog 兜底
- Q:Redis 和 DB 双写一致性如何保证? A:最终一致(Cache-Aside + 订阅 binlog),强一致只能加分布式锁串行化
- Q:Redlock 安全吗? A:单机 GC/网络抖动下不绝对安全,金融场景用 ZK/etcd
- Q:缓存击穿和雪崩区别? A:击穿是单热 key 失效,雪崩是大量 key 同时失效