Redis 分布式锁未完成逻辑前过期怎么办?

分布式锁是分布式系统中解决资源竞争的重要工具,Redis 因其高性能和简单性,常被用来实现分布式锁。然而,一个常见问题是:如果锁的持有者在完成业务逻辑前,锁因为过期时间(TTL)到了而被自动释放,会发生什么?如何解决这个问题?本文将深入探讨这个问题,结合实际场景和 Go 代码示例,教你如何设计健壮的分布式锁。

什么是 Redis 分布式锁?

Redis 分布式锁利用 Redis 的 SETNX(Set if Not Exists)命令实现互斥锁。基本流程是:

  1. 客户端尝试通过 SETNX key value 设置一个键,如果键不存在则设置成功,表示获取锁。
  2. 为键设置过期时间(EXPIRESETEX 参数),防止死锁。
  3. 执行业务逻辑。
  4. 完成后通过 DEL key 释放锁。

用一个生活化的例子解释:
想象你在公司食堂排队打饭,只有一个打饭窗口(共享资源)。你到窗口时,先挂上一个“占用中”的牌子(SETNX),并设定 5 分钟后牌子自动失效(EXPIRE)。你打完饭后摘下牌子(DEL)。但如果打饭时间超过 5 分钟,牌子自动消失,其他人可能误以为窗口空闲,导致混乱。这就是锁过期的问题。

锁过期问题的场景

假设你在电商系统中用 Redis 分布式锁控制库存扣减:

  1. 客户端 A 获取锁,键为 lock:order:123,值为唯一标识(如客户端 ID 或随机 UUID),TTL 为 10 秒。
  2. 客户端 A 开始扣减库存,但由于网络延迟、数据库慢查询或复杂计算,耗时超过 10 秒。
  3. 锁自动过期,键被删除。
  4. 客户端 B 获取同一把锁(lock:order:123),也开始扣减库存。
  5. 客户端 A 和 B 同时操作库存,可能导致超卖或数据不一致。

这个问题的核心是:锁的持有时间超出了预设的 TTL,导致锁失效,破坏了互斥性。

为什么会出现锁过期?

  1. TTL 设置不合理:过期时间太短,无法覆盖业务逻辑的执行时间。
  2. 业务逻辑不可预测:如网络抖动、第三方服务延迟、垃圾回收(GC)暂停等。
  3. 未检查锁有效性:释放锁时未验证锁是否仍由自己持有,可能误删其他客户端的锁。
  4. 单点 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 分钟,直到你打完饭。

实现步骤

  1. 获取锁时,记录锁的唯一标识(如 UUID)。
  2. 启动一个 goroutine,每隔 TTL 的 1/3 时间检查锁是否存在且值未变。
  3. 如果锁仍有效,使用 EXPIRE 延长 TTL。
  4. 业务逻辑完成后,停止续期并释放锁。

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. 安全的锁释放(避免误删)

思路:释放锁时,验证锁的值是否仍为自己持有的值,防止误删其他客户端的锁。

场景举例
在食堂打饭时,你摘牌子前先确认牌子上的名字是否是你的。如果不是,说明有人已经接管了窗口,你就不摘牌子。

实现步骤

  1. 获取锁时,设置一个唯一值(如 UUID)。
  2. 释放锁时,使用 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 算法步骤

  1. 客户端尝试在 N 个独立 Redis 节点上获取锁(SETNX)。
  2. 如果在maker 多数节点(N/2+1)上获取成功,且总耗时小于锁的 TTL,锁获取成功。
  3. 执行业务逻辑。
  4. 释放锁时,向所有节点发送 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. 结合业务补偿机制

思路:在锁过期可能导致数据不一致时,通过业务层的补偿机制(如日志、事务回滚或最终一致性)修复问题。

场景举例
在库存扣减中,如果锁过期导致超卖,可以记录每次操作的日志,事后通过补偿任务检查库存是否正确,并调整。

实现步骤

  1. 每次操作记录操作日志(存入 Redis 或数据库)。
  2. 定期运行补偿任务,检查日志与实际数据的一致性。
  3. 如果发现超卖,触发回滚或通知人工处理。

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("获取锁失败")
	}
}

优点

  • 提供事后补救机制,适合复杂业务场景。
  • 不依赖锁的绝对可靠性。

局限性

  • 增加系统复杂性,补偿逻辑需精心设计。
  • 可能引入延迟,需权衡实时性。

最佳实践总结

为了最大程度避免锁过期问题,建议结合以下实践:

  1. 合理 TTL:根据业务耗时设置 TTL,留有余量。
  2. 锁续期:使用看门狗机制动态延长锁。
  3. 安全释放:用 Lua 脚本确保只释放自己的锁。
  4. Redlock:在高可用场景下使用多节点锁。
  5. 业务补偿:通过日志和补偿机制修复不一致。
  6. 监控和告警:监控锁的获取失败率和过期情况,及时发现问题。

注意事项

  1. 避免单点依赖:单点 Redis 故障可能导致锁失效,建议使用 Redis 集群或 Redlock。
  2. 客户端健壮性:客户端崩溃可能导致锁未释放,需确保续期机制可靠。
  3. 性能开销:续期和 Redlock 增加网络开销,需权衡性能和可靠性。
  4. 时钟同步:Redlock 对节点时钟一致性要求较高,需确保服务器时间同步。

总结

Redis 分布式锁因其简单高效被广泛使用,但锁过期问题可能导致严重的并发冲突。通过合理设置 TTL、实现锁续期、安全释放锁、使用 Redlock 算法以及结合业务补偿机制,可以有效解决锁过期问题。本文

评论 0