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

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

引入缓存的根本原因:数据库扛不住读 QPS。Redis 单机 10 万 QPS,MySQL 单机几千 QPS,差两个数量级。

但凡数据有两份副本(DB + Cache),就必然存在不一致窗口。一致性问题的本质:

写操作要更新两个存储,无法做到原子。 不论先后顺序如何,中间都会有读到旧值的可能。


二、缓存读写的四种基本模式

2.1 Cache-Aside(旁路缓存,最常用)

复制代码
读:先查缓存 → 命中返回;未命中 → 查 DB → 写缓存 → 返回
写:先更新 DB → 删除缓存
python 复制代码
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 解决方案:延迟双删

python 复制代码
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:空值缓存

python 复制代码
data = db.query(...)
if not data:
    redis.setex(key, 60, "NULL")  # 短 TTL 避免堆积
    return None

方案 B:布隆过滤器(推荐高 QPS 场景)

python 复制代码
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:互斥锁重建

python 复制代码
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,值里带过期时间)

python 复制代码
{
    "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 分两步

python 复制代码
if redis.setnx(lock, "1"):
    redis.expire(lock, 10)  # ❌ 中间宕机就死锁了
    do_business()
    redis.delete(lock)

错误版本 2:SET NX EX,但删锁不校验

python 复制代码
if redis.set(lock, "1", nx=True, ex=10):
    do_business()  # 业务跑了 11 秒,锁已过期
    redis.delete(lock)  # ❌ 删了别人的锁

正确版本:唯一标识 + Lua 原子删

python 复制代码
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 复制代码
# 简化版 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 实现(顺序临时节点)

css 复制代码
/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(性能差)

python 复制代码
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 预扣 + 异步落库

python 复制代码
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 热点)

python 复制代码
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 同时失效
相关推荐
爱勇宝5 小时前
第 1 章:别把“需求文档”当成真正的需求
前端·后端·程序员
IT_陈寒10 小时前
闭包陷阱让我加了两天班,JavaScript你真行
前端·人工智能·后端
易协同低代码11 小时前
通达OA核心类库TD类深度解析
后端
Gopher_HBo11 小时前
Go语言学习笔记(十八)Gin处理Session
后端
谭光志12 小时前
工具塞满上下文窗口怎么办?深度拆解 AI Agent Tool Search 按需加载实现原理
前端·后端·ai编程
她说..12 小时前
Java 默认值设置方式
java·开发语言·后端·springboot
foggyprojects12 小时前
从0开始,一句话启动AI DataAgent
后端·数据分析·ai编程
郡杰12 小时前
一些基础和问题解决
后端
陈随易12 小时前
前端项目部署只要30秒
前端·后端·程序员
YIAN12 小时前
从零手写文件读取 MCP 服务:一文吃透 Model Context Protocol 全链路通信原理
前端·后端·mcp