Reids实现简单分布式锁
什么是分布式锁
分布式锁,简单来说就是在分布式环境下(也称为云环境)不同实例之间抢一把锁。
和普通的锁比起来,也就是抢锁的从线程 (协程)变成了实例。
分布式锁之所以难,基本上都和网络有关。
- 请求可能会超时
怎么设计分布式锁
使用redis设计分布式锁可以很容易地想到使用SETNX
,设置一个不可更改的键值对。基本过程分为加锁和解锁主要两个操作。在某些需要原子操作的情况下,可以配合lua
脚本一起。
加解锁流程
加锁通过
SETNX
,存入一个key,对应的值是一个随机的uuid
,并且需要设置过期时间,最后返回一个Lock
结构体。解锁需要先验证uuid,如果验证成功,则释放锁。
问题
为什么需要设置过期时间?
如果没有过期时间,那么会出现「实例1」抢到锁之后,不小心奔溃了,终止了运行,后续的实例将永远无法获取到锁。
为什么需要设置随机uuid为key的值?
本质上,我们就是需要一个唯一的值,当解锁的时候,需要验证该把锁是不是自己的,防止把其他实例加的锁解锁了。
什么情况下可能会出现错误?
加锁解锁都可能遇到诸如网络错误,redis服务端错误等错误的情况,这种都会将erro返回。
- 加锁可能会遇到该锁已经被他人持有,而无法加锁
- 解锁可能会遇到1. 不是该锁的持有人 2. 锁过期了 3. 锁被人删了 等情况,而无法解锁
为什么要使用lua脚本来解锁?
当我们发送查询操作时,返回结果后,再发送删除操作,这两个操作中间可能会出现延迟(因为网络条件),导致出现误删他人的锁的情况。
代码
1 | package redisLock |
优化分布式锁
手动续约
在某些情况下,用户端可能会执行超时,这时只能眼巴巴地看着锁过期,于是为了优化分布式锁,我们加上了一个续约方法!能让用户可以自行手动续约~
首先在Lock
结构体添加一个过期时间属性:
1 | type Lock struct { |
在加锁的时候,将过期时间传入返回的Lock中。
再构造如下的续约方法:
1 | func (l *Lock) ManualRefresh(ctx context.Context) error { |
这个方法使用到了lua
脚本:
1 | if redis.call("GET", KEYS[1]) == ARGV[1] then |
这里有两个返回error的地方:
第一个地方是意味着可能服务器出错了,或者超时了
第二个地方意味着锁确实存在,但是却不是自己的锁,或者锁没有了
自动续约
手动续约的方法看起来很简单,但是在用户实现的时候会产生很多困难:
间隔多久调一次续约方法?
这个还比较简单,可以约定一个时间,然后一直进行for循环(提示:用
ticker
)如果续约方法请求出错怎么办?
- 可能是出现网络问题,或者Redis服务器奔溃,这个时候一般进行再次重试。但假如一直失败就会一直重试
- 返回其他的error,将如何处理?
如果真的续约失败,用户将如何处理?
这时候我们可以自己封装一个AutoRefresh
方法给用户:
1 | func (l *Lock) AutoRefresh(interval time.Duration, timeout time.Duration) error { |
自动续约的可控性非常差,因此我们并不是很鼓励用户使用这个API。甚至于如果用户想要万无一失地使用这个布式锁,那么必须要自己手动调用Refresh,并且小心处理各种error。
此外,续约的间隔,应该综合考虑服务可用性。例如如果我们将分布式锁的过期时间设置为10秒,而且预期2秒内绝大慨率续约成功,那么就可以考虑将续约间隔设置为8秒。
加锁重试
加锁可能遇到偶发性的失败,在这种情况下,可以尝试重试。重试逻辑:
如果超时了,则触发重试逻辑,判断Key是否存在
若Key不存在,「直接加锁」
若Key存在,则检查一下key对应的值是不是我们刚才超时加锁请求的值
如果是,直接返回,前一次「加锁成功」了(这里你可能需要考虑重置一下过期时间)
如果不是,直接返回,「加锁失败」
这里有三种可能:
- key不存在,直接加锁,返回成功
- key存在,且value值相等,返回成功
- key存在,但value值不相等,返回失败
总结
分布式锁是一个比较简单的工具,在Redis加持下响应本来就很快,但是不完全可靠,最重要的还是要优化自己的业务性能。
使用分布式锁,你不能指望框架提供万无一失的方案,自己还是要处理各种异常情况(超时) 自己写分布式锁,要考虑过期时间,以及要不要续约。
不管要对锁做什么操作,首先要确认这把锁是我们自己的锁。
大多数时候,与其选择复杂方案,不如直接让业务失败,可能成本还要低一点。(有时候直接赔钱,比你部署一大堆节点,招一大堆开发,搞好几个机房还要便宜,而且便宜很多)也就是选择恰好的方案, 而不是完美的方案。