我们知道reids 可以通过 SET lock_key unique_value NX PX 10000 来做分布式事务锁,但其中还有些细节值得思考。

解锁

解锁就是删key,为了防止解错了锁,删掉了不是自己持有的,就需要对 key 的值 unique_value 进行判断,得是『我』自己才行。

解锁是有两个操作,这时就需要 Lua 脚本来保证解锁的原子性,因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。

1
2
3
4
5
6
// 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end

go demo

关于 redis lua 释放锁的操作,可以看下这个 go 版本的 demo:

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

import (
"context"
"fmt"
"github.com/go-redis/redis/v8"
"time"
)

func main() {
// 连接Redis
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379", // Redis地址
Password: "", // 密码
DB: 0, // 使用默认DB
})

// 生成一个唯一的标识符,用作锁的值
lockID := "mylock"

// 获取锁
lockAcquired, err := acquireLock(rdb, lockID, time.Second*10)
if err != nil {
fmt.Println("Error acquiring lock:", err)
return
}

if lockAcquired {
defer releaseLock(rdb, lockID)
fmt.Println("Lock acquired successfully")

// 在这里执行需要加锁的代码

} else {
fmt.Println("Failed to acquire lock")
}
}

// 获取分布式锁
func acquireLock(rdb *redis.Client, lockID string, expiration time.Duration) (bool, error) {
ctx := context.Background()
// SETNX命令尝试将键设置为给定值
// 如果键已经存在,SETNX返回false,否则返回true
lockAcquired, err := rdb.SetNX(ctx, lockID, "locked", expiration).Result()
if err != nil {
return false, err
}
return lockAcquired, nil
}

// 释放分布式锁
func releaseLock(rdb *redis.Client, lockID string) error {
ctx := context.Background()
// 释放锁的Lua脚本
script := `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`
// 执行Lua脚本
_, err := rdb.Eval(ctx, script, []string{lockID}, []string{"locked"}).Result()
if err != nil {
return err
}
return nil
}

过期时间

过期时间的设置也有点学问,太长了还好,仅仅影响性能。太短了,锁自己就释放了,问题就大了。

如果一定要纠结这个过期时间,有种方案:起一个守护进程来对这个 key 续约,快到期的时候,如果没用完,就续约。程序用完之后杀死这个守护进程(停止续约)

主从延迟

如果主从延迟了,其他客户端读到的从库没更新最新数据,其实没问题,因为主节点没挂,set 操作都要走主节点的。

如果主节点挂了呢?

主节点挂了,但是挂之前 A 已经拿到锁,这时候新主被选举出来,把锁给了 B,这时候 A/B 都持有这个锁。

怎么办?

redis 提供了一种分布式锁算法『红锁』 Redlock

它是基于多个 Redis 节点的分布式锁,即使有节点发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。官方推荐是至少部署 5 个 Redis 节点,而且都是主节点,它们之间没有任何关系,都是一个个孤立的节点。

Redlock 算法的基本思路,是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。

这样一来,即使有某个 Redis 节点发生故障,因为锁的数据在其他节点上也有保存,所以客户端仍然可以正常地进行锁操作,锁的数据也不会丢失。

Redlock 算法加锁三个过程:

第一步是,客户端获取当前时间(t1)。
第二步是,客户端按顺序依次向 N 个 Redis 节点执行加锁操作:
加锁操作使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。
如果某个 Redis 节点发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,我们需要给「加锁操作」设置一个超时时间(不是对「锁」设置超时时间,而是对「加锁操作」设置超时时间),加锁操作的超时时间需要远远地小于锁的过期时间,一般也就是设置为几十毫秒。
第三步是,一旦客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁,就再次获取当前时间(t2),然后计算计算整个加锁过程的总耗时(t2-t1)。如果 t2-t1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败。
可以看到,加锁成功要同时满足两个条件(简述:如果有超过半数的 Redis 节点成功的获取到了锁,并且总耗时没有超过锁的有效时间,那么就是加锁成功):

条件一:客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁;
条件二:客户端从大多数节点获取锁的总耗时(t2-t1)小于锁设置的过期时间。
加锁成功后,客户端需要重新计算这把锁的有效时间,计算的结果是「锁最初设置的过期时间」减去「客户端从大多数节点获取锁的总耗时(t2-t1)」。如果计算的结果已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。

加锁失败后,客户端向所有 Redis 节点发起释放锁的操作,释放锁的操作和在单节点上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。