redis分布式锁

187 阅读10分钟

u.geekbang.org/lesson/468?…

github repo: github.com/gotomicro/r…

什么是分布式锁

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

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

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

用redis实现一个分布式锁

上锁

实现一个分布式锁的起点,就是利用 setnx命令,确保可以排他地设置一个键值对。

以下代码中:

  • 为什么要设置过期时间?不应该是用户主动释放么?
  • 为什么要用uuid来作为值?

然后在两个返回error的地方,是在什么情况下返回的?

  1. 比如 context 超时,redis服务器问题,网络问题等
  2. 加锁失败,没抢到锁

这里我们取名叫做 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 作为值?

释放锁

释放锁的时候,需要做两件事:

  1. 看看是不是自己加的锁(比较redis里面的值是不是自己的)
  2. 如果是,直接释放锁

那么什么情况下会不是呢?

  • 自己的锁过期了,然后别人又加了锁
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进行优化。