


























在 Redis 的常见应用中,分布式锁是一个老生常谈的问题,本文主要讲讲怎么去实现一个分布式锁(最近真·写了不少 Lua 脚本)。
对于加锁操作,理论上应该是:
另外,1 或者 2 应该都是原子的。而 Redis 中针对这个操作只要一个 Set 就能搞定。
为此,我们先复习一下 SET:
SET key value [NX | XX] [GET] [EX seconds | PX milliseconds |
EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL]
其中 Options:
换言之,假设我们把锁作为一个 redis key,那么加锁只要使用 SET key value NX 就行了。
另外,为了避免程序错误导致锁没释放,还需要加入一个超时时间,比如我们预估一个请求 timeout = 800ms,那么锁的时间可以设计为 1s,进行一些容错 SET key value NX EX 1
因为加锁 = SET key,那么解锁自然是 DEL。但是问题是,不是谁都能释放锁的,只有那个拥有锁的对象可以释放锁。
因此对象匹配和释放锁需要是原子的:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
1
2type Lock struct {
3 redis *redis.Client
4 name string
5 timeout time.Duration
6 uuid string
7}
8
9type Options struct {
10 Uuid string
11}
12
13func (o *Options) GetUuid() string {
14 if o.Uuid == "" {
15 return ""
16 } else {
17 return o.Uuid
18 }
19}
20
21// KEYS[1] = lockName
22// ARGV[1] = uuid
23const lockLua = `
24if redis.call("GET", KEYS[1]) == ARGV[1] then
25 return redis.call("DEL", KEYS[1])else
26 return 0
27end
28`
29
30func NewLock(name string, redis *redis.Client, timeout time.Duration, options *Options) *Lock {
31 if name == "" {
32 panic("lock name is empty")
33 } return &Lock{
34 redis: redis,
35 name: name,
36 timeout: timeout,
37 uuid: options.GetUuid(),
38 }}
39
40func (l *Lock) Uuid() string {
41 return l.uuid
42}
43
44func (l *Lock) Lock(ctx context.Context, options *Options) (bool, error) {
45 uuid := l.uuid
46 if options.GetUuid() != "" {
47 uuid = options.GetUuid()
48 } res, err := l.redis.SetNX(ctx, l.name, uuid, l.timeout).Result()
49 if err != nil {
50 fmt.Printf("SetNX failed: %v\n", err)
51 return false, err
52 }
53 return res, nil
54}
55
56func (l *Lock) Unlock(ctx context.Context, options *Options) (bool, error) {
57 uuid := l.uuid
58 if options.GetUuid() != "" {
59 uuid = options.GetUuid()
60 } result, err := l.redis.Eval(ctx, lockLua, []string{l.name}, uuid).Result()
61 if err != nil {
62 return false, err
63 }
64 return result.(int64) == 1, nil
65}
66
优点很明显:
当然,也不全是优点,比如:
要解决这个问题,Redis 提供了一个 Redlock 算法来实现分布式锁。
Redlock 的核心理念和投票类似,也就是,既然一个 master 可能会存在问题,那我多加几个 master,不就不会出现问题了吗?
假设我们准备了五个 Redis master 节点,那么客户端在获取锁时会往五个实例申请持有锁。这里需要注意的是:
因此我们记录拿第一个锁的开始时间,和最后一个锁的结束时间来判断锁的持有情况:
如果最终只有少数持有了锁,我们还需要释放资源。
对于释放资源来说,可能存在「其实我成功了,但是网络失败」的情况,因此不应当只针对成功的节点发释放请求,而应该广播给每一个 master。

如果失败时会先尝试重试,避免同时有多个客户端都在申请获取资源产生脑裂问题,最终没有人可以持有锁,也因此客户端的总体响应速度越快,出现这种情况的概率就越小。
要保证崩溃恢复,我们必然会考虑将数据持久化,如果不进行持久化,那么节点重启时就可能会遇到当时我们单 Redis 中遇到的问题。
如果持久化了,那么问题将会改善很多,但改善并不代表着解决,如果实例崩溃后一直不可用,那只是参与投票的人少了,似乎没什么问题。
但如果实例崩溃后快速的恢复了,而此时 AOF 的数据没有来得及刷到磁盘中,就仍然会遇到相同的问题。解决方案就是将恢复时间拉长,这个恢复重启时间需要大于锁的有效时间,这样重启时所有的锁都到期了,就不会存在问题了。
上一步我们提到要考虑过期时间,但即使时钟是近似同步的,可能每个 master 中的 time 也会存在一定误差,因此我们可以设置一个漂移量来修复这个问题。
如果锁本身有效期较短,且得到时已经快到期了,可以尝试发送一个指令来进行续期。在 go 的 redlock 包中已有实现:
1var touchWithSetNXScript = redis.NewScript(1, `
2 if redis.call("GET", KEYS[1]) == ARGV[1] then
3 return redis.call("PEXPIRE", KEYS[1], ARGV[2])
4 elseif redis.call("SET", KEYS[1], ARGV[1], "PX", ARGV[2], "NX") then
5 return 1
6 else
7 return 0
8 end
9`)
10
11var touchScript = redis.NewScript(1, `
12 if redis.call("GET", KEYS[1]) == ARGV[1] then
13 return redis.call("PEXPIRE", KEYS[1], ARGV[2])
14 else
15 return 0
16 end
17`)
18
RedLock 算法相比单机来说更加可靠,但是实际应用中仍然会受到一些挑战:
下图是其中一个问题出现的例子:

在例子中提到了 GC 导致的 pause,当然实际上,可能也会有其他原因导致类似的效果,比如 CPU 资源竞争,网络延迟。此时光看时间判断就没什么用了。
要解决这个问题,可以在存储侧引入版本号校对,有点类似于一些业务的更新策略,如果发现是老的版本,则不允许更新。

但问题是,Redlock 本身并没有这样的机制去保证这一设计,我们也很难保证计数器的一致性。而如果真的在业务侧计入了版本,那么相当于有序写入,似乎和「互斥锁」也没多大关系。
这也成为了一个 Redlock 高不成低不就的漏洞。因此也有文章抨击这一算法没什么卵用。
综上来看,选择怎么样的锁也是一个问题,在现实程序中,我们经常看到单 master 的锁实现,因为他相比 RedLock 来说更加轻量,如果并不需要强一致性和可靠性,允许少量误差的前提下,用它可能更方便。
除了 Redis 以外,我们也可以用 etcd 或者 zookeeper 来实现分布式锁,这个我们下次再研究。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。