我人傻了!新入职的同事三下五除二就搭建了一个简易版秒杀系统

今天来分享下这段时间学习的一个秒杀项目,黑马点评。
好久没跟着学这些,感觉非常非常好玩,又想捣鼓点项目玩玩了 哈哈。
我也简单搭建了一个,用到了这些技术。
Springboot2 + Redis7 + Lua + Redisson + MySQL8 + RabbitMQ3.9 + MybatisPlus + Hutool
其中 Redis 和 MySQL 都是之前搭建在云端的 K8S 上的 主从 结构,用 Traefik 做总网关。
RabbitMQ 则是之前在本地虚拟机上用 docker 搭建的 ,还有 Prometheus + Grafana 监控。
思路

隐藏秒杀地址
这个就是实现一个用户一个地址,给脚本工具加点难度。
根据需要生成这个 path,比如用 md5 混淆下 。
然后放到 Redis 中 key :秒杀活动 ID+’path‘ + 秒杀商品 ID+用户 ID , value :path

真实的秒杀地址如下

lua 脚本预扣库存
用 lua 脚本来保证这个操作的原子性,判断 库存 key 存不存在,数量够不够,够的话执行扣减操作

bug
我这样写的脚本是有问题的,没有进行 重复订单校验 , 以及 set 这个 订单信息 到 redis 中。
这 3 步操作应该是原子性的,校验,扣减,设置
所以即便 lua 脚本能保证操作的原子性,但是并发情况下会出现 少卖 的情况。

模拟同个用户 50 个并发,100 个库存


改正版
改正后也就正常了,之前我是老想着 订单 ID 的生成要从 分布式 ID 中获取,想尽量较少这个 网络请求 的,一不小心就疏忽了。(以后得先把 核心思路 写下,再思考优化,不能边写边想优化了🐷)
分布式 ID ,我之前研究这个 美团 Leaf 也是想简单搭建一个,奈何总喜欢偷懒🐷,这里我是用 Hutool 的 雪花算法 简单生成的。

保存订单信息到 Redis
出 大 bug 之前,我以为这里只是做 重复订单校验 的,没想到,还有这种情况 👇
MQ 挂了,消息还没发送出去,甚至一开始就没连接上的情况。
比如 我这个本机和虚拟机 休眠后得重启下 虚拟网络 vm8,不然连不上去。

意料之外~
所以,这里得写个小脚本,将 订单信息 发送到 MQ 中,在紧急情况下能快速补救。
分布式锁
目前用 jvm 级别的锁其实就足够了,但后面上集群还得改代码,干脆一鼓作气。

锁的粒度,不能太大,主要防止用户重复下单。
比如
第一版 错误的 lua 脚本中,就会出现 重复下单 的情况
集群模式下,多个消费者的情况,此时谁先拿到分布式锁,谁就可以消费这个订单,避免重复下单

通过分布式锁,保证这个订单只有一个消费者消费,即便在多个消费者模式下,也不会出现 重复下单 的情况。
同时,也可以防止使用 Redis 出现意外,就像上面 错误使用 lua 脚本的案例,以及 可能存在的 key 过期等问题导致的重复下单问题。
当然,这还不是 兜底方案 ,万一这个 分布式锁 也出现意外了呢,所以保险起见,还需要给 订单表 建立 唯一索引(用户 id+商品 id),靠数据库本身保证了。
这里如果不用分布式锁,那就得从数据库层面去保证了,得用 select …… for update 开启 悲观锁,那效率会进一步降低的。
注意,这里也是 缓存击穿 的常见解决思路,分布式锁,双重检查锁模式。
事务
我这里是简易版的,没有涉及到 分库分表,所以也谈不上这个 分布式事务。
这里我用的 编程式事务 ,毕竟 扣减库存和保存订单 要在一个事务里,用注解的话还得考虑这个失效的场景,获取这个代理对象去执行,没有这个 编程式事务 来得方便。

假设 订单在订单库中,商品在商品库中,那这种情况下,是不是还得考虑这个 分布式事务 呢?
我可能还是不会选择这个 分布式事务 ,我会直接往 商品库 中 建立一个 秒杀订单表 或者在 订单库 中建立这个 秒杀商品库存表,甚至专门弄一个 秒杀库 , 冗余 一下,事后如果需要同步到相应的 库表 中,再进行相关的操作。
那假如还有个积分系统呢 ?
比如 支付回调后,更新订单状态的同时,还要更新这个用户积分。
这我还是会选择 MQ ,通过 MQ 的可靠性 来达到这个 最终一致性

先发送消息到积分系统,更新订单信息单独在事务中。
这是分布式事务中常见的一种解决方案 基于 MQ 可靠性消息的最终一致性方案。
有时间可以学习下 Seata

重试机制
上图将 MySQL 和 MQ 的操作放一起,还得小心这个 MQ 的异常,导致这个 事务回滚,但是 ACK 还是正常发出去的情况。
这里我最后还将异常抛出去,是为了触发这个 重试机制 ,配置文件中 开启 RabbitMQ 消费者重试机制即可。
ACK 前发生异常,事务回滚,触发重试机制。
ACK 中发生异常,捕获,丢弃异常,提交事务。再次消费时,发现是重复订单。
ACK 后还有异常,未捕获,事务回滚,但消息已经被 ACK,触发了重试机制,在重试期间没有异常,则正常处理。如果重试后还有异常,则会出现 消息丢失 的情况,这又得 紧急处理 了。
防止超卖
有两个扣减动作
Redis 预扣库存,这里得在 lua 脚本中操作。

MySQL 扣减库存,这里核心就是 乐观锁的方式 a=a-1 where a > 0;

缓存
这里再简短啰嗦下
缓存穿透
针对不存在的 key ,可以用 布隆过滤器
缓存击穿
key 刚好过期,或者 商品成了爆款
用 分布式锁 , 双重检查锁模式 能解决上面这两种情况,锁的粒度也是这个 商品。
针对 key 刚好过期 的情况,我了解到一种新的处理思路:逻辑过期
不在 Redis 中判断是否过期,在 代码 中进行判断,过期的话获取锁,开线程去更新,但实现起来比较复杂。
缓存雪崩
大量 key 同时过期,可以 给不同的 Key 的 TTL 添加随机值 ,给业务添加多级缓存 ,降级限流策略安排上
总结
到这里,这个简易秒杀系统就介绍完了,至于 限流,用户鉴权,标记 ,订单支付,超时处理,消息的顺序性 …… 再到大一点的 集群,缓存一致性 等等东西,得抽空再完善下了。
搭建过程中,最有意思的是,一直防着 超卖,结果还出现了 少卖 的场景😂
所以这 Redis 预扣库存 也得谨慎呀,lua 脚本 三合一疗程:查,扣,存 😂
MySQL 也一样,分布式锁,事务 ,查,扣,存

希望到时能把笔记中的技术都过一遍。

学习笔记
下面是我用 JMeter 测试的一些数据情况👇
JMeter
这里两个 http 请求分别模拟,获取秒杀地址,开始秒杀。

jmeter 500 个并发,100 件库存
报告一
这个 平均响应 是 326 ms , 50 % 的请求是 245 ms,99% 是 1342 ms ,最小是 21 ms,最大是 1359 ms ,吞吐量是 605/s 。

这个成绩。。一言难尽,这还是用了 MQ 异步下单 ,还有 内存标记,Redis 预扣库存 的结果,而且是 预热了 JVM 的情况😱
这最大的开销应该是网络问题,要访问 云服务器 K8S 中的 Redis 以及 本地虚拟机上的 MQ。
或者是我的老伙计性能问题,又得跑项目,还得测试,这 CPU ,内存,网卡 估计也忙坏了。
简单分析下 👇
获取秒杀地址 , 这里就访问一次 Redis ,执行 Set 命令。
开始秒杀 中,涉及的网路操作有
校验地址
是否重复下单
预扣库存 lua 脚本
发送订单信息到 MQ(虚拟机上)
后面把项目搭建到云服务器上再来测下。
报告二
这里看到 第一个请求 的 RT 都比第二个请求的 小。

Redis

Redis 内存使用情况(测试前)

Redis 内存使用情况(使用后)
可以看到,内存多了 0.1M 左右,这是多了 601 个 key

至于怎么多了 32 条 client connection , 只能做个简单的推测先了
项目中使用了这个 redisson 做分布式锁,占用了 25 条

简单看下源码

拿到服务器上的所有连接,排掉之前的 5 条,刚好剩下 32 条。

这里看到使用 resp3 的有 7 条,刚好符合,应该是 RedisTemplate 相关创建的。
这里简单看下源码, Redis 6 开始默认使用 RESP3 的协议的

RabbitMQ
下面是从 Prometheus + Grafana 监控截取的

RabbitMQ 使用情况(测试前)

RabbitMQ 使用情况(测试中)
这里 发送端和消费端 在一个应用上,共用一条 connection, 发送端创建了 24 个 channel , 消费端 2 个。


发送端第一条 MQ 数据

发送端第一条 MQ 数据被 ACK
从这个监控图可以看到,消费端开始消费的时间点大概是 16:47:00
而生产者发送第一条消息和被 confirm 的时间大概是 16:46:30 ;这个有误差是因为这个监控自动刷新的频率是 15s ,目前是最小的了(可能是我挑的模板问题,或者是这并发太小😂)

消费者消费能力,大概每秒 2 个 ack

channel
K8S
minikube 节点,上面运行了 Redis 主从 , MySQL 主从。

K8S 的情况(测试前)

K8S 的情况(测试后)
基本没变化。
后面再把 MQ 和 镜像仓库搭建一下,然后再把项目丢上去跑跑看看 ,到时再看看这个测试报告。
over!🐷
原文:https://mp.weixin.qq.com/s/ogx1MAUH0-RTsdWQG2N5CA
作者:Java4ye
如果感觉本文对你有帮助,点赞关注支持一下,想要了解更多 Java 后端,大数据,算法领域最新资讯可以关注我公众号【架构师老毕】私信 666 还可获取更多 Java 后端,大数据,算法 PDF+大厂最新面试题整理+视频精讲
评论