使用Redis实现分布式锁的方法


Posted in Redis onJune 16, 2022

Redis 中的分布式锁如何使用

分布式锁的使用场景

为了保证我们线上服务的并发性和安全性,目前我们的服务一般抛弃了单体应用,采用的都是扩展性很强的分布式架构。

对于可变共享资源的访问,同一时刻,只能由一个线程或者进程去访问操作。这时候我们就需要做个标识,如果当前有线程或者进程在操作共享变量,我们就做个标记,标识当前资源正在被操作中, 其它的线程或者进程,就不能进行操作了。当前操作完成之后,删除标记,这样其他的线程或者进程,就能来申请共享变量的操作。通过上面的标记来保证同一时刻共享变量只能由一个线程或者进行持有。

对于单体应用:多个线程之间访问可变共享变量,比较容易处理,可简单使用内存来存储标示即可;

分布式应用:这种场景下比较麻烦,因为多个应用,部署的地址可能在不同的机房,一个在北京一个在上海。不能简单的存储标示在内存中了,这时候需要使用公共内存来记录该标示,栗如 Redis,MySQL 。。。

使用 Redis 来实现分布式锁

这里来聊聊如何使用 Redis 实现分布式锁

Redis 中分布式锁一般会用 set key value px milliseconds nx 或者 SETNX+Lua来实现。

因为 SETNX 命令,需要配合 EXPIRE 设置过期时间,Redis 中单命令的执行是原子性的,组合命令就需要使用 Lua 才能保证原子性了。

看下如何实现

使用 set key value px milliseconds nx 实现

因为这个命令同时能够设置键值和过期时间,同时Redis中的单命令都是原子性的,所以加锁的时候使用这个命令即可

func (r *Redis) TryLock(ctx context.Context, key, value string, expire time.Duration) (isGetLock bool, err error) {
	// 使用 set nx
	res, err := r.Do(ctx, "set", key, value, "px", expire.Milliseconds(), "nx").Result()
	if err != nil {
		return false, err
	}
	if res == "OK" {
		return true, nil
	}
	return false, nil
}

SETNX+Lua 实现

如果使用 SETNX 命令,这个命令不能设置过期时间,需要配合 EXPIRE 命令来使用。

因为是用到了两个命令,这时候两个命令的组合使用是不能保障原子性的,在一些并发比较大的时候,需要配合使用 Lua 脚本来保证命令的原子性。

func tryLockScript() string {
	script := `
		local key = KEYS[1]

		local value = ARGV[1] 
		local expireTime = ARGV[2] 
		local isSuccess = redis.call('SETNX', key, value)

		if isSuccess == 1 then
			redis.call('EXPIRE', key, expireTime)
			return "OK"
		end

		return "unLock"    `
	return script
}

func (r *Redis) TryLock(ctx context.Context, key, value string, expire time.Duration) (isGetLock bool, err error) {
	// 使用 Lua + SETNX
	res, err := r.Eval(ctx, tryLockScript(), []string{key}, value, expire.Seconds()).Result()
	if err != nil {
		return false, err
	}
	if res == "OK" {
		return true, nil
	}
	return false, nil
}

除了上面加锁两个命令的区别之外,在解锁的时候需要注意下不能误删除别的线程持有的锁

为什么会出现这种情况呢,这里来分析下

举个栗子

1、线程1获取了锁,锁的过期时间为1s;

2、线程1完成了业务操作,用时1.5s ,这时候线程1的锁已经被过期时间自动释放了,这把锁已经被别的线程获取了;

3、但是线程1不知道,接着去释放锁,这时候就会将别的线程的锁,错误的释放掉。

使用Redis实现分布式锁的方法

面对这种情况,其实也很好处理

1、设置 value 具有唯一性;

2、每次删除锁的时候,先去判断下 value 的值是否能对的上,不相同就表示,锁已经被别的线程获取了;

看下代码实现

var UnLockErr = errors.New("未解锁成功")
func unLockScript() string {
	script := `
		local value = ARGV[1] 
		local key = KEYS[1]
		local keyValue = redis.call('GET', key)
		if tostring(keyValue) == tostring(value) then
			return redis.call('DEL', key)
		else
			return 0
		end
    `
	return script
}
func (r *Redis) Unlock(ctx context.Context, key, value string) (bool, error) {
	res, err := r.Eval(ctx, unLockScript(), []string{key}, value).Result()
	if err != nil {
		return false, err
	}
	return res.(int64) != 0, nil
}

代码可参考lock

上面的这类锁的最大缺点就是只作用在一个节点上,即使 Redis 通过 sentinel 保证高可用,如果这个 master 节点由于某些原因放生了主从切换,那么就会出现锁丢失的情况:

1、在 Redis 的 master 节点上拿到了锁;

2、但是这个加锁的 key 还没有同步到 slave 节点;

3、master 故障,发生了故障转移,slave 节点升级为 master 节点;

4、导致锁丢失。

针对这种情况如何处理呢,下面来聊聊 Redlock 算法

使用 Redlock 实现分布式锁

在 Redis 的分布式环境中,我们假设有 N 个 Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在 N 个实例上使用与在 Redis 单实例下相同方法获取和释放锁。现在我们假设有 5 个 Redis master 节点,同时我们需要在5台服务器上面运行这些 Redis 实例,这样保证他们不会同时都宕掉。

为了取到锁,客户端营该执行以下操作:

1、获取当前Unix时间,以毫秒为单位。

2、依次尝试从5个实例,使用相同的key和具有唯一性的 value(例如UUID)获取锁。当向 Redis 请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在 5-50 毫秒之间。这样可以避免服务器端 Redis 已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个 Redis 实例请求获取锁;

3、客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的 Redis 节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功;

4、如果取到了锁,key 的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果);

5、如果因为某些原因,获取锁失败(没有在至少N/2+1个 Redis 实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些 Redis 实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。

根据官方的推荐,go 版本中 Redsync 实现了这一算法,这里看下具体的实现过程

redsync项目地址

// LockContext locks m. In case it returns an error on failure, you may retry to acquire the lock by calling this method again.
func (m *Mutex) LockContext(ctx context.Context) error {
	if ctx == nil {
		ctx = context.Background()
	}
	value, err := m.genValueFunc()
	if err != nil {
		return err
	}
	
	for i := 0; i < m.tries; i++ {
		if i != 0 {
			select {
			case <-ctx.Done():
				// Exit early if the context is done.
				return ErrFailed
			case <-time.After(m.delayFunc(i)):
				// Fall-through when the delay timer completes.
			}
		}
		start := time.Now()
		// 尝试在所有的节点中加锁
		n, err := func() (int, error) {
			ctx, cancel := context.WithTimeout(ctx, time.Duration(int64(float64(m.expiry)*m.timeoutFactor)))
			defer cancel()
			return m.actOnPoolsAsync(func(pool redis.Pool) (bool, error) {
				// acquire 加锁函数
				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.driftFactor)))
		if n >= m.quorum && now.Before(until) {
			m.value = value
			m.until = until
			return nil
		}
		_, err = func() (int, error) {
			ctx, cancel := context.WithTimeout(ctx, time.Duration(int64(float64(m.expiry)*m.timeoutFactor)))
			defer cancel()
			return m.actOnPoolsAsync(func(pool redis.Pool) (bool, error) {
				// 解锁函数
				return m.release(ctx, pool, value)
			})
		}()
		if i == m.tries-1 && err != nil {
			return err
		}
	}
	return ErrFailed
}
// 遍历所有的节点,并且在每个节点中执行传入的函数
func (m *Mutex) actOnPoolsAsync(actFn func(redis.Pool) (bool, error)) (int, error) {
	type result struct {
		Status bool
		Err    error
	}
	ch := make(chan result)
	// 执行传入的函数
	for _, pool := range m.pools {
		go func(pool redis.Pool) {
			r := result{}
			r.Status, r.Err = actFn(pool)
			ch <- r
		}(pool)
	}
	n := 0
	var err error
	// 计算执行成功的节点数目
	for range m.pools {
		r := <-ch
		if r.Status {
			n++
		} else if r.Err != nil {
			err = multierror.Append(err, r.Err)
		}
	}
	return n, err
}
// 手动解锁的lua脚本
var deleteScript = redis.NewScript(1, `
	if redis.call("GET", KEYS[1]) == ARGV[1] then
		return redis.call("DEL", KEYS[1])
	else
		return 0
	end
`)
// 手动解锁
func (m *Mutex) release(ctx context.Context, pool redis.Pool, value string) (bool, error) {
	conn, err := pool.Get(ctx)
	if err != nil {
		return false, err
	}
	defer conn.Close()
	status, err := conn.Eval(deleteScript, m.name, value)
	if err != nil {
		return false, err
	}
	return status != int64(0), nil
}

分析下思路

1、遍历所有的节点,然后尝试在所有的节点中执行加锁的操作;

2、收集加锁成功的节点数,如果没有达到指定的数目,释放刚刚添加的锁;

关于 Redlock 的缺点可参见

How to do distributed locking

锁的续租

Redis 中分布式锁还有一个问题就是锁的续租问题,当锁的过期时间到了,但是业务的执行时间还没有完成,这时候就需要对锁进行续租了

续租的流程

1、当客户端加锁成功后,可以启动一个定时的任务,每隔一段时间,检查业务是否完成,未完成,增加 key 的过期时间;

2、这里判断业务是否完成的依据是:

  • 1、这个 key 是否存在,如果 key 不存在了,就表示业务已经执行完成了,也就不需要进行续租操作了;
  • 2、同时需要校验下 value 值,如果 value 对应的值和之前写入的值不同了,说明当前锁已经被别的线程获取了;

看下 redsync 中续租的实现

// Extend resets the mutex's expiry and returns the status of expiry extension.
func (m *Mutex) Extend() (bool, error) {
	return m.ExtendContext(nil)
}

// ExtendContext resets the mutex's expiry and returns the status of expiry extension.
func (m *Mutex) ExtendContext(ctx context.Context) (bool, error) {
	start := time.Now() 
	// 尝试在所有的节点中加锁
	n, err := m.actOnPoolsAsync(func(pool redis.Pool) (bool, error) {
		return m.touch(ctx, pool, m.value, int(m.expiry/time.Millisecond))
	})
	if n < m.quorum {
		return false, err
	}
	// 判断下锁的过期时间
	now := time.Now()
	until := now.Add(m.expiry - now.Sub(start) - time.Duration(int64(float64(m.expiry)*m.driftFactor)))
	if now.Before(until) {
		m.until = until
		return true, nil
	}
	return false, ErrExtendFailed
}

var touchScript = redis.NewScript(1, `
	// 需要先比较下当前的value值
	if redis.call("GET", KEYS[1]) == ARGV[1] then
		return redis.call("PEXPIRE", KEYS[1], ARGV[2])
	else
		return 0
	end
`)

func (m *Mutex) touch(ctx context.Context, pool redis.Pool, value string, expiry int) (bool, error) {
	conn, err := pool.Get(ctx)
	if err != nil {
		return false, err
	}
	defer conn.Close()
	status, err := conn.Eval(touchScript, m.name, value, expiry)
	if err != nil {
		return false, err
	}
	return status != int64(0), nil
}

1、锁的续租需要客户端去监听和操作,启动一个定时器,固定时间来调用续租函数给锁续租;

2、每次续租操作的时候需要匹配下当前的 value 值,因为锁可能已经被当前的线程释放了,当前的持有者可能是别的线程;

看看 SETEX 的源码

SETEX 能保证只有在 key 不存在时设置 key 的值,那么这里来看看,源码中是如何实现的呢

// https://github.com/redis/redis/blob/7.0/src/t_string.c#L78
// setGenericCommand()函数是以下命令: SET, SETEX, PSETEX, SETNX.的最底层实现
void setGenericCommand(client *c, int flags, robj *key, robj *val, robj *expire, int unit, robj *ok_reply, robj *abort_reply) {
    ...

    found = (lookupKeyWrite(c->db,key) != NULL);
    // 这里是 SETEX 实现的重点
	// 如果nx,并且在数据库中找到了这个值就返回
	// 如果是 xx,并且在数据库中没有找到键值就会返回
	
	// 因为 Redis 中的命令执行都是单线程操作的
	// 所以命令中判断如果存在就返回,能够保证正确性,不会出现并发访问的问题
    if ((flags & OBJ_SET_NX && found) ||
        (flags & OBJ_SET_XX && !found))
    {
        if (!(flags & OBJ_SET_GET)) {
            addReply(c, abort_reply ? abort_reply : shared.null[c->resp]);
        }
        return;
    }

    ...
}

1、命令的实现里面加入了键值是否存在的判断,来保证 NX 只有在 key 不存在时设置 key 的值;

2、因为 Redis 中总是一个线程处理命令的执行,单命令是能够保证原子性,不会出现并发的问题。

为什么 Redis 可以用来做分布式锁

分布式锁需要满足的特性

  • 互斥性:在任意时刻,对于同一个锁,只有一个客户端能持有,从而保证一个共享资源同一时间只能被一个客户端操作;
  • 安全性:即不会形成死锁,当一个客户端在持有锁的期间崩溃而没有主动解锁的情况下,其持有的锁也能够被正确释放,并保证后续其它客户端能加锁;
  • 可用性:当提供锁服务的节点发生宕机等不可恢复性故障时,“热备” 节点能够接替故障的节点继续提供服务,并保证自身持有的数据与故障节点一致。
  • 对称性:对于任意一个锁,其加锁和解锁必须是同一个客户端,即客户端 A 不能把客户端 B 加的锁给解了。

那么 Redis 对上面的特性是如何支持的呢?

1、Redis 中命令的执行都是单线程的,虽然在 Redis6.0 的版本中,引入了多线程来处理 IO 任务,但是命令的执行依旧是单线程处理的;

2、单线程的特点,能够保证命令的执行的是不存在并发的问题,同时命令执行的原子性也能得到保证;

3、Redis 中提供了针对 SETNX 这样的命令,能够保证同一时刻是只会有一个请求执行成功,提供互斥性的保障;

4、Redis 中也提供了 EXPIRE 超时释放的命令,可以实现锁的超时释放,避免死锁的出现;

5、高可用,针对如果发生主从切换,数据丢失的情况,Redis 引入了 RedLock 算法,保证了 Redis 中主要大部分节点正常运行,锁就可以正常运行;

6、Redis 中本身没有对锁提供续期的操作,不过一些第三方的实现中实现了 Redis 中锁的续期,类似 使用 java 实现的 Redisson,使用 go 实现的 redsync,当然自己实现也不是很难,实现过程可参见上文。

总体来说,Redis 中对分布式锁的一些特性都提供了支持,使用 Redis 实现分布式锁,是一个不错的选择。

分布式锁如何选择

1、如果业务规模不大,qps 很小,使用 Redis,etcd,ZooKeeper 去实现分布式锁都不会有问题,就看公司了基础架构了,如果有现成的 Redis,etcd,ZooKeeper 直接用就可以了;

2、Redis 中分布式锁有一定的安全隐患,如果业务中对安全性要求很高,那么 Redis 可能就不适合了,etcd 或者 ZooKeeper 就比较合适了;

3、如果系统 qps 很大,但是可以容忍一些错误,那么 Redis 可能就更合适了,毕竟 etcd或者ZooKeeper 背面往往都是较低的吞吐量和较高的延迟。

总结

1、在分布式的场景下,使用分布式锁是我们经常遇到的一种场景;

2、使用 Redis 实现锁是个不错的选择,Redis 的单命令的执行是原子性的同时借助于 Lua 也可以很容易的实现组合命令的原子性;

3、针对分布式场景下主从切换,数据同步不及时的情况,redis 中引入了 redLock 来处理分布式锁;

4、根据 martin 的描述,redLock 是繁重的,且存在安全性,不过我们可以根据自己的业务场景做出判断;

5、需要注意的是在设置分布式锁的时候需要设置 value 的唯一性,并且每次主动删除锁的时候需要匹配下 value 的正确性,避免误删除其他线程的锁;

参考

【Redis核心技术与实战】https://time.geekbang.org/column/intro/100056701
【Redis设计与实现】https://book.douban.com/subject/25900156/
【Redis 的学习笔记】https://github.com/boilingfrog/Go-POINT/tree/master/redis
【Redis 分布式锁】https://redis.io/docs/reference/patterns/distributed-locks/
【How to do distributed locking】https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
【etcd 实现分布式锁】https://www.cnblogs.com/ricklz/p/15033193.html#分布式锁
【Redis中的原子操作(3)-使用Redis实现分布式锁】https://boilingfrog.github.io/2022/06/15/ Redis中的原子操作 (3)-使用Redis实现分布式锁/

到此这篇关于使用Redis实现分布式锁的方法的文章就介绍到这了,更多相关Redis分布式锁内容请搜索三水点靠木以前的文章或继续浏览下面的相关文章希望大家以后多多支持三水点靠木!


Tags in this post...

Redis 相关文章推荐
redis限流的实际应用
Apr 24 Redis
redis 查看所有的key方式
May 07 Redis
使用Redis实现秒杀功能的简单方法
May 08 Redis
Redis高级数据类型Hyperloglog、Bitmap的使用
May 24 Redis
Django使用redis配置缓存的方法
Jun 01 Redis
Redis缓存-序列化对象存储乱码问题的解决
Jun 21 Redis
解析redis hash应用场景和常用命令
Aug 04 Redis
Redis分布式锁Redlock的实现
Aug 07 Redis
Redis高并发防止秒杀超卖实战源码解决方案
Nov 01 Redis
解决 Redis 秒杀超卖场景的高并发
Apr 12 Redis
Redis基本数据类型String常用操作命令
Jun 01 Redis
Redis全局ID生成器的实现
Jun 05 Redis
关于Redis的主从复制及哨兵问题
Jun 16 #Redis
Redis实现分布式锁的五种方法详解
Redis实现短信验证码登录的示例代码
Jun 14 #Redis
Redis批量生成数据的实现
Jun 05 #Redis
Redis实现订单过期删除的方法步骤
Jun 05 #Redis
浅谈Redis缓冲区机制
Redis全局ID生成器的实现
Jun 05 #Redis
You might like
php操作mysql数据库的基本类代码
2014/02/25 PHP
PHP比较运算符的详细介绍
2015/09/29 PHP
php实现mysql数据库连接操作及用户管理
2015/11/08 PHP
PHP命名空间namespace用法实例分析
2016/09/27 PHP
PHP实现简易图形计算器
2020/08/28 PHP
在textarea中显示html页面的javascript代码
2007/04/20 Javascript
prototype Element学习笔记(篇一)
2008/10/26 Javascript
jQuery Div中加载其他页面的实现代码
2009/02/27 Javascript
jquery中获取元素的几种方式小结
2011/07/05 Javascript
JavaScript 原型继承
2011/12/26 Javascript
鼠标滑在标题上显示图片的JS代码
2013/11/19 Javascript
javascript实现客户端兼容各浏览器创建csv并下载的方法
2015/03/23 Javascript
41个Web开发者必须收藏的JavaScript实用技巧
2016/07/22 Javascript
手机端点击图片放大特效PhotoSwipe.js插件实现
2016/08/24 Javascript
AngularJS改变元素显示状态
2017/04/20 Javascript
vue 路由页面之间实现用手指进行滑动的方法
2018/02/23 Javascript
解决Js先触发失去焦点事件再执行点击事件的问题
2018/08/30 Javascript
浅谈react性能优化的方法
2018/09/05 Javascript
vue-week-picker实现支持按周切换的日历
2019/06/26 Javascript
基于vue项目设置resolves.alias: '@'路径并适配webstorm
2020/12/02 Vue.js
[02:41]DOTA2英雄基础教程 谜团
2013/12/10 DOTA
[50:28]2018DOTA2亚洲邀请赛 3.31 小组赛 A组 Newbee vs KG
2018/04/01 DOTA
python使用电子邮件模块smtplib的方法
2016/08/28 Python
使用Python搭建虚拟环境的配置方法
2018/02/28 Python
python如何通过实例方法名字调用方法
2018/03/21 Python
python读取和保存视频文件
2018/04/16 Python
Python 访问限制 private public的详细介绍
2018/10/16 Python
Pytorch卷积层手动初始化权值的实例
2019/08/17 Python
Python3的unicode编码转换成中文的问题及解决方案
2019/12/10 Python
python中图像通道分离与合并实例
2020/01/17 Python
PyTorch安装与基本使用详解
2020/08/31 Python
Html5大文件断点续传实现方法
2015/12/05 HTML / CSS
草莓网中国:StrawberryNet中国
2020/08/17 全球购物
个人简历自我评价八例
2013/10/31 职场文书
社会公德演讲稿
2014/05/20 职场文书
建国大业观后感800字
2015/06/01 职场文书