扣丁狼【项目实战】秒杀场景超高含金量项目,3000 QPS 高并发秒杀系统实现 分布式架构的秒杀操作

397 阅读18分钟

秒杀的业务场景

  1. 当前用户必须登录;
  2. 基于秒杀的商品id,查询秒杀商品的对象信息
  3. 判断当前的秒杀在秒杀的时间范围内;
  4. 判断秒杀的库存存在
  5. 减库存,创建订单操作

image.png

image.png

函数接口信息 doSeckill()

  1. 三个参数,一个秒杀的商品id,
  2. 秒杀的时间,以及当前用户登判断的token。
  3. 用户的登录信息存储在redis中,key就是当前用户携带的token,value为当前用户的信息 image.png

image.png

image.png

image.png

image.png

image.png

image.png

上面不清楚的看博客,在自定义请求中juejin.cn/post/745330…

image.png

(三)接下来模拟测试,用户访问doSeckill()

模拟500个用户登录成功去进行抢购数据,把这500个用户。 模拟创建500个用户,这500个用户创建成功保持到用户表和密码表中,然后生产用户的token信息也存储到redis中。同时会把产生的500个用户的token数据存储的token数据存储到一个本地的文件中,后续jemter压测的时候,可以读取本地的这个token文件,携带token去访问后端的数据,这样的话jmeter就能够模拟500个用户登录成功去进行秒杀操作了相当的经典。

image.png

image.png

image.png

image.png

接下来,就是最经典的jmeter压测操作

1创建一个线程组,100个线程运行500次

image.png

创建访问的http请求

image.png

image.png

jmeter创建配置元件,读取本地的token文件,然后将token文件的数据,随机选择一个作为http的请求头信息带入

image.png

image.png

jmeter创建配置元件读取本地的token文件成功后,需要将token参数作为http的请求头信息带入,接下来操作如下

image.png

image.png

模拟测试效果如下

image.png

秒杀接口分析

image.png

image.png

秒杀失效的原因就是在多线程操作的情况下,查询库存和扣减库存不是原子性

比如商品A在库存中只有一件商品,两个用户登录的时候,查询库存都能够访问得到当前的商品有1个库存,所以都能够继续后续的操作,接下来最后就是下单,两个用户都能够执行下单操作,最后商品就超卖,卖了2次,商品的库存就变成了-1.

库存超卖的解决方案

第一种方案,使用本地的jvm 锁操作,在扣减库存的地方加锁,在扣减库存之前,先查询当前的库存是否存在,如果存在再去减库存,通过当前进程的jvm加锁,保证原子性操作

image.png

上述操作存在比较严重的问题,通过当前进程的jvm加锁,保证原子性操作,jvm锁操作只能保证当前的实例有效,如果存在多个jvm进程,必须要采用分布式锁操作。

image.png

第一个版本redis实现分布式锁--最基础版本

在现在工作中,为保障服务的高可用,应对单点故障、负载量过大等单机部署带来的问题,生产环境常用多机部署。为解决多机房部署导致的数据不一致问题,我们常会选择用分布式锁

目前其他比较常见的实现方案我列举在下面:

  1. 基于缓存实现分布式锁(本文主要使用 redis 实现)
  2. 基于数据库实现分布式锁
  3. 基于 zookeeper 实现分布式锁

本文是基于 redis 缓存实现分布式锁,其中使用了 setnx 命令加锁,expire 命令设置过期时间并 lua 脚本保证事务一致性。Java 实现部分基于 JIMDB 提供的接口。JIMDB 是京东自主研发的基于 Redis 的分布式缓存与高速键值存储服务。

2 SETNX

基本语法:SETNX KEY VALUE

SETNX 是表示 SET ifNot eXists, 即命令在指定的 key 不存在时,为 key 设置指定的值。

KEY 是表示待设置的 key 名

VALUE 是设置 key 的对应值

若设置成功,则返回 1;若设置失败(key 存在),则返回 0。

image.png

这里有下面的几个注意点: key的数值为常量:seckill:product:stockcount time:当前秒杀属于那个时间范围,是下午两点,还是下午四点 id:为秒杀商品的id。

Redis 2.6.12 版本前后对比:

2.6.12 版本前:分布式锁并不能**只用 SETNX 实现,需要搭配 EXPIRE 命令设置过期时间,否则,key 将永远有效。**其中,为保证 SETNX 和 EXPIRE 在同一个事务里,我们需要借助 LUA 脚本来完成事务实现。(由于在写这篇文章时,JIMDB 还未支持 SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]  语法,故本文依然用 lua 事务)

2.6.12 版本后:SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]  语法糖可用于分布式锁并支持原子操作,无需 EXPIRE 命令设置过期时间。

3 LUA 脚本 什么是 LUA 脚本? Lua 是一种轻量小巧的脚本语言,用标准 C 语言编写并以源代码形式开放,其设计目的是为了嵌入应用程序种,从而为程序提供灵活的扩展和定制功能。

为什么需要用到 LUA 脚本? 本文的锁实现是基于两个 Redis 命令 - SETNX 和 EXPIRE。 为保证命令的原子性,我们将这两个命令写入 LUA 脚本,并上传至 Redis 服务器。Redis 服务器会单线程执行 LUA 脚本,以确保两个命令在执行期间不被其他请求打断。

要保证redis的两个操作setnx和expire两个命令操作的原子性,可以使用为保证 SETNX 和 EXPIRE 在同一个事务里,我们需要借助 LUA 脚本来完成事务实现。

LUA 脚本的优势 减少网络开销。若干命令的多次请求,可组合成一个脚本进行一次请求 高复用性。脚本编辑一次后,相同代码逻辑可多处使用,只需将不同的参数传入即可。 原子性。若期望多个命令执行期间不被其他请求打断,或出现竞争状态,可以用 LUA 脚本实现,同时保证了事务的一致性。 分布式锁 LUA 脚本的实现 假设在同一时刻只能创建一个订单,我们可以将orderId作为 key 值,uuid作为 value 值。过期时间设置为3秒。

LUA 脚本如下,通过 Redis 的 eval/evalsha 命令实现:

完整的lua脚本如下所示

image.png

然后注册一个读取lua脚本的bean对象

image.png

接下来就可以执行lua脚本实现分布式锁了

image.png

image.png

上述分布式锁存在一个非常重要的问题在于

1、释放锁其实只需要把锁的key删除即可,使用del xxx指令。不过,仔细思考,如果在我们执行del之前, 服务突然宕机,那么锁岂不是永远无法删除了?! 为了避免因服务宕机引起锁无法释放问题,我们可以在获取锁的时候,给锁加一个有效时间,当时间超 出时,就会自动释放锁,这样就不会死锁了

并且要保证锁被删除,要放在try  finally中

2 .就是分布式锁的超时时间的设置,如果设置为10秒在使用Redis实现分布式锁时,处理超时时间是非常重要的,以确保在获取锁的客户端在一定时间内未能完成任务时,锁能够自动释放,

5bfea8c21957dd2c425ec733881fee17_1151107-20200220115246802-1579442442.png

处理超时时间是非常重要的,以确保在获取锁的客户端在一定时间内未能完成任务时,锁能够自动释放

2、大家思考一下,释放锁就是用DEL语句把锁对应的key给删除,有没有这么一种可能性:

  1. 3个进程:A和B和C,在执行任务,并争抢锁,此时A获取了锁,并设置自动过期时间为10s
  2. A开始执行业务,因为某种原因,业务阻塞,耗时超过了10秒,此时锁自动释放了
  3. B恰好此时开始尝试获取锁,因为锁已经自动释放,成功获取锁
  4. A此时业务执行完毕,执行释放锁逻辑(删除key),于是B的锁被释放了,而B其实还在执行业务
  5. 此时进程C尝试获取锁,也成功了,因为A把B的锁删除了。 问题出现了:B和C同时获取了锁,违反了互斥性! 如何解决这个问题呢?我们应该在删除锁之前,判断这个锁是否是自己设置的锁,如果不是(例如自己 的锁已经超时释放),那么就不要删除了。 那么问题来了:如何得知当前获取锁的是不是自己呢?

我们可以在set 锁时,存入当前线程的唯一标识!删除锁前,判断下里面的值是不是与自己标识释放一致,如果不一致,说明不是自己的锁,就不要删除了。 这里通过线程id来实现

f0ebc67f0fcbdf8ace04a92daf49e915_1151107-20200220115331472-1761900188.png

image.png

image.png

接下来要实现下面的几个问题,接下expire的时间要能够独立保证稳定性,expier的过期时间能够依据业务的执行情况进行出来

image.png

接下来创建看门狗

image.png

接下来通过线程池改造来实现看门狗的效果

image.png

在项目启动的时候,在配置文件中注入一个线程池的对象

image.png

image.png

image.png

在当前的秒杀商品下单成功之后,需要把看门狗的业务逻辑删除掉

image.png

分布式锁的总结


image.png

redisson实现分布式锁,内部执行lua保证了原子性同时也引入了看门狗

image.png

image.png

总结

上面分析的代码在redis单机版本上面是没有问题的,但是在redis的哨兵模式和集群模式下,上面的代码是存在问题的。

为了避免节点挂掉导致的问题,我们可以采用Redis集群的方法来实现Redis的高可用。

Redis集群方式共有三种:主从模式,哨兵模式,cluster(集群)模式

其中主从模式会保证数据在从节点还有一份,但是主节点挂了之后,需要手动把从节点切换为主节点。它非常简单,但是在实际的生产环境中是很少使用的。

哨兵模式就是主从模式的升级版,该模式下会对响应异常的主节点进行主观下线或者客观下线的操作,并进行主从切换。它可以保证高可用。

cluster (集群)模式保证的是高并发,整个集群分担所有数据,不同的 key 会放到不同的 Redis 中。每个 Redis 对应一部分的槽。

(上面三种模式也是面试重点,可以说很多道道出来,由于不是本文重点就不详细描述了。主要表达的意思是你得在面试的时候遇到相关问题,需要展示自己是知道这些东西的,都是面试的套路。)

在上面描述的集群模式下还是会出现一个问题,由于节点之间是采用异步通信的方式。如果刚刚在 Master 节点上加了锁,但是数据还没被同步到 Salve。这时 Master 节点挂了,它上面的锁就没了,等新的 Master 出来后(主从模式的手动切换或者哨兵模式的一次 failover 的过程),就可以再次获取同样的锁,出现一把锁被拿到了两次的场景。

锁都被拿了两次了,也就不满足安全性了。一个安全的锁,不管是不是分布式的,在任意一个时刻,都只有一个客户端持有。

image.png

四、Redission实现分布式锁存在的问题

blog.csdn.net/weixin_4543… Redission使用看门狗续期的方案在大多数场景下是挺不错的,但在极端情况下还是会存在问题,比如: 四、Redission实现分布式锁存在的问题 Redission使用看门狗续期的方案在大多数场景下是挺不错的,但在极端情况下还是会存在问题,比如:

  1. 线程1首先获取锁成功,将键值对写入redis的master节点。
  2. 在redis将master数据同步到slave节点之前,master故障了。
  3. 此时会触发故障转移,将其中一个slave升级为master。
  4. 但新的master并没有线程1写入的键值对,因此如果此时来个线程2,也同样可以获取到锁,这就违背了锁的初衷。

1.2 redlock的原理 1.redlock思想:

RedLock 是对集群的每个节点进行加锁,如果大多数节点(N/2+1)加锁成功,则才会认为加锁成功。 这样即使集群中有某个节点挂掉了,因为大部分集群节点都加锁成功了,所以分布式锁还是可以继续使用的。

2.作用

RedLock 算法旨在解决单个 Redis实例作为分布式锁时可能出现的单点故障问题,通过在多个独立运行的 Redis 实例上同时获取锁的方式来提高锁服务的可用性和安全性。

1.3 实现步骤 基于客户端的实现,是基于多个独立的Redis Master节点的一种实现(一般为5)。client依次向各个节点申请锁,若能从多数个节点中申请锁成功并满足一些条件限制,那么client就能获取锁成功。它通过独立的N个Master节点,避免了使用主备异步复制协议的缺陷,只要多数Redis的master节点正常就能正常工作,显著提升了分布式锁的安全性、可用性。

image.png

image.png

image.png

上面的代码存在很严重的性能问题,接下来需要对上述的代码进行重构和优化

优化思路如下,比如库存只有200个商品,只让200个现场进进来,提高并发的访问效率

image.png

需要对上面的decrStockStockcount函数进行优化,提高并发效率,比如库存只有200个商品,只让200个现场进进来,提高并发的访问效率

采用乐观锁的方式实现

image.png

乐观锁的实现通常有两种主要的方式:使用数据版本(Version)记录机制和时间戳机制。这两种方式都是为了确保在并发环境中,当多个事务试图修改同一份数据时,能够正确地处理这些请求,避免数据不一致的问题。

  1. 使用数据版本(Version)记录机制

这是最常见的一种乐观锁实现方式。具体来说,就是在数据库表中增加一个数字类型的version字段来表示数据被修改的次数。当读取数据时,将version字段的值一同读出;在更新数据时,会检查当前记录的version值是否与之前读取的一致。如果一致,则更新成功,并将version值加1;如果不一致,则认为数据已经被其他事务修改,当前更新失败,通常会提示用户重新尝试。

线程A和线程B都同时得到了数据库,得到的数据都是一样的,都是name 为未元,version为1,接下来, 线程A先操作了数据库把name跟新为了zhongliu,version版本的值变成了2,接下来现在B去更新这个记录,携带的version为1,个数据库中的2不一致,数据库就会插入失败,抛出异常,在业务代码中做逻辑处理。

接下来我们看看扣减库存这个代码如何进行优化,类似引入version版本,原来的扣减库存的sql语句如下所示:

image.png

优化的sql如何,每个线程在操作的时候,让数据库来实现乐观锁,判断stockcount是否大于0,如果大于0,每个线程都更新成功,如果判断stockcount小于0.数据库更新的时候会抛出异常

image.png

整个减库存的代码就变成通过下面的乐观锁来实现了

image.png

image.png

使用了乐观锁实现了decrStockCount方法后,只需要上面的两个代码,但是上面的代码还是需要从数据库中每次都去查询数据库中的库存是否村,能不能对上面的代码进行优化,引入redis缓存

通过eslatic-job每天早上凌晨加载,各个时间场次的库存清单

image.png

image.png

image.png

上面的代码存在一定的逻辑性问题,就是用一个用户对秒杀商品抢购两次的情况

第一次用户1已经抢购了秒杀商品,第二次进入通过redis判断库存是否存在的时候,因为当前库存还存在,value的值大于0,所以会将redis中的存款减去1

然后进入到第5个步骤,判断当前的用户是否已经登录,第一次用户1已经抢购了秒杀商品,所以登录表中已经存在了,所以执行下面的代码会抛出异常,不能让用户抢购两次,但是上面第四步中判断库存是否充足,已经将redis中的存款减去1,所以存在问题

解决的办法两种情况,第一种用户抛出了登录异常后,将第四步中判断库存是否充足,已经将redis中的存款加1,保证数据一致性

第二种办法,4和5的代码顺利调整下,先执行判断是否已经登录后,在判断库存是否充足的操作

image.png

但是上,4和5的代码顺利调整下,先执行判断是否已经登录后,在判断库存是否充足的操作,但是上面的操作还是存在问题就是上面标红的两个框,不具备原子性操作,比如判断用户是否已经下过订单和创建订单减库存操作,存在线程安全问题,因为这里没有使用分布式锁。

可以使用redis的setnx来 保证用户已经下个单,来保证原子性

image.png 可以使用redis的setnx来 保证用户已经下个单,来保证原子性,如果用户登录成功了,执行redis操作的时候就会报错,同时判断用户是否登录不用在查询数据库了,性能得到了大幅度的提升 上面的这个操作只能够保证一个用户下单抢购一次,我们可以使用下面的代码进行优化,让同一个用户能够访问两次,抢购两次

代码调整为下面的形式

image.png

image.png

上面的代码在提示的时候存在问题,比如用判断用户是否重复下单的key需要进行删除,如果库存被卖完了,需要将用户已经重复下的key删除;如果不删除,当用户一直在访问的时候,判断是否已经下单,提示用户不能重复下单,本质上是库存不足了, 所以在如果库存被卖完了,需要将用户已经重复下的key删除

image.png

image.png

接下来还可以进行优化,

上面通过redis优化了库存是否充足操作

通过redis判断用户是否已经重复下单;

通过数据库的乐观锁实现了库存操作

通过数据库的乐观锁实现了库存操作,这里还是要大量访问数据库,这里还可以进行优化,在进行秒杀操作的函数上的入口,先判断当前的秒杀商品是否已经完成,如果完成了后续的操作都不在进行。通过本地的jvm 满足线程安全的conurrenthashmap来实现

image.png

image.png

在库存卖完跟新hashmap的标注位

image.png

通过上面的操作大量提高的tps,qps能够从上面的100突破到现在的1200;