超卖和分布式锁解决方案

超卖和分布式锁解决方案

背景

要说现在在高并发场景中,哪个概念最火,那当属“秒杀”了。那么秒杀也是有自己的一些特点的:

  • 大量用户同一时间访问,造成瞬时访问量激增。
  • 数据库的并发读写激增,导致负载非常高。
  • 请求数远大于库存数,只有部分人才能秒杀成功。

当然,这篇文章不具体讲秒杀系统的设计了,主要讲一讲在秒杀系统中的一环——Redis分布式锁。虽然商家都希望自己的东西卖的越多越好,但是大多数场景下,秒杀的库存并不是特别多,这时候我们就得避免“超卖”问题的发生了。

下单的流程

正常下单的流程

当用户的下单请求到达服务端时,为了防止恶意下单,首先系统中肯定要做风控的。如果说有大量的请求过来,可能需要接口限流。然后创建订单、创建成功后扣除库存。这种方案,算是最常见的解决方案了。而且也能够保证订单不会超卖,因为创建订单之后就减库存,已经封装成了一个原子操作。

但是这样也有很明显的缺点:并发高了,操作数据库的次数会增加,对数据库的压力不用想都知道很高。

预扣库存

为了避免数据库负载增加太多,我们就可以从减少操作数据库 IO 入手。比如说扣库存这一操作,我们就没必要直接去数据库了,对吗?我们可以把库存缓存到 redis 进行预扣库存。然后通过消息队列来异步生成订单,这样用户等待的速度就会快很多,同时也给数据库减压了。

订单的生成是异步的,咱们一般都会放到 MQ、kafka 这样的即时消费队列中处理,订单量比较少的情况下,生成订单非常快,用户几乎不用排队。而且在电商平台买,订单都会有个超时时间的,时间到了未支付,会自动退单。

单机下扣库存的处理

上面我们说到了,下单的流程中,是需要保证扣库存和创建订单的原子性的,那么在单机的情况下,就需要用事务来进行处理了。

分布式锁

秒杀的场景,往往伴随着高并发,但是单机的承载能力并不算很好,而且要考虑到服务的可用性,所以我们可能要上集群。在分布式场景中,我们为了实现不同客户端的线程对代码和资源的同步访问,保证在多线程下处理共享数据的安全性,就需要用到分布式锁技术。本篇文章的主角就来了——Redis分布式锁。

记得刚学到Java的锁概念的时候,一个通俗易懂的例子就是:一个进程进入了叫redis的厕所,但是发现坑满了(上锁了),然后就只能放弃上厕所(加锁)或者等一下再看看(重试)🤣

Redis 分布式锁的三大核心要素就是:加锁、解锁、锁超时。

首先,redis 单机和多机实现的锁,是不同的。一些人将单机 Redis 排除了分布式锁的范畴,为了避免争议,这里我也不站边了。就我所了解的,有以下一些方案:

SETNX 命令

直接使用 Redis 的 SETNX 命令,该指令只在 key 不存在的情况下,将 key 的值设置为 value,若 key 已经存在,则 SETNX 命令不做任何动作。key 是锁的唯一标识,可以按照业务需要锁定的资源来命名。

这种方式比较简单,但也存在弊端,三大核心要素的锁超时给漏了。一旦业务在释放锁之前,出现了问题,就可能导致锁无法释放,从而导致死锁。你可以理解为上厕所时纸掉坑里了,,,

EXPIRE 命令

为了防止死锁,我们可以在拿到锁之后,用 EXPIRE 对 key 设置一个过期时间。这样就能保证在没有显式释放的情况下,防止长时间被独占,因为时间到了锁会自动释放。

没错,即使实现了三大核心要素,依旧存在着一些问题。很明显的,加锁命令和设置超时时间的命令,是非原子性的。也就是说,如果在执行 SETNX 和 EXPIRE 之间发生异常,仍然可能会导致锁的超时。

使用 SET 指令扩展

为了解决前面出现的原子性问题,我们可以使用 SET 指令的扩展参数来解决。但是同时引来了一个新的问题:锁可能被提前释放了。我举个例子,线程 A 加锁后,设定的超时时间是 5s ,但是处于某些意外,执行时间为 6s。但是这时候锁已经早就自动释放了,同时被线程 B 给抢占了。但是线程 A 依旧释放了锁,也就导致了错误释放了锁

但是也不是无法解决的,我们可以给每个锁设置一个唯一的标记。别忘了 redis 是 key-value 形式的。我们加锁,是加到 key 上面去了,但是我们同样的可以在 value 上面,设置唯一的标识。然后在释放锁之前验证一下,如果当前锁的 value 和我加上去的 value 一样,那我们就释放。

又双叒叕新问题了(逐渐暴躁),判断 value 和删除 key 是两个独立的操作,所以肯定无法保证原子性。

使用 lua 脚本

为了保证判断 value 和删除 key 的原子性,我们就需要用到 lua 脚本进行处理了,lua 脚本可以保证连续多个指令的原子性执行。

1
2
3
4
5
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

lua 脚本解决了错误释放锁的问题,但是却依旧没解决提前释放锁的问题。

Redlock

Redlock 本质上是使用 Redis 实现分布式锁的规范算法,它有很多实现,我们常见的 Redisson 就是其中一个。但是 Redlock 争议也是有的,贴几个链接大家自己去看吧:

https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html

http://antirez.com/news/101

https://redis.io/topics/distlock

https://carlosbecker.com/posts/distributed-locks-redis/

主要是关于安全性方面的一些争议,不过对于大多数场景来说,其实是完全够用了。

基于Java 实现的 Redisson

这是一张网图,基本上可以看出工作机制。

Redisson 是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。

官方文档地址:https://github.com/redisson/redisson/wiki/%E7%9B%AE%E5%BD%95

updatedupdated2021-08-032021-08-03
加载评论