1 redis分布式锁实现原理
分布式锁的几个核心性质如下:
- 独占性:对于同一把锁,在同一时刻只能被一个取锁方占有,这是锁最基础的一项特征
- 健壮性:即不能产生死锁(dead lock).假如某个占有锁的使用方因为宕机而无法主动执行解锁操作,锁也应该能够被正常传承下去,被其他使用方所延续
- 对称性:加锁和解锁的使用方必须为同一身份,不允许非法释放他人持有的分布式锁
- 高可用:当提供分布式锁服务的基础组件中存在少量节点发生故障时,应该不能影响到分布式锁服务的稳定性
1.1 redis实现分布式锁
借用小徐先生的绘制图
基于redis实现的主动轮询型分布式锁的实现思路如下:
- 针对同一把分布式锁,使用同一条数据进行标识(相同的key)
- 假如在储存介质成功插入了这条数据(这条数据之前不存在),则被认定加锁成功
- 把从存储介质中删除该条数据这一行认定为释放锁的操作
- 如果在插入该条数据时,发现数据已经存在(即锁已经被他人持有),则在有限时间内持续轮询,直到数据被他人删除(即为他人释放了锁),并由自身完成数据插入(取锁成功)
- 因为是并发场景,需要保证1.检查数据是否已经被插入 2.数据不存在则插入数据 这两个步骤之间是原子化不可拆分的(在redis中是set only if not exist -- SETNX操作)
1.2 redis的过期时间不精准问题
在使用redis分布式锁时,为避免持有锁的使用方因为异常状况导致无法正常解锁,进而引发死锁问题,我们可以使用到redis的数据过期时间expire机制,
我们使用redis插入数据时通常会给分布式锁对应的kv数据设置一个
过期时间expire time,这样即便使用方在持有锁期间发生宕机无法正常解锁,锁对应的数据项也会在到达过期时间阈值后被自动删除,实现分布式锁释放的效果。下面是redis的setex指令
但是,这种expire机制的使用会引入一个新的问题--过期时间不精准。因此此处设置的过期时间只能是一个经验值(通常情况下偏于保守),既然是经验值,那就做不到百分之一百的严谨性。试想如果占有锁的使用方在业务处理流程中因为一些异常的耗时(如IO、GC等),导致业务逻辑处理时间超过了预设的过期时间,就会导致锁被提前释放,此时在原使用方的视角中,他以为锁此时还是持有在自己手上,但是,实际情况是所得数据已经被删除,这个时候别的取锁方有可能取锁成功,于是就有可能出现一把锁同时被多个使用方占有的问题,锁的基本性质--独占性遭到破坏。
一个样例:
- 时间线1:用户A与redis交互成功取得分布式锁,并设置好过期时间,锁会在时间线3过期
- 时间线2:用户A持续处理业务逻辑,过程中出现异常状况,比如io处理耗时出现异常,或者出现gc等,导致业务处理时间过长(超过时间线3也就是过了锁的有效时间范围)
- 时间线3:用户A取得锁到期自动释放,但是这个时候用户A还在处理业务,根本不知道锁已经过期
- 时间线4:用户B尝试获取分布式锁,获取成功
- 时间线5:用户A业务处理完成,尝试释放锁,但是却发现所的归属权已经易主
在时间线4-5的时间范围内,用户A和用户B都认为自己是持有分布式锁的,因此他们都会放心地对锁背后保护临界资源作出修改,最终导致临界资源的数据一致性出现问题。(后续介绍看门狗策略解决这个问题)
1.3 redis数据弱一致性问题
redis的容错机制:为避免单点故障引起数据丢失问题,redis会基于主从复制的方式实现数据备份增加服务的容错性。(以哨兵机制为例,哨兵会持续监听master节点的健康状况,倘若master节点发生故障,哨兵会负责slave节点上位成为master,以保证整个集群能够正常对外提供服务)
分布式系统的CAP理论:
- C:consistency 一致性
- A:availablility 可用性
- P:Partition tolerance 分区容错性
CAP理论的核心在于,C,A,P三者不可得兼,最多只能满足其二。在分布式场景中p必须得到满足,于是存储主键会根据策略的倾向性被划分为注重于C的CP流派和倾向于A的AP流派。
redis走的是AP流派,为了保障服务的可用性和吞吐量,redis在进行数据的主从同步时,redis用的是异步执行机制。(借用小徐先生的绘制图)
试想以下的场景:
- 时间线1:使用方A在redis master节点加锁成功,完成了锁数据的写入操作
- 时间线2:redis master宕机了,锁数据还没来得及同步到slave节点
- 时间线3:未同步锁的数据的slave节点被哨兵升级为新的master
- 时间线4:使用方B前来取锁,由于新的master中没有该锁的数据,所以使用方B加锁成功
- 这个时候一锁多持的问题又产生了,锁的独占性遭到破坏
这个问题的解决方案是redis 红锁(redlock),后续展开
2 redis分布式锁实现源码
2.1 redis相关准备
1. 使用redisgo的sdk封装redis的方法。(主要使用redisgo封装好的连接池获取redis连接)gomodule/redigo: Go client for Redis (github.com)
2.加锁操作: 使用SETNEX(),设置带过期时间的kv对,只有在k不存在时才能设置成功
3.解锁操作:使用Eval(),通过lua脚本原子化执行两个步骤:(1)get操作获取val,查看是否和当前使用方身份一致 (2)如果身份验证通过,执行del操作删除锁数据
4.延迟锁的使用时间:同样通过lua脚本实现, (1)get操作获取val,查看是否和当前使用方身份一致 (2) 如果身份一致,执行expire完成锁数据的续期
5.锁的身份标识以使用方的进程id+协程id拼接成一个字符串
2.2 数据结构
option配置项 (如果没有指定锁的过期时间会使用默认的过期时间并开启看门狗)
package redis_lock_test
import "time"
const (
// 默认空闲连接超时释放时间 10s
DefaultIdleTimeoutSeconds = 10
// 默认最大空闲连接数量
DefaultMaxIdle = 20
// 默认最大激活连接数量
DefaultMaxActive = 100
// 分布式锁的过期时间
DefaultLockExpiredSeconds = 3
// 看门狗的工作间隙
WatchDogWorkStepSeconds = 1
)
// 连接配置
type ClientOptions struct {
// 最大空闲连接数量
maxIdle int
// 空闲连接超时时间
idleTimeoutSeconds int
// 最大连接数量
maxActive int
// 阻塞模式下是否等待。获取连接时如果已经达到了最大连接数量,true的话会等待别的协程释放连接,false就不会等待直接返回
wait bool
// 连接方式
netWork string
// 连接地址
address string
// 连接密码
password string
}
type ClientOption func(c *ClientOptions)
// 配置连接最大空闲数量
func WithMaxIdle(maxIdle int) ClientOption {
return func(c *ClientOptions) {
c.maxIdle = maxIdle
}
}
// 配置空闲连接超时时间
func WithIdleTimeout(idleTimeoutSeconds int) ClientOption {
return func(c *ClientOptions) {
c.idleTimeoutSeconds = idleTimeoutSeconds
}
}
// 配置最大连接数量
func WithMaxActive(maxActive int) ClientOption {
return func(c *ClientOptions) {
c.maxActive = maxActive
}
}
// 阻塞模式下是否等待
func WithWaitMode() ClientOption {
return func(c *ClientOptions) {
c.wait = true
}
}
// 修复连接配置的信息
func RepairClient(c *ClientOptions) {
if c.maxIdle < 0 {
c.maxIdle = DefaultMaxIdle
}
if c.idleTimeoutSeconds < 0 {
c.idleTimeoutSeconds = DefaultIdleTimeoutSeconds
}
if c.maxActive < 0 {
c.maxActive = DefaultMaxActive
}
}
// 锁的配置
type LockOptions struct {
// 是否开启阻塞模式
isBlock bool
// 阻塞模式等待时间
blockWaitSeconds int64
// 锁的过期时间
expireSeconds int64
// 是否开启看门狗策略
watchDogMode bool
}
type LockOption func(l *LockOptions)
// 配置锁的阻塞模式
func WithBlock() LockOption {
return func(l *LockOptions) {
l.isBlock = true
}
}
// 配置阻塞模式等待时间
func WithBlockWaitSeconds(blockWaitSeconds int64) LockOption {
return func(l *LockOptions) {
l.blockWaitSeconds = blockWaitSeconds
}
}
// 配置锁的过期时间
func WithExpireSeconds(expireSeconds int64) LockOption {
return func(l *LockOptions) {
l.expireSeconds = expireSeconds
}
}
// 配置看门狗模式
func WithWatchDogMode() LockOption {
return func(l *LockOptions) {
l.watchDogMode = true
}
}
// 修复锁的配置信息
func RepairLock(l *LockOptions) {
if l.isBlock && l.blockWaitSeconds <= 0 {
// 阻塞模式默认等待5s
l.blockWaitSeconds = 5
}
if l.expireSeconds <= 0 {
// 没有配置或者配置错误,使用默认锁的过期时间并且开启看门狗
l.expireSeconds = DefaultLockExpiredSeconds
l.watchDogMode = true
}
}
// 红锁配置信息
type RedLockOptions struct {
// 单节点连接超时时间
singleNodesTimeout time.Duration
// 锁的过期时间
expireDuration time.Duration
}
type RedLockOption func(r *RedLockOptions)
// 配置单节点连接超时时间
func WithSingleNodesTimeoutSeconds(singleNodesTimeout time.Duration) RedLockOption {
return func(r *RedLockOptions) {
r.singleNodesTimeout = singleNodesTimeout
}
}
// 配置红锁的过期时间
func WithExpireDuration(expireDuration time.Duration) RedLockOption {
return func(r *RedLockOptions) {
r.expireDuration = expireDuration
}
}
// 修复红锁的配置信息
func RepairRedLockOption(r *RedLockOptions) {
if r.singleNodesTimeout <= 0 {
r.singleNodesTimeout = 50
}
}
// 单节点配置
type SingleNodeConf struct {
// 连接方式
NetWork string
// 连接地址
Address string
// 密码
Password string
// 连接配置选项
Opts []ClientOption
}
redis是封装redis的SetNEX和Eval方法
package redis_lock_test
import (
"context"
"errors"
"strings"
"time"
"github.com/gomodule/redigo/redis"
)
type LockClient interface {
SetNEX(ctx context.Context, key, value string, expireSeconds int64) (int64, error)
Eval(ctx context.Context, src string, keyCount int, keyAndArgs []interface{}) (interface{}, error)
}
// Client Redis 客户端
type Client struct {
pool *redis.Pool
ClientOptions
}
// 创建Redis客户端
func NewClient(netWork, address, password string, clientOption ...ClientOption) *Client {
c := Client{
ClientOptions: ClientOptions{
netWork: netWork,
address: address,
password: password,
},
}
for _, opt := range clientOption {
opt(&c.ClientOptions)
}
RepairClient(&c.ClientOptions)
pool := c.getRedisPool()
return &Client{
pool: pool,
}
}
// 创建redis连接池
func (c *Client) getRedisPool() *redis.Pool {
return &redis.Pool{
// 最大空闲连接
MaxIdle: c.maxIdle,
// 空闲等待超时时间
IdleTimeout: time.Duration(c.idleTimeoutSeconds) * time.Second,
// 最大连接鼠粮
MaxActive: c.maxActive,
// 阻塞模式下是否等待连接
Wait: c.wait,
// 创建redis连接的函数
Dial: func() (redis.Conn, error) {
conn, err := c.getRedisConn()
if err != nil {
return nil, err
}
return conn, nil
},
TestOnBorrow: func(c redis.Conn, t time.Time) error {
_, err := c.Do("PING")
return err
},
}
}
// 与redis建立连接
func (c *Client) getRedisConn() (redis.Conn, error) {
if c.address == "" {
panic("Cannot get redis address from config")
}
// 加载redis密码进入连接配置
var dialOpts []redis.DialOption
if len(c.password) > 0 {
dialOpts = append(dialOpts, redis.DialPassword(c.password))
}
conn, err := redis.DialContext(context.Background(), c.netWork, c.address, dialOpts...)
if err != nil {
return nil, err
}
return conn, nil
}
// 从redis的连接池获取连接
func (c *Client) GetConn(ctx context.Context) (redis.Conn, error) {
return c.pool.GetContext(ctx)
}
// 封装redis的setnex方法
func (c *Client) SetNEX(ctx context.Context, key, value string, expireSeconds int64) (int64, error) {
if key == "" || value == "" {
return -1, errors.New("redis SET key or value can't be empty")
}
conn, err := c.pool.GetContext(ctx)
if err != nil {
return -1, err
}
defer conn.Close()
reply, err := conn.Do("SET", key, value, "EX", expireSeconds, "NX")
if err != nil {
return -1, nil
}
if respStr, ok := reply.(string); ok && strings.ToLower(respStr) == "ok" {
return 1, nil
}
return redis.Int64(reply, err)
// return redis.Int64(conn.Do("SET", key, value, "EX", expireSeconds, "NX"))
}
// Eval 支持使用lua脚本
func (c *Client) Eval(ctx context.Context, src string, keyCount int, keyAndArgs []interface{}) (interface{}, error) {
args := make([]interface{}, 2+len(keyAndArgs))
// lua语句变量名称
args[0] = src
// 有多少个键
args[1] = keyCount
copy(args[2:], keyAndArgs)
conn, err := c.pool.GetContext(ctx)
if err != nil {
return -1, err
}
defer conn.Close()
return conn.Do("EVAL", args...)
}
redisLock锁的对象
package redis_lock_test
import (
"context"
"errors"
"redis-lock-test/utils"
"sync/atomic"
"time"
"github.com/gomodule/redigo/redis"
)
// redis 分布式锁的公共前缀
const RedisLockKeyPrefix = "REDIS_LOCK_PREFIX_"
// 锁已经被别的协程取得
var ErrLockAcquireByOthers = errors.New("lock is acquired by others")
var ErrNil = redis.ErrNil
// 判断错误是不是可重试错误
func IsRetryableErr(err error) bool {
return errors.Is(err, ErrLockAcquireByOthers)
}
// 基于redis实现的分布式锁
type RedisLock struct {
// 锁的配置
LockOptions
// 锁的键
key string
// 锁的标识
token string
// redis客户端
client LockClient
// 看门狗运行标识
runningDog int32
// 看门狗关闭函数
stopDog context.CancelFunc
}
// 创建锁的对象
func NewRedisLock(key string, client LockClient, lockOption ...LockOption) *RedisLock {
r := RedisLock{
key: key,
token: utils.GetProcessAndGoroutineIDStr(),
client: client,
}
for _, opt := range lockOption {
opt(&r.LockOptions)
}
RepairLock(&r.LockOptions)
return &r
}
2.3 加锁操作
加锁过程整体流程图如下:
先不看看门狗
在执行redis分布式锁加锁操作的同时,使用的是redis的SetNEX指令,其中;设置的key对应的是分布式锁的唯一标识键,value对应的是使用方的身份标识token。
加锁操作分为阻塞模式和非阻塞模式两种类型。
- 非阻塞模式:加锁流程会尝试一次SetNEX操作,如果发现锁数据已经存在,说明锁已经被他人持有,此时直接返回错误。锁数据不存在则上锁成功,返回nil
// redisLock对象加锁
func (r *RedisLock) Lock(ctx context.Context) (err error) {
defer func() {
if err != nil {
return
}
// 加锁成功后判断是否需要开启看门狗模式
if r.watchDogMode {
r.startWatchDog(ctx)
}
}()
err = r.tryLock(ctx)
// 加锁成功直接返回
if err == nil {
return nil
}
// 加锁失败,先判断是否是阻塞模式
if !r.isBlock {
// 不是阻塞模式直接返回错误
return err
}
// 阻塞模式下接着判断是否为可重试的错误
if !IsRetryableErr(err) {
return err
}
// 到此即为阻塞模式下取锁失败并且错误是可重试,进入阻塞模式取锁
return r.blockTryLock(ctx)
}
// 尝试加锁
func (r *RedisLock) tryLock(ctx context.Context) (err error) {
resp, err := r.client.SetNEX(ctx, r.getLockKey(), r.token, r.expireSeconds)
// if err != nil {
// // 如果该数据已经存入会出现 redigo: nil returned
// return err
// }
if err != nil || resp != 1 {
return ErrLockAcquireByOthers
}
return nil
}
// 获取redis的key
func (r *RedisLock) getLockKey() string {
return RedisLockKeyPrefix + r.key
}
- 阻塞模式:在第一次尝试加锁SetNEX操作失败后,进入阻塞模式,然后创建一个50ms的time ticker,在time ticker的驱动下会轮询执行tryLock操作尝试取锁,一直到出现下面的三种情况之一,流程才会结束:
- 1.上下文context被终止
- 2.达到了阻塞模式等锁的超时阈值
- 3.成功取到锁
// 阻塞模式下尝试取锁
func (r *RedisLock) blockTryLock(ctx context.Context) (err error) {
// 阻塞模式下超时时间
blockTimeoutCh := time.After(time.Duration(r.blockWaitSeconds) * time.Second)
// 每隔 50ms尝试一次上锁操作
ticker := time.NewTicker(time.Duration(50) * time.Millisecond)
defer ticker.Stop()
for range ticker.C {
select {
case <-ctx.Done():
return errors.New("context is done")
case <-blockTimeoutCh:
return errors.New("blocking wait time exceeds upper limit")
default:
// 默认放行
}
// 放行后继续尝试上锁
err = r.tryLock(ctx)
if err == nil {
return nil
}
if !IsRetryableErr(err) {
return err
}
}
// 此处不可达
return nil
}
2.4 解锁操作
解锁方法基于lua脚本实现,原子化的执行一下的两个步骤:
- 校验锁是否属于自己 --> 拿自己的token和锁的数据value进行比对
- 如果锁是属于自己的,调用redis的del指令删除锁数据
// 解锁
func (r *RedisLock) UnLock(ctx context.Context) error {
defer func() {
// 停止看门狗
if r.stopDog != nil {
r.stopDog()
}
}()
keyAndArgs := []interface{}{r.getLockKey(), r.token}
resp, err := r.client.Eval(ctx, LuaCheckAndDeleteDistributionLock, 1, keyAndArgs)
if err != nil {
return err
}
if ret := resp.(int64); ret != 1 {
return errors.New("can't unlock without ownership of lock")
}
return nil
}
解锁的lua脚本实现如下:
// 判断锁的拥有者以及删除锁
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
`
3 watch dog实现原理
- 在执行reids分布式锁的上锁操作时,通过SetNEX指令完成锁数据的设置,携带了一个默认的锁数据过期时间
- 确认上锁成功之后,异步启动一个watchDog守护协程,按照锁的过期时间的1/4或者1/3的节奏,持续地对锁数据进行expire续期操作
- 在解锁成功后,会负责关闭watchDog,回收协程资源。(看门狗和使用方使用的是同一个上下文context,所以使用方如果异常结束,看门狗也会自动关闭,并且看门狗会先检测锁的所有权再延期数据,只要删了锁数据,看门狗也会关闭,不会再继续续约)
3.1 看门狗的数据结构
// 锁的配置
type LockOptions struct {
// 是否开启阻塞模式
isBlock bool
// 阻塞模式等待时间
blockWaitSeconds int64
// 锁的过期时间
expireSeconds int64
// 是否开启看门狗策略
watchDogMode bool
}
// 基于redis实现的分布式锁
type RedisLock struct {
// 锁的配置
LockOptions
// 锁的键
key string
// 锁的标识
token string
// redis客户端
client LockClient
// 看门狗运行标识
runningDog int32
// 看门狗关闭函数
stopDog context.CancelFunc
}
- watchDogMode 用户再创建分布式锁时如果没有指定锁的过期时间或者设置时间为负或者设置了WithWatchDogMode()的option,该标识符会被置为true,只有该为true时看门狗才有启动的机会。
- runningDog 用于标识锁对应的看门狗是否正在运行,0未运行,1正在运行,通过校验该字段,保证一把锁只有一个看门狗
- stopDog 适用于停止看门狗的控制器函数,实际上就是context.CancelFunc类型,通过context的方式停止看门狗协程
3.2 启动看门狗
// redisLock对象加锁
func (r *RedisLock) Lock(ctx context.Context) (err error) {
defer func() {
if err != nil {
return
}
// 加锁成功后判断是否需要开启看门狗模式
if r.watchDogMode {
r.startWatchDog(ctx)
}
}()
//...
}
如果加锁成功,并且watchDog标识为true,此时会启动看门狗,startWatchDog方法步骤如下:
- 开启一轮cas自旋操作,确保将runningDog标识的值由0改为1,流程才会继续执行
- 创建一个子context用于管理看门狗的生命周期,同时将context的cancel函数注入到RedisLock.stopLock中,用于后续解锁时关闭看门狗协程
- 调用runWatchDog()方法,遵循用户定义的时间节奏,持续地执行对分布式锁的延期操作
// 开启看门狗模式
func (r *RedisLock) startWatchDog(ctx context.Context) {
// 原子操作开启看门狗
for !atomic.CompareAndSwapInt32(&r.runningDog, 0, 1) {
}
ctx, r.stopDog = context.WithCancel(ctx)
go func() {
defer func() {
// 协程结束后关闭看门狗
atomic.StoreInt32(&r.runningDog, 0)
}()
// 运行看门狗
r.runWatchDog(ctx)
}()
}
// 运行看门狗
func (r *RedisLock) runWatchDog(ctx context.Context) {
// 看门狗工作间隙
ticker := time.NewTicker(WatchDogWorkStepSeconds * time.Second)
defer ticker.Stop()
for range ticker.C {
select {
case <-ctx.Done():
return
default:
// 默认放行
}
// 放行之后延迟锁的使用时间
// 续约的时候需要额外加5s 防止网络延迟导致锁提前释放
_ = r.DelayLockTime(ctx, WatchDogWorkStepSeconds+5)
}
}
// 延迟锁的使用时间
func (r *RedisLock) DelayLockTime(ctx context.Context, expireTime int64) (err error) {
keyAndArgs := []interface{}{r.getLockKey(), r.token, expireTime}
resp, err := r.client.Eval(ctx, LuaCheckAndExpireDistributionLock, 1, keyAndArgs)
if err != nil {
return err
}
if ret := resp.(int64); ret != 1 {
return errors.New("can't expire lock without ownership of lock")
}
return nil
}
延期锁的过期时间也是基于lua脚本实现以下两个步骤:
1.校验锁是否属于自己(拿当前锁的token和锁数据的value进行比对)
2.如果锁属于自己,则调用redis的expire指令进行锁数据的延期操作
// 判断锁的拥有者以及延长锁的使用时间
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.3 停止看门狗
当用户成功释放了分布式锁,会负责停止看门狗的运行。再确认解锁操作已经成功时,会调用RedisLock.stopWatchDog控制器,通过终止看门狗守护协程对应的context的方式,使得看门狗停止。在停止看门狗时会把runningDog标识的值由1置为0的cas操作,目的是为了保证看门狗协程不会重复运行,进一步规避潜在的写成泄露问题。
// 解锁
func (r *RedisLock) UnLock(ctx context.Context) error {
defer func() {
// 停止看门狗
if r.stopDog != nil {
r.stopDog()
}
}()
keyAndArgs := []interface{}{r.getLockKey(), r.token}
resp, err := r.client.Eval(ctx, LuaCheckAndDeleteDistributionLock, 1, keyAndArgs)
if err != nil {
return err
}
if ret := resp.(int64); ret != 1 {
return errors.New("can't unlock without ownership of lock")
}
return nil
}
4 red lock实现原理
借用小徐先生的绘制图
4.1 多数派原则
所谓的多数派原则,就是在确定一项决策时,让所有参与者进行投票,只有投票赞成该决策的人数多余参与者人数的一半成为多数派时,这个决策才能通过(少数服从多数)
在红锁RedLock实现中,会基于多数派准则进行CAP中一致性C和可用性A之间矛盾的缓和,保证在RedLock下所有的redis节点中达到半数节点可用时,整个红锁就能正常提供服务。
4.2 红锁 red Lock
过程如下
- 假设有2N+1个redis节点(通常设置为奇数,有利于多数派原则的执行效率)
- 这些redis节点彼此之间是相互独立的,不存在从属关系
- 每次客户端进行加锁操作的时候,会同时对2N+1个节点发起加锁请求
- 每次客户端向一个节点发起锁请求时,会设置一个很小的请求处理超时阈值
- 客户端对2N+1个节点发起加锁操作时,只有在小于请求处理超时阈值的时间内完成了加锁操作,才视为完成了一次加锁请求
- 统计加锁成功的数量
- 如果加锁成功的数量大于等于一半,则视为红锁加锁成功
- 如果加锁成功的数量小于一半,则视为红锁加锁失败,此时会遍历2N+1个节点对刚才上的锁进行解锁操作,有利于资源回收,提供后续使用方的取锁效率
4.3 实现源码
// 红锁配置信息
type RedLockOptions struct {
// 单节点上锁操作超时时间 单节点上锁所需时间阈值
singleNodesTimeout time.Duration
// 锁的过期时间
expireDuration time.Duration
}
type RedLockOption func(r *RedLockOptions)
// 配置单节点连接超时时间
func WithSingleNodesTimeoutSeconds(singleNodesTimeout time.Duration) RedLockOption {
return func(r *RedLockOptions) {
r.singleNodesTimeout = singleNodesTimeout
}
}
// 配置红锁的过期时间
func WithExpireDuration(expireDuration time.Duration) RedLockOption {
return func(r *RedLockOptions) {
r.expireDuration = expireDuration
}
}
// 修复红锁的配置信息
func RepairRedLockOption(r *RedLockOptions) {
if r.singleNodesTimeout <= 0 {
r.singleNodesTimeout = 50
}
}
// 单节点配置
type SingleNodeConf struct {
// 连接方式
NetWork string
// 连接地址
Address string
// 密码
Password string
// 连接配置选项
Opts []ClientOption
}
RedLock是红锁的实现类,其中内置了redis锁节点列表:locks。使用红锁RedLock时需要满足以下规则:
- 每个redis lock背后对应的锁节点需要是独立的redis物理节点
- redis锁节点数量至少达到3个
- 如果用户显示的指定了红锁的过期时间,那么要保证设置的所有redis锁节点的上锁请求时间阈值之和要小于红锁过期时间的1/10,以保证用户取得红锁后又充足的时间处理业务逻辑
// 红锁中每个节点默认的处理超时时间为50ms
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't use redLock less than 3 node")
}
r := &RedLock{}
for _, opt := range opts {
opt(&r.RedLockOptions)
}
RepairRedLockOption(&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)
r.locks = append(r.locks, NewRedisLock(key, client, WithExpireSeconds(int64(r.expireDuration.Seconds()))))
}
return r, nil
}
4.4 red lock 加锁
- 遍历所有的redis锁节点,分别执行加锁操作
- 对每个redis锁节点的上锁所需时间阈值进行限制,保证控制在singleNodesTimeout之内
- 记录在控制范围singleNodesTimeout下加锁成功的redis节点数量
- 遍历执行完所有redis节点的加锁操作之后,如果加锁成功的数量达到所有redis节点数量的一半及以上,则视为红锁加锁成功
- 如果redis节点加锁成功数量未达到多数,则红锁加锁失败,此时会调用红锁的解锁操作,尝试对所有的redis锁节点执行一次解锁操作
// 红锁加锁
func (r *RedLock) Lock(ctx context.Context) error {
// 加锁成功的数量
var successfulCnt int
for _, lock := range r.locks {
startTime := time.Now()
err := lock.Lock(ctx)
cost := time.Since(startTime)
if err == nil && cost <= r.singleNodesTimeout {
successfulCnt++
}
}
if successfulCnt < len(r.locks)>>1+1 {
// 如果加锁成功的数量少于半数节点则加锁失败,释放刚才加成功的锁
r.UnLock(ctx)
return errors.New("lock failed")
}
return nil
}
// 红锁解锁
func (r *RedLock) UnLock(ctx context.Context) error {
var err error
for _, lock := range r.locks {
if _err := lock.UnLock(ctx); _err != nil {
err = _err
}
}
return err
}