Redis-13 分布式锁
1. 分布式锁要解决什么问题
单机程序里用 synchronized、Mutex 就能保护临界区,因为所有线程在同一个进程的同一块内存里。但在分布式系统中,多个进程跑在不同机器上,进程内的锁完全无效。
典型场景:
用户点了两次"下单"按钮(或者前端重试),两个请求打到两台不同的应用服务器:
服务器 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.12 给 SET 加了参数,可以一条命令原子地完成"加锁 + 设过期":
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 释放(正确的基础版)
两个改进:
- 锁的 value 存一个唯一标识(UUID / 机器ID+线程ID+随机数),标明"这把锁是谁的";
- 释放锁时先检查是不是自己的,且检查和删除必须原子(用 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 的实现细节问题
-
SET NX PX在多个独立节点上的成功不构成"共识":Redlock 不是一个真正的共识算法(不像 Raft/Paxos 有严格的证明),它更像是"多数投票 + 时间假设"的启发式方法; -
节点重启会破坏互斥性:
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?” 回应,主要观点:
- Redlock 不依赖绝对时钟,只依赖"时钟以大致正确的速率前进"(相对时间),这个假设在实践中通常成立;
- 进程暂停的问题对任何锁系统都存在,不是 Redlock 独有的缺陷。ZooKeeper 也一样(它的 session 超时同样可能在 GC 期间过期);
- Fencing token 的方案很好,但很多系统的资源侧不支持 token 校验,所以现实中大部分人还是需要"尽力而为"的锁;
- 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 的原因:留出重试余量,可以容忍连续两次续期失败(网络抖动)。
看门狗的三个注意点:
- 续期失败要能被感知并告警——否则锁悄悄过期,业务在无保护状态下继续跑;
- 必须有最大续期总时长(如 10 分钟),否则业务死循环会导致锁被永久续期,退化成死锁;
Unlock时要等看门狗真正退出,避免它在锁已删除后又续期。
Q5:Redis 分布式锁在主从切换时会失效吗?
会,而且这是无法通过客户端代码解决的根本问题。
时刻 T1:客户端 A 在主节点加锁成功
时刻 T2:【主节点还没把这条命令同步给从节点就宕机了】(复制是异步的)
时刻 T3:哨兵把从节点提升为新主节点 —— 新主节点上【没有这把锁】
时刻 T4:客户端 B 在新主节点加锁 → 【成功】
→ 【A 和 B 同时持有锁,互斥性彻底被破坏】
根源:Redis 主从复制是异步的(第 10 篇)——主节点执行完命令立即返回客户端,不等从节点确认。
缓解手段(都不彻底):
WAIT 1 100:加锁后等待至少 1 个从节点确认。但它只保证从节点收到并写入内存,不保证 fsync,而且哨兵可能选了另一个更落后的从节点;- Redlock:用 5 个独立的 Redis 主节点,多数派加锁成功才算获得锁(第 6 节,但本身有争议);
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 吗?
大多数情况不用。 理由:
- 要求 5 个完全独立的 Redis 实例(不是集群、不是主从),部署运维成本高(5 倍资源);
- 换来的安全性提升有限且有争议(见 Q7);
- 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:threadId,value = 重入次数。加锁时如果 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 维护等待队列)、RReadWriteLock、RSemaphore、RCountDownLatch、RedissonMultiLock。
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:threadId,Go 没有稳定的 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 脚本(一次往返、服务端原子、性能高一个数量级),配合限流 + 异步下单 + 数据库乐观锁兜底。
xingliuhua