Database · #redis#distributed-lock#redlock

Redis分布式锁的正确实现方式

2024.10.14 11 min 4.3k
// 目录 · contents

引言

分布式锁是分布式系统中最常见的协调原语之一。在微服务架构下,多个服务实例需要互斥地访问共享资源(如库存扣减、订单创建等),分布式锁就是解决这类问题的核心工具。Redis由于其高性能和广泛使用,成为实现分布式锁的热门选择,但要正确实现一个可靠的Redis分布式锁并不简单。

基础方案:SETNX

最简单的分布式锁

1
2
3
4
5
6
# 加锁: SET key value NX EX seconds
# NX: 只在key不存在时设置(Not eXists)
# EX: 设置过期时间(秒)
SET lock:order:1001 "uuid-random-value" NX EX 30

# 解锁: 先验证value再删除(需要Lua脚本保证原子性)
sequenceDiagram
    participant C1 as Client 1
    participant R as Redis
    participant C2 as Client 2

    C1->>R: SET lock:order NX EX 30 (value=uuid1)
    R->>C1: OK (获取锁成功)

    C2->>R: SET lock:order NX EX 30 (value=uuid2)
    R->>C2: nil (获取锁失败,key已存在)

    Note over C1: 执行业务逻辑...

    C1->>R: Lua脚本: if GET == uuid1 then DEL
    R->>C1: 1 (释放锁成功)

    C2->>R: SET lock:order NX EX 30 (value=uuid2)
    R->>C2: OK (现在获取锁成功)

为什么需要随机 value

value必须是一个随机唯一值(如UUID),用于释放锁时验证身份。

sequenceDiagram
    participant C1 as Client 1
    participant R as Redis
    participant C2 as Client 2

    C1->>R: SET lock NX EX 30
    R->>C1: OK

    Note over C1: 业务处理耗时过长<br/>锁自动过期了!

    C2->>R: SET lock NX EX 30
    R->>C2: OK (锁已过期,C2获取到锁)

    Note over C1: 业务处理完成,释放锁
    C1->>R: DEL lock
    Note over R: 删除了C2的锁!

    Note over C2: C2的锁被C1误删<br/>其他客户端可以获取锁<br/>互斥性被破坏!

正确的解锁操作

必须使用Lua脚本保证”检查value并删除”的原子性:

1
2
3
4
5
6
7
8
-- unlock.lua
-- KEYS[1]: 锁的key
-- ARGV[1]: 锁的value(当前客户端的uuid)
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import redis
import uuid

class SimpleRedisLock:
def __init__(self, client, key, ttl=30):
self.client = client
self.key = f"lock:{key}"
self.ttl = ttl
self.value = str(uuid.uuid4())

def acquire(self):
"""尝试获取锁"""
return self.client.set(
self.key, self.value,
nx=True, ex=self.ttl
)

def release(self):
"""释放锁(Lua脚本保证原子性)"""
script = """
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
"""
# 使用 Redis EVAL 命令执行 Lua 脚本,保证原子性
return self.client.execute_command(
"EVAL", script, 1, self.key, self.value
)

基础方案的问题

  1. 锁过期但业务未完成:TTL到期后锁自动释放,但业务还在执行
  2. 不可重入:同一线程不能重复获取同一把锁
  3. 不支持阻塞等待:获取失败只能轮询重试
  4. 单点故障:Redis Master宕机时,Slave可能没有同步到锁数据

Redisson 实现方案

Redisson是Java生态中最成熟的Redis客户端,提供了功能完善的分布式锁实现。

Redisson 锁的核心机制

flowchart TD
    A[尝试获取锁] --> B{锁是否存在?}
    B -->|不存在| C[创建Hash<br/>field=clientId:threadId<br/>value=1]
    B -->|存在| D{是否是自己的锁?}
    D -->|是| E[重入: value+1]
    D -->|否| F[返回锁的剩余TTL]
    C --> G[设置过期时间]
    E --> G
    G --> H[启动WatchDog<br/>每10秒续期到30秒]
    F --> I[订阅解锁消息<br/>等待通知后重试]

Redisson 加锁的 Lua 脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- 尝试获取锁
-- KEYS[1]: 锁的key
-- ARGV[1]: 锁的过期时间 (默认30000ms)
-- ARGV[2]: 客户端标识 (clientId:threadId)

-- 锁不存在,直接获取
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

-- 锁存在且不是自己的,返回剩余TTL
return redis.call('pttl', KEYS[1])

WatchDog 自动续期

Redisson的WatchDog机制解决了”锁过期但业务未完成”的问题:

sequenceDiagram
    participant C as Client
    participant WD as WatchDog Timer
    participant R as Redis

    C->>R: 获取锁 (TTL=30s)
    R->>C: OK
    C->>WD: 启动WatchDog

    Note over C: 执行业务逻辑...

    WD->>R: 第10秒: PEXPIRE lock 30000 (续期到30s)
    R->>WD: OK

    Note over C: 继续执行...

    WD->>R: 第20秒: PEXPIRE lock 30000 (再次续期)
    R->>WD: OK

    Note over C: 业务完成

    C->>R: 释放锁
    C->>WD: 停止WatchDog
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Redisson分布式锁使用示例
RLock lock = redissonClient.getLock("lock:order:1001");

try {
// 尝试获取锁,最多等待10秒,锁自动过期时间30秒
// 如果不指定leaseTime,WatchDog会自动续期
boolean acquired = lock.tryLock(10, TimeUnit.SECONDS);
if (acquired) {
// 执行业务逻辑
processOrder("1001");
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}

// 可重入锁
RLock lock = redissonClient.getLock("lock:inventory");
lock.lock(); // 第一次加锁
lock.lock(); // 重入,计数+1
lock.unlock(); // 计数-1
lock.unlock(); // 计数归零,真正释放锁

// 公平锁(按请求顺序获取)
RLock fairLock = redissonClient.getFairLock("lock:fair");

// 读写锁
RReadWriteLock rwLock = redissonClient.getReadWriteLock("lock:rw");
RLock readLock = rwLock.readLock();
RLock writeLock = rwLock.writeLock();

// 联锁(MultiLock): 对多个资源同时加锁
RLock lock1 = redissonClient.getLock("lock:account:1");
RLock lock2 = redissonClient.getLock("lock:account:2");
RLock multiLock = redissonClient.getMultiLock(lock1, lock2);
multiLock.lock();

Redlock 算法

单节点 Redis 的问题

sequenceDiagram
    participant C1 as Client 1
    participant M as Redis Master
    participant S as Redis Slave

    C1->>M: SET lock NX EX 30
    M->>C1: OK (获取锁成功)

    Note over M: Master宕机!<br/>锁数据尚未同步到Slave

    S->>S: 提升为新Master

    participant C2 as Client 2
    C2->>S: SET lock NX EX 30
    S->>C2: OK (也获取到了锁!)

    Note over C1, C2: 两个客户端同时持有锁!<br/>互斥性被破坏!

Redlock 算法流程

Redis作者antirez提出的Redlock算法,使用N个(通常5个)独立的Redis实例来实现更安全的分布式锁:

flowchart TD
    A[开始获取锁] --> B[记录当前时间 T1]
    B --> C[依次向N个Redis实例请求加锁<br/>每个实例设置较短的超时时间]
    C --> D[记录当前时间 T2]
    D --> E{在过半实例 N/2+1<br/>上加锁成功?}
    E -->|否| F[在所有实例上释放锁<br/>随机延迟后重试]
    E -->|是| G{T2-T1 小于锁的过期时间?}
    G -->|否| F
    G -->|是| H[获取锁成功!<br/>锁的有效时间 = 过期时间 - T2加T1]
    H --> I[执行业务逻辑]
    I --> J[在所有实例上释放锁]

Redlock的关键步骤:

  1. 获取当前时间T1(毫秒级)
  2. 依次向N个独立Redis实例发送SET NX EX请求,每个实例设置较短的网络超时(如5-50ms)
  3. 获取当前时间T2,计算获取锁总耗时
  4. 如果在过半实例(N/2+1)上加锁成功,且总耗时小于锁的TTL,则认为获取锁成功
  5. 锁的实际有效时间 = TTL - (T2-T1) - 时钟漂移补偿
  6. 如果获取失败,在所有实例上释放锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import time
import uuid
import redis

class RedLock:
def __init__(self, redis_instances, ttl=30000):
"""
redis_instances: N个独立Redis连接
ttl: 锁的过期时间(毫秒)
"""
self.instances = redis_instances
self.ttl = ttl
self.quorum = len(redis_instances) // 2 + 1
self.clock_drift_factor = 0.01
self.retry_count = 3
self.retry_delay = 200 # ms

def acquire(self, resource):
value = str(uuid.uuid4())
retry = 0

while retry < self.retry_count:
n = 0
start_time = int(time.time() * 1000)

# 依次在每个实例上尝试加锁
for instance in self.instances:
try:
if instance.set(resource, value,
nx=True, px=self.ttl):
n += 1
except redis.RedisError:
pass

elapsed = int(time.time() * 1000) - start_time
drift = int(self.ttl * self.clock_drift_factor) + 2

# 判断是否获取成功
validity = self.ttl - elapsed - drift
if n >= self.quorum and validity > 0:
return {
'value': value,
'validity': validity
}

# 获取失败,释放所有实例上的锁
for instance in self.instances:
self._release_instance(instance, resource, value)

retry += 1
time.sleep(self.retry_delay / 1000.0)

return None

def release(self, resource, value):
for instance in self.instances:
self._release_instance(instance, resource, value)

def _release_instance(self, instance, resource, value):
"""使用Lua脚本原子性地验证并释放锁"""
unlock_script = """
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
"""
try:
instance.execute_command(
"EVAL", unlock_script, 1, resource, value
)
except redis.RedisError:
pass

Martin Kleppmann 的质疑

分布式系统专家Martin Kleppmann发表了著名的文章”How to do distributed locking”,对Redlock算法提出了深刻的质疑。

质疑一:GC暂停导致锁失效

sequenceDiagram
    participant C1 as Client 1
    participant R as Redis 5个实例
    participant DB as 共享资源

    C1->>R: 获取Redlock TTL=30s
    R->>C1: 锁获取成功

    Note over C1: GC暂停了35秒!<br/>Full GC / STW

    Note over R: 30秒后锁过期

    participant C2 as Client 2
    C2->>R: 获取Redlock
    R->>C2: 锁获取成功

    Note over C1: GC恢复,认为自己还持有锁

    C1->>DB: 写入数据
    C2->>DB: 写入数据
    Note over DB: 数据不一致!

质疑二:时钟跳变问题

Redlock的安全性依赖于各Redis实例的时钟大致同步。如果某个实例发生时钟跳变(NTP调整、闰秒等),可能导致锁提前过期:

1
2
3
4
5
6
7
8
9
10
11
实例1: 锁过期时间 T+30s
实例2: 锁过期时间 T+30s
实例3: 时钟向前跳了15s,锁只剩 15s 就过期
实例4: 锁过期时间 T+30s
实例5: 锁过期时间 T+30s

如果实例3的锁过期 + 实例5网络故障:
只有实例1, 2, 4持有锁 (3个 >= quorum)
但如果实例3锁过期后,另一个客户端在实例3, 5上获取到锁 (2个)
加上实例3本身 = 3个 >= quorum
两个客户端同时持有锁!

Fencing Token 方案

Martin Kleppmann提出,真正安全的分布式锁应该使用Fencing Token:

sequenceDiagram
    participant C1 as Client 1
    participant Lock as Lock Service
    participant DB as Storage

    C1->>Lock: 获取锁
    Lock->>C1: token=33

    Note over C1: GC暂停,锁过期

    participant C2 as Client 2
    C2->>Lock: 获取锁
    Lock->>C2: token=34

    C2->>DB: 写入 token=34
    DB->>DB: 记录最新token=34
    DB->>C2: OK

    Note over C1: GC恢复

    C1->>DB: 写入 token=33
    DB->>DB: token=33 小于最新token=34
    DB->>C1: REJECTED 过期的token

    Note over DB: 数据一致性得到保证!

Fencing Token的关键点: 1. 每次获取锁时返回一个单调递增的token 2. 写入共享资源时必须携带token 3. 共享资源(如数据库)拒绝旧token的写入

Redis 分布式锁 vs ZooKeeper

graph TD
    subgraph Redis分布式锁
        R1[优点] --> R1a[性能极高 10万+ QPS]
        R1 --> R1b[实现简单]
        R1 --> R1c[客户端生态丰富]
        R2[缺点] --> R2a[主从切换可能丢失锁]
        R2 --> R2b[时钟依赖]
        R2 --> R2c[没有原生Fencing Token]
    end
    subgraph ZooKeeper分布式锁
        Z1[优点] --> Z1a[CP系统 一致性保证强]
        Z1 --> Z1b[临时有序节点 天然支持]
        Z1 --> Z1c[Watch机制 无需轮询]
        Z2[缺点] --> Z2a[性能较低 1万左右QPS]
        Z2 --> Z2b[运维复杂]
        Z2 --> Z2c[客户端实现复杂]
    end
维度 Redis ZooKeeper etcd
一致性模型 AP(最终一致性) CP(强一致性) CP(强一致性)
性能 极高 中等 中等偏高
锁释放机制 TTL过期 临时节点 + Session Lease + TTL
可重入 需要客户端实现 客户端支持 需要客户端实现
公平性 需要额外实现 有序节点天然支持 需要额外实现
生态 Redisson等成熟方案 Curator clientv3

实践建议

选择合适的方案

flowchart TD
    A[需要分布式锁] --> B{对一致性要求?}
    B -->|极高: 金融/交易| C[ZooKeeper / etcd]
    B -->|较高: 库存/订单| D{能否接受极低概率的锁失效?}
    D -->|能| E[Redisson + 幂等设计]
    D -->|不能| C
    B -->|一般: 缓存/限流| F[Redis SETNX 基础方案]
    E --> G[配合业务层Fencing]
    F --> H[注意设置合理的TTL]

最佳实践

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 1. 锁的粒度要尽可能细
// Bad: 锁整个库存服务
RLock lock = redisson.getLock("lock:inventory");

// Good: 只锁特定商品的库存
RLock lock = redisson.getLock("lock:inventory:sku:" + skuId);

// 2. 设置合理的等待时间和过期时间
boolean acquired = lock.tryLock(
5, // 最多等待5秒
30, // 锁自动过期时间30秒
TimeUnit.SECONDS
);

// 3. 在finally中释放锁
try {
if (acquired) {
// 业务逻辑
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}

// 4. 配合幂等性设计
// 即使锁出现极端情况下的失效,幂等操作也不会造成数据错误
// 例如: 使用数据库唯一约束防止重复创建订单
// INSERT INTO orders (order_no, ...) VALUES (?, ...)
// 唯一约束on order_no保证不会重复

// 5. 监控锁的使用情况
// 记录锁的获取时间、持有时间、等待时间
// 对持有时间过长的锁发出告警

锁超时时间的选择

1
2
3
4
5
6
7
8
TTL的设置原则:
- 太短: 业务未完成锁就过期了 -> 互斥性问题
- 太长: 持有锁的客户端宕机后,其他客户端等待时间过长

推荐方案:
1. 使用Redisson的WatchDog自动续期(不手动设置leaseTime)
2. 如果手动设置TTL,设为业务最长执行时间的3-5倍
3. 结合监控,如果锁持有时间经常接近TTL,说明需要调大

总结

Redis分布式锁的正确实现需要理解其局限性并做出合理的权衡:

  • SETNX + TTL + Lua解锁是基础方案,适用于对一致性要求不极高的场景
  • Redisson提供了可重入锁、公平锁、WatchDog等完善的功能,是Java生态的首选
  • Redlock算法通过多个独立Redis实例提升安全性,但Martin Kleppmann指出了其在GC暂停和时钟跳变场景下的理论缺陷
  • Fencing Token是从根本上解决分布式锁安全性问题的方案,但需要共享资源端的配合
  • 对一致性要求极高的场景,建议使用ZooKeeper或etcd等CP系统
  • 无论使用哪种分布式锁方案,都应该在业务层面做好幂等性设计作为最后的安全网

踩坑记录

优惠券核销服务出现重复核销 bug,损失了一批优惠券额度,复盘才发现历史遗留代码用的是 SETNX + EXPIRE 两步实现分布式锁。在高并发下,SETNX 成功后服务进程偶发重启,EXPIRE 没来得及执行,锁永久不释放,其他线程全部阻塞在等锁。

更严重的是另一个问题:锁超时设置 5s,但优惠券核销偶尔因下游支付系统慢会超过 5s,锁到期后自动释放,第二个线程拿到锁进来,导致同一张券被核销两次。

修复分三步:第一步用 SET key value NX EX seconds 原子命令替换两步操作;第二步加 Lua 脚本保证「只有持有者才能释放锁」(验证 value 是自己的 UUID);第三步引入 Redisson 的 watchdog 机制,持有锁期间每 10s 自动续期,彻底消除业务超时导致的锁提前释放。

实测结果

方案 压测重复核销率(1000次/轮) 锁永久不释放风险 业务超时安全性
SETNX + EXPIRE(两步) 0.3% 有(进程崩溃时) 不安全(超时自动丢锁)
SET NX EX + Lua 验证 0% 不安全(超时仍丢锁)
Redisson(含 watchdog) 0% 安全(自动续期)

bug 影响范围:约 3,000 张优惠券被重复核销,损失已通过补偿处理。

我的看法

Redis 分布式锁有一个经常被忽视的问题:即使用了 Redlock,在 Redis 主从切换的瞬间,新主节点可能没有原来的锁数据,导致两个客户端同时持锁。

对资金、库存、优惠券这种强一致性场景,我更倾向于用数据库的 SELECT FOR UPDATE 或乐观锁(version 字段),性能低一点,但可靠性远高于 Redis 锁。Redis 分布式锁更适合「排他但允许偶发失效」的场景,比如防重复提交、任务调度去重。不要把 Redis 锁用在不能出错的地方。

作者 · authorzt
发布 · date2024-10-14
篇幅 · length4.3k 字 · 11 min
许可 · licenseCC BY-SA 4.0
$ echo "comments" · 评论