github repo: github.com/gotomicro/r…
什么是分布式锁
分布式锁,简单来说就是在分布式环境下不同实例之间抢一把锁。
和普通的锁相比,也就是抢锁的从线程(协程)变成了实例。
分布式锁之所以难,基本上都和网络有关。
用redis实现一个分布式锁
上锁
实现一个分布式锁的起点,就是利用 setnx命令,确保可以排他地设置一个键值对。
以下代码中:
- 为什么要设置过期时间?不应该是用户主动释放么?
- 为什么要用uuid来作为值?
然后在两个返回error的地方,是在什么情况下返回的?
- 比如 context 超时,redis服务器问题,网络问题等
- 加锁失败,没抢到锁
这里我们取名叫做 TryLock,是因为它并不能保证加锁成功。
// Try就是能成就成,不能成就拉倒的意思,就试着锁一下,不会重试
// TryLock (ctx, key, time.Second * 10)
func (c *Client) TryLock(ctx context.Context, key string,
expiration time.Duration) (*Lock, error) {
value := uuid.New().String()
res, err := c.client.SetNX(ctx, key, value, expiration).Result()
if err != nil {
return nil, err // 1. 比如 context 超时,redis服务器问题,网络问题等
}
if !res {
return nil, ErrFailedToPreemptLock // 2. 加锁失败
}
return newLock(c.client, key, value, expiration), nil
}
如果没有过期时间?
为什么使用 uuid 作为值?
释放锁
释放锁的时候,需要做两件事:
- 看看是不是自己加的锁(比较redis里面的值是不是自己的)
- 如果是,直接释放锁
那么什么情况下会不是呢?
- 自己的锁过期了,然后别人又加了锁
func (l *Lock) Unlock(ctx context.Context) error {
defer func() {
l.unlock <- struct{}{}
close(l.unlock)
}()
res, err := l.client.Eval(ctx, luaUnlock, []string{l.key}, l.value).Int64()
if err == redis.Nil {
return ErrLockNotHold
}
if err != nil {
return err
}
// 要判断 res 是不是 1
if res == 0 {
// 这把锁不是你的,或者这个 key 不存在
return ErrLockNotHold
}
return nil
}
lua脚本里面要检测key的对应的值是不是我设置的值,是的话就释放锁。如果不是,说明出了点问题。
Lua脚本逻辑很简单:
- 使用redis get 命令查看key对应的value,如果相等,那么意味着自己的锁还在
- 否则,说明锁已经被释放了,可能是过期了,也可能是被人误删除了
-- unlock.lua
if redis.call("get", KEYS[1]) == ARGV[1]
then
return redis.call("del", KEYS[1])
else
return 0
end
续约:解决超时问题
过期时间设置多长?
续约
手动续约
我们单独提供一个Refresh的方法。这个方法使用了lua脚本。
这里有三个返回error的地方
- 第一个地方是 redis.Nil的检测,说明根本没有拿到锁
- 第二个地方意味着可能服务器出错了,或者超时了
- 第三个地方意味着锁确实存在,但是却不是自己的锁
func (l *Lock) Refresh(ctx context.Context) error {
res, err := l.client.Eval(ctx, luaRefresh, []string{l.key}, l.value, l.expiration.Milliseconds()).Int64()
if err == redis.Nil {
return ErrLockNotHold // 说明根本没有拿到锁
}
if err != nil { // 可能服务器出错了,或者超时了
return err
}
if res != 1 { //锁确实存在,但是却不是自己的锁
return ErrLockNotHold
}
return nil
}
-- refresh.lua
-- 两个动作:1. 检测是不是预期中的值(也就是,是不是你的锁);
-- 2. 如果是,删除;如果不是,返回一个值
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("pexpire", KEYS[1], ARGV[2])
else
-- 返回 0 代表的是 key 不在,或者值不对
return 0
end
Refresh方法本身很简单,难其实在使用的时候:
-
第一个问题:间隔多久续约一次?
-
第二个问题:如果Refresh返回了error,怎么处理?
- 如果返回的是超时的error,要怎么办
- 如果返回的是其他的error,又要怎么办
-
第三个问题:如果确认续约失败了,怎么中断后续的业务?
首先回答第三个问题:那就是这个问题基本无解,因为业务代码一旦执行,你除非自己手动检测分布式锁,并且手动中断,不然是没有办法的。
// 手动续约
func ExampleLock_Refresh() {
var lock *Lock
end := make(chan struct{}, 1)
go func() {
ticker := time.NewTicker(time.Second * 30) // 每隔30秒续约一次
for {
select {
case <-ticker.C:
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
err := lock.Refresh(ctx)
cancel()
// 错误处理
if err == context.DeadlineExceeded {
// 超时,按照道理来说,你应该立刻重试
// 超时之下可能续约成功了,也可能没成功
}
if err != nil {
// 其它错误,你要考虑这个错误能不能继续处理
// 如果不能处理,你怎么通知后续业务中断?
}
case <-end:
// 你的业务退出了
}
}
}()
// 后面是你的业务
fmt.Println("Finish")
// 你的业务完成了
end <- struct{}{}
// Output:
// Finish
}
自动续约
考虑到对大部分用户来说,处理分布式锁的各种异常情况,还是一个比较棘手的事情,我们可以考虑提供自动续约的API。
这种自动续约的API还是很难设计的,因为用户自己手动续约要面对的问题,我们一样要面对:
-
隔多久续约,续多长? 这里我们让用户来指定多久续约一次,因为这个跟网络,redis服务器的稳定性有关,而每次续多长,我们就直接使用原本的过期时间。
-
如何处理超时,以及超时设置多长的时间? 我们选择再次尝试续约。超时意味着也不知道究竟有没有续约成功,而且大多数时候超时都是偶发性的,所以可以立刻再次尝试。缺点就是如果此时真的redis服务器崩溃或者网络不通,那么会导致无限次尝试续约。超时时间我们让用户指定。
-
如何通知用户续约失败? 我们只处理超时因此的续约失败,其他情况下,我们告诉用户遇到了无法处理的error。
-
要不要设置续约次数上限? 例如一个业务,不断续约以至于十分钟都没释放分布式锁,要不要强制释放。我们的答案是不设置,理由依旧是如果用户有这种需求,他应该自己手动续约。
type Lock struct {
client redis.Cmdable
key string
value string
expiration time.Duration
unlock chan struct{}
}
func newLock(client redis.Cmdable, key string, value string, expiration time.Duration) *Lock {
return &Lock{
client: client,
key: key,
value: value,
expiration: expiration,
unlock: make(chan struct{}, 1), // 这里得给一个缓冲,不然丢不进去
}
}
func (l *Lock) Unlock(ctx context.Context) error {
defer func() {
l.unlock <- struct{}{}
close(l.unlock)
}()
res, err := l.client.Eval(ctx, luaUnlock, []string{l.key}, l.value).Int64()
if err == redis.Nil {
return ErrLockNotHold
}
if err != nil {
return err
}
// 要判断 res 是不是 1
if res == 0 {
// 这把锁不是你的,或者这个 key 不存在
return ErrLockNotHold
}
return nil
}
func (l *Lock) AutoRefresh(interval time.Duration, timeout time.Duration) error {
// timeout 调用redis超时时间
ch := make(chan struct{}, 1) // 用于接收超时重试的信号
defer close(ch)
ticker := time.NewTicker(interval) // 每次续约间隔多久
for {
select {
case <-ch:
ctx, cancel := context.WithTimeout(context.Background(), timeout)
err := l.Refresh(ctx)
cancel()
if err == context.DeadlineExceeded {
ch <- struct{}{}
continue
}
if err != nil {
return err
}
case <-ticker.C: // 30秒到了
ctx, cancel := context.WithTimeout(context.Background(), timeout)
err := l.Refresh(ctx)
cancel()
if err == context.DeadlineExceeded {
// 超时立刻重试
ch <- struct{}{}
continue
}
if err != nil {
return err
}
case <-l.unlock: // 但凡收到用户解锁的信号(业务处理完了),就不再续约,结束了
return nil
}
}
}
func ExampleLock_AutoRefresh() {
var lock *Lock
go lock.AutoRefresh(time.Second*30, time.Second)
// 你的业务
}
自动续约的可控性非常差,因此我们并不是很鼓励用户使用这个API(因为还是会返回error需要处理)。甚至于如果用户想要万无一失地使用这个分布式锁,那么必须要自己手动调用Refresh,并且小心处理各种error。
此外,续约的间隔,应该综合考虑服务的可用性。例如如果我们将分布式锁的过期时间设置为10秒,而且预期2秒内绝大概率续约成功,那么就可以考虑将续约间隔设置为8秒。
加锁重试与重试策略
加锁可能遇到偶发性的失败,在这种情况下,可以尝试重试。重试逻辑:
-
如果超时了,则直接加锁
-
检查一下key对应的值是不是我们刚才超时加锁请求的值
- 如果是,直接返回,前一次加锁成功了(这里你可能需要考虑重置一下过期时间)
- 如果不是,直接返回,加锁失败
加锁重试非常类似于自动续约,也是要考虑:
- 怎么重试?隔多久重试一次,总共重试几次?也就是重试策略的问题
- 什么情况下应该重试,什么情况下不应该重试?
根据我们在自动续约里面的机制,很容易就能猜出来:
- 超时了: 这种情况下都不知道锁有没有拿到
- 此时正有人持有锁,我们要等别人释放锁
加锁重试:
-- lock.lua
local val = redis.call('get', KEYS[1])
-- 在加锁的重试的时候,要判断自己上一次是不是加锁成功了
if val == false then
-- key 不存在
return redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2])
elseif val == ARGV[1] then
-- 刷新过期时间
redis.call('expire', KEYS[1], ARGV[2])
return "OK"
else
-- 此时别人持有锁
return ""
end
func (c *Client) Lock(ctx context.Context, key string,
expiration time.Duration, retry rlock.RetryStrategy, timeout time.Duration) (*Lock, error) {
value := uuid.New().String()
var timer *time.Timer
defer func() {
if timer != nil {
timer.Stop()
}
}()
for {
lctx, cancel := context.WithTimeout(ctx, timeout)
// 尝试获得锁
res, err := c.client.Eval(lctx, luaLock, []string{key}, value, expiration).Bool()
cancel()
if err != nil && !errors.Is(err, context.DeadlineExceeded) {
// 非超时错误,那么基本上代表遇到了一些不可挽回的场景,所以没必要继续尝试了
// 比如说 redis server 崩溃了,或者 EOF了
return nil, err
}
if res {
// 获得了锁
return newLock(c.client, key, value, expiration), nil
}
// 以下为重试分支
interval, ok := retry.Next()
if !ok {
// 到达了最多重试次数,不用重试
return nil, ErrFailedToPreemptLock
}
if timer == nil {
timer = time.NewTimer(interval)
}
timer.Reset(interval) // 睡眠问题
select {
case <-ctx.Done(): // 整个过程超时
return nil, ctx.Err()
case <-timer.C: // 睡眠时间到
}
}
}
实际上,释放锁也可以考虑重试,只是相比之下,释放锁问题没那么严重。释放锁的情况下只有超时是值得重试的,其他情况都不需要重试。
重试的接口设计成迭代器的形态。 用户可以轻易通过扩展这个接口来实现自己的重试策略,比如等时间间隔策略。缺点就是这种接口设计没有引入上下文的概念,那么用户在实现接口的时候就没有办法根据上下文,例如上一次调用的error来决定要不要重试。
type RetryStrategy interface {
// Next 返回下一次重试的间隔,如果不需要继续重试,那么第二参数发挥 false
Next() (time.Duration, bool)
}
type FixIntervalRetry struct {
// 重试间隔
Interval time.Duration
// 最大次数
Max int
cnt int
}
func (f *FixIntervalRetry) Next() (time.Duration, bool) {
f.cnt++
return f.Interval, f.cnt <= f.Max
}
singleflight优化
在非常高并发,并且热点集中的情况下,可以考虑结合singleflight来进行优化。也就是说,本地所有的gouroutine自己先竞争一把,胜利者再去抢全局的分布式锁。
func (c *Client) SingleflightLock(ctx context.Context, key string,
expiration time.Duration, retry rlock.RetryStrategy, timeout time.Duration) (*Lock, error) {
for {
flag := false // 标记是不是自己拿到了锁
resCh := c.s.DoChan(key, func() (interface{}, error) {
flag = true
return c.Lock(ctx, key, expiration, retry, timeout)
})
select {
case res := <-resCh:
if flag { // 确实是自己拿到了锁
if res.Err != nil {
return nil, res.Err
}
return res.Val.(*Lock), nil
}
case <-ctx.Done(): // 监听超时
return nil, ctx.Err()
}
}
}
Redlock
主从切换
前面讨论的都是单点的Redis,在集群部署的时候,需要额外考虑一个问题:主从切换。
没有办法解决这个问题。
Redlock
我们这一次不再部署单一主从集群,而是多个主节点(没有从节点)。
比如说我们部署五个主节点,那么加锁过程是类似的,只是要在五个主节点都加上锁,如果多数(这里是三个),都成功了,那么就认为加锁成功。
要点
- 分布式锁怎么实现?基本原理就是我们刚才讲的那些,核心就是setnx,只不过在我们引入重试之后,就需要使用lua脚本了。
- 分布式锁的过期时间怎么设置?按照自己业务的耗时,例如999线的时间设置超时时间,重要的是引入续约机制。
- 怎么延长过期时间(续约)?什么时候续约,续约多久,续约失败怎么办。续约失败之后,无非就是中断业务执行,并且可能还要执行一些回滚或者补偿动作。
- 分布式锁加锁失败有什么原因?超时,网络故障,redis服务器故障,以及锁被人持有。
- 怎么优化分布式锁的性能?也没什么好方法,根源上还是尽量避免使用分布式锁,硬要用就可以考虑singleflight进行优化。