目录

Redis-13 分布式锁

1. 分布式锁要解决什么问题

单机程序里用 synchronizedMutex 就能保护临界区,因为所有线程在同一个进程的同一块内存里。但在分布式系统中,多个进程跑在不同机器上,进程内的锁完全无效。

典型场景

用户点了两次"下单"按钮(或者前端重试),两个请求打到两台不同的应用服务器:

服务器 A                          服务器 B
1. 查库存 = 1                     1. 查库存 = 1
2. 判断 1 >= 1,可以下单           2. 判断 1 >= 1,可以下单
3. 扣库存 → 0                     3. 扣库存 → -1  ← 【超卖!】
4. 创建订单                       4. 创建订单

需要一个跨进程、跨机器的互斥机制,这就是分布式锁。

1.1 分布式锁的正确性要求

一个可用的分布式锁必须满足四点:

要求 说明 违反的后果
互斥性(Mutual Exclusion) 同一时刻只有一个客户端能持有锁 锁失效,并发问题依旧
避免死锁(Deadlock Free) 持有锁的客户端崩溃后,锁最终能被释放 锁永久不释放,业务全部卡死
容错性(Fault Tolerance) 只要大部分 Redis 节点存活,锁服务就可用 单点故障
只能释放自己的锁 A 不能释放 B 持有的锁 互斥性被破坏

理解这四点是理解后面所有实现细节的钥匙——每一个演进步骤都是为了满足其中某一条

1.2 为什么用 Redis 做分布式锁

方案 优点 缺点
数据库(唯一索引/悲观锁) 可靠、和业务事务一致 性能差(每次加锁都是 DB 写)、锁超时难处理
Redis 性能极高(10 万 QPS)、实现简单、有过期机制 不保证强一致(异步复制会丢锁)
ZooKeeper / etcd 强一致(CP)、临时节点自动释放、有 watch 机制 性能低于 Redis(每次写要多数派确认)、运维复杂

选型原则

  • 绝大多数场景用 Redis:性能好、够用。因为大多数"分布式锁"的场景本质是性能优化(避免重复计算)或减少冲突,而不是绝对的正确性保证;
  • 正确性绝对不能妥协(比如涉及金钱、且没有别的兜底机制)→ 用 ZooKeeper/etcd,或者把最终一致性交给数据库的唯一约束/乐观锁。

2. 实现的演进过程

理解每一版的问题,才能理解最终方案为什么长成那样。

2.1 版本一:SETNX(有致命问题)

SETNX lock:order:1001 1      # 返回 1 = 加锁成功;返回 0 = 已被别人持有
# ... 执行业务 ...
DEL lock:order:1001          # 释放锁

问题:会死锁。 如果客户端在执行业务时崩溃(进程被 kill、机器断电、OOM),DEL 永远不会执行,这个锁就永久存在,之后所有客户端都拿不到锁,业务彻底卡死。

2.2 版本二:SETNX + EXPIRE(仍有致命问题)

SETNX lock:order:1001 1
EXPIRE lock:order:1001 30     # 加个过期时间,30 秒后自动释放
# ... 业务 ...
DEL lock:order:1001

问题:这两条命令不是原子的。 如果在 SETNX 成功之后、EXPIRE 执行之前客户端崩溃(或者网络断了),锁就没有过期时间,退化成版本一的死锁。

这个窗口虽然很小(微秒级),但在高 QPS 下必然会发生。

历史上有人用 SETNX 存"过期时间戳"再配合 GETSET 来绕过这个问题,但逻辑复杂且有边界 bug(比如多个客户端同时发现锁过期时会互相覆盖)。不要用这种方案。

2.3 版本三:SET NX EX(原子加锁,但还不够)

Redis 2.6.12SET 加了参数,可以一条命令原子地完成"加锁 + 设过期"

SET lock:order:1001 1 NX EX 30
# OK 表示加锁成功,nil 表示失败

这解决了死锁问题。但还有一个严重问题:可能释放别人的锁。

时刻 T0:客户端 A 加锁成功,TTL = 30 秒
时刻 T1:A 的业务执行超时了(GC 停顿、DB 慢查询、网络抖动),花了 35 秒
时刻 T2(第 30 秒):锁自动过期释放
时刻 T3(第 31 秒):客户端 B 加锁成功,开始执行业务
时刻 T4(第 35 秒):A 的业务终于执行完了,执行 DEL lock:order:1001
                    → 【删掉了 B 的锁!】
时刻 T5:客户端 C 加锁成功 → 【B 和 C 同时在临界区,互斥性被破坏】

2.4 版本四:唯一标识 + Lua 释放(正确的基础版)

两个改进

  1. 锁的 value 存一个唯一标识(UUID / 机器ID+线程ID+随机数),标明"这把锁是谁的";
  2. 释放锁时先检查是不是自己的,且检查和删除必须原子(用 Lua)。
# 加锁:value 是唯一标识
SET lock:order:1001 <uuid-abc-123> NX EX 30

# 释放锁:必须用 Lua 保证原子
EVAL "
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end
" 1 lock:order:1001 <uuid-abc-123>

为什么释放必须用 Lua:如果分成 GET → 判断 → DEL 三步:

时刻 T1:A 执行 GET,返回自己的 uuid,判断"是我的锁"
时刻 T2:【锁刚好过期】,B 加锁成功
时刻 T3:A 执行 DEL → 【删掉了 B 的锁】

问题一模一样。Lua 让"判断 + 删除"成为一个不可分割的操作。

这个版本满足了:互斥性、避免死锁、只释放自己的锁。最基础的可用实现

2.5 版本四仍然存在的问题

即使到了版本四,还有三个问题:

问题一:业务执行时间超过锁的 TTL

锁会在业务还没做完时就自动释放,导致另一个客户端进入临界区。两个客户端同时在临界区,互斥性依然被破坏(只是不会误删别人的锁了)。

  • TTL 设太短 → 业务没做完锁就没了;
  • TTL 设太长 → 客户端崩溃后要等很久锁才释放,业务卡死。

解决方案:看门狗(自动续期) —— 见第 3 节。

问题二:不可重入

同一个线程再次获取自己已持有的锁会失败(因为 NX 会失败)。这在"方法 A 加锁后调用方法 B,B 也要加同一把锁"的场景会自己把自己锁死

解决方案:用 hash 记录重入次数 —— 见第 4 节。

问题三:主从异步复制导致锁失效(最根本的问题)

时刻 T1:客户端 A 在主节点加锁成功
时刻 T2:【主节点还没把这条命令同步给从节点就宕机了】
时刻 T3:哨兵把从节点提升为新主节点(新主节点上【没有这把锁】)
时刻 T4:客户端 B 在新主节点加锁 → 【成功!】
        → 【A 和 B 同时持有锁,互斥性被彻底破坏】

这是无法通过客户端代码解决的,因为根源是 Redis 主从复制是异步的(第 10 篇)。这就是 Redlock 想解决的问题(第 6 节),但 Redlock 本身也充满争议。


3. 看门狗(自动续期)

3.1 思路

在持有锁的期间,起一个后台线程定期给锁续期,只要业务还在跑,锁就不会过期。

1. 加锁时设一个较短的 TTL(如 30 秒)
2. 启动一个后台任务,每 TTL/3(10 秒)检查一次:
   ├─ 如果锁还是自己的 → 把 TTL 重置为 30 秒
   └─ 如果锁不是自己的了(说明已经出问题)→ 停止续期
3. 业务执行完,释放锁并停止续期任务

关键点:客户端崩溃时续期线程也随之消失,锁会在最后一次续期的 30 秒后自动过期。这就同时解决了"业务超时"和"崩溃死锁"两个矛盾的需求。

续期间隔为什么是 TTL/3:留出重试余量。如果间隔 = TTL,一次续期失败锁就过期了;TTL/3 意味着可以容忍连续两次失败。

3.2 续期的 Lua 脚本

-- KEYS[1]=锁key  ARGV[1]=唯一标识  ARGV[2]=新的TTL(毫秒)
-- 只有锁还是自己的才续期
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return 0

3.3 Go 完整实现

package dlock

import (
    "context"
    "errors"
    "time"

    "github.com/google/uuid"
    "github.com/redis/go-redis/v9"
)

var (
    ErrLockFailed  = errors.New("获取锁失败")
    ErrNotLockOwner = errors.New("不是锁的持有者")
)

// 释放锁:判断 + 删除必须原子
var unlockScript = redis.NewScript(`
    if redis.call('GET', KEYS[1]) == ARGV[1] then
        return redis.call('DEL', KEYS[1])
    end
    return 0
`)

// 续期:判断 + 续期必须原子
var renewScript = redis.NewScript(`
    if redis.call('GET', KEYS[1]) == ARGV[1] then
        return redis.call('PEXPIRE', KEYS[1], ARGV[2])
    end
    return 0
`)

type Lock struct {
    rdb   *redis.Client
    key   string
    value string          // 唯一标识
    ttl   time.Duration

    cancelWatchdog context.CancelFunc   // 用于停止看门狗
    watchdogDone   chan struct{}
}

func New(rdb *redis.Client, key string, ttl time.Duration) *Lock {
    return &Lock{
        rdb:   rdb,
        key:   key,
        value: uuid.NewString(),
        ttl:   ttl,
    }
}

// TryLock 尝试加锁(不阻塞)
func (l *Lock) TryLock(ctx context.Context) error {
    ok, err := l.rdb.SetNX(ctx, l.key, l.value, l.ttl).Result()
    if err != nil {
        return err
    }
    if !ok {
        return ErrLockFailed
    }
    l.startWatchdog()
    return nil
}

// Lock 阻塞加锁(带超时和退避重试)
func (l *Lock) Lock(ctx context.Context, waitTimeout time.Duration) error {
    deadline := time.Now().Add(waitTimeout)
    backoff := 10 * time.Millisecond

    for {
        err := l.TryLock(ctx)
        if err == nil {
            return nil
        }
        if !errors.Is(err, ErrLockFailed) {
            return err   // 真正的错误(网络等),直接返回
        }
        if time.Now().After(deadline) {
            return ErrLockFailed
        }

        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(backoff):
        }
        // 指数退避,上限 200ms,避免高并发下的重试风暴
        if backoff < 200*time.Millisecond {
            backoff *= 2
        }
    }
}

// Unlock 释放锁
func (l *Lock) Unlock(ctx context.Context) error {
    l.stopWatchdog()

    n, err := unlockScript.Run(ctx, l.rdb, []string{l.key}, l.value).Int()
    if err != nil {
        return err
    }
    if n == 0 {
        // 锁已经不是自己的了(已过期或被强制释放)
        // 说明业务可能已经在无锁保护下执行了一段时间,应该告警
        return ErrNotLockOwner
    }
    return nil
}

// startWatchdog 启动看门狗,定期续期
func (l *Lock) startWatchdog() {
    ctx, cancel := context.WithCancel(context.Background())
    l.cancelWatchdog = cancel
    l.watchdogDone = make(chan struct{})

    go func() {
        defer close(l.watchdogDone)
        // 每 ttl/3 续期一次,留出重试余量
        ticker := time.NewTicker(l.ttl / 3)
        defer ticker.Stop()

        for {
            select {
            case <-ctx.Done():
                return
            case <-ticker.C:
                // 用独立的短超时 context,避免续期请求卡住
                rctx, rcancel := context.WithTimeout(context.Background(), 2*time.Second)
                ok, err := renewScript.Run(rctx, l.rdb,
                    []string{l.key}, l.value, l.ttl.Milliseconds()).Int()
                rcancel()

                if err != nil {
                    // 续期失败(网络问题),记日志但继续尝试
                    // 下次 ticker 还有机会(这就是间隔取 ttl/3 的意义)
                    continue
                }
                if ok == 0 {
                    // 锁已经不是自己的了,续期没有意义,停止看门狗
                    // 【这里应该告警】:说明业务执行时间远超预期,
                    // 或者发生了主从切换导致锁丢失
                    return
                }
            }
        }
    }()
}

func (l *Lock) stopWatchdog() {
    if l.cancelWatchdog != nil {
        l.cancelWatchdog()
        <-l.watchdogDone     // 等看门狗真正退出,避免它在释放后又续期
        l.cancelWatchdog = nil
    }
}

使用

lock := dlock.New(rdb, "lock:order:1001", 30*time.Second)

if err := lock.Lock(ctx, 5*time.Second); err != nil {
    return fmt.Errorf("获取锁失败: %w", err)
}
defer func() {
    if err := lock.Unlock(context.Background()); err != nil {
        log.Printf("释放锁异常: %v", err)   // 要告警
    }
}()

// 临界区业务逻辑
doBusiness()

3.4 看门狗的注意点

(1)续期失败必须能被感知

如果续期连续失败(网络分区),锁最终会过期,业务却还在跑。此时业务处于"无锁保护"状态,必须:

  • 记日志 + 告警
  • 更严谨的做法:让业务代码能感知到锁已失效并主动中止(比如通过 context cancel)。

(2)stopWatchdog 必须等看门狗真正退出

如果 Unlock 只是发了 cancel 信号就返回,看门狗可能在锁已经被删除后又执行一次续期。虽然续期脚本会检查 value 不匹配而失败,但如果这时另一个客户端刚好拿到了同一个 key 的锁而 uuid 又碰巧相同(几乎不可能,但逻辑上要严谨),就会出问题。上面代码用 <-l.watchdogDone 保证了这一点。

(3)不要无限续期

如果业务因为 bug 死循环了,看门狗会永久续期,导致锁永远不释放——这就退化成了死锁。

改进:给看门狗设一个最大续期总时长(比如 10 分钟),超过就停止续期并告警。

(4)看门狗的 goroutine/线程泄漏

必须保证任何退出路径(包括 panic)都会调用 stopWatchdog,否则 goroutine 永久泄漏。用 defer 保证。


4. 可重入锁

4.1 为什么需要

func MethodA(ctx context.Context) {
    lock.Lock(ctx)              // 获取锁
    defer lock.Unlock(ctx)
    MethodB(ctx)                // 调用 B
}

func MethodB(ctx context.Context) {
    lock.Lock(ctx)              // ← 【永远失败!自己把自己锁死了】
    defer lock.Unlock(ctx)
}

可重入:同一个持有者(同一线程/协程)可以重复获取自己已持有的锁,只是要记录重入次数,释放时逐层减 1,减到 0 才真正删除锁。

4.2 用 hash 实现

用 hash 而不是 string:field 是持有者标识,value 是重入次数

-- ========== 加锁 ==========
-- KEYS[1]=锁key  ARGV[1]=TTL(毫秒)  ARGV[2]=持有者标识
if redis.call('EXISTS', KEYS[1]) == 0 then
    -- 锁不存在,直接获取
    redis.call('HSET', KEYS[1], ARGV[2], 1)
    redis.call('PEXPIRE', KEYS[1], ARGV[1])
    return 1
end
if redis.call('HEXISTS', KEYS[1], ARGV[2]) == 1 then
    -- 锁存在且是自己的 → 重入,计数 +1 并刷新 TTL
    redis.call('HINCRBY', KEYS[1], ARGV[2], 1)
    redis.call('PEXPIRE', KEYS[1], ARGV[1])
    return 1
end
-- 锁被别人持有
return 0
-- ========== 释放锁 ==========
-- KEYS[1]=锁key  ARGV[1]=持有者标识  ARGV[2]=TTL(毫秒)
if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 0 then
    return nil        -- 不是自己的锁(或已过期)
end
local count = redis.call('HINCRBY', KEYS[1], ARGV[1], -1)
if count > 0 then
    -- 还有重入层级,只刷新 TTL 不删除
    redis.call('PEXPIRE', KEYS[1], ARGV[2])
    return 0          -- 表示"部分释放"
end
-- 计数归零,真正删除锁
redis.call('DEL', KEYS[1])
return 1              -- 表示"完全释放"

持有者标识的构造:必须能唯一标识"一个执行流":

  • Java:UUID + ":" + threadId(Redisson 的做法);
  • Go:UUID(进程级)+ goroutine 标识。Go 没有稳定的 goroutine ID,所以通常把锁标识通过 context 传递,或者用一个 per-goroutine 的显式 token。
// Go 的推荐做法:把 lock 实例通过 context 传递,避免依赖 goroutine ID
type lockKey struct{}

func WithLock(ctx context.Context, l *Lock) context.Context {
    return context.WithValue(ctx, lockKey{}, l)
}

func LockFrom(ctx context.Context) (*Lock, bool) {
    l, ok := ctx.Value(lockKey{}).(*Lock)
    return l, ok
}

5. Redisson 的实现原理

Redisson 是 Java 生态里最成熟的 Redis 客户端,它的 RLock 是分布式锁的事实标准实现。理解它的设计能学到很多。

5.1 基本用法

Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);

RLock lock = redisson.getLock("lock:order:1001");

// 方式一:加锁并自动续期(看门狗,默认 30 秒 TTL)
lock.lock();
try {
    doBusiness();
} finally {
    lock.unlock();
}

// 方式二:指定 TTL(【注意:指定 leaseTime 后看门狗不生效!】)
lock.lock(10, TimeUnit.SECONDS);

// 方式三:带等待超时的尝试加锁(推荐)
if (lock.tryLock(5, 30, TimeUnit.SECONDS)) {   // 等 5 秒,锁 30 秒
    try {
        doBusiness();
    } finally {
        lock.unlock();
    }
}

5.2 核心机制

(1)加锁的 Lua 脚本(和第 4 节的可重入实现基本一致)

if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;
return redis.call('pttl', KEYS[1]);

注意最后一行:加锁失败时返回锁的剩余 TTL,客户端可以据此决定等待多久(比盲目重试聪明得多)。

ARGV[2]UUID:threadId,实现了线程级的可重入

(2)看门狗(Watchdog)

  • 默认 lockWatchdogTimeout = 30000ms(30 秒);
  • lockWatchdogTimeout / 3(10 秒)续期一次;
  • 关键:只有不指定 leaseTime 时看门狗才生效。如果调用 lock(10, TimeUnit.SECONDS) 显式指定了租期,Redisson 认为"你自己知道要锁多久",不启动看门狗,10 秒后锁一定过期。

这是 Redisson 最容易踩的坑:很多人以为 lock(10, SECONDS) 是"至少锁 10 秒然后自动续期",实际是"最多锁 10 秒"。要看门狗就不能传 leaseTime。

(3)等锁不靠轮询,靠 pub/sub

这是 Redisson 一个很聪明的设计:

1. 客户端 A 持有锁
2. 客户端 B 加锁失败 → 【SUBSCRIBE redisson_lock__channel:{lockKey}】
   然后【阻塞等待】(用 Semaphore),不做无意义的轮询
3. A 释放锁时,unlock 的 Lua 脚本里会执行:
   redis.call('publish', KEYS[2], ARGV[1])
4. B 收到消息 → 被唤醒 → 立即尝试加锁

好处

  • 避免了轮询的资源浪费(100 个等锁的客户端每 10ms 轮询一次 = 1 万 QPS 的无效请求);
  • 响应更及时(锁一释放就被唤醒,而不是等下一次轮询)。

代价:依赖 pub/sub(会丢消息),所以 Redisson 仍然保留了超时兜底——如果没收到通知,等待超时后也会重试。这是"优化 + 兜底"的经典设计。

(4)释放锁的 Lua 脚本

if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
    return nil;                                      -- 不是自己的锁
end;
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then
    redis.call('pexpire', KEYS[1], ARGV[2]);         -- 还有重入层级
    return 0;
else
    redis.call('del', KEYS[1]);
    redis.call('publish', KEYS[2], ARGV[1]);         -- 【通知等锁的客户端】
    return 1;
end;

5.3 Redisson 提供的其他锁类型

类型 说明
RLock 可重入锁(最常用)
RFairLock 公平锁:用一个 list 维护等待队列,按申请顺序获得锁,避免"饥饿"
RReadWriteLock 读写锁:读读共享、读写互斥、写写互斥。适合读多写少的临界区
RSemaphore 信号量:允许 N 个客户端同时持有(限流、连接池)
RCountDownLatch 分布式 CountDownLatch,等待 N 个任务完成
RedissonMultiLock 联锁:一次性锁住多个 key,全部成功才算成功
RedissonRedLock Redlock 实现(Redisson 3.x 后期已标记废弃,见第 6 节)

公平锁的实现思路(值得了解):

数据结构:
├─ 一个 zset(redisson_lock_queue):等待队列,score 是"预计可获取锁的时间戳"
└─ 一个 hash(redisson_lock_timeout):记录每个等待者的超时时间

加锁时:
1. 先清理队列里已超时的等待者
2. 如果自己不在队首 → 加入队列,返回"需要等待的时间"
3. 如果自己在队首且锁空闲 → 加锁成功,从队列移除自己

公平锁的代价很大(多次 Redis 交互 + 队列维护),只在"饥饿问题真实存在且不可接受"时才用。


6. Redlock 算法与争议

这是分布式系统领域一场著名的公开辩论,面试中问到能体现深度。

6.1 Redlock 要解决的问题

第 2.5 节的问题三:主从异步复制导致锁失效

antirez 提出的解法思路是:不依赖主从复制,而是部署 N 个(通常 5 个)完全独立的 Redis 主节点(互相之间没有任何复制关系),向多数节点加锁成功才算获得锁。

6.2 算法步骤

1. 客户端获取【当前时间】 T1(毫秒精度)

2. 【依次】向 N 个(如 5 个)独立的 Redis 节点尝试加锁:
   ├─ 用相同的 key 和相同的随机 value
   ├─ 每个节点设置一个【很短的网络超时】(如 5~50ms,远小于锁的 TTL)
   └─ 如果某个节点没在超时内响应,【立即放弃这个节点,去下一个】
      (这一点很重要:避免在已经挂掉的节点上长时间阻塞)

3. 客户端获取【当前时间】 T2,计算加锁总耗时 = T2 - T1

4. 【判定成功的两个条件必须同时满足】:
   ├─ 条件一:在【多数节点】(N/2 + 1,即 5 个里的 3 个)加锁成功
   └─ 条件二:总耗时 (T2 - T1) < 锁的 TTL
      (否则锁可能在你判定成功之前就已经在某些节点上过期了)

5. 成功 → 锁的【真实有效期】 = 初始 TTL - (T2 - T1) - 时钟漂移补偿
   ↑ 必须减掉获取锁花的时间,因为那段时间锁已经在计时了

6. 失败(任一条件不满足)→ 【向所有 N 个节点发送释放锁的请求】
   (包括那些你以为没加锁成功的节点——因为可能是响应丢了但实际加锁成功了)

6.3 Martin Kleppmann 的批评

2016 年,《Designing Data-Intensive Applications》(DDIA)的作者 Martin Kleppmann 发表了著名文章 “How to do distributed locking”,对 Redlock 提出了根本性质疑,antirez 随后回应。这场辩论非常经典。

批评一:分布式锁的两种用途需要区分

Martin 指出使用分布式锁有两种截然不同的目的:

用途 说明 对正确性的要求
效率(Efficiency) 避免重复做同一件事(如避免重复发邮件、重复计算) 偶尔失效可以接受(多发一封邮件不是灾难)
正确性(Correctness) 防止数据损坏、状态不一致(如防止金额算错) 绝对不能失效

如果是为了"效率":用单节点 Redis 锁就够了,Redlock 的复杂度不值得。

如果是为了"正确性"Redlock 也不够安全,理由见下。

批评二:Redlock 依赖"时间"这个不可靠的假设

这是最核心的批评。 Redlock 的正确性建立在"各节点的时钟以大致相同的速率前进"这个假设上,但这在真实系统里不成立:

(1)时钟跳变

  • NTP 校时可能让时钟向前或向后跳跃(尤其是首次同步或时钟漂移较大时);
  • 运维手动改时间;
  • 闰秒处理不当(历史上造成过大规模故障);
  • 虚拟机迁移后时钟不同步。

如果某个节点的时钟向前跳了,它上面的锁会提前过期。多个节点同时发生就可能导致多数节点的锁都提前失效,而客户端还以为自己持有锁。

(2)进程暂停(GC、page fault 等)—— 更致命

Martin 给出的经典时序图:

客户端 1              锁服务              存储(如文件系统/DB)
    │
    ├─ 获取锁 ────────▶  OK(TTL 30s)
    │
    ├─ 【STW GC 暂停 40 秒】       ← 或者 page fault、CPU 被抢占、
    │                                网络分区、虚拟机被挂起
    │                    (30s 后锁过期)
    │                          ◀──── 客户端 2 获取锁 OK
    │                                客户端 2 写入数据 ✅
    │
    ├─ GC 结束,客户端 1 醒来,
    │  【它以为自己还持有锁】
    ├─ 写入数据 ─────────────────────────────▶ ❌ 【数据损坏!】

关键点无论用什么锁服务(Redis、Redlock、ZooKeeper、etcd),这个问题都存在。因为锁服务无法阻止一个"以为自己还持有锁"的客户端去写数据。

所以 Martin 的结论是:如果你真的需要正确性保证,光有锁是不够的,必须在被保护的资源那一侧做校验。

Martin 的解决方案:Fencing Token

1. 锁服务在授予锁时,同时返回一个【单调递增的 token】(如 33, 34, 35...)
2. 客户端把 token 一起传给存储系统
3. 【存储系统记录它见过的最大 token】,拒绝所有 token 更小的写入
客户端 1 拿到 token = 33
客户端 1 GC 暂停...
客户端 2 拿到 token = 34,写入 → 存储记录 max_token = 34 ✅
客户端 1 醒来,用 token = 33 写入 → 【存储发现 33 < 34,拒绝】✅

这才是真正安全的方案。 但要求:

  • 锁服务能提供单调递增的 token(ZooKeeper 的 zxid、etcd 的 revision 天然满足;Redis 没有这个能力——Redlock 用的是随机 value,无法比较大小);
  • 被保护的资源必须支持 token 校验(这通常需要改造存储系统,很多时候做不到)。

批评三:Redlock 的实现细节问题

  1. SET NX PX 在多个独立节点上的成功不构成"共识":Redlock 不是一个真正的共识算法(不像 Raft/Paxos 有严格的证明),它更像是"多数投票 + 时间假设"的启发式方法;

  2. 节点重启会破坏互斥性

5 个节点 A B C D E,客户端 1 在 A B C 加锁成功(多数),持有锁

节点 C 【重启】(且没有开持久化,或者 AOF 的 fsync 还没落盘)
→ C 上的锁消失

客户端 2 尝试加锁,在 C D E 成功(也是多数!)
→ 【客户端 1 和 2 同时持有锁】

antirez 的应对方案是 delayed restart(延迟重启):节点重启后等待"超过最大锁 TTL"的时间再开始服务。但这又依赖了时间假设,而且实际运维中很难保证(进程崩溃后 systemd 立刻重启了怎么办)。

antirez 的回应要点

antirez 写了 “Is Redlock safe?” 回应,主要观点:

  1. Redlock 不依赖绝对时钟,只依赖"时钟以大致正确的速率前进"(相对时间),这个假设在实践中通常成立;
  2. 进程暂停的问题对任何锁系统都存在,不是 Redlock 独有的缺陷。ZooKeeper 也一样(它的 session 超时同样可能在 GC 期间过期);
  3. Fencing token 的方案很好,但很多系统的资源侧不支持 token 校验,所以现实中大部分人还是需要"尽力而为"的锁;
  4. Redlock 适合"想要比单节点 Redis 更安全,但不想引入 ZooKeeper 的复杂度“的中间地带。

6.4 结论与实践建议

这场辩论的实践结论:

你的需求 推荐方案
效率优化(避免重复工作,偶尔失效可接受) 单节点 Redis 锁 + 看门狗(最常见,够用)
正确性保证(数据不能错) 不要只依赖分布式锁
① 用数据库的唯一约束 / 乐观锁(version 字段)/ 悲观锁做最终保证;
② 或用 ZooKeeper/etcd + fencing token
想要更高的锁可用性 单节点锁 + 主从哨兵(接受切换时的极小概率失效)

关于 Redlock 的实际态度

  • 工业界实际使用 Redlock 的很少。它要求 5 个完全独立的 Redis 实例(不是集群,不是主从),部署和运维成本高(5 倍资源),换来的安全性提升有限且有争议;
  • Redisson 的 RedissonRedLock 在 3.x 后期已被标记为 @Deprecated,官方建议直接用 RLock(配合 Redis 主从或集群),理由是"实践中 RLock 已经足够,Redlock 增加的复杂度不值得”;
  • 绝大多数场景的正确做法:用简单的单节点 Redis 锁做第一道防线(挡住 99.9% 的并发),同时在数据库层面用唯一索引或乐观锁做最终兜底

这个"锁 + 数据库兜底"的双层设计是真正的工程答案

// 第一层:Redis 锁(挡住绝大部分并发,避免无谓的 DB 冲突)
if err := lock.Lock(ctx, 3*time.Second); err != nil {
    return errors.New("系统繁忙,请重试")
}
defer lock.Unlock(ctx)

// 第二层:数据库的乐观锁/唯一约束(最终正确性保证)
// UPDATE stock SET count = count - 1 WHERE id = ? AND count >= 1
result := db.Exec("UPDATE stock SET count = count - 1 WHERE id = ? AND count >= 1", id)
if affected, _ := result.RowsAffected(); affected == 0 {
    return errors.New("库存不足")   // 即使锁失效了,这里也不会超卖
}

7. 工程实践要点

7.1 锁粒度要尽可能细

// ❌ 粗粒度:所有订单串行,吞吐极低
lock := New(rdb, "lock:order", ttl)

// ✅ 细粒度:不同订单互不影响
lock := New(rdb, fmt.Sprintf("lock:order:%d", orderID), ttl)

// ✅ 更细:按业务维度
lock := New(rdb, fmt.Sprintf("lock:stock:sku:%d", skuID), ttl)

锁粒度是分布式锁性能的第一决定因素。一个粗粒度的锁能把整个系统的吞吐拉低到"1 / 临界区耗时"。

7.2 临界区要尽可能短

// ❌ 把 RPC、DB 查询、复杂计算都放进临界区
lock.Lock(ctx, timeout)
data := callRemoteService()      // 200ms
result := heavyCompute(data)     // 100ms
db.Save(result)                  // 50ms
lock.Unlock(ctx)                 // 临界区 350ms → 单锁 QPS 只有 ~3

// ✅ 只把真正需要互斥的部分放进去
data := callRemoteService()
result := heavyCompute(data)
lock.Lock(ctx, timeout)
db.Save(result)                  // 临界区 50ms → 单锁 QPS ~20
lock.Unlock(ctx)

7.3 一定要有超时和降级

// ❌ 无限等待:一个卡住的锁会让所有请求堆积,最终打爆线程池/连接池
lock.Lock(ctx, 0)

// ✅ 有等待上限 + 降级策略
if err := lock.Lock(ctx, 3*time.Second); err != nil {
    metrics.LockFailCounter.Inc()
    return ErrSystemBusy       // 快速失败,让客户端重试
}

为什么无限等待很危险:如果某个锁因为 bug 长时间不释放,所有等锁的请求会在服务端堆积,占满 goroutine/线程和数据库连接,最终导致整个服务雪崩(而不只是这一个接口不可用)。

7.4 释放锁失败必须告警

if err := lock.Unlock(ctx); err != nil {
    if errors.Is(err, ErrNotLockOwner) {
        // 【严重】锁在业务执行期间失效了
        // 说明业务执行时间远超 TTL,或发生了主从切换
        // 意味着这段时间可能有其他客户端也进入了临界区
        log.Errorf("锁已失效!可能存在并发问题: key=%s", lock.key)
        metrics.LockLostCounter.Inc()   // 必须告警
    } else {
        log.Errorf("释放锁失败(网络等): %v", err)
        // 这种情况锁会自动过期,不用特殊处理,但要记录
    }
}

ErrNotLockOwner 是一个强信号:它说明"你以为自己持有锁的期间,锁实际已经不属于你了"。必须查明原因(业务变慢?TTL 太短?发生了主从切换?)。

7.5 不要用分布式锁做"流量控制"

有人用分布式锁来限制并发(比如"同时只允许 10 个请求处理"),这不合适:

  • 锁是互斥语义(1 个),不是限流语义(N 个);
  • 应该用 RSemaphore(Redisson)或者自己用 Lua 实现计数器/令牌桶(第 8 篇)。

7.6 集群模式下的注意点

(1)锁 key 会落在某一个节点上

单个锁的 QPS 受那个节点的能力限制。热点锁(比如秒杀的库存锁)会让那个节点成为瓶颈。

(2)Redisson 的 MultiLock 在集群下要注意 slot

RedissonMultiLock 一次锁多个 key,如果这些 key 在不同 slot,Redisson 会分别向各节点发请求(能工作但不是原子的)。

(3)不要用 hash tag 把所有锁固定到一个 slot

有人为了"方便"给所有锁 key 加同样的 hash tag(如 {lock}:order:1),这会让所有锁请求都打到同一个节点,既是热点又失去了分片意义(第 12 篇讲的倾斜问题)。

7.7 完整的检查清单

写一个分布式锁实现时,逐条对照:

  • 加锁用 SET key value NX EX/PX原子,不能拆成 SETNX + EXPIRE)
  • value 是唯一标识(UUID 等),不是固定值
  • 释放锁用 Lua 保证"判断持有者 + 删除"原子
  • 看门狗自动续期(或者 TTL 明确大于业务最长耗时)
  • 看门狗有最大续期时长上限(防止业务死循环导致永久持有)
  • 看门狗在任何退出路径(含 panic)都能停止(用 defer)
  • Unlock等待看门狗真正退出
  • 续期失败和释放失败都有日志和告警
  • 加锁有等待超时,不无限阻塞
  • 重试用指数退避(避免重试风暴)
  • 锁粒度足够细(按业务 ID 而不是按业务类型)
  • 临界区足够短(IO 和计算尽量移出去)
  • 需要重入时用 hash + 计数
  • 正确性关键的业务在数据库层有兜底(唯一索引/乐观锁)

8. 高频面试题

Q1:怎么用 Redis 实现分布式锁?

加锁(一条命令原子完成):

SET lock:key <uuid> NX EX 30
  • NX 保证互斥(只有不存在时才能设置);
  • EX 30 保证不死锁(客户端崩溃后自动释放);
  • value = uuid 唯一标识持有者。

释放(必须用 Lua 保证原子):

if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
end
return 0

外加看门狗:后台线程每 TTL/3 检查并续期,只要业务还在跑锁就不过期;客户端崩溃时续期线程随之消失,锁自动过期。

这四点分别对应分布式锁的四个正确性要求:互斥性(NX)、避免死锁(EX + 看门狗随进程消亡)、只释放自己的锁(uuid + Lua)、业务超时不误放(看门狗)。

Q2:为什么不能用 SETNX + EXPIRE 两条命令?

因为不是原子的。如果在 SETNX 成功之后、EXPIRE 执行之前客户端崩溃或网络中断,这把锁就没有过期时间,永久存在,导致所有后续客户端都拿不到锁,业务彻底卡死(死锁)。

窗口虽然只有微秒级,但在高 QPS 下必然发生。

Redis 2.6.12 之后 SET 支持了 NX/EX 参数,必须用 SET key value NX EX seconds 一条命令完成

(历史上有人用 SETNX 存过期时间戳 + GETSET 来绕过,但逻辑复杂且有边界 bug——多个客户端同时发现锁过期时会互相覆盖——不要用。)

Q3:为什么释放锁必须用 Lua 脚本?

因为要保证"判断是不是自己的锁“和”删除“两个操作原子。如果分开:

时刻 T1:A 执行 GET,返回自己的 uuid,判断"是我的锁"
时刻 T2:【锁刚好过期自动释放】,客户端 B 加锁成功
时刻 T3:A 执行 DEL → 【删掉了 B 的锁】
时刻 T4:客户端 C 加锁成功 → 【B 和 C 同时在临界区】

Lua 让"判断 + 删除"成为不可分割的一步,彻底消除这个窗口。

同理,续期也必须用 Lua(判断是自己的锁 + PEXPIRE)。

Q4:锁的过期时间应该设多长?业务执行超时怎么办?

这是一个两难

  • TTL 太短 → 业务还没做完锁就过期了,别人进入临界区,互斥性被破坏
  • TTL 太长 → 客户端崩溃后要等很久锁才释放,业务长时间卡死

解法:看门狗(自动续期)

1. 加锁时设一个较短的 TTL(如 30 秒)
2. 后台线程每 TTL/3(10 秒)用 Lua 检查"锁还是自己的吗",是就重置 TTL 为 30 秒
3. 业务完成 → 释放锁 + 停止续期
4. 【客户端崩溃 → 续期线程随进程消失 → 锁在 30 秒后自动过期】

间隔取 TTL/3 的原因:留出重试余量,可以容忍连续两次续期失败(网络抖动)。

看门狗的三个注意点

  1. 续期失败要能被感知并告警——否则锁悄悄过期,业务在无保护状态下继续跑;
  2. 必须有最大续期总时长(如 10 分钟),否则业务死循环会导致锁被永久续期,退化成死锁;
  3. Unlock 时要等看门狗真正退出,避免它在锁已删除后又续期。

Q5:Redis 分布式锁在主从切换时会失效吗?

会,而且这是无法通过客户端代码解决的根本问题。

时刻 T1:客户端 A 在主节点加锁成功
时刻 T2:【主节点还没把这条命令同步给从节点就宕机了】(复制是异步的)
时刻 T3:哨兵把从节点提升为新主节点 —— 新主节点上【没有这把锁】
时刻 T4:客户端 B 在新主节点加锁 → 【成功】
        → 【A 和 B 同时持有锁,互斥性彻底被破坏】

根源:Redis 主从复制是异步的(第 10 篇)——主节点执行完命令立即返回客户端,不等从节点确认。

缓解手段(都不彻底)

  1. WAIT 1 100:加锁后等待至少 1 个从节点确认。但它只保证从节点收到并写入内存,不保证 fsync,而且哨兵可能选了另一个更落后的从节点;
  2. Redlock:用 5 个独立的 Redis 主节点,多数派加锁成功才算获得锁(第 6 节,但本身有争议);
  3. min-replicas-to-write:缩小脑裂窗口,不能根除。

根本解法正确性关键的业务不要只依赖 Redis 锁——用数据库的唯一约束/乐观锁做最终保证,或者用 ZooKeeper/etcd(CP 系统)。

Q6:什么是 Redlock?它的算法步骤是什么?

Redlock 是 antirez 为解决"主从切换导致锁失效"提出的算法。核心思路:部署 N 个(通常 5 个)完全独立的 Redis 主节点(互相之间没有任何复制关系),向多数节点加锁成功才算获得锁。

1. 记录当前时间 T1
2. 【依次】向 5 个独立节点加锁(相同 key、相同随机 value):
   ├─ 每个节点设一个【很短的网络超时】(5~50ms,远小于锁 TTL)
   └─ 超时就【立即放弃该节点去下一个】(避免在挂掉的节点上阻塞)
3. 记录当前时间 T2,计算总耗时 = T2 - T1
4. 【两个条件必须同时满足才算成功】:
   ├─ 在【多数节点】(3 个)加锁成功
   └─ 总耗时 < 锁的 TTL
      (否则锁可能在你判定成功前就已经在某些节点上过期了)
5. 成功 → 锁的【真实有效期】= TTL - (T2-T1) - 时钟漂移补偿
6. 失败 → 【向所有 5 个节点】发释放请求
   (包括你以为没成功的节点,因为可能是响应丢了但实际加锁成功了)

Q7:Redlock 有什么争议?(Martin Kleppmann 的批评)

DDIA 作者 Martin Kleppmann 在 “How to do distributed locking” 中提出三点核心批评:

(1)要区分锁的两种用途

  • 效率(避免重复做事,偶尔失效可接受)→ 单节点 Redis 锁就够了,Redlock 的复杂度不值得;
  • 正确性(数据不能错)→ Redlock 也不够安全

(2)Redlock 依赖"时间"这个不可靠假设

  • 时钟跳变:NTP 校时可能让时钟向前/向后跳跃,节点上的锁会提前过期
  • 进程暂停(最致命)
客户端 1 获取锁(TTL 30s)
→ 【STW GC 暂停 40 秒】(或 page fault / CPU 被抢占 / VM 挂起)
→ 30 秒后锁过期,客户端 2 获取锁并写入 ✅
→ 客户端 1 醒来,【它以为自己还持有锁】,写入 → 【数据损坏】

这个问题对任何锁服务都存在(Redis、Redlock、ZooKeeper、etcd 都一样),因为锁服务无法阻止一个"以为自己还持有锁"的客户端去写数据。

(3)节点重启会破坏互斥性

5 个节点,客户端 1 在 A B C 加锁成功(多数),持有锁
节点 C 重启(未持久化 / AOF 还没 fsync)→ C 上的锁消失
客户端 2 在 C D E 加锁成功(也是多数!)→ 【两个客户端同时持有锁】

antirez 的应对是 delayed restart(重启后等待超过最大 TTL 再服务),但这又依赖时间假设且运维上难保证。

Martin 的解决方案:Fencing Token

1. 锁服务授予锁时同时返回一个【单调递增的 token】(33, 34, 35...)
2. 客户端把 token 一起传给存储系统
3. 【存储系统记录见过的最大 token,拒绝更小 token 的写入】

客户端 1 拿 token=33 → GC 暂停
客户端 2 拿 token=34 → 写入成功,存储记录 max=34
客户端 1 醒来用 token=33 写入 → 【33 < 34,被拒绝】✅

要求:锁服务能提供单调递增 token(ZooKeeper 的 zxid、etcd 的 revision 天然支持,Redis 没有这个能力——Redlock 用的是随机 value 无法比较),且被保护的资源必须支持 token 校验(通常要改造存储系统,很多时候做不到)。

antirez 的回应:Redlock 只依赖"时钟以大致正确的速率前进”(相对时间)而非绝对时钟;进程暂停对任何锁系统都存在不是 Redlock 独有;Fencing token 很好但很多资源侧不支持;Redlock 是"比单节点更安全但不想引入 ZK 复杂度"的中间选择。

Q8:实际生产中该用 Redlock 吗?

大多数情况不用。 理由:

  1. 要求 5 个完全独立的 Redis 实例(不是集群、不是主从),部署运维成本高(5 倍资源);
  2. 换来的安全性提升有限且有争议(见 Q7);
  3. Redisson 的 RedissonRedLock 在 3.x 后期已被标记 @Deprecated,官方建议直接用 RLock,理由是"实践中 RLock 已足够,Redlock 增加的复杂度不值得"。

正确的工程做法是"双层设计"

// 第一层:Redis 单节点锁(挡住 99.9% 的并发,避免无谓的 DB 冲突)
if err := lock.Lock(ctx, 3*time.Second); err != nil {
    return ErrSystemBusy
}
defer lock.Unlock(ctx)

// 第二层:数据库的乐观锁/唯一约束(最终正确性保证)
result := db.Exec("UPDATE stock SET count = count - 1 WHERE id = ? AND count >= 1", id)
if affected, _ := result.RowsAffected(); affected == 0 {
    return ErrOutOfStock    // 【即使 Redis 锁失效了,这里也不会超卖】
}

核心思想:把 Redis 锁当作性能优化(减少数据库冲突和无效竞争),把数据库的约束当作正确性保证。这样既有高性能又有强正确性。

Q9:Redisson 的分布式锁有什么特点?

四个关键设计:

(1)可重入(用 hash + 计数)

value 是一个 hash,field = UUID:threadIdvalue = 重入次数。加锁时如果 field 已存在就 HINCRBY +1;释放时 HINCRBY -1减到 0 才真正 DEL

(2)看门狗自动续期

默认 lockWatchdogTimeout = 30000ms,每 30000/3 = 10 秒续期一次。

最容易踩的坑只有不指定 leaseTime 时看门狗才生效。调用 lock(10, TimeUnit.SECONDS) 显式指定租期后 Redisson 不启动看门狗,10 秒后锁一定过期。很多人误以为它是"至少锁 10 秒然后续期"。

(3)等锁不轮询,用 pub/sub 通知

客户端 B 加锁失败 → SUBSCRIBE redisson_lock__channel:{key} → 阻塞等待(Semaphore)
客户端 A 释放锁 → unlock 的 Lua 脚本里 PUBLISH 一条消息
客户端 B 被唤醒 → 立即尝试加锁

好处:避免 100 个等锁客户端每 10ms 轮询造成 1 万 QPS 的无效请求;响应更及时。同时保留超时兜底(pub/sub 会丢消息),是"优化 + 兜底"的典范。

(4)加锁失败时返回锁的剩余 TTL

Lua 脚本最后 return redis.call('pttl', KEYS[1]),客户端据此决定等待多久,比盲目重试聪明。

Redisson 还提供 RFairLock(公平锁,用 zset 维护等待队列)、RReadWriteLockRSemaphoreRCountDownLatchRedissonMultiLock

Q10:怎么实现可重入的分布式锁?

hash 而不是 string:field 是持有者标识,value 是重入次数

加锁

if redis.call('EXISTS', KEYS[1]) == 0 then
    redis.call('HSET', KEYS[1], ARGV[2], 1)      -- 首次获取
    redis.call('PEXPIRE', KEYS[1], ARGV[1])
    return 1
end
if redis.call('HEXISTS', KEYS[1], ARGV[2]) == 1 then
    redis.call('HINCRBY', KEYS[1], ARGV[2], 1)   -- 重入,计数 +1
    redis.call('PEXPIRE', KEYS[1], ARGV[1])
    return 1
end
return 0                                          -- 被别人持有

释放

if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 0 then return nil end
local count = redis.call('HINCRBY', KEYS[1], ARGV[1], -1)
if count > 0 then
    redis.call('PEXPIRE', KEYS[1], ARGV[2])      -- 还有层级,只刷 TTL
    return 0
end
redis.call('DEL', KEYS[1])                        -- 归零才真正删除
return 1

持有者标识的构造:必须唯一标识"一个执行流"。Java 用 UUID:threadId(Redisson 的做法);Go 没有稳定的 goroutine ID,推荐把 lock 实例通过 context 传递,而不是依赖 goroutine 标识。

Q11:分布式锁获取失败后应该怎么处理?

三种策略,按业务性质选

(1)快速失败(推荐用于用户请求)

if err := lock.TryLock(ctx); err != nil {
    return ErrSystemBusy    // 立即返回"系统繁忙,请重试"
}

(2)有限等待 + 指数退避(推荐用于后台任务)

if err := lock.Lock(ctx, 3*time.Second); err != nil {   // 最多等 3 秒
    return err
}
// 内部用指数退避重试:10ms → 20ms → 40ms → ... → 200ms(上限)

为什么要指数退避:固定间隔重试在高并发下会形成重试风暴(100 个客户端每 10ms 齐刷刷地重试 = 1 万 QPS 的无效请求,还会因为同时到达而互相冲突)。

(3)绝对不要无限等待

lock.Lock(ctx, 0)   // ❌ 危险

如果某个锁因 bug 长时间不释放,所有等锁请求会在服务端堆积,占满 goroutine/线程池和数据库连接,最终导致整个服务雪崩(不只是这一个接口挂)。

Q12:ZooKeeper 实现的分布式锁和 Redis 有什么区别?

维度 Redis ZooKeeper / etcd
一致性模型 AP(异步复制,主从切换会丢锁) CP(写要多数派确认,锁不会因切换丢失)
性能 (10 万 QPS 级) 低(每次写要过共识协议,通常几千 QPS)
锁的释放 依赖 TTL 过期 临时节点(ephemeral)随 session 断开自动删除
等锁机制 轮询或 pub/sub 通知 watch 机制(原生支持,更可靠)
公平性 需自己实现(zset 队列) 临时顺序节点天然公平(按序号排队)
Fencing token ❌ 不支持(随机 value 无法比较) zxid / revision 天然单调递增
运维复杂度 高(要维护 ZK/etcd 集群)

ZooKeeper 锁的实现方式(了解即可):

1. 在 /locks/mylock 下创建【临时顺序节点】(如 /locks/mylock/lock-0000000001)
2. 获取所有子节点,判断自己是否是【序号最小的】
   ├─ 是 → 获得锁
   └─ 不是 → 【watch 前一个节点】,阻塞等待
3. 前一个节点被删除(持有者释放锁或崩溃)→ 收到 watch 通知 → 回到步骤 2
4. 释放锁 = 删除自己的节点

关键优势:① 临时节点在 session 断开时自动删除,不需要 TTL 猜测业务耗时;② watch 前一个节点而不是所有节点,避免"羊群效应";③ 天然公平。

选型性能优先且有兜底 → Redis(覆盖 95% 的场景);正确性绝对不能妥协 → ZooKeeper/etcd + fencing token

Q13:秒杀场景该怎么用分布式锁?

答案是:秒杀的核心路径通常不用分布式锁,而是用 Lua 脚本。

因为分布式锁在热点场景下性能很差(所有请求串行等一把锁),而Lua 脚本本身就是原子的(第 8 篇):

-- KEYS[1]=库存key  KEYS[2]=已购用户集合  ARGV[1]=用户ID  ARGV[2]=数量
-- 所有校验在前,所有写入在后(Lua 不能回滚)
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
    return -1     -- 已购买过(一人一单)
end
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock < tonumber(ARGV[2]) then
    return -2     -- 库存不足
end
redis.call('DECRBY', KEYS[1], ARGV[2])
redis.call('SADD', KEYS[2], ARGV[1])
return 1

一次网络往返、服务端原子执行、无需加锁解锁的额外开销,性能比分布式锁高一个数量级。

完整的秒杀架构(第 14 篇会详述):

1. 前端:按钮防重复点击 + 验证码/答题(削峰)
2. 网关层:限流(令牌桶)+ 黑名单
3. 应用层:本地缓存"已售完"标记,直接拦掉后续请求
4. Redis:【Lua 脚本原子扣减库存 + 一人一单校验】← 核心
5. 消息队列:扣减成功后发消息,【异步】创建订单(削峰填谷)
6. 数据库:最终扣减,用【乐观锁/唯一索引】做最终兜底
   UPDATE stock SET count=count-1 WHERE id=? AND count>=1

分布式锁在这里的位置:只在"需要跨多个 Redis key 且无法用单个 Lua 完成"的复杂场景才用,或者用在后台的对账/补偿任务上(低频、不怕慢)。


小结

  • 分布式锁的四个正确性要求互斥性、避免死锁、容错性、只能释放自己的锁。每个实现细节都对应其中一条。
  • 实现演进SETNX(死锁)→ SETNX+EXPIRE(不原子,仍死锁)→ SET NX EX(原子加锁,但会误删别人的锁)→ SET NX EX + 唯一 value + Lua 释放(正确的基础版)。
  • 释放锁必须用 Lua:把"判断是自己的锁"和"删除"变成原子操作,否则锁刚好过期时会误删别人的锁。续期同理
  • 看门狗解决了 TTL 的两难:TTL 短了业务没做完就失效,长了崩溃后卡死太久。后台线程每 TTL/3(留重试余量)续期,客户端崩溃时续期线程随进程消亡,锁自动过期。
  • 看门狗三个坑:续期失败要告警(否则业务在无锁保护下裸奔)、必须有最大续期总时长(防业务死循环导致永久持锁)、Unlock 要等看门狗真正退出
  • 可重入用 hash:field = 持有者标识value = 重入次数HINCRBY 加减,归零才 DEL。Java 用 UUID:threadIdGo 没有稳定的 goroutine ID,应通过 context 传递锁实例
  • Redisson 的四个设计:hash 可重入、看门狗(指定 leaseTime 后就不生效——最大的坑)pub/sub 通知代替轮询(+ 超时兜底)、加锁失败返回剩余 TTL。
  • 主从异步复制会导致锁失效(主节点加锁成功但未同步就宕机 → 新主上没有这把锁 → 两个客户端同时持有),这是无法用客户端代码解决的根本问题
  • Redlock:5 个完全独立的主节点,多数派成功 + 总耗时 < TTL 才算获得锁,有效期要减去获取耗时;失败时向所有节点发释放请求。
  • Martin Kleppmann 的批评:要区分锁用于效率(单节点够了)还是正确性(Redlock 也不安全);Redlock 依赖时钟假设(NTP 跳变、GC/进程暂停导致"以为自己还持锁"——对任何锁服务都成立);节点重启破坏互斥性。他的方案是 fencing token(单调递增 + 资源侧校验),但 Redis 提供不了这种 token(ZK 的 zxid / etcd 的 revision 天然支持)。
  • 实际生产不推荐 Redlock(5 倍资源、收益有争议、Redisson 已把 RedissonRedLock 标记废弃)。正确做法是双层设计:Redis 锁做性能优化(挡住 99.9% 并发)+ 数据库唯一约束/乐观锁做最终正确性保证。
  • 工程要点:锁粒度细(按业务 ID)、临界区短(IO 移出去)、必须有等待超时(无限等待会导致整个服务雪崩)、重试用指数退避(防重试风暴)、释放失败要告警ErrNotLockOwner 是并发问题的强信号)。
  • 秒杀等热点场景不用分布式锁,用 Lua 脚本(一次往返、服务端原子、性能高一个数量级),配合限流 + 异步下单 + 数据库乐观锁兜底。