加锁
SETNX lock_key any_value 返回1则加锁成功;DEL lock_key 释放。可以通过这个最简单的方式实现分布式抢锁,但实际上还要做很多优化
1 等待与重试
客户端没抢到不应该立刻判为失败,应当进入等待:
- 循环轮询(Spin Lock):等待一会询问
- 事件监听:可以采用发布订阅机制,或者Keyspace Notifications(键空间通知)
当收到超时响应时,应当重试加锁,但不知道处于什么状态,所以要先get,重试机制要判断锁有没有被他人抢走,要用到UUID:
- GET 返回 nil(key不存在),说明锁没有被占用,自己之前也没抢成功,再次加锁
- GET 返回 自己的Id,说明抢到了,但是返回时丢包了,所以重置下过期时间再用就行了
- GET 返回 其他Id,说明已经被他人抢走了,进入等待逻辑。
2 锁过期与续约
当锁释放前宕机了,锁被一直占用,所以要有自动过期EXPIRE机制;
- 抢锁动作应当和过期时间为一个原子操作,否则抢完挂了还是一样。
SET lock_key unique_value NX EX 30 - 过期时间在P999要求5秒内完成,设置为10秒,而P9999设为30秒或一分钟。
当客户端执行超过预期,过期资源释放,但客户端还在操作改资源,破坏了互斥。所以有锁续约机制,也被叫做看门狗:
- 在客户端抢到锁后,“客户端"后台挂起一个线程,过一会发一个续约请求。
- 续约请求也因为网络波动没了呢?保守策略:业务中断回滚。激进策略:认为续约失败是小概率事件,业务逻辑继续执行。
- 业务中断,当分布式锁出问题了,其并不能直接帮你中断业务,需要业务代码自己实现。如果是for循环,在每步判断一下,如果固定步骤,在关键步骤判断。目前业界无银弹,只能业务实现。
3 锁释放
过期时间和释放锁的冲突:当A释放锁时刚好过期,锁被B拿走了,A删锁给B的删掉了。用Lua脚本保证释放删除的原子性。
先查是不是自己的,再删。
Redlock
虽然上面的看起来全面了,但是实际上只是单例Redis,分布式的是客户端。要让Redis本身有分布式锁,需要RedLock。采用分布式常用的投票机制。
加锁流程:
- 客户端记录当前时间戳。
- 尝试在所有实例上加锁,并且为每个实例设置一个很短的连接和响应超时(如50毫秒),防止在某个宕机节点上浪费太多时间。
- 统计加锁成功的实例数量。如果超过半数成功,并且总耗时小于锁的有效时间(过期时间-加锁耗时),则认为加锁成功。
- 锁的真正有效时间=初始设置的过期时间-加锁总耗时。
释放锁流程:必须向所有实例发送释放锁命令,Lua脚本执行。
RedLock代价成本过高,很多公司不采用。因为RedLock要求的Redis不是客户端查数据主从架构的那个集群,是自己另外搭建的Redis分布式锁集群。
锁的性能优化
Singleflight 模式
当一个实例中有一堆线程等着抢同一个锁,那就指派一个线程去抢,这样就避免竞争过于激烈的情况。如果本来就没什么并发,相当于没有什么优化。
本地锁交接
当一个线程抢到锁后,如果本地有其他线程要用,就转交给他,由他使用并释放。但是这个方法引入了本地状态依赖,并且又把锁资源带回了单一实例,破坏了分布式原则。
分布式锁替换
- 数据库乐观锁:很多业务场景可以用带版本号的乐观锁代替
- 一致性哈希负载均衡:使用哈希算法使其固定路由到同一个实例,由分布式转换成了单体。
锁总结
- 锁争抢:并发抢锁
- 僵尸锁:抢了不还
- 锁过期:没做完锁就释放
- 锁不可靠:发锁者挂了
1 单节点锁
SET lock_key unique_value NX EX 30
- 唯一id,保证操作的是自己的锁(加id是最基础的方案)
- NX,not eXist。当锁不被占才强,基础的互斥
- EX,过期时间,防僵尸
2 高可靠锁
Redlock 算法,就是放多个节点,选举产生,票数过半给锁,这样一个发锁的挂了还有其他的。节点数量选择奇数;保证节点独立性;合理设置请求超时。性能开销大,非核心业务1和3足以解决。
3 看门狗锁
少数较长任务会出现问题3,即没干完就被过期策略释放了。采用一个后台线程 看门狗各段时间轮询,Redisson实现了该锁。