设计分布式锁
设计原则
- 互斥性。在任意时刻,只有一个客户端持有锁。
- 不死锁。分布式锁本质上是一个基于租约(Lease)的租借锁,如果客户端获得锁后自身出现异常,锁能够在一段时间后自动释放,资源不会被锁死。
- 一致性。硬件故障或网络异常等外部问题,以及慢查询、自身缺陷等内部因素都可能导致 Redis 发生高可用切换,replica 提升为新的 master。此时,如果业务对互斥性的要求非常高,锁需要在切换到新的 master 后保持原状态。
设计的六个层次
你只看到了第二层,你把我想成了第一层。实际上,我在第五层。
——芜湖大司马
Redis 实现分布式锁有六个层次,看看大家平常用的分布式锁处在第几个层次。
层次一:
1 | redis.SetNX(ctx, key, "1") |
使用 SetNx 命令,可以解决互斥性的问题,但不能做到不死锁。
层次二:
1 | redis.SetNX(ctx, key, "1", expiration) |
使用 lua 脚本保证 SetNX 与 Expire 的原子性,做到了不死锁,但是做不到一致性。
层次三:
1 | redis.SetNX(ctx, key, randomValue, expiration) |
分布式锁的值设定一个随机数,删除时只删除当前线程/协程抢到的锁,避免在程序运行过慢锁过期时删除别的线程/协程的锁,能做到一定程度的一致性。
层次四:
1 | func myFunc() (errCode *constant.ErrorCode) { |
解决超时且成功的问题,写入超时且成功是偶现的、灾难性的经典问题。
还存在的问题是:
- 单点问题,单 master 有问题,如果有主从,那主从复制过程有问题时,也存在问题
- 锁过期然后没完成流程怎么办
层次五:
启动定时器,在锁过期却没完成流程时续租,只能续租当前线程/协程抢占的锁。
1 | // 以下为续租的lua脚本,实现CAS(compare and set) |
能保障锁过期的一致性,但是解决不了单点问题。
同时,可以发散思考一下,如果续租的方法失败怎么办?我们如何解决“为了保证高可用而使用的高可用方法的高可用问题”这种套娃问题?开源类库 Redisson 使用了看门狗的方式一定程度上解决了锁续租的问题,但是这里,个人建议不要做锁续租,更简洁优雅的方式是延长过期时间,由于我们分布式锁锁住代码块的最大执行时长是可控的(依赖于 RPC、DB、中间件等调用都设定超时时间),因而我们可以把超时时间设得大于最大执行时长即可简洁优雅地保障锁过期的一致性。
层次六:
Redis 的主从同步(replication)是异步进行的,如果向 master 发送请求修改了数据后 master 突然出现异常,发生高可用切换,缓冲区的数据可能无法同步到新的 master(原 replica)上,导致数据不一致。如果丢失的数据跟分布式锁有关,则会导致锁的机制出现问题,从而引起业务异常。针对这个问题介绍两种解法:
(1)使用红锁(RedLock)。红锁是 Redis 作者提出的一致性解决方案。红锁的本质是一个概率问题:如果一个主从架构的 Redis 在高可用切换期间丢失锁的概率是 k%,那么相互独立的 N 个 Redis 同时丢失锁的概率是多少?如果用红锁来实现分布式锁,那么丢锁的概率是(k%)^N。鉴于 Redis 极高的稳定性,此时的概率已经完全能满足产品的需求。
红锁的问题在于:
- 加锁和解锁的延迟较大。
- 难以在集群版或者标准版(主从架构)的 Redis 实例中实现。
- 占用的资源过多,为了实现红锁,需要创建多个互不相关的云 Redis 实例或者自建 Redis。
(2)使用 WAIT 命令。Redis 的 WAIT 命令会阻塞当前客户端,直到这条命令之前的所有写入命令都成功从 master 同步到指定数量的 replica,命令中可以设置单位为毫秒的等待超时时间。客户端在加锁后会等待数据成功同步到 replica 才继续进行其它操作。执行 WAIT 命令后如果返回结果是 1 则表示同步成功,无需担心数据不一致。相比红锁,这种实现方法极大地降低了成本。