加锁

SETNX lock_key any_value 返回1则加锁成功;DEL lock_key 释放。可以通过这个最简单的方式实现分布式抢锁,但实际上还要做很多优化

1 等待与重试

客户端没抢到不应该立刻判为失败,应当进入等待:

  1. 循环轮询(Spin Lock):等待一会询问
  2. 事件监听:可以采用发布订阅机制,或者Keyspace Notifications(键空间通知)

当收到超时响应时,应当重试加锁,但不知道处于什么状态,所以要先get,重试机制要判断锁有没有被他人抢走,要用到UUID:

  1. GET 返回 nil(key不存在),说明锁没有被占用,自己之前也没抢成功,再次加锁
  2. GET 返回 自己的Id,说明抢到了,但是返回时丢包了,所以重置下过期时间再用就行了
  3. GET 返回 其他Id,说明已经被他人抢走了,进入等待逻辑。

2 锁过期与续约

当锁释放前宕机了,锁被一直占用,所以要有自动过期EXPIRE机制;

  1. 抢锁动作应当和过期时间为一个原子操作,否则抢完挂了还是一样。SET lock_key unique_value NX EX 30
  2. 过期时间在P999要求5秒内完成,设置为10秒,而P9999设为30秒或一分钟。

当客户端执行超过预期,过期资源释放,但客户端还在操作改资源,破坏了互斥。所以有锁续约机制,也被叫做看门狗:

  1. 在客户端抢到锁后,“客户端"后台挂起一个线程,过一会发一个续约请求。
  2. 续约请求也因为网络波动没了呢?保守策略:业务中断回滚。激进策略:认为续约失败是小概率事件,业务逻辑继续执行。
  3. 业务中断,当分布式锁出问题了,其并不能直接帮你中断业务,需要业务代码自己实现。如果是for循环,在每步判断一下,如果固定步骤,在关键步骤判断。目前业界无银弹,只能业务实现。

3 锁释放

过期时间和释放锁的冲突:当A释放锁时刚好过期,锁被B拿走了,A删锁给B的删掉了。用Lua脚本保证释放删除的原子性。
先查是不是自己的,再删。

Redlock

虽然上面的看起来全面了,但是实际上只是单例Redis,分布式的是客户端。要让Redis本身有分布式锁,需要RedLock。采用分布式常用的投票机制。

加锁流程:

  1. 客户端记录当前时间戳。
  2. 尝试在所有实例上加锁,并且为每个实例设置一个很短的连接和响应超时(如50毫秒),防止在某个宕机节点上浪费太多时间。
  3. 统计加锁成功的实例数量。如果超过半数成功,并且总耗时小于锁的有效时间(过期时间-加锁耗时),则认为加锁成功。
  4. 锁的真正有效时间=初始设置的过期时间-加锁总耗时。

释放锁流程:必须向所有实例发送释放锁命令,Lua脚本执行。

RedLock代价成本过高,很多公司不采用。因为RedLock要求的Redis不是客户端查数据主从架构的那个集群,是自己另外搭建的Redis分布式锁集群。

锁的性能优化

Singleflight 模式

当一个实例中有一堆线程等着抢同一个锁,那就指派一个线程去抢,这样就避免竞争过于激烈的情况。如果本来就没什么并发,相当于没有什么优化。

本地锁交接

当一个线程抢到锁后,如果本地有其他线程要用,就转交给他,由他使用并释放。但是这个方法引入了本地状态依赖,并且又把锁资源带回了单一实例,破坏了分布式原则。

分布式锁替换

  1. 数据库乐观锁:很多业务场景可以用带版本号的乐观锁代替
  2. 一致性哈希负载均衡:使用哈希算法使其固定路由到同一个实例,由分布式转换成了单体。

锁总结

  1. 锁争抢:并发抢锁
  2. 僵尸锁:抢了不还
  3. 锁过期:没做完锁就释放
  4. 锁不可靠:发锁者挂了

1 单节点锁

SET lock_key unique_value NX EX 30

  1. 唯一id,保证操作的是自己的锁(加id是最基础的方案)
  2. NX,not eXist。当锁不被占才强,基础的互斥
  3. EX,过期时间,防僵尸

2 高可靠锁

Redlock 算法,就是放多个节点,选举产生,票数过半给锁,这样一个发锁的挂了还有其他的。节点数量选择奇数;保证节点独立性;合理设置请求超时。性能开销大,非核心业务1和3足以解决。

3 看门狗锁

少数较长任务会出现问题3,即没干完就被过期策略释放了。采用一个后台线程 看门狗各段时间轮询,Redisson实现了该锁。