Consul 之分布式锁

锁的种类

在并发编程中,我们首先会学习到的知识点就是 ,锁能够 简单有效 地解决并发编程中 共享资源竞争 的问题,当然还有更高级的 无锁并发编程 ,如 MVCC 等,但这部分不在此次讨论范围内,有兴趣的同学可以自行学习或者期待后面的文章。

我们先来说说锁,为了适应各种各样的并发场景,诞生了合适各样的锁,如:

  • 互斥锁
  • 读写锁
  • 自旋锁
  • ...

一般我们会根据使用场景来选取最适合的锁进行应用,而不熟悉并发编程的读者可以先来了解锁的概念,再进行下一节的阅读。

分布式锁

在如今的后台服务中,能提供 相同功能 的早已经不是单台服务器了,在分布式环境中,如何保证 CAP定理 是对于后台服务的一大挑战,不同的功能对 CAP 的要求并不一致。而对于 Consistence 要求非常高的服务,如:订单支付库存 等,使用 分布式锁 能有效保证数据的一致性。

在分布式锁的领域,最有名的无疑 zookeeper ,它是 Apache软件基金会 的顶级项目,提供开源的分布式配置、同步、命名等服务,我们先来看下如何借助它来实现分布式锁的。

zookeeper 使用顺序自增组群管理抢占分布式锁,客户端抢占分布式锁的流程图如下:

graph TD
start(开始)
finish(结束)
lockgroup{存在锁组群}
createlg[创建锁组群]
appendlink[监听上一个节点信息]
releaselock[释放锁]
acquirelock[获取锁]
waitinglock[等待锁]
working[进行业务操作]

start-->lockgroup
lockgroup-.yes.->appendlink
lockgroup-.no.->createlg
createlg-->acquirelock
acquirelock-->working
working-->releaselock
appendlink-->waitinglock
waitinglock-.->acquirelock
releaselock-->finish

这看似很简单的过程,其实解决了很多分布式环境下抢占锁的 难点 ,下面分别展开说下。

难点

死锁

死锁是带锁的并发编程下出现的绝大部分问题,该问题可以被简单描述为:

当两个以上的运算单元,双方都在等待对方停止运行,以获取系统资源,但是没有一方提前退出时,就称为死锁。

例子:当处理程序 P1 已经占用了 S1 资源下,需要 R2 进一步处理,而同时, P2 在已经占用了 R2 的情况下,需要 R1 进行进一步处理,此时, P1 以及 P2 就进行了死循环的等待中,从而产生死锁。

产生死锁的 四大必要 条件分别为:

  • 互斥条件
  • 不可抢占条件
  • 占用并申请条件
  • 循环等待条件

解决死锁的问题只需要打破出现死锁的 四大必要条件 之一即可,在分布式环境中,我们使用 session + TTL 来打破 循环等待条件

当一个客户端尝试操作一把分布式锁的时候,我们必须校验其 session 是否为锁的拥有者,则无法进行操作。

当一个客户端已经持有一把分布式锁后发生了 掉线 ,在超出了 TTL 时间后无法连接上,则回收其锁的拥有权。

惊群效应

如果是熟悉网络的童鞋,应该知道什么是 惊群效应 ,因为在 epoll 中也存在该问题。

我们来举一个例子说明惊群效应:老农养了 N 只鸽子,而老农在进行喂食的时候,每次抛出食物的时候,都会引起所有鸽子的注意,纷纷来抢夺食物,这就是 惊群效应

我们可以将 鸽子 比作 进程老农喂食 比作 锁的释放 ,分布式锁的争夺情形是否跃然纸上?

为了避免发生惊群效应, NginxZooKeeper 分别使用了不同的方案解决,但是他们的核心解决思路都是一致的,下面我们来看看 ZooKeeper 是怎么解决 惊群效应 的。

我们都知道,在 ZooKeeper 内部会使用临时目录节点的形式创建分布式锁,其中每个节点对应一个客户端的申请锁请求。

当客户端来请求该锁的时候, ZooKeeper 会生成一个 ${lock-name}-${i} 的临时目录,此后每次请求该锁的时候,就会生成 ${lock-name}-${i+1} 的目录,如果此时在 ${lock-name} 中拥有最小的 i 的客户端会获得该锁,而该客户端使用结束之后,就会删除掉自己的临时目录,并通知后续节点进行锁的获取。

没错,这个 iZooKeeper 解决惊群效应的利器,它被称为 顺序节点

脑裂

脑裂是集群环境中肯定会遇到的问题,其出现的主要原因为 网络波动

举最常见的 双机热备 的场景,节点A节点B 是相同功能的两个服务,它们彼此通过心跳保持联系,并作为对方的备份。但如果此时 AB 的网络连接被中断了, A 会尝试占用 B 的资源,而 B 会尝试占用 A 的资源,这就是 脑裂 问题。

当集群中出现 脑裂 的时候,往往会出现多个 master 的情况,这样数据的一致性会无法得到保障,从而导致整个服务无法正常运行。

而在 ZooKeeper 的分布式锁场景中,如果 客户端A 已经得到了锁,但是却因为网络波动原因断开了与 ZooKeeper 的连接,那么下一个顺序节点 客户端B 就会获得锁,但是因为 客户端A 此时依然还持有该锁 ,从而发生了 脑裂 问题。

解决脑裂问题有两种方式,可以将集群中的服务作为 P2P 节点,避免 Leader 与 Salve 的切换,另一种方案更简单一些,那就是当客户端与 ZooKeeper 非正常断开连接的时候, ZooKeeper 应该尝试向客户端发起 多次重试 机制,并在一段时间后依然无法连接上,再让下一个顺序客户端获取锁。

Consul 实现

Consul 是 Go 实现的一个轻量级 服务发现KV存储 的工具,它通过强一致性的KV存储实现了简易的 分布式锁 ,下面我们根据源码看下 Consul 是怎么解决以上分布式锁的难点的。

// api/lock.go

// Lock 分布式锁数据结构
type Lock struct {
    c    *Client   // 提供访问consul的API客户端
    opts *LockOptions // 分布式锁的可选项
    
    isHeld       bool          // 该锁当前是否已经被持有
    sessionRenew chan struct{} // 通知锁持有者需要更新session
    locksession  string        // 锁持有者的session
    l            sync.Mutex    // 锁变量的互斥锁
}

// LockOptions 提供分布式锁的可选项参数
type LockOptions struct {
    Key              string        // 锁的 Key,必填项,且必须有 KV 的写权限
    Value            []byte        // 锁的内容,以下皆为选填项
    Session          string        // 锁的session,用于判断锁是否被创建
    SessionOpt       *SessionEntry // 自定义创建session条目,用于创建session,避免惊群
    SessionName      string        // 自定义锁的session名称,默认为 "Consul API Lock"
    SessionTTL       string        // 自定义锁的TTL时间,默认为 "15s"
    MonitorRetries   int           // 自定义监控的重试次数,避免脑裂问题
    MonitorRetryTime time.Duration // 自定义监控的重试时长,避免脑裂问题
    LockWaitTime     time.Duration // 自定义锁的等待市场,避免死锁问题
    LockTryOnce      bool          // 是否只重试一次,默认为false,则为无限重试
}

LockOptions 中带有 session / TTL / monitor / wait 等字眼的成员变量可以看出,consul 已经考虑到解决我们上一节提到的三个难点,下面来看看实现代码中是如何使用的。

先来看看生成可用的分布式锁的函数 LockOpts

// api/lock.go

// LockOpts 通过传入锁的参数,返回一个可用的锁
// 必须注意的是 opts.Key 必须在 KV 中有写权限
func (c *Client) LockOpts(opts *LockOptions) (*Lock, error) {
    if opts.Key == "" {
        return nil, fmt.Errorf("missing key")
    }
    if opts.SessionName == "" {
        opts.SessionName = DefaultLockSessionName // "Consul API Lock"
    }
    if opts.SessionTTL == "" {
        opts.SessionTTL = DefaultLockSessionTTL // "15s"
    } else {
        if _, err := time.ParseDuration(opts.SessionTTL); err != nil {
            return nil, fmt.Errorf("invalid SessionTTL: %v", err)
        }
    }
    if opts.MonitorRetryTime == 0 {
        opts.MonitorRetryTime = DefaultMonitorRetryTime  // 2 * time.Second
    }
    if opts.LockWaitTime == 0 {
        opts.LockWaitTime = DefaultLockWaitTime   // 15 * time.Second
    }
    l := &Lock{
        c:    c,
        opts: opts,
    }
    return l, nil
}

我们可以在这个函数中可以注意到:

15s 的 SessionTTL 用于解决死锁、脑裂问题。

2s 的 MonitorRetryTime 是一个长期运行的协程用于监听当前锁持有者,用于解决脑裂问题。

15s 的 LockWaitTime 用于设置尝试获取锁的超时时间,用于解决死锁问题。

Lock 有3个可供其他包访问的函数,分别为 Lock / Unlock / Destroy ,下面按照顺序展开细说。

// Lock尝试获取一个可用的锁,可以通过一个非空的 stopCh 来提前终止获取
// 如果返回的锁发生异常,则返回一个被关闭的 chan struct ,应用程序必须要处理该情况
func (l *Lock) Lock(stopCh <-chan struct) (<-chan struct{}, error) {
    // 先锁定本地互斥锁
    l.l.Lock()
    defer l.l.Unlock()
    
    // 本地已经获取到分布式锁了
    if l.isHeld {
        return nil, ErrLockHeld
    }
    
    // 检查是否需要创建session
    l.lockSession = l.opts.Session
    if l.lockSession == "" {
        s, err := l.createSession()
        if err != nil {
            return nil, fmt.Errorf("failed to create session: %v", err)
        }
        
        l.sessionRenew = make(chan struct{})
        l.lockSession = s
        session := l.c.Session()
        go session.RenewPeriodic(l.opts.SessionTTL, s, nil, l.sessionRenew)
        
        // 如果我们无法锁定该分布式锁,清除本地session
        defer func() {
            if !l.isHeld {
                close(l.sessionRenew)
                l.sessionRenew = nil
            } 
        }()
        
        // 准备向consul KV发送查询锁操作的参数
        kv := l.c.KV()
        qOpts := &QueryOptions{
            WaitTime: l.opts.LockWaitTime,
        }
        
        start := time.Now()
        attempts := 0
WAIT:
        // 判断是否需要退出锁争夺的循环
        select {
        case <-stopCh:
            return nil, nil
        default:
        }
        
        // 处理只重试一次的逻辑
        if l.opts.LockTryOnce && attempts > 0 { // 配置该锁只重试一次且已经重试至少一次了
            elapsed := time.Since(start)  // 获取当前时间偏移量
            if elapsed > qOpts.WaitTime { // 当超过设置中的剩余等待时间
                return nil, nil           // 返回空结果
            }
            
            qOpts.WaitTime -= elapsed  // 重设剩余等待时间
        }
        attempts++  // 已尝试次数自增1
        
        // 阻塞查询该存在的分布式锁,直至无法获取成功
        pair, meta, err := kv.Get(l.opts.Key, qOpts)
        if err != nil {
            return nil, fmt.Errorf("failed to read lock: %v", err)
        }
    }
}
// Unlock 尝试释放 consul 分布式锁,如果发生异常则返回 error
func (l *Lock) Unlock() error {
    // 在释放锁之前必须先把 Lock 结构锁住
    l.l.Lock()
    defer l.l.Unlock()
    
    // 确认我们依然持有该锁
    if !isHeld {
        return ErrLockNotHeld
    }
    
    // 提前先将锁的持有权释放
    l.isHeld = false
    
    // 清除刷新 session 通道
    if l.sessionRenew != nil {
        defer func() {
            close(l.sessionRenew)
            l.sessionRenew = nil
        }()
    }
    
    // 获取当前 session 持有的锁信息
    lockEnt := l.lockEntry(l.lockSession)
    l.lockSession = ""
    
    kv := l.c.KV()
    _, _, err := kv.Release(lockEnt, nil) // 将持有的锁尝试释放
    if err != nil {
        return fmt.Errorf("failed to release lock: %v", err)
    }
    return nil
}
​// Destroy 尝试销毁分布式锁,虽然这不是必要的操作。
// 如果该锁正在被使用,则返回error
func (l *Lock) Destroy() error {
    // 在释放锁之前必须先把 Lock 结构锁住
    l.l.Lock()
    defer l.l.Unlock()
    
    // 确认我们依然持有该锁
    if !isHeld {
        return ErrLockNotHeld
    }
    
    // 获取锁
    kv := l.c.KV()
    pair, _, err := kv.Get(l.opts.Key, nil)
    if err != nil {
        return fmt.Errorf("failed to read lock: %v", err)
    }
    
    if pair == nil {
        return nil
    }
    
    // 检查是否有可能状态冲突
    if pair.Flags != LockFlagValue {
        return ErrLockConflict
    }
    
    // 如果锁正在被持有,则返回异常
    if pair.Session != "" {
        return ErrLockUse
    }
    
    // 尝试通过 CAS 删除分布式锁
    didRemove, _, err := kv.DeleteCAS(pair, nil)
    if err != nil {
        return fmt.Errorf("failed to remove lock: %v", err)
    }
    if !didRemove { // 如果没有删除成功,则返回异常
        return ErrLockInUse
    }
    return nil
}

参考文献

  • Wiki - 死锁

你可能感兴趣的:(Consul 之分布式锁)