分布式锁一二事

961 阅读6分钟

前言

回想起来,已经半年没有更了,当然也从没停止过打工人的脚步。。。

锁的含义

单机环境下,锁是用来控制多个线程访问共享资源的方式,通过防止多个线程同时访问共享资源影响最终结果。

实际开发过程中,开发者面对的通常是分布式环境,而在分布式系统的设计与实现当中,通常会遇到多个进程同时访问共享资源的场景,为了保证数据最终一致性(BASE理论),分布式锁成为分布式系统设计中的重要组成部分。

为什么使用分布式锁

分布式锁通常用于保证位于分布式环境的多个节点,对共享资源的访问或操作仅进行一次(at least once)。 使用分布式锁的两个主要原因有:

1、效率性(Efficiency):减少了对于共享资源的重复访问及操作,节省了资源。
2、正确性(Correctness):预防多个进程同时对共享资源进行访问及操作,影响最终结果的正确性。

分布式锁的实现方式主要分为以下2类:

1、基于Redis的实现,例如RedLock算法等
2、基于分布式协调服务实现,例如etcd、ZooKeeper实现

基于Redis实现

使用Redis实现分布式锁的关键在于决定并发控制的维度,也就是如何拼装lock key

1、共享资源维度的控制
2、业务唯一键维度(uid)等的控制

  • 加锁
SET resource_name my_random_value NX PX 30000
  • 解锁
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

共享资源维度的控制需要保证redis的lock value为全局唯一的随机值

释放锁需要删除对应的lock key,而删除前需要校验当前进程是否为该锁的持有进程。

为什么setnx全局唯一值?

redis分布式锁共享资源维度setnx不是随机值.png

上图就解释了为什么lock key的值要设置成全局唯一值用来标识哪个client获取了分布式锁。 可以看到,Client1获取到锁后,由于处理时间超出锁过期时间,Client2获取到锁;Client1恢复执行完逻辑释放锁,其实已经吧Client2的锁给释放了,后续会导致一系列并发问题。

setnx使用全局唯一值.png

锁过期释放导致的对共享资源的同时访问

锁超时导致的并发问题.png

解决上述问题,有以下几种思路进行切入:

1、过期时间设置要充分(当然不能太久),满足比绝大部分(除极少数异常情况)业务处理时间要大,使上述情况出现的尽可能少。
2、如果分布式锁控制的是MySQL,可通过先查询对应行数据(使用uid、instanceID等,需注意主从延时),后加锁的方式,结合索引约束,实现对应业务逻辑。
2、fencing token(分布式自增唯一ID,例如ZooKeeper的zxid)来保证共享资源访问的先后顺序,在共享资源判断token的是否是最新的来进行访问控制。
3、lock key对应的唯一随机值在对共享资源进行访问时,通过check and set机制校验进程对共享资源访问的合法性。

fencing token

指的是每次有Client获取到锁时,就会递增的全局唯一id。这样就可以根据Client获取锁的顺序来对共享资源进行操作(类似乐观锁的实现),具体如下图所示。

In this context, a fencing token is simply a number that increases (e.g. incremented by the lock service) every time a client acquires the lock.

fencing token或unique_id.png

但fencing token的方式存在一个问题,获取锁的先后顺序,就决定了后续取哪个操作的结果,但大部分情况下,并发请求之间的时序性很难保证。

Redlock

Redlock算法是简单来讲就是上述单机实现在分布式系统版本的扩展;算法需要N个Redis master节点,获取锁成功的条件为N/2 + 1个节点setnx操作成功,具体如下图所示。

Redlock图示.png

go-redsync

  • 配置信息
m := &Mutex{
   name:         name,
   expiry:       8 * time.Second,
   tries:        32,
   delayFunc:    func(tries int) time.Duration { return 500 * time.Millisecond },
   genValueFunc: genValue,
   factor:       0.01,
   quorum:       len(r.pools)/2 + 1,
   pools:        r.pools,
}
  • 单节点加锁方法
func (m *Mutex) acquire(ctx context.Context, pool redis.Pool, value string) (bool, error) {
   conn, err := pool.Get(ctx)
   if err != nil {
      return false, err
   }
   defer conn.Close()
   reply, err := conn.SetNX(m.name, value, m.expiry)
   if err != nil {
      return false, err
   }
   return reply, nil
}
  • 加锁方法
func (m *Mutex) LockContext(ctx context.Context) error {
   value, err := m.genValueFunc()
   if err != nil {
      return err
   }

   for i := 0; i < m.tries; i++ {
      if i != 0 {
         time.Sleep(m.delayFunc(i))
      }

      start := time.Now()

      n, err := m.actOnPoolsAsync(func(pool redis.Pool) (bool, error) {
         return m.acquire(ctx, pool, value)
      })
      if n == 0 && err != nil {
         return err
      }

      now := time.Now()
      until := now.Add(m.expiry - now.Sub(start) - time.Duration(int64(float64(m.expiry)*m.factor)))
      if n >= m.quorum && now.Before(until) {
         m.value = value
         m.until = until
         return nil
      }
      _, _ = m.actOnPoolsAsync(func(pool redis.Pool) (bool, error) {
         return m.release(ctx, pool, value)
      })
   }

   return ErrFailed
}

基于分布式一致性服务的实现

除借助Redis的能力外,还可以通过引入分布式一致性服务实现,如下:

1、ZooKeeper
2、etcd
3、Google Chubby(但ZooKeeper作为Chubby的开源实现,本文介绍Zookeeper、etcd的实现)

ZooKeeper

ZooKeeper是一个典型的分布式数据一致性解决方案,主要可以用于以下场景。

1、数据发布/订阅(动态配置等)
2、负载均衡
3、命名服务
4、分布式协调/通知
5、集群管理
6、master节点选举
7、分布式锁
8、分布式队列\

本文主要聚焦使用ZooKeeper实现分布式锁的原理及实现,其中以排他锁为例。

ZooKeeper排他锁.png

  • 锁被定义为一个临时顺序节点,例如/exclusive_lock/lock(可以为任何ZooKeeper路径)。
  • 获取锁的过程为所有客户端都试图通过调用create()接口,创建/exclusive_lock/lock节点,ZooKeeper会保证只有一个客户端创建成功,那么就可以认为该客户端获取了锁。
  • 释放锁:已经提到,/exclusive_lock/lock是一个临时节点,因此在以下情况下,都有可能释放锁。

1、当前获取锁的客户端机器发生宕机,临时节点被删除。
2、正常执行完业务逻辑后,客户端主动将创建的临时节点删除。

  • 通知:由于通过watch机制watch了/exclusive节点,一旦/exclusive的子节点发生变化,都会通知到客户端,客户端接到通知后,就会重复“获取锁”的流程。

go-zk-lock

  • 加锁
// for循环
func (this *Dlocker) Lock() {
   for !this.lock() {
   }
}


// 获取锁
func (this *Dlocker) lock() (isSuccess bool) {
   isSuccess = false
   defer func() {
      e := recover()
      if e == zk.ErrConnectionClosed {
         //try reconnect the zk server
         log.Println("connection closed, reconnect to the zk server")
         reConnectZk()
      }
   }()
   this.innerLock.Lock()
   defer this.innerLock.Unlock()
   //create a znode for the locker path
   var err error
   this.lockerPath, err = this.createZnodePath()
   this.checkErr(err)

   //get the znode which get the lock
   minZnodePath, err := this.getMinZnodePath()
   this.checkErr(err)

   if minZnodePath == this.lockerPath {
      // if the created node is the minimum znode, getLock success
      isSuccess = true
   } else {
      // if the created znode is not the minimum znode,
      // listen for the last znode delete notification
      lastNodeName := this.getLastZnodePath()
      watchPath := this.basePath + "/" + lastNodeName
      isExist, _, watch, err := getZkConn().ExistsW(watchPath)
      this.checkErr(err)
      if isExist {
         select {
         //get lastNode been deleted event
         case event := <-watch:
            if event.Type == zk.EventNodeDeleted {
               //check out the lockerPath existence
               isExist, _, err = getZkConn().Exists(this.lockerPath)
               this.checkErr(err)
               if isExist {
                  //checkout the minZnodePath is equal to the lockerPath
                  minZnodePath, err := this.getMinZnodePath()
                  this.checkErr(err)
                  if minZnodePath == this.lockerPath {
                     isSuccess = true
                  }
               }
            }
         //time out
         case <-time.After(this.timeout):
            // if timeout, delete the timeout znode
            children, err := this.getPathChildren()
            this.checkErr(err)
            for _, child := range children {
               data, _, err := getZkConn().Get(this.basePath + "/" + child)
               if err != nil {
                  continue
               }
               if modules.CheckOutTimeOut(data, this.timeout) {
                  err := getZkConn().Delete(this.basePath+"/"+child, 0)
                  if err == nil {
                     log.Println("timeout delete:", this.basePath+"/"+child)
                  }
               }
            }
         }
      } else {
         // recheck the min znode
         // the last znode may be deleted too fast to let the next znode cannot listen to it deletion
         minZnodePath, err := this.getMinZnodePath()
         this.checkErr(err)
         if minZnodePath == this.lockerPath {
            isSuccess = true
         }
      }
   }

   return
}
  • 释放锁
func (this *Dlocker) unlock() (isSuccess bool) {
   isSuccess = false
   defer func() {
      e := recover()
      if e == zk.ErrConnectionClosed {
         //try reconnect the zk server
         log.Println("connection closed, reconnect to the zk server")
         reConnectZk()
      }
   }()
   err := getZkConn().Delete(this.lockerPath, 0)
   if err == zk.ErrNoNode {
      isSuccess = false
      return
   } else {
      this.checkErr(err)
   }
   isSuccess = true
   return
}

etcd

etcd是具有强一致性的key-value存储服务,其实现方式与ZooKeeper大同小异,可以参考etcdsync

附录

martin kleppmannRedlock作者之间的争论

martin.kleppmann.com/2016/02/08/…
antirez.com/news/101

参考