分布式锁是分布式系统中解决资源竞争的重要工具,Redis 因其高性能和简单性,常被用来实现分布式锁。然而,一个常见问题是:如果锁的持有者在完成业务逻辑前,锁因为过期时间(TTL)到了而被自动释放,会发生什么?如何解决这个问题?本文将深入探讨这个问题,结合实际场景和 Go 代码示例,教你如何设计健壮的分布式锁。
什么是 Redis 分布式锁?
Redis 分布式锁利用 Redis 的 SETNX
(Set if Not Exists)命令实现互斥锁。基本流程是:
- 客户端尝试通过
SETNX key value
设置一个键,如果键不存在则设置成功,表示获取锁。
- 为键设置过期时间(
EXPIRE
或 SET
带 EX
参数),防止死锁。
- 执行业务逻辑。
- 完成后通过
DEL key
释放锁。
用一个生活化的例子解释:
想象你在公司食堂排队打饭,只有一个打饭窗口(共享资源)。你到窗口时,先挂上一个“占用中”的牌子(SETNX
),并设定 5 分钟后牌子自动失效(EXPIRE
)。你打完饭后摘下牌子(DEL
)。但如果打饭时间超过 5 分钟,牌子自动消失,其他人可能误以为窗口空闲,导致混乱。这就是锁过期的问题。
锁过期问题的场景
假设你在电商系统中用 Redis 分布式锁控制库存扣减:
- 客户端 A 获取锁,键为
lock:order:123
,值为唯一标识(如客户端 ID 或随机 UUID),TTL 为 10 秒。
- 客户端 A 开始扣减库存,但由于网络延迟、数据库慢查询或复杂计算,耗时超过 10 秒。
- 锁自动过期,键被删除。
- 客户端 B 获取同一把锁(
lock:order:123
),也开始扣减库存。
- 客户端 A 和 B 同时操作库存,可能导致超卖或数据不一致。
这个问题的核心是:锁的持有时间超出了预设的 TTL,导致锁失效,破坏了互斥性。
为什么会出现锁过期?
- TTL 设置不合理:过期时间太短,无法覆盖业务逻辑的执行时间。
- 业务逻辑不可预测:如网络抖动、第三方服务延迟、垃圾回收(GC)暂停等。
- 未检查锁有效性:释放锁时未验证锁是否仍由自己持有,可能误删其他客户端的锁。
- 单点 Redis 的可靠性问题:Redis 主节点故障或数据未同步,可能导致锁失效。
如何解决锁过期问题?
以下是解决 Redis 分布式锁过期问题的几种方法,从基础到高级,逐步讲解,并提供 Go 代码示例。
1. 合理设置 TTL
思路:根据业务逻辑的预计耗时,设置一个足够长的 TTL,同时保证不会过长以避免死锁。
场景举例:
在库存扣减场景中,假设扣减逻辑平均耗时 2 秒,最大可能 5 秒(考虑网络延迟等),可以设置 TTL 为 10 秒,作为安全缓冲。
Go 代码示例:
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
|
package main
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
"time"
)
const (
LockKey = "lock:order:123"
LockValue = "client-a-uuid"
TTL = 10 * time.Second
)
func acquireLock(client *redis.Client, ctx context.Context) bool {
// 使用 SET NX EX 原子操作设置锁
ok, err := client.SetNX(ctx, LockKey, LockValue, TTL).Result()
if err != nil {
fmt.Printf("获取锁失败: %v\n", err)
return false
}
return ok
}
func releaseLock(client *redis.Client, ctx context.Context) error {
// 简单释放锁(后续会优化)
return client.Del(ctx, LockKey).Err()
}
func main() {
ctx := context.Background()
client := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
// 尝试获取锁
if acquireLock(client, ctx) {
fmt.Println("成功获取锁")
// 模拟业务逻辑
time.Sleep(2 * time.Second)
fmt.Println("业务逻辑执行完成")
// 释放锁
if err := releaseLock(client, ctx); err != nil {
fmt.Printf("释放锁失败: %v\n", err)
}
} else {
fmt.Println("获取锁失败")
}
}
|
局限性:
- 无法准确预测业务逻辑耗时,TTL 可能仍不足。
- 如果业务逻辑异常卡死,锁可能长时间占用。
2. 锁续期(Watchdog 机制)
思路:在锁持有期间,启动一个后台 goroutine(看门狗),定期检查锁是否仍由自己持有,并延长 TTL,直到业务逻辑完成。
场景举例:
继续用食堂打饭的例子。你挂上“占用中”牌子后,派一个助手每隔 3 分钟检查牌子是否还在,如果还在就再延长 5 分钟,直到你打完饭。
实现步骤:
- 获取锁时,记录锁的唯一标识(如 UUID)。
- 启动一个 goroutine,每隔 TTL 的 1/3 时间检查锁是否存在且值未变。
- 如果锁仍有效,使用
EXPIRE
延长 TTL。
- 业务逻辑完成后,停止续期并释放锁。
Go 代码示例:
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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
|
package main
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
"time"
)
const (
LockKey = "lock:order:123"
LockValue = "client-a-uuid"
TTL = 10 * time.Second
)
type Lock struct {
client *redis.Client
ctx context.Context
key string
value string
ttl time.Duration
stopWatch chan struct{}
}
// acquireLock 获取锁并启动续期
func acquireLock(client *redis.Client, ctx context.Context, key, value string, ttl time.Duration) (*Lock, bool) {
ok, err := client.SetNX(ctx, key, value, ttl).Result()
if err != nil || !ok {
return nil, false
}
lock := &Lock{
client: client,
ctx: ctx,
key: key,
value: value,
ttl: ttl,
stopWatch: make(chan struct{}),
}
// 启动续期 goroutine
go lock.watchdog()
return lock, true
}
// watchdog 续期锁
func (l *Lock) watchdog() {
ticker := time.NewTicker(l.ttl / 3) // 每 1/3 TTL 检查一次
defer ticker.Stop()
for {
select {
case <-l.stopWatch:
return
case <-ticker.C:
// 检查锁是否仍由自己持有
val, err := l.client.Get(l.ctx, l.key).Result()
if err == nil && val == l.value {
// 延长 TTL
l.client.Expire(l.ctx, l.key, l.ttl)
} else {
return // 锁已失效,停止续期
}
}
}
}
// releaseLock 释放锁
func (l *Lock) releaseLock() error {
// 停止续期
close(l.stopWatch)
// 检查锁是否仍由自己持有
val, err := l.client.Get(l.ctx, l.key).Result()
if err == nil && val == l.value {
return l.client.Del(l.ctx, l.key).Err()
}
return nil // 锁已失效或被他人持有
}
func main() {
ctx := context.Background()
client := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
// 尝试获取锁
lock, ok := acquireLock(client, ctx, LockKey, LockValue, TTL)
if ok {
fmt.Println("成功获取锁")
// 模拟较长的业务逻辑
time.Sleep(15 * time.Second)
fmt.Println("业务逻辑执行完成")
// 释放锁
if err := lock.releaseLock(); err != nil {
fmt.Printf("释放锁失败: %v\n", err)
}
} else {
fmt.Println("获取锁失败")
}
}
|
优点:
- 动态延长锁的持有时间,适应不可预测的业务耗时。
- 自动停止续期,防止无意义的资源占用。
局限性:
- 增加了系统复杂性,需要管理续期 goroutine。
- 如果客户端崩溃,续期 goroutine 停止,可能仍导致锁过期。
3. 安全的锁释放(避免误删)
思路:释放锁时,验证锁的值是否仍为自己持有的值,防止误删其他客户端的锁。
场景举例:
在食堂打饭时,你摘牌子前先确认牌子上的名字是否是你的。如果不是,说明有人已经接管了窗口,你就不摘牌子。
实现步骤:
- 获取锁时,设置一个唯一值(如 UUID)。
- 释放锁时,使用 Lua 脚本原子性地检查值并删除键。
Go 代码示例:
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
|
package main
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
"time"
)
const (
LockKey = "lock:order:123"
LockValue = "client-a-uuid"
TTL = 10 * time.Second
)
// Lua 脚本:安全释放锁
const releaseScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`
func acquireLock(client *redis.Client, ctx context.Context, key, value string, ttl time.Duration) bool {
ok, err := client.SetNX(ctx, key, value, ttl).Result()
if err != nil {
fmt.Printf("获取锁失败: %v\n", err)
return false
}
return ok
}
func releaseLock(client *redis.Client, ctx context.Context, key, value string) error {
// 执行 Lua 脚本
result, err := client.Eval(ctx, releaseScript, []string{key}, value).Int()
if err != nil {
return fmt.Errorf("释放锁失败: %v", err)
}
if result == 0 {
return fmt.Errorf("锁已失效或被他人持有")
}
return nil
}
func main() {
ctx := context.Background()
client := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
// 尝试获取锁
if acquireLock(client, ctx, LockKey, LockValue, TTL) {
fmt.Println("成功获取锁")
// 模拟业务逻辑
time.Sleep(2 * time.Second )
fmt.Println("业务逻辑执行完成")
// 安全释放锁
if err := releaseLock(client, ctx, LockKey, LockValue); err != nil {
fmt.Printf("释放锁失败: %v\n", err)
}
} else {
fmt.Println("获取锁失败")
}
}
|
优点:
- 防止误删其他客户端的锁,提高安全性。
- Lua 脚本保证操作原子性。
局限性:
4. 使用 Redlock 算法
思路:在多节点 Redis 集群中实现分布式锁,通过多数节点(N/2+1)达成共识来获取锁,提高可靠性和容错性。
场景举例:
你在多个食堂窗口(Redis 节点)挂牌子,只有当超过半数的窗口接受你的牌子时,你才能打饭。即使某个窗口的牌子提前失效,只要多数窗口有效,锁依然安全。
Redlock 算法步骤:
- 客户端尝试在 N 个独立 Redis 节点上获取锁(
SETNX
)。
- 如果在maker 多数节点(N/2+1)上获取成功,且总耗时小于锁的 TTL,锁获取成功。
- 执行业务逻辑。
- 释放锁时,向所有节点发送
DEL
命令。
Go 代码示例(简化版,假设 3 个 Redis 节点):
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
74
75
76
77
78
79
80
81
82
83
84
85
86
87
|
package main
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
"time"
)
const (
LockKey = "lock:order:123"
LockValue = "client-a-uuid"
TTL = 10 * time.Second
)
type Redlock struct {
clients []*redis.Client
ctx context.Context
key string
value string
ttl time.Duration
locked int // 成功锁定的节点数
}
func NewRedlock(addrs []string, ctx context.Context, key, value string, ttl time.Duration) *Redlock {
clients := make([]*redis.Client, len(addrs))
for i, addr := range addrs {
clients[i] = redis.NewClient(&redis.Options{Addr: addr})
}
return &Redlock{
clients: clients,
ctx: ctx,
key: key,
value: value,
ttl: ttl,
}
}
func (r *Redlock) Acquire() bool {
start := time.Now()
r.locked = 0
// 尝试在所有节点上获取锁
for _, client := range r.clients {
ok, err := client.SetNX(r.ctx, r.key, r.value, r.ttl).Result()
if err == nil && ok {
r.locked++
}
}
// 检查是否在多数节点上获取锁,且未超时
elapsed := time.Since(start)
if r.locked >= (len(r.clients)/2+1) && elapsed < r.ttl {
return true
}
// 获取失败,释放已获取的锁
r.Release()
return false
}
func (r *Redlock) Release() {
for _, client := range r.clients {
val, err := client.Get(r.ctx, r.key).Result()
if err == nil && val == r.value {
client.Del(r.ctx, r.key)
}
}
}
func main() {
ctx := context.Background()
addrs := []string{"localhost:6379", "localhost:6380", "localhost:6381"}
redlock := NewRedlock(addrs, ctx, LockKey, LockValue, TTL)
// 尝试获取锁
if redlock.Acquire() {
fmt.Println("成功获取 Redlock")
// 模拟业务逻辑
time.Sleep(2 * time.Second)
fmt.Println("业务逻辑执行完成")
// 释放锁
redlock.Release()
} else {
fmt.Println("获取 Redlock 失败")
}
}
|
优点:
- 提高锁的可靠性,单点故障不影响整体锁。
- 适合高可用场景。
局限性:
- 实现复杂,需维护多个 Redis 节点。
- 网络延迟可能导致锁获取失败。
- 时钟同步要求较高。
5. 结合业务补偿机制
思路:在锁过期可能导致数据不一致时,通过业务层的补偿机制(如日志、事务回滚或最终一致性)修复问题。
场景举例:
在库存扣减中,如果锁过期导致超卖,可以记录每次操作的日志,事后通过补偿任务检查库存是否正确,并调整。
实现步骤:
- 每次操作记录操作日志(存入 Redis 或数据库)。
- 定期运行补偿任务,检查日志与实际数据的一致性。
- 如果发现超卖,触发回滚或通知人工处理。
Go 代码示例(记录操作日志):
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
|
package main
import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
"time"
)
const (
LockKey = "lock:order:123"
LockValue = "client-a-uuid"
TTL = 10 * time.Second
LogKey = "log:order:123"
)
func acquireLock(client *redis.Client, ctx context.Context, key, value string, ttl time.Duration) bool {
ok, err := client.SetNX(ctx, key, value, ttl).Result()
if err != nil {
fmt.Printf("获取锁失败: %v\n", err)
return false
}
return ok
}
func logOperation(client *redis.Client, ctx context.Context, operation string) error {
// 记录操作日志
return client.RPush(ctx, LogKey, fmt.Sprintf("%s at %s", operation, time.Now())).Err()
}
func releaseLock(client *redis.Client, ctx context.Context, key, value string) error {
val, err := client.Get(ctx, key).Result()
if err == nil && val == value {
return client.Del(ctx, key).Err()
}
return nil
}
func main() {
ctx := context.Background()
client := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
// 尝试获取锁
if acquireLock(client, ctx, LockKey, LockValue, TTL) {
fmt.Println("成功获取锁")
// 记录操作
if err := logOperation(client, ctx, "扣减库存"); err != nil {
fmt.Printf("记录日志失败: %v\n", err)
}
// 模拟业务逻辑
time.Sleep(2 * time.Second)
fmt.Println("业务逻辑执行完成")
// 释放锁
if err := releaseLock(client, ctx, LockKey, LockValue); err != nil {
fmt.Printf("释放锁失败: %v\n", err)
}
} else {
fmt.Println("获取锁失败")
}
}
|
优点:
- 提供事后补救机制,适合复杂业务场景。
- 不依赖锁的绝对可靠性。
局限性:
- 增加系统复杂性,补偿逻辑需精心设计。
- 可能引入延迟,需权衡实时性。
最佳实践总结
为了最大程度避免锁过期问题,建议结合以下实践:
- 合理 TTL:根据业务耗时设置 TTL,留有余量。
- 锁续期:使用看门狗机制动态延长锁。
- 安全释放:用 Lua 脚本确保只释放自己的锁。
- Redlock:在高可用场景下使用多节点锁。
- 业务补偿:通过日志和补偿机制修复不一致。
- 监控和告警:监控锁的获取失败率和过期情况,及时发现问题。
注意事项
- 避免单点依赖:单点 Redis 故障可能导致锁失效,建议使用 Redis 集群或 Redlock。
- 客户端健壮性:客户端崩溃可能导致锁未释放,需确保续期机制可靠。
- 性能开销:续期和 Redlock 增加网络开销,需权衡性能和可靠性。
- 时钟同步:Redlock 对节点时钟一致性要求较高,需确保服务器时间同步。
总结
Redis 分布式锁因其简单高效被广泛使用,但锁过期问题可能导致严重的并发冲突。通过合理设置 TTL、实现锁续期、安全释放锁、使用 Redlock 算法以及结合业务补偿机制,可以有效解决锁过期问题。本文
评论 0