缓存一致性与分布式锁:工程踩坑全解

8 阅读8分钟

一、为什么要缓存?为什么会有一致性问题?

引入缓存的根本原因:数据库扛不住读 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)

关键点

  1. 唯一 token:避免误删别人的锁
  2. Lua 原子:判断 + 删除必须原子(否则 GET 后到 DEL 前也可能过期)
  3. 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 上限)。


七、避坑清单

  1. 缓存 key 一定加业务前缀user:profile:123 而非 123
  2. TTL 必加随机抖动:防雪崩
  3. 大 key 拆分:单 value > 10KB 就要警惕,> 1MB 必拆
  4. 热 key 监控:Redis --hotkeys、阿里云有专门工具
  5. 不要把 Redis 当数据库:开 AOF + RDB 也只是降低丢失概率
  6. 本地缓存 + Redis 多级:减少 Redis 网络开销,但要解决多实例本地缓存一致(监听 Redis Pub/Sub 或 binlog)
  7. 缓存预热:服务启动时主动加载热数据,避免冷启动雪崩
  8. 降级开关:缓存挂了能切到 DB(限流保护)
  9. 锁的粒度尽量小order:create:user_123 而非 order:create
  10. 锁不要嵌套:容易死锁,必要时用可重入锁

八、面试高频题速记

  • Q:为什么删缓存不更新? A:幂等、节省计算、避免无效更新
  • Q:删缓存失败怎么办? A:消息队列重试 / 订阅 binlog 兜底
  • Q:Redis 和 DB 双写一致性如何保证? A:最终一致(Cache-Aside + 订阅 binlog),强一致只能加分布式锁串行化
  • Q:Redlock 安全吗? A:单机 GC/网络抖动下不绝对安全,金融场景用 ZK/etcd
  • Q:缓存击穿和雪崩区别? A:击穿是单热 key 失效,雪崩是大量 key 同时失效