Files
autumn-recruitment/05.架构/distributed-lock/Redis 分布式锁.md
T

8.0 KiB
Raw Blame History

tags, create time, update time
tags create time update time
arch/distributed
redis-distributed-lock
redlock
redisson
reentrant-lock
watch-dog
2026-08-08 18:00 2026-08-08 18:00

Redis 分布式锁

概述

Redis 分布式锁是最广泛使用的分布式同步原语之一。本文深入分析 SETNX + EXPIRE 的原子性问题、Redlock 算法的设计与争议、Redisson 看门狗续期机制、可重入锁的 hash key 设计,以及各类边界场景的死锁分析与规避方案。

核心原理

SETNX + EXPIRE 的原子性陷阱

最直观的分布式锁实现是两条命令组合:

// ❌ 非原子操作 — 存在竞态条件
redis.set("lock", "client_id", "NX");   // 尝试加锁
redis.expire("lock", 30, "EX");          // 设置过期时间

问题:如果 set 成功但 expire 失败(网络抖动、进程崩溃),锁永远不会释放——死锁。

解决方案:使用 Redis 2.6.12+ 支持的原子语法 SET key value NX EX seconds:

// ✅ 原子操作
String result = redis.set("lock", clientId, "NX", "EX", 30);
if ("OK".equals(result)) {
    // 加锁成功
}

Redis 将 SET + NX + EX/PX 打包成一条指令执行,消除了中间状态的竞态窗口。

Warning

只支持 SET ... NX 而不设过期时间是另一个常见错误。客户端宕机后锁永久不释放,需要人工介入清理。

Redlock 多实例容错

Redisson 作者 Yonatan Katz 提出的 Redlock 算法,旨在解决单点故障下锁不可用的问题:

sequenceDiagram
    participant Client as 锁客户端
    participant R1 as Redis Master 1
    participant R2 as Redis Master 2
    participant R3 as Redis Master 3
    participant R4 as Redis Master 4
    participant R5 as Redis Master 5

    loop 依次向 5 个节点发送 SET NX
        Client->>R1: SET lock uuid NX EX 30
        R1-->>Client: OK (t1)
        Client->>R2: SET lock uuid NX EX 30
        R2-->>Client: OK (t2)
        Client->>R3: SET lock uuid NX EX 30
        R3-->>Client: OK (t3)
        Client->>R4: SET lock uuid NX EX 30
        R4-->>Client: nil (已超时或未写入)
        Client->>R5: SET lock uuid NX EX 30
        R5-->>Client: OK (t5)
    end

    Note over Client: success_count = 4 >= N/2+1 = 3
    Note over Client: acquireTime = t5 - t1
    Note over Client: effectiveTTL = 30s - acquireTime
    Client->>R2: DEL lock (释放)
    Client->>R5: DEL lock (释放)

核心规则:

  1. 至少部署 5 个无主从复制的独立 Redis 实例。
  2. 客户端按顺序尝试 SET NX,记录总耗时 T。
  3. 获得多数派(≥ N/2+1)OK 才算加锁成功,有效 TTL = 设定值 − T。
  4. 释放锁时向所有节点发 DEL(即使某节点没拿到锁也没关系)。

Redlock 的争议(Erik Hellemans 的质疑):

  • 当 clock drift 发生时,一个实例可能已经过期并释放了锁,而客户端持有的旧锁仍然有效,导致两把锁"同时存在"。
  • 解决方案是设置足够大的过期时间(如 10s~30s),使 clock drift(通常 < 200ms)的影响可忽略不计。
  • Martin Kleppmann 和 Conan Ho 在论文中证明了在特定 clock-drift 和网络延迟条件下,Redlock 确实可能失效,但实际生产中 5 实例 + 大超时 + 业务幂等的组合已经足够可靠。

Tip

面试回答:如果你的业务对锁的正确性是绝对不可妥协的(如金融扣款),不要依赖任何单一的分布式锁实现。应该加上数据库层面的二次校验(唯一约束 + CAS),用"锁 + 数据一致性"双重保障。

Redisson 看门狗续期机制

Redis 分布式锁的致命问题是:业务逻辑执行时间超过锁的过期时间会怎样?锁自动释放 → 其他客户端进入 → 数据竞争。

Redisson 的方案是看门狗(Watch Dog):

步骤 行为 触发时机
加锁时不指定 leaseTime 启动看门狗后台任务 首次获取锁成功
每 10 秒检查一次锁是否仍持有 续期到默认 30 秒 定时调度
业务未结束时重复续期 锁不会被误释放 只要业务还在运行
业务完成主动 unlock 关闭看门狗 finally 块中
客户端意外宕机 看门狗停止 → 锁自然过期 Redis 侧 30 秒后自动删除
// Redisson 看门狗工作原理伪代码
RLock lock = redisson.getLock("myLock");
lock.lock(); // 没有传入 leaseTime 参数
try {
    doBusiness(); // 看门狗每 10s 续期一次,每次续 30s
} finally {
    lock.unlock(); // 显式释放,关闭看门狗
}

Note

如果明确知道业务执行时间上限,建议在 lock(leaseTime) 时直接传入精确的到期时间,避免看门狗的额外开销和复杂度。

可重入锁设计

可重入意味着同一个线程可以多次获取同一把锁而不被阻塞。Redisson 的实现方式是 hash key + count 计数器:

// Lua 脚本:可重入加锁
local key = KEYS[1]
local threadId = ARGV[1]
local lockValue = redis.call('get', key)

if lockValue == false then
    redis.call('set', key, threadId, 'EX', ARGV[2])
    return 1
end

if redis.call('hexists', key, threadId) == 1 then
    redis.call('hincrby', key, threadId, 1)
    redis.call('expire', key, ARGV[2])
    return 1 // 重入成功
end

return 0 // 被其他线程持有

每个锁的 key 是一个 Hash:{threadID}: 1 表示第一个线程已获得锁且计数为 1;第二次重入变为 {threadID}: 2。unlock 时递减计数,归零才真正删除 key。

锁超时时间设定原则

因素 建议 说明
GC 停顿时间 ≥ 3 × GC 最大停顿 避免因 Full GC 导致看门狗错过续期
网络 RTT 波动 ≥ 网络 99.9th percentile P999 的网络延迟才有代表性
业务执行时间 不确定时不设或设大值 能用看门狗就让它工作
最小安全值 30 秒起步 小于 10 秒的锁几乎必然有问题

死锁场景分析

场景 原因 解法
客户端宕机 锁未释放 过期时间兜底 + 看门狗机制
并发持有多把锁 A 持有锁1等锁2,B 持有锁2等锁1(经典死锁) 全局一致的加锁顺序
时钟回拨 NTP 同步异常导致系统时钟倒退 检测时钟回拨 > 阈值则拒绝服务
Redis 主从切换 主节点加了锁还没来得及复制到从节点就宕机 Redlock + 业务幂等校验

代码示例

Go 中使用 go-redis 实现简单的分布式锁:

func tryLock(ctx context.Context, key, val string, ttl time.Duration) bool {
    ok, err := rdb.SetNX(ctx, key, val, ttl).Result()
    if err != nil || !ok {
        return false
    }
    return true
}

func releaseLock(ctx context.Context, key, val string) bool {
    lua := redis.NewScript(`
        if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
        end
        return 0
    `)
    return lua.Run(ctx, []string{key}, val).Int() == 1
}

解锁必须用 Lua 保证"检查 value + 删除"的原子性。否则出现竞态:A 读到自己的 value,在 del 之前 B 重新获得了锁,A 的 del 就把 B 的锁删掉了。

实践场景

秋招高频问题:

  • "为什么 Redis 锁不用 WATCH MULTI/EXEC?" — Watch 是基于 optimistic locking 的 MVCC 思想,适合读多写少的场景。分布式锁需要的是互斥语义而非版本碰撞检测,WATCH 在高并发下冲突率极高,性能远不如 SET NX。
  • "Redlock 真的有用吗?什么时候该用/不该用?" — 如果只是微服务内的普通业务锁(如防止用户重复点击),单机 Redis + SET NX 完全够用。只有对可用性要求极高的核心链路(如秒杀库存扣减)才值得上 Redlock。代价是多倍的 Redis 实例和维护复杂度。
  • "缓存穿透 + 分布式锁怎么配合?" — 典型模式:先查缓存 → miss → 用分布式锁保护 DB 查询 → 落缓存。关键是用锁保护"查 + 写缓存"这个复合操作,而不是只保护 DB 查询。

关联笔记