从茅台超卖案例看分布式锁要素

茅台超卖案例

它是如何导致超卖的呢?

首先和你简单介绍下此案例中的 Redis 简易分布式锁实现方案,它使用了 Redis SET 命令来实现。

  1. SET key value [EX seconds|PX milliseconds|EXAT timestamp|PXAT milliseconds-timestamp|KEEPTTL] [NX|XX]
  2. [GET]

简单给你介绍下 SET 命令重点参数含义:

  • EX 设置过期时间,单位秒;
  • NX 当 key 不存在的时候,才设置 key;
  • XX 当 key 存在的时候,才设置 key。

此业务就是基于 Set key value EX 10 NX 命令来实现的分布式锁,并通过 JAVA 的 try-finally 语句,执行 Del key 语句来释放锁,简易流程如下:

# 对资源key加锁,key不存在时创建,并且设置,10秒自动过期
SET key value EX 10 NX
业务逻辑流程1,校验用户身份
业务逻辑流程2,查询并校验库存(get and compare)
业务逻辑流程3,库存>0,扣减库存(Decr stock),生成秒杀茅台订单

# 释放锁
Del key

为什么需要原子的设置 key 及过期时间?

  • NX 参数是为了保证当分布式锁不存在时,只有一个 client 能写入此 key 成功,获取到此锁。我们使用分布式锁的目的就是希望在高并发系统中,有一种互斥机制来防止彼此相互干扰,保证数据的一致性。
  • 因此分布式锁的第一核心要素就是互斥性、安全性。在同一时间内,不允许多个 client 同时获得锁。

为什么需要原子的设置 key 及过期时间?

  • 假设我们未设置 key 自动过期时间,在 Set key value NX 后,如果程序 crash 或者发生网络分区后无法与 Redis 节点通信,毫无疑问其他 client 将永远无法获得锁。这将导致死锁,服务出现中断。
  • 有的同学意识到这个问题后,使用如下 SETNX 和 EXPIRE 命令去设置 key 和过期时间,这也是不正确的,因为你无法保证 SETNX 和 EXPIRE 命令的原子性。
# 对资源key加锁,key不存在时创建
SETNX key value
# 设置KEY过期时间
EXPIRE key 10
业务逻辑流程

# 释放锁
Del key
  • 这就是分布式锁第二个核心要素,活性。在实现分布式锁的过程中要考虑到 client 可能会出现 crash 或者网络分区,你需要原子申请分布式锁及设置锁的自动过期时间,通过过期、超时等机制自动释放锁,避免出现死锁,导致业务中断。

为什么基于 Set key value EX 10 NX 命令还出现了超卖呢?

  • 原来是抢购活动开始后,加锁逻辑中的业务流程 1 访问的用户身份服务出现了高负载,导致阻塞在校验用户身份流程中 (超时 30 秒),然而锁 10 秒后就自动过期了,因此其他 client 能获取到锁。关键是阻塞的请求执行完后,它又把其他 client 的锁释放掉了,导致进入一个恶性循环。
  • 因此申请锁时,写入的 value 应确保唯一性(随机值等)。client 在释放锁时,应通过 Lua 脚本原子校验此锁的 value 与自己写入的 value 一致,若一致才能执行释放工作
  • 更关键的是库存校验是通过 get and compare 方式,它压根就无法防止超卖。正确的解决方案应该是通过 LUA 脚本实现 Redis 比较库存、扣减库存操作的原子性(或者在每次只能抢购一个的情况下,通过判断Redis Decr 命令的返回值即可。此命令会返回扣减后的最新库存,若小于 0 则表示超卖)。

从这个问题中我们可以看到,分布式锁实现具备一定的复杂度,它不仅依赖存储服务提供的核心机制,同时依赖业务领域的实现。无论是遭遇高负载、还是宕机、网络分区等故障,都需确保锁的互斥性、安全性,否则就会出现严重的超卖生产事故。

要考虑的场景:

  • 超时
  • 崩溃
  • 网络

为什么大家都比较喜欢使用 Redis 作为分布式锁实现?

  • 考虑到在秒杀等业务场景上存在大量的瞬间、高并发请求,加锁与释放锁的过程应是高性能、高可用的。而 Redis 核心优点就是快、简单,是随处可见的基础设施,部署、使用也及其方便,因此广受开发者欢迎。
  • 这就是分布式锁第三个核心要素,高性能、高可用。加锁、释放锁的过程性能开销要尽量低,同时要保证高可用,确保业务不会出现中断。

Redis 分布式锁问题

我们从茅台超卖案例中为你总结出的分布式核心要素(互斥性、安全性、活性、高可用、高性能)说起。

主备切换可能会导致基于 Redis 实现的分布式锁出现安全性问题

首先,如果我们的分布式锁跑在单节点的 Redis Master 节点上,那么它就存在单点故障,无法保证分布式锁的高可用。于是我们需要一个主备版的 Redis 服务,至少具备一个 Slave 节点。

我们又知道 Redis 是基于主备异步复制协议实现的 Master-Slave 数据同步,如下图所示,若 client A 执行 SET key value EX 10 NX 命令,redis-server 返回给 client A 成功后,Redis Master 节点突然出现 crash 等异常,这时候 Redis Slave 节点还未收到此命令的同步。

image.png

若你部署了 Redis Sentinel 等主备切换服务,那么它就会以 Slave 节点提升为主,此时 Slave 节点因并未执行 SET key value EX 10 NX 命令,因此它收到 client B 发起的加锁的此命令后,它也会返回成功给 client。那么在同一时刻,集群就出现了两个 client 同时获得锁,分布式锁的互斥性、安全性就被破坏了。

发生网络分区等场景下也可能会导致出现脑裂

Redis 集群出现多个 Master,进而也会导致多个 client 同时获得锁。

如下图所示,Master 节点在可用区 1,Slave 节点在可用区 2,当可用区 1 和可用区 2 发生网络分区后,部署在可用区 2 的 Redis Sentinel 服务就会将可用区 2 的 Slave 提升为 Master,而此时可用区 1 的 Master 也在对外提供服务。因此集群就出现了脑裂,出现了两个 Master,都可对外提供分布式锁申请与释放服务,分布式锁的互斥性被严重破坏。

image.png

主备切换、脑裂是 Redis 分布式锁的两个典型不安全的因素,本质原因是 Redis 为了满足高性能,采用了主备异步复制协议,同时也与负责主备切换的 Redis Sentinel 服务是否合理部署有关。

有没有其他方案解决呢?

当然有,Redis 作者为了解决 SET key value [EX] 10 [NX]命令实现分布式锁不安全的问题,提出了RedLock 算法。它是基于多个独立的 Redis Master 节点的一种实现(一般为 5)。client 依次向各个节点申请锁,若能从多数个节点中申请锁成功并满足一些条件限制,那么 client 就能获取锁成功。

  • 它通过独立的 N 个 Master 节点,避免了使用主备异步复制协议的缺陷,只要多数 Redis 节点正常就能正常工作,显著提升了分布式锁的安全性、可用性。
  • 但是,它的实现建立在一个不安全的系统模型上的,它依赖系统时间,当时钟发生跳跃时,也可能会出现安全性问题。你要有兴趣的话,可以详细阅读下分布式存储专家 Martin 对RedLock 的分析文章,Redis 作者的也专门写了一篇文章进行了反驳