什么是分布式锁

分布式锁,简单来说就是在分布式环境下(也称为云环境)不同实例之间抢一把锁。

image-20230321112908208

和普通的锁比起来,也就是抢锁的从线程 (协程)变成了实例。

分布式锁之所以难,基本上都和网络有关。

  • 请求可能会超时

怎么设计分布式锁

使用redis设计分布式锁可以很容易地想到使用SETNX,设置一个不可更改的键值对。基本过程分为加锁和解锁主要两个操作。在某些需要原子操作的情况下,可以配合lua脚本一起。

加解锁流程

  • 加锁通过SETNX,存入一个key,对应的值是一个随机的uuid,并且需要设置过期时间,最后返回一个Lock结构体。

  • 解锁需要先验证uuid,如果验证成功,则释放锁。

问题

  1. 为什么需要设置过期时间?

    如果没有过期时间,那么会出现「实例1」抢到锁之后,不小心奔溃了,终止了运行,后续的实例将永远无法获取到锁。

  2. 为什么需要设置随机uuid为key的值?

    本质上,我们就是需要一个唯一的值,当解锁的时候,需要验证该把锁是不是自己的,防止把其他实例加的锁解锁了。

  3. 什么情况下可能会出现错误?

    加锁解锁都可能遇到诸如网络错误,redis服务端错误等错误的情况,这种都会将erro返回。

    • 加锁可能会遇到该锁已经被他人持有,而无法加锁
    • 解锁可能会遇到1. 不是该锁的持有人 2. 锁过期了 3. 锁被人删了 等情况,而无法解锁
  4. 为什么要使用lua脚本来解锁?

    当我们发送查询操作时,返回结果后,再发送删除操作,这两个操作中间可能会出现延迟(因为网络条件),导致出现误删他人的锁的情况。

代码

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
package redisLock

import (
"context"
_ "embed"
"errors"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"time"
)

type Client struct {
client redis.Cmdable
}

// 创建一个redis
func NewClient(c redis.Cmdable) *Client {
return &Client{client: c}
}

var (
ErrLockFail = errors.New("加锁失败")
ErrLockNotHold = errors.New("未持有锁")
)

// 尝试加锁
func (c *Client) TryLock(ctx context.Context, key string, expiration time.Duration) (*Lock, error) {
val := uuid.New().String()
res, err := c.client.SetNX(ctx, key, val, expiration).Result()
// 连不上redis,加锁超时
if err != nil {
return nil, err
}
// 该锁已经被他人持有
if !res {
return nil, ErrLockFail
}
return newLock(c.client, key, val), nil
}

var (
//go:embed script/lock.lua
luaUnlock string
)

type Lock struct {
client redis.Cmdable
key string
val string
}

func newLock(client redis.Cmdable, key string, val string) *Lock {
return &Lock{
client: client,
key: key,
val: val,
}
}

// 解锁
func (l *Lock) UnLock(ctx context.Context) error {
res, err := l.client.Eval(ctx, luaUnlock, []string{l.key}, l.val).Result()
// 查询为空,表示没有该锁
if err == redis.Nil {
return ErrLockNotHold
}
// 连不上redis,加锁超时
if err != nil {
return err
}
// 查询失败,可能是 1. 不是该锁的持有人 2. 锁过期了
if res == 0 {
return ErrLockNotHold
}
return nil
}

优化分布式锁

手动续约

在某些情况下,用户端可能会执行超时,这时只能眼巴巴地看着锁过期,于是为了优化分布式锁,我们加上了一个续约方法!能让用户可以自行手动续约~

首先在Lock结构体添加一个过期时间属性:

1
2
3
4
5
6
type Lock struct {
Client redis.Cmdable
Key string
Val string
Expiration time.Duration
}

在加锁的时候,将过期时间传入返回的Lock中。

再构造如下的续约方法:

1
2
3
4
5
6
7
8
9
10
func (l *Lock) ManualRefresh(ctx context.Context) error {
res, err := l.Client.Eval(ctx, luaRefresh, []string{l.Key}, l.Val, l.Expiration.Milliseconds()).Int64()
if err != nil {
return err
}
if res != 1 {
return ErrLockNotHold
}
return nil
}

这个方法使用到了lua脚本:

1
2
3
4
5
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
return 0
end

这里有两个返回error的地方:

  • 第一个地方是意味着可能服务器出错了,或者超时了

  • 第二个地方意味着锁确实存在,但是却不是自己的锁,或者锁没有了

自动续约

手动续约的方法看起来很简单,但是在用户实现的时候会产生很多困难:

  1. 间隔多久调一次续约方法?

    这个还比较简单,可以约定一个时间,然后一直进行for循环(提示:用ticker

  2. 如果续约方法请求出错怎么办?

    • 可能是出现网络问题,或者Redis服务器奔溃,这个时候一般进行再次重试。但假如一直失败就会一直重试
    • 返回其他的error,将如何处理?
  3. 如果真的续约失败,用户将如何处理?

这时候我们可以自己封装一个AutoRefresh方法给用户:

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
func (l *Lock) AutoRefresh(interval time.Duration, timeout time.Duration) error {
ch := make(chan struct{}, 1)
defer close(ch) // 记得释放掉chan
ticker := time.NewTicker(interval)
for {
select {
case <-ch: // 自动超时重试
ctx, cancel := context.WithTimeout(context.Background(), timeout)
err := l.ManualRefresh(ctx)
cancel()
if err == context.DeadlineExceeded {
// 超时需要立刻重试
ch <- struct{}{}
continue
}
if err != nil {
return err
}
case <-ticker.C: // 按实际重试时间重试
ctx, cancel := context.WithTimeout(context.Background(), timeout) // 自动续约是脱离上下文的,所以需要传入自己的context
err := l.ManualRefresh(ctx) // 调用续约方法
cancel()
if err == context.DeadlineExceeded {
ch <- struct{}{} // 超时需要立刻重试
continue
}
if err != nil {
return err
}
case <-l.unlock: // 出现用户释放锁的信号则停止自动续约
return nil
}
}
}

自动续约的可控性非常差,因此我们并不是很鼓励用户使用这个API。甚至于如果用户想要万无一失地使用这个布式锁,那么必须要自己手动调用Refresh,并且小心处理各种error

此外,续约的间隔,应该综合考虑服务可用性。例如如果我们将分布式锁的过期时间设置为10秒,而且预期2秒内绝大慨率续约成功,那么就可以考虑将续约间隔设置为8秒。

加锁重试

加锁可能遇到偶发性的失败,在这种情况下,可以尝试重试。重试逻辑:

  1. 如果超时了,则触发重试逻辑,判断Key是否存在

  2. 若Key不存在,「直接加锁」

  3. 若Key存在,则检查一下key对应的值是不是我们刚才超时加锁请求的值

    • 如果是,直接返回,前一次「加锁成功」了(这里你可能需要考虑重置一下过期时间)

    • 如果不是,直接返回,「加锁失败」

这里有三种可能:

  • key不存在,直接加锁,返回成功
  • key存在,且value值相等,返回成功
  • key存在,但value值不相等,返回失败

总结

分布式锁是一个比较简单的工具,在Redis加持下响应本来就很快,但是不完全可靠,最重要的还是要优化自己的业务性能。

  • 使用分布式锁,你不能指望框架提供万无一失的方案,自己还是要处理各种异常情况(超时) 自己写分布式锁,要考虑过期时间,以及要不要续约。

  • 不管要对锁做什么操作,首先要确认这把锁是我们自己的锁

  • 大多数时候,与其选择复杂方案,不如直接让业务失败,可能成本还要低一点。(有时候直接赔钱,比你部署一大堆节点,招一大堆开发,搞好几个机房还要便宜,而且便宜很多)也就是选择恰好的方案, 而不是完美的方案。