redis 分布式锁进阶篇

文摘   科技   2023-05-12 21:50   北京  

0 前言

前一周刚和大家分享了一篇 “Golang 分布式锁技术攻略”,在文章中我提出了:在基于 redis 实现分布式锁时,由于缺失锁续约机制以及存在数据弱一致的问题,可能导致在异常场景下分布式锁的独占性无法得到保证. 本文以解决这两个问题作为主线,基于 watchDog 看门狗机制以及 redLock 红锁机制给出对应的解决方案.

 

1 redis 分布式锁实现原理

所谓分布式锁,应当基本如下几项核心性质:

  • • 独占性:对于同一把锁,在同一时刻只能被一个取锁方占有,这是锁最基础的一项特征

  • • 健壮性:即不能产生死锁(dead lock). 假如某个占有锁的使用方因为宕机而无法主动执行解锁动作,锁也应该能够被正常传承下去,被其他使用方所延续使用

  • • 对称性:加锁和解锁的使用方必须为同一身份. 不允许非法释放他人持有的分布式锁

  • • 高可用:当提供分布式锁服务的基础组件中存在少量节点发生故障时,应该不能影响到分布式锁服务的稳定性

 

为保证本篇内容的完整性,下面对 redis 分布式锁的基本实现原理进行介绍,内容在一定程度上和前文“Golang 分布式锁技术攻略”的第2、3章内容有所类似.

1.1 redis 实现分布式锁

前文中我介绍过,分布式锁在实现上可以分为主动轮询型和watch回调型两种模式. 基于 redis 实现的分布式锁属于主动轮询型,其实现思路为:

  • • 针对于同一把分布式锁,使用同一条数据进行标识(以 redis 为例,则为 key 相同的 kv 对)

  • • 假如在存储介质成功插入了该条数据(之前数据不存在),则被认定为加锁成功

  • • 把从存储介质中删除该条数据这一行为认定为释放锁操作

  • • 倘若在插入该条数据时,发现数据已经存在(锁已被他人持有),则持续轮询,直到数据被他人删除(他人释放锁),并由自身完成数据插入动作为止(取锁成功)

  • • 由于是并发场景,需要保证 (1)检查数据是否已被插入(2)数据不存在则插入数据 这两个步骤之间是原子化不可拆分的(在 redis 中是 set only if not exist —— SETNX 操作)

 

1.2 过期时间不精准问题

在使用 redis 分布式锁时,为避免持有锁的使用方因为异常状况导致无法正常解锁,进而引发死锁问题,我们可以使用到 redis 的数据过期时间 expire 机制.

我们通常会在插入分布式锁对应的 kv 数据时设置一个过期时间 expire time,这样即便使用方在持有锁期间发生宕机无法正常解锁,锁对应的数据项也会在达到过期时间阈值后被自动删除,实现分布式锁释放的效果. 此处我们可以通过 redis 的 SETEX 指令,一气呵成地完成上锁+设置过期时间的两个步骤.

 

值得一提的是,这种 expire 机制的使用会引入一个新的问题——过期时间不精准. 因为此处设置的过期时间只能是一个经验值(通常情况下偏于保守),既然是经验值,那就做不到百分之百的严谨性. 试想假如占有锁的使用方在业务处理流程中因为一些异常的耗时(如 IO、GC等),导致业务逻辑处理时间超过了预设的过期时间,就会导致锁被提前释放. 此时在原使用方的视角中,锁仍然持有在自己手中,但在实际情况下,锁数据已经被删除,其他取锁方可能取锁成功,于是就可能引起一把锁同时被多个使用方占用的问题,锁的基本性质——独占性遭到破坏.

我们举一个具体的例子进一步说明这个问题:

  • • moment1:用户 A 与 redis 交互成功取得分布式锁,并设置好过期时间,锁预计在 moment 3 过期

  • • moment2:用户 A 持续处理业务逻辑,过程中出现异常状况,比如 io 处理耗时超出预期,或者出现 GC 等,导致业务处理时间过长(超过 moment3)

  • • moment3:分布式锁自动释放,但是用户 A 不得而知,仍然沉浸在自身的业务处理流程中

  • • moment4:用户 B 尝试获取分布式锁,获取成功

  • • moment5:用户 A 业务流程处理结束,尝试释放锁,却发现锁的归属权已经易主

于是我们发现在 moment4~moment5 的时间范围内,用户 A 和 用户 B 都认为自己是持有分布式锁的,因此它们都会放心地对锁背后保护的临界资源做出修改,最终导致临界资源的数据一致性出现问题.

针对这个问题,在分布式锁工具 redisson 中给出了解决方案——看门狗策略(watch dog strategy):在锁的持有方执行业务逻辑处理的过程中时,需要异步启动一个看门狗守护协程,持续为分布式锁的过期阈值进行延期操作,具体内容我们放在本文第 3 4 章中展开介绍.

 

1.3 数据弱一致性问题

下面我们聊聊 redis 分布式锁可能存在的另一个问题.

回顾 redis 的容错机制:为避免单点故障引起数据丢失问题,redis 会基于主从复制的方式实现数据备份增加服务的容错性. (以哨兵机制为例,哨兵会持续监听 master 节点的健康状况,倘若 master 节点发生故障,哨兵会负责扶持 slave 节点上位成为 master,以保证整个集群能够正常对外提供服务)

此外再补充一个设定,在分布式系统存在一个经典的 CAP 理论:

  • • C:consistency,一致性

  • • A:availability,可用性

  • • P:Partition tolerance,分区容错性

CAP 理论的核心在于,C、A、P 三者不可兼得,最多只能满足其二. 在分布式场景中 P 必须得到满足,于是存储组件会根据策略的倾向性被划分为注重于 C 的 CP 流派和倾向于 A 的 AP 流派.

(大家如果对这部分内容感兴趣,可以阅读我之前发表的文章——“两万字长文解析 raft 算法原理”,里面会聊到经典的分布式共识算法 raft 如何通过精妙的算法设计来化解 C 和 A 之间看似不可调和的矛盾,使得分布式系统能够同时满足数据的强一致性和服务的高可用性)

 

今天我们要聊的主角 redis 走的是 AP 路线,为了保证服务的可用性和吞吐量,redis 在进行数据的主从同步时,采用的是异步执行机制.

我们试想一种场景:

  • • moment1:使用方 A 在 redis master 节点加锁成功,完成了锁数据的写入操作

  • • moment2:redis master 宕机了,锁数据还没来得及同步到 slave 节点

  • • moment3:未同步到锁数据的 slave 节点被哨兵升级为新的 master

  • • moment4:使用方 B 前来取锁,由于新 master 中确实锁数据,所以使用方 B 加锁成功

于是,一锁多持问题就产生了,分布式锁的独占性遭到破坏.

关于这个问题,一个比较经典的解决方案是:redis 红锁(redlock,全称 redis distribution lock). redLock 的策略是通过增加锁的数量并基于多数派准则来解决这个问题. 具体内容我们在本文 5、6 章中展开介绍.

 

2 redis 分布式锁实现源码

第 2 章我会向大家展示一下我在 golang redis 客户端 redigo 基础上实现的 redis 分布式锁开源框架:redis_lock. 在这个项目中,我实现了 redis 分布式锁的基础框架,并在此之上实现了 watchDog 看门狗和 redLock 红锁两项机制. 本章中我主要会展示 redis 分布式锁基础的能力和框架,关于 watchDog 和 redLock 的源码内容会放在本文第 4、6 章中展开介绍.

 

github地址: https://github.com/xiaoxuxiansheng

 

2.1 基本框架

首先,我在 redigo sdk 之上,基于 redis 连接池模式,封装实现了一个简易的 redis 客户端.

 

接下来,以分布式锁使用方的进程 id + 协程 id 拼接生成一个字符串,作为使用方的身份标识;在执行加锁操作时,通过 redis 的 setNEX 操作,把生成的字符串设置为 val,并且设定好锁数据的过期时间;

在执行解锁操作时,通过 lua 脚本原子化执行两个步骤:(1)get 操作获取 val,查看是否和当前使用方身份一致;(2)倘若身份校验通过,执行 del 操作删除锁数据;

在加锁/解锁操作之外,额外支持一个延长锁过期时间的操作. 该操作同样通过 lua 脚本实现,包括两个步骤:(1)get 操作获取 val,查看是否和当前使用方身份一致;(2)倘若身份校验通过,执行 expire 完成锁数据的续期

 

2.2 数据结构

用户在使用 redis_lock 时,可以通过 option 配置项显式指定(1)使用分布式锁时是否采用阻塞模式;(2)阻塞模式下等待锁的超时阈值;(3)分布式锁的过期时间;等几个配置项.

值得一提的是,倘若用户未显式指定锁的过期时间或者指定了一个负值,则锁会自动激活看门狗模式.

package redis_lock


import "time"


const (
    // ...
    // 默认的分布式锁过期时间
    DefaultLockExpireSeconds = 30
    // 看门狗工作时间间隙
    WatchDogWorkStepSeconds = 10
)


// ...
type LockOption func(*LockOptions)


func WithBlock() LockOption {
    return func(*LockOptions) {
        o.isBlock = true
    }
}


func WithBlockWaitingSeconds(waitingSeconds int64) LockOption {
    return func(*LockOptions) {
        o.blockWaitingSeconds = waitingSeconds
    }
}


func WithExpireSeconds(expireSeconds int64) LockOption {
    return func(*LockOptions) {
        o.expireSeconds = expireSeconds
    }
}


func repairLock(*LockOptions) {
    if o.isBlock && o.blockWaitingSeconds <= 0 {
        // 默认阻塞等待时间上限为 5 秒
        o.blockWaitingSeconds = 5
    }


    // 倘若未设置分布式锁的过期时间,则会启动 watchdog
    if o.expireSeconds > 0 {
        return
    }


    // 用户未显式指定锁的过期时间,则此时会启动看门狗
    o.expireSeconds = DefaultLockExpireSeconds
    o.watchDogMode = true
}


type LockOptions struct {
    isBlock             bool
    blockWaitingSeconds int64
    expireSeconds       int64
    watchDogMode        bool
}


const RedisLockKeyPrefix = "REDIS_LOCK_PREFIX_"


var ErrLockAcquiredByOthers = errors.New("lock is acquired by others")


func IsRetryableErr(err error) bool {
    return errors.Is(err, ErrLockAcquiredByOthers)
}

 

RedisLock 是对应于分布式锁的类型. 其中内置了如下几个字段:

  • • 配置类 LockOptions

  • • 分布式锁的唯一标识键 key

  • • 分布式锁使用方的身份标识 token

  • • 分布式锁与 redis 交互的客户端 client

  • • runningDog 和 stopDog 两个字段和看门狗模式相关,在本文第 4 章中展开介绍

// 基于 redis 实现的分布式锁,不可重入,但保证了对称性
type RedisLock struct {
    LockOptions
    key    string
    token  string
    client *Client


    // 看门狗运作标识
    runningDog int32
    // 停止看门狗
    stopDog context.CancelFunc
}


func NewRedisLock(key string, client *Client, opts ...LockOption) *RedisLock {
    r := RedisLock{
        key:    key,
        token:  utils.GetProcessAndGoroutineIDStr(),
        client: client,
    }


    for _, opt := range opts {
        opt(&r.LockOptions)
    }


    repairLock(&r.LockOptions)
    return &r
}

 

2.3 加锁

执行分布式锁的加锁操作时,使用到的是 redis 的 setNEX 指令,其中设置 key 对应的是分布式锁的唯一标识键,val 对应的是使用方的身份标识 token.

加锁操作分为非阻塞模式和阻塞模式两种类型.

在非阻塞模式下,加锁流程会尝试执行一次 setNEX 动作,倘若发现锁数据已经存在,说明锁已经被他人持有,此时会直接返回错误.

import (
    "context"
    "errors"
    "fmt"
    "sync/atomic"
    "time"


    "github.com/xiaoxuxiansheng/redis_lock/utils"
)


// Lock 加锁.
func (*RedisLock) Lock(ctx context.Context) (err error) {
    defer func() {
        if err != nil {
            return
        }
        // 加锁成功的情况下,会启动看门狗
        // 关于该锁本身是不可重入的,所以不会出现同一把锁下看门狗重复启动的情况
        r.watchDog(ctx)
    }()


    // 不管是不是阻塞模式,都要先获取一次锁
    err = r.tryLock(ctx)
    if err == nil {
        return nil
    }


    // 非阻塞模式加锁失败直接返回错误
    if !r.isBlock {
        return err
    }


    // 判断错误是否可以允许重试,不可允许的类型则直接返回错误
    if !IsRetryableErr(err) {
        return err
    }


    // 基于阻塞模式持续轮询取锁
    err = r.blockingLock(ctx)
    return
}


func (*RedisLock) tryLock(ctx context.Context) error {
    // 首先查询锁是否属于自己
    reply, err := r.client.SetNEX(ctx, r.getLockKey(), r.token, r.expireSeconds)
    if err != nil {
        return err
    }
    if reply != 1 {
        return fmt.Errorf("reply: %d, err: %w", reply, ErrLockAcquiredByOthers)
    }


    return nil
}


func (*RedisLock) getLockKey() string {
    return RedisLockKeyPrefix + r.key
}

 

 

在阻塞模式下,会创建一个执行间隔为 50 ms 的 time ticker,在 time ticker 的驱动下会轮询执行 tryLock 操作尝试取锁,直到出现下述四种情况之一时,流程才会结束:

  • • 成功取到锁

  • • 上下文 context 被终止

  • • 阻塞模式等锁的超时阈值达到了

  • • 取锁时遇到了预期之外的错误

func (*RedisLock) blockingLock(ctx context.Context) error {
    // 阻塞模式等锁时间上限
    timeoutCh := time.After(time.Duration(r.blockWaitingSeconds) * time.Second)
    // 轮询 ticker,每隔 50 ms 尝试取锁一次
    ticker := time.NewTicker(time.Duration(50) * time.Millisecond)
    defer ticker.Stop()


    for range ticker.{
        select {
        // ctx 终止了
        case <-ctx.Done():
            return fmt.Errorf("lock failed, ctx timeout, err: %w", ctx.Err())
            // 阻塞等锁达到上限时间
        case <-timeoutCh:
            return fmt.Errorf("block waiting time out, err: %w", ErrLockAcquiredByOthers)
        // 放行
        default:
        }


        // 尝试取锁
        err := r.tryLock(ctx)
        if err == nil {
            // 加锁成功,返回结果
            return nil
        }


        // 不可重试类型的错误,直接返回
        if !IsRetryableErr(err) {
            return err
        }
    }


    // 不可达
    return nil
}

 

2.4 解锁

解锁方法基于 lua 脚本实现,保证原子化地执行两个步骤:

  • • 校验锁是否属于自己.(拿当前锁的 token 和锁数据的 val 进行对比)

  • • 如果锁是属于自己的,则调用 redis del 指令删除锁数据

// Unlock 解锁. 基于 lua 脚本实现操作原子性.
func (*RedisLock) Unlock(ctx context.Context) (err error) {
    defer func() {
        if err != nil {
            return
        }


        // 停止看门狗
        if r.stopDog != nil {
            r.stopDog()
        }
    }()


    keysAndArgs := []interface{}{r.getLockKey(), r.token}
    reply, _err := r.client.Eval(ctx, LuaCheckAndDeleteDistributionLock, 1, keysAndArgs)
    if _err != nil {
        err = _err
        return
    }


    if ret, _ := reply.(int64); ret != 1 {
        err = errors.New("can not unlock without ownership of lock")
    }


    return nil
}

 

解锁操作的 lua 脚本实现如下:

// LuaCheckAndDeleteDistributionLock 判断是否拥有分布式锁的归属权,是则删除
const LuaCheckAndDeleteDistributionLock = `
  local lockerKey = KEYS[1]
  local targetToken = ARGV[1]
  local getToken = redis.call('get',lockerKey)
  if (not getToken or getToken ~= targetToken) then
    return 0
  else
    return redis.call('del',lockerKey)
  end
`

 

2.5 延期锁

为了支持看门狗模式的运行,此处补充实现了延长锁过期时间的操作. 该方法也是基于 lua 脚本实现,分为两个步骤:

  • • 校验锁是否属于自己.(拿当前锁的 token 和锁数据的 val 进行对比)

  • • 如果锁是属于自己的,则调用 redis 的 expire 指令进行锁数据的延期操作

// 更新锁的过期时间,基于 lua 脚本实现操作原子性
func (*RedisLock) DelayExpire(ctx context.Context, expireSeconds int64) error {
    keysAndArgs := []interface{}{r.getLockKey(), r.token, expireSeconds}
    reply, err := r.client.Eval(ctx, LuaCheckAndExpireDistributionLock, 1, keysAndArgs)
    if err != nil {
        return err
    }


    if ret, _ := reply.(int64); ret != 1 {
        return errors.New("can not expire lock without ownership of lock")
    }


    return nil
}

 

延期锁的 lua 脚本实现如下:

const LuaCheckAndExpireDistributionLock = `
  local lockerKey = KEYS[1]
  local targetToken = ARGV[1]
  local duration = ARGV[2]
  local getToken = redis.call('get',lockerKey)
  if (not getToken or getToken ~= targetToken) then
    return 0
  else
    return redis.call('expire',lockerKey,duration)
  end
`

 

3 watch dog 实现原理

3.1 续约机制

 

聊看门狗机制前先回顾一下前文 “Golang 分布式锁技术攻略” 中介绍 watch 回调型分布式锁时聊到的 etcd 续约机制,因为该机制实际上和此处要聊的 watchDog 看门狗机制非常类似.

租约,顾名思义,是一份具有时效性的协议,一旦达到租约上规定的截止时间,租约就会失去效力

在使用 etcd 的租约能力时,用户会先预设一个租约过期时间,但并非一个绝对意义的截止时间,因为租约是支持动态续约操作的. 接下来用户可以异步启动一个续约协程,按照指定的时间节奏进行续约操作,延长租约的过期时间. 这样实现的好处在于:

  • • 使用方规避了因为业务逻辑处理过长,导致租约数据(包含了分布式锁)提前过期释放的问题(因为有续约协程持续进行续约)

  • • 规避了因锁持有方宕机导致租约数据无法释放,内部包含的分布式锁产生死锁问题(倘若持有方宕机了,那续约协程也就停止工作了,续约工作未正常执行,租约会在下一个过期时间节点被回收,包含的锁数据也就释放了)

 

3.2 redisson

和大家明确一下 watchDog 这种思路的出处,是源自于一款基于 java 编写的 redis 分布式锁工具 redisson 当中. 本文内容就是借鉴了 redisson 的实现思路,实现了一个 golang 版本的看门狗.

redisson 的 github 地址:https://github.com/redisson/redisson

 

3.3 看门狗 watchDog

下面聊聊看门狗模式的执行流程(其实和 etcd 的租约续约机制非常类似):

  • • 在执行 redis 分布式锁的上锁操作时,通过 setNEX 指令完成锁数据的设置,携带了一个默认的锁数据过期时间

  • • 确认上锁成功后,异步启动一个 watchDog 守护协程,按照锁默认过期时间 1/4 ~ 1/3 的节奏(可自由设置),持续地对锁数据进行 expire 续期操作

  • • 在解锁成功后,会负责关闭 watchDog,回收协程资源. (由于看门狗续期操作会先检查锁的所有权再延期数据,因此实际上使用方只要删除了锁数据,续期操作就不会生效了. 回收看门狗协程是为了规避协程泄漏问题)

 

4 watch dog 实现源码

4.1 数据结构

在引入看门狗机制后,分布式锁实现类 RedisLock 当中新增了以下几项内容:

  • • runningDog 用于标识锁对应的看门狗是否正在运行,通过该字段作为校验条件,保证一把锁内最多只有一只看门狗启动

  • • stopDog 是用于停止看门狗的控制器函数,实际上是 context.CancelFunc 类型,通过终止 context 的方式来逼停看门狗协程

  • • 在 LockOptions 中新增了 watchDogMode 标识. 用户在创建分布式锁时倘若未设置过期时间或者设置过期时间为负值的话,该标识会被置为 true,此时看门狗才真正有机会得到启动.

// 基于 redis 实现的分布式锁,不可重入,但保证了对称性
type RedisLock struct {
    LockOptions
    key    string
    token  string
    client *Client


    // 看门狗运作标识
    runningDog int32
    // 停止看门狗
    stopDog context.CancelFunc
}

 

type LockOptions struct {
    isBlock             bool
    blockWaitingSeconds int64
    expireSeconds       int64
    // 看门狗模式标识
    watchDogMode        bool
}

 

4.2 启动看门狗

 

// Lock 加锁.
func (*RedisLock) Lock(ctx context.Context) (err error) {
    defer func() {
        if err != nil {
            return
        }
        // 加锁成功的情况下,会启动看门狗
        // 关于该锁本身是不可重入的,所以不会出现同一把锁下看门狗重复启动的情况
        r.watchDog(ctx)
    }()
    // ...
}

 

启动看门狗的启动时机,是在确认使用方成功取得分布式锁之后. 此时会步入 RedisLock.watchDog 方法当中,方法内的执行步骤包括:

  • • 判断 watchDogMode 标识是否为 true,只有为 true 才会真正启动看门狗

  • • 开启一轮 cas 自旋操作,确保基于 cas 操作将 runningDog 标识的值由 0 改为 1 时,流程才会向下执行. 这段自旋流程的目的是为了避免看门狗的重复运行

  • • 创建出一个子 context 用于协调看门狗协程的生命周期,同时将子 context 的 cancel 函数注入到 RedisLock.stopLock 当中,作为后续关闭看门狗协程的控制器

  • • 调用 RedisLock.runWatchDog 方法,遵循用户定义的时间节奏,持续地执行对分布式锁的延期操作. (延期操作保证只有在锁是属于自己时,延期动作才能成功. 具体见本文 2.5 小节)

// 启动看门狗
func (*RedisLock) watchDog(ctx context.Context) {
    // 1. 非看门狗模式,不处理
    if !r.watchDogMode {
        return
    }


    // 2. 确保之前启动的看门狗已经正常回收
    for !atomic.CompareAndSwapInt32(&r.runningDog, 0, 1) {
    }


    // 3. 启动看门狗
    ctx, r.stopDog = context.WithCancel(ctx)
    go func() {
        defer func() {
            atomic.StoreInt32(&r.runningDog, 0)
        }()
        r.runWatchDog(ctx)
    }()
}

 

func (*RedisLock) runWatchDog(ctx context.Context) {
    ticker := time.NewTicker(WatchDogWorkStepSeconds * time.Second)
    defer ticker.Stop()


    for range ticker.{
        select {
        case <-ctx.Done():
            return
        default:
        }


        // 看门狗负责在用户未显式解锁时,持续为分布式锁进行续期
        // 通过 lua 脚本,延期之前会确保保证锁仍然属于自己
        _ = r.DelayExpire(ctx, WatchDogWorkStepSeconds)
    }
}

 

4.3 停止看门狗

当用户在成功完成分布式锁的释放动作后,会负责停止看门狗的运行. 在确认解锁操作已经成功时,会调用 RedisLock.stopWatchDog 控制器,通过终止看门狗守护协程对应 context 的方式,逼停看门狗协程. 在这个过程中,当看门狗协程已经停止时,会把 RedisLock.runningDog 标识为 0. 这样倘若用户重新加锁并需要启动看门狗时,才能完成将 runningDog 标识由 0 置为 1 的 cas 操作. 设置这个标识的目标,是为了保证看门狗协程不会重复运行,进一步规避潜在的协程泄漏问题.

// Unlock 解锁. 基于 lua 脚本实现操作原子性.
func (*RedisLock) Unlock(ctx context.Context) (err error) {
    defer func() {
        if err != nil {
            return
        }


        // 停止看门狗
        if r.stopDog != nil {
            r.stopDog()
        }
    }()


    // ...
}

 

5 red lock 实现原理

本文 1.3 小节提出了因 redis 走的是注重于高可用性的 AP 流派,因此存在数据弱一致的问题,进而导致 redis 分布式锁可能出现同时被多方持有的情况. 本章开始,基于 red lock 红锁机制尝试提出这个问题的解决方案.

 

5.1 多数派原则

所谓多数派原则,就是做出一项决议之前,让所有的参与者进行投票表决,只有投赞同票的人数达到参与者总人数的一半以上成为多数派时,这项决议才被通过.

在红锁 RedLock 实现中,会基于多数派准则进行 CAP 中一致性 C 和可用性 A 之间矛盾的缓和,保证在 RedLock 下所有 redis 节点中达到半数以上节点可用时,整个红锁就能够正常提供服务.

 

5.2 红锁 redLock

红锁 Redlock 全称 redis distribution lock,是 redis 作者 antirez 提出的一种分布式锁实现方案.

在红锁的实现中:

  • • 我们假定集群中有 2N+1个 redis 节点(通常将节点总数设置为奇数,有利于多数派原则的执行效率)

  • • 这些 redis 节点彼此间是相互独立的,不存在从属关系

  • • 每次客户端尝试进行加锁操作时,会同时对2N+1个节点发起加锁请求

  • • 每次客户端向一个节点发起加锁请求时,会设定一个很小的请求处理超时阈值

  • • 客户端依次对2N+1个节点发起加锁请求,只有在小于请求处理超时阈值的时间内完成了加锁操作,才视为一笔加锁成功的请求

  • • 过完2N+1个节点后,统计加锁成功的请求数量

  • • 倘若加锁请求成功数量大于等于N+1(多数派),则视为红锁加锁成功

  • • 倘若加锁请求成功数量小于N+1,视为红锁加锁失败,此时会遍历2N+1个节点进行解锁操作,有利于资源回收,提供后续使用方的取锁效率

 

6 red lock 实现源码

6.1 数据结构

首先实现了红锁的两个配置项 Option,用户可以显式指定红锁 RedLock 中单个锁节点的请求过期时间以及宏观意义上整个红锁的过期时间.

用户在创建红锁时,需要通过传入一个 SingleNodeConf 列表的方式,显式指定每个 redis 锁节点的地址信息.

type RedLockOption func(*RedLockOptions)


type RedLockOptions struct {
    singleNodesTimeout time.Duration
    expireDuration     time.Duration
}


func WithSingleNodesTimeout(singleNodesTimeout time.Duration) RedLockOption {
    return func(*RedLockOptions) {
        o.singleNodesTimeout = singleNodesTimeout
    }
}


func WithRedLockExpireDuration(expireDuration time.Duration) RedLockOption {
    return func(*RedLockOptions) {
        o.expireDuration = expireDuration
    }
}


type SingleNodeConf struct {
    Network  string
    Address  string
    Password string
    Opts     []ClientOption
}


func repairRedLock(*RedLockOptions) {
    if o.singleNodesTimeout <= 0 {
        o.singleNodesTimeout = DefaultSingleLockTimeout
    }
}

 

RedLock 是红锁的实现类,其中内置了 redis 锁节点列表:locks. 后续用户在使用红锁 RedLock 时需要遵循如下规则:

  • • 每个 redis lock 背后对应的锁节点需要是独立的 redis 物理节点

  • • redis 锁节点的数量至少达到3个

  • • 倘若用户显示指定了红锁 RedLock 的过期时间,那么需要保证设置的所有 redis 锁节点的请求超时阈值之和需要小于红锁过期时间的 1/10,以保证用户取得红锁后有充足的时间处理业务逻辑

package redis_lock
import (
    "context"
    "errors"
    "time"
)


// 红锁中每个节点默认的处理超时时间为 50 ms
const DefaultSingleLockTimeout = 50 * time.Millisecond


type RedLock struct {
    locks []*RedisLock
    RedLockOptions
}


func NewRedLock(key string, confs []*SingleNodeConf, opts ...RedLockOption) (*RedLock, error) {
    // 3 个节点以上,红锁才有意义
    if len(confs) < 3 {
        return nil, errors.New("can not use redLock less than 3 nodes")
    }


    r := RedLock{}
    for _, opt := range opts {
        opt(&r.RedLockOptions)
    }


    repairRedLock(&r.RedLockOptions)
    if r.expireDuration > 0 && time.Duration(len(confs))*r.singleNodesTimeout*10 > r.expireDuration {
        // 要求所有节点累计的超时阈值要小于分布式锁过期时间的十分之一
        return nil, errors.New("expire thresholds of single node is too long")
    }


    r.locks = make([]*RedisLock, 0, len(confs))
    for _, conf := range confs {
        client := NewClient(conf.Network, conf.Address, conf.Password, conf.Opts...)
        r.locks = append(r.locks, NewRedisLock(key, client, WithExpireSeconds(int64(r.expireDuration.Seconds()))))
    }


    return &r, nil
}

 

6.2 加锁

 

红锁 RedLock 加锁流程的核心步骤包括:

  • • 遍历对所有的 redis 锁节点,分别执行加锁操作

  • • 对每笔 redis 锁节点的交互请求进行时间限制,保证控制在 singleNodesTimeout 之内

  • • 对 singleNodesTimeout 请求耗时内成功完成的加锁请求数进行记录

  • • 遍历执行完所有 redis 节点的加锁操作后,倘若成功加锁请求数量达到 redis 锁节点总数的一半以上,则视为红锁加锁成功

  • • 倘若 redis 节点锁加锁成功数量未达到多数,则红锁加锁失败,此时会调用红锁的解锁操作,尝试对所有的 redis 锁节点执行一次解锁操作

func (*RedLock) Lock(ctx context.Context) error {
    var successCnt int
    for _, lock := range r.locks {
        startTime := time.Now()
        // 保证请求耗时在指定阈值以内
        _ctx, cancel := context.WithTimeout(ctx, r.singleNodesTimeout)
        defer cancel()
        err := lock.Lock(_ctx)
        cost := time.Since(startTime)
        if err == nil && cost <= r.singleNodesTimeout {
            successCnt++
        }
    }


    if successCnt < len(r.locks)>>1+1 {
        // 倘若加锁失败,则进行解锁操作
        _ = r.Unlock(ctx)
        return errors.New("lock failed")
    }


    return nil
}

 

6.3 解锁

在红锁 RedLock 的解锁流程中,会对遍历所有的 redis 锁节点,依次执行解锁操作. 其中解锁操作内容可见本文 2.4 小节,会基于 lua 脚本先检查后删数据,保证解锁操作的合法性.

// 解锁时,对所有节点广播解锁
func (*RedLock) Unlock(ctx context.Context) error {
    var err error
    for _, lock := range r.locks {
        if _err := lock.Unlock(ctx); _err != nil {
            if err == nil {
                err = _err
            }
        }
    }
    return err
}

 

7 总结

本文针对于 redis 分布式锁中存在的过期时间不精确以及数据弱一致性问题提出了对应的解决方案——看门狗机制和红锁机制. 内容介绍均分为原理分析和源码展示两种方式.


小徐先生的编程世界
在学钢琴,主业码农
 最新文章