一、什么是分布式锁
- 非分布式锁,mutex,就是一把锁,非分布式锁强调的是在进程内,多个goroutine去抢这把锁。
- 分布式锁是指在一个分布式的环境下,锁是一台机器上,实例1是在一台机器上,实例2又是在另一台机器上,所以归根到底,分布式锁就是在分布式环境下不同实例之间抢一把锁。
- 实例是通过网络通信去抢分布式锁的。大多数情况下,不同实例是部署在同一个机房的不同物理机器上,所以是通过网络通信来抢锁。而这个锁在本次实验中是用redis来放的,所以它们很可能不是在同一个机房,故是需要通过网络通信的。
- 多个线程在一个进程内抢锁,只有两种情况,一种是抢到,另一种是没抢到;但是在分布式环境下,有三种情况,除了以上两种,还有就是不知道有没有抢到,因为网络通信可能超时了,你不知道你的请求有没有发给锁,你也不知道万一你的请求发给了锁,锁的响应有没有发送出来,这些你都不知道,你只知道网络超时了。
所以分布式锁之所以难,基本上都和网络有关。不管是写分布式锁,还是用别人写的分布式锁,都会很烦,就是因为大部分精力都会用在处理跟网络通信有关的问题上。
二、用Redis来实现一个分布式锁
-
我们思考一下:在Redis里面抢锁,或者获得一把锁,指代的是什么东西呢?
Redis主要就是存储k-v数据,所以用Redis实现一个分布式锁的起点,就是利用setnx命令,确保可以排他地设置一个键值对。 -
本质上Redis的分布式锁就是一个键值对。
-
为什么要强调排他性?因为你的分布式锁强调的是只能有一个实例持有这把锁。
-
setnx如果返回1,则代表成功了,如果返回0,有三种可能:有可能别人把这把锁抢走了,也有可能你的请求没发送过去,也有可能请求发送过去了,也拿到了,但响应没发回来,所以在分布式环境下,你必然是要面对一些超时的问题。
-
加锁就是把key-value给设置好,解锁就是把key-value给删掉。
第一版
package redis_lock
import (
"context"
"errors"
"github.com/redis/go-redis/v9"
"time"
)
type Client struct {
client redis.Cmdable
}
// 定义完Client后,我们要给Client一个初始化函数
func NewClient(c redis.Cmdable) *Client {
return &Client{
client: c,
}
}
func (c *Client) Lock(ctx context.Context, key string) error {
res, err := c.client.SetNX(ctx, key, "123", time.Minute).Result()
if err != nil { // 很可能是网络通信出问题,导致加锁没成功,所以会报错
return err
}
if !res { // 锁被某个实例占据了
return errors.New("加锁失败")
}
return nil // 如果没出错,报错error就是空
}
func (c *Client) Unlock(ctx context.Context, key string) error {
res, err := c.client.Del(ctx, key).Result() //这句代码并不是真正将key删掉,而是获取删除key会得到什么样的结果,会得到什么样的报错
if err != nil {
return err
}
if res != 1 {
return errors.New("解锁失败")
}
return nil
}
Client结构体
- 为什么设置这个Client,因为我们希望用户是去操作这个Client,通过这个客户端来操作,在这个客户端上去创建一把新的锁
- client redis.Client也可以
为什么要用Cmdable,那么如果不用Cmdable,我们可以有什么选择?
Client代表的是一个比较简单的客户端,相比之下,ClusterClient就显得比较复杂了,它主要是针对Redis集群而言的。 - 你作为分布式锁的设计者,用户可能用什么?可能用单个Client节点,也可能用集群,这个作为设计者是无法控制的,但我们可以找到一个公共的接口,恰好Redis里面是有这个接口的,是Cmdable。而且考虑接口还有什么好处?你可以注入不同的实现,比如在单元测试的时候,我们可以为其注入
- 一个Lock的实现。
NewClient函数
- 返回值的类型一般写成(*Client,error)
- 注意return返回结构体的引用的时候,里面初始化每个参数都要加上逗号
Lock函数
- 到了这一步,我们就需要想一下,这个加锁的过程,应该接收一个怎么样的参数呢
首先,按照go的惯例,先给它来个context
其次,你是用什么来代表这个锁,也就是key,排他性地设置键值对,你关心里面那个值吗?不关心!只需要把key设置好就行,value在这里并不重要,所以参数就设置key即可
需不需要给它一个过期时间呢?因为加锁后,有解锁操作就行了呀 - Lock函数返回值是个error类型,也就是返回报错信息,所以Lock这个函数并不是真正进行加锁,而是获得加锁后会出现的报错信息
- SetNX函数中,context和key肯定是要传的,value的值既然不在意就随便设置了,过期时间的话,绝大多数业务一分钟过期时间都是够的
- Result函数的两个返回值分别是bool型和error型
Unlock函数
- Del函数就是context,和一大堆的键(key)
Del(ctx context.Context, keys ...string) *IntCmd
- 执行if res != 1 { }这个判断语句的情况:
1、lock锁了之后,unlock解锁,经过前面的删除锁,判断err信息为空,也就是可以删除,但是到了这里来,却显示了res为0,表示锁是被占用着 说明上个实例占用锁的时候超时,过期时间已过,但还占用着锁。(删除了key,就相当于删除了锁,数据库中key当然是可以删除的。)
2、直接登录redis,直接把key给删了,也就是有可能被人删了 - 过期也可以相当于不再生效了,因为别人也可以拿到锁了
被人删了也是一样,别人setnx的时候又可以拿到锁
所以这个if res != 1 { } 分支可以没有必要,将此段删除,可以算是一个不错的优化,并且此函数第一句话res就用_下划线代替。 这样想的话就大错特错,错的很离谱。
第二版:
package redis_lock
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"time"
)
type Client struct {
client redis.Cmdable
}
// 定义完Client后,我们要给Client一个初始化函数
func NewClient(c redis.Cmdable) *Client {
return &Client{
client: c,
}
}
func (c *Client) Lock(ctx context.Context, key string,
expiration time.Duration) (*Lock, error) {
value := uuid.New().String() // uuid能够生成的value是唯一的
res, err := c.client.SetNX(ctx, key, value, expiration).Result() // 过期时间要是多少,让用户自己去确定
if err != nil { // 很可能是网络通信出问题,导致加锁没成功,所以会报错
return nil, err
}
if !res { // 锁被某个实例占据了
return nil, errors.New("加锁失败")
}
return newLock(c.client, key, value), nil
}
// 不能只返回error,要返回一个实体的Lock
type Lock struct {
client redis.Cmdable
key string
value string
}
func newLock(client redis.Cmdable, key string, value string) *Lock {
return &Lock{
client: client,
key: key,
}
}
func (l *Lock) Unlock(ctx context.Context) error {
// 解锁的时候,你要确保,这把锁还是你的锁,没有被人篡夺
val, err := l.client.Get(ctx, l.key).Result()
if err != nil {
return err
}
// t1时刻,val还是你设置的,你拿过来了
// t2时刻,就有人篡改了,把Redis的值改成了新值
// t3时刻,你执行了删除,把别人的值删了
if l.value == val {
_, err := l.client.Del(ctx, l.key).Result() //这句代码并不是真正将key删掉,而是获取删除key会得到什么样的结果,会得到什么样的报错
if err != nil {
return err
}
}
return nil
}
改动的点:
1、Lock函数
- 设置了一个value,uuid确保了这个值生成后是唯一的
- 超时时间不再自己设置,而是写成函数形参让用户自己去传递
- 函数的返回值变成了(*Lock, error)
- 补全每个return,最后如果加锁成功的话,就返回这个锁🔐的指针,error信息就为空,即没有报错信息
2、定义了一个Lock结构体
3、定义了一个newLock函数
4、Unlock函数
- 不能返回一个error,而要返回一个实体的Lock,所以定义一个Lock结构体,并且结构体里面同样写入client
- 在Unlock函数中,不再需要一个key作为函数形参了,写入Lock结构体中即可,这样只需要利用Lock的实例化对象就可以调用key了。
-
if res != 1 { // 执行此if语句的原因可能有: // 1.过期(通过在Lock函数中设置超时时间的函数形参来解决此问题,用户的业务需要多少时间,就自己定义多少时间) // 2.被人删了(这个就无法控制了,因为这是用户主动登录数据库去做的删除操作) return errors.New("解锁失败") } - 你在删除key的时候,完全没有去看value
- 如果这个锁过期掉,那么就会有其它实例用这个锁,那在Unlock函数中删除的时候,删的就是这个其它实例的锁。那么解锁的时候,你就要确保,这把锁还是你的锁,没有被人篡夺。就像说,你还是老大,这把锁你拿着。
那么我怎么知道,这把锁还是我的锁?这个时候,就要用到value了。在Lock加锁的时候,把value设置好,在Unlock解锁的时候,判断一下跟原来的值是否一样,一样的话,再去删锁val, err := l.client.Get(ctx, l.key).Result() // val是指当前锁key对应的实例的id值 if err != nil { return err } // t1时刻,val还是你设置的,你拿过来了 // t2时刻,就有人篡改了,把Redis的值改成了新值 // t3时刻,你执行了删除,把别人的值删了 // if l.value == val { }
以上代码可以分为两个步骤:要将「判断是不是我家的锁」和「是我家的锁就删掉」,这两个操作合并起来,所以我们可以引入一个lua脚本,因为 Lua 脚本可以保证连续多个指令的原子性执行。
第三版
package redis_lock
import (
"context"
_ "embed"
"errors"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"time"
)
var (
ErrLockNotHold = errors.New("未持有锁")
ErrFailedToPreemptLock = errors.New("加锁失败")
)
type Client struct {
client redis.Cmdable
}
// 定义完Client后,我们要给Client一个初始化函数
func NewClient(c redis.Cmdable) *Client {
return &Client{
client: c,
}
}
// 叫TryLock是因为,你只是试着加了下锁,大概率是加锁失败的
func (c *Client) TryLock(ctx context.Context, key string,
expiration time.Duration) (*Lock, error) {
value := uuid.New().String() // uuid能够生成的value是唯一的
res, err := c.client.SetNX(ctx, key, value, expiration).Result() // 过期时间要是多少,让用户自己去确定
if err != nil { // 很可能是网络通信出问题,导致加锁没成功,所以会报错
return nil, err
}
if !res { // 锁被某个实例占据了
return nil, ErrFailedToPreemptLock
}
return newLock(c.client, key, value), nil
}
var (
//go:embed unlock.lua
luaUnlock string
)
// 不能只返回error,要返回一个实体的Lock
type Lock struct {
client redis.Cmdable
key string
value string
}
func newLock(client redis.Cmdable, key string, value string) *Lock {
return &Lock{
client: client,
key: key,
value: value,
}
}
func (l *Lock) Unlock(ctx context.Context) error {
res, err := l.client.Eval(ctx, luaUnlock, []string{l.key}, l.value).Int64() // 传了key和value这两个数据进去
if err == redis.Nil { // 当key不存在的时候
return ErrLockNotHold
}
if err != nil { // 其它报错信息
return err
}
// 要判断res是不是1
if res != 1 {
// 这把锁不是你的, 或者这个key不存在
return ErrLockNotHold
}
return nil
}
lua脚本
--两个动作:1.检测是不是预期中的值(也就是,是不是你的锁):
--2.如果是,删除:如果不是,返回一个值
if redis.call("get", KEY[1]) == ARGV[1] then -- redis的Call命令,去调用get命令,去删除key,用key[1]表示
return redis.call("del", KEYS[1]) -- 删除后,会返回1
else
-- 返回 0 代表的是 key 不在,或者值不对
return 0
end