Java 商品抢购系统设计,实现与优化
参考教程:https://www.bilibili.com/video/BV1sf4y1L7KE
0. 使用技术
Java 1.8
SpringBoot 2.7.4
RabbitMQ 3.11
Redis 6.2.7
Mybatis-plus 3.5.2
MySQL 5.7
1. 流程
- 功能开发
- 系统压测
- 分布式会话
- 优化
- 页面优化
- 服务优化
- 安全优化
2. 高并发带来的一些问题
可能会遇到黄牛,大量并发写和并发读。
因此设计系统要保证高可用,保证数据一致性,高性能。
3. 参考配置
4. 分布式 Session 问题
多台 Tomcat 配合 Nginx 出现用户登录的问题。
Nginx 使用默认的负载均衡策略(轮询),请求将会按照时间顺序逐一分发到后端应用上。
比如,首先用户在 Tomcat1 上登陆后,用户信息保存在 Tomcat1 的 Session 中。过了一会请求被 Nginx 分发到了 Tomcat2 上,因为 Tomcat2 上的 Session 没有用户信息,此时又要登录。
4.1 可使用的解决方案
4.1.1 Session复制
优点:
- 无需修改代码,只需要修改 Tomcat 配置
缺点:
- Session 同步传输占用内网贷款
- 多台 Tomcat 同步性能指数下降
- Session 占用内存,无法有效水平扩展
- 我加机器就是为了扩展内存,你这 Session 同步,每台机器还是占用那么多内存,太冗余了
4.1.2 前端存储
优点:
- 不占服务器内存
缺点:
- 存在安全风险
- Cookie 是明文的,不安全
- 数据大小受到 Cookie 限制
- 占用外网带宽
- 前端到后端传送对象也需要占用带宽的
4.1.3 Session 粘滞
优点:
- 无需修改代码
- 服务端可以水平扩展
缺点:
- 增加新机器,会重新 Hash,导致重新登陆
- 应用重启,需要重新登陆
4.1.4 后端集中存储
优点:
- 安全
- 容易水平扩展
缺点:
- 增加复杂度
- 需要修改代码
4.2 选择第四种方案(Redis)
docker 安装 Redis 6.2.7
客户端使用 Another Redis Desktop Manager 1.5.8
4.2.1 示例
set key value [ex 10 | px 10000] [nx | xx]
- nx 表示当不存在该 key 设置成功
- xx 表示存在该 key 设置成功
- ex 10 十秒后过期
- px 10000 一万毫秒(即十秒)后过期
4.3 SpringSession 实现方式(抛弃这种实现)
当执行 session.setAttribute() 时会自动导入 Redis
4.4 直接用 Redis 存储信息的实现方式(使用该实现)
首先实现序列化方法,默认的 JDK 序列方法可读性有点差。采用 Jackson 的序列化。
首次登录先存 Cookie,然后在 Redis 中存储 {“user:cookie”:userObject},下一次访问的时候直接拿着本地的 Cookie 去 Redis 取信息。
如果每个页面都需要判断是否登录太麻烦了。如何解决?
- 先配置用户参数解析器,在解析器中通过 cookie 向 Redis 中取信息,然后直接将对象变为控制器的入参即可。
5. 优化前 Jmeter 压测记录
5.0 测试环境
- Windows 10,Intel i7 7700HQ,内存 16G
- Linux CentOS 7(虚拟机环境)4 核心,内存 4G
redis 单节点,mysql
5.1 获取商品列表信息
设置线程数 5000,每个线程重复请求 10 次。
重复此操作 3 次。
5.1.1 Windows 测试
QPS:1498.9
5.1.2 Linux 测试
QPS:832.8
5.2 秒杀
设置商品为 10。
设置线程数 1000,每个线程重复请求 10 次。
重复此操作 3 次。
5.2.1 Windows 测试
QPS:1111.9
可以看到数据库中超卖了 2 件:
订单表和秒杀订单表中的数据条数各有 338 条:
5.2.2 Linux 测试
QPS:812.1
数据中超卖了 18 件:
订单表和秒杀订单表中的数据条数各有 196 条数据:
5.3 总结
可以看到在并发环境下会出现超卖问题,以及商品库存小于 1 的时候还会加入订单。接下来将基于此进行优化,解决线程同步以及提高 QPS。
6. 初次优化
6.1 对象 JSON 缓存
每次获取商品列表或者某件商品的信息的时候,先从 Redis 中取,若 Redis 中没有,再去数据库找,并加入 Redis 缓存中。
// 先从 Redis 中取缓存, 若返回的对象不为空则直接返回对象
ValueOperations valueOperations = redisTemplate.opsForValue();
List<GoodsVo> goodList = (List<GoodsVo>) valueOperations.get("goodsList");
if (!ObjectUtils.isEmpty(goodList)) {
return goodList;
}
// 若 Redis 中没有则从数据库加载
List<GoodsVo> list = goodsService.findGoodVo();
// 设置失效时间 5 min
valueOperations.set("goodsList", list, 5, TimeUnit.MINUTES);
return list;
6.2 解决数据库超卖问题以及将下单信息加入缓存
- 在数据库中将 seckill_order 表中 user_id 字段和 goods_id 字段组成联合唯一索引,解决同一个用户多次秒杀同一件商品。
- 判定库存为 < 1 时,抛出售空的异常。
- 将下单信息加入 Redis 缓存,多次购买同一件商品时直接从 Redis 中获取。
7. 加入对象 JSON 缓存,解决数据库超卖问题以及将下单信息加入缓存 Jmeter 压测记录
7.1 获取商品列表测试
设置线程数 5000,每个线程重复请求 10 次。
重复此操作 3 次。
7.1.1 Windows 测试
QPS:3171.3
7.1.2 Linux 测试
QPS:2333.6
7.2 秒杀
设置线程数 1000,每个线程重复请求 10 次。
重复此操作 3 次。
7.2.1 Windows 测试
QPS:1421.1
7.2.2 Linux 测试
QPS:1064.8
7.3 总结
相对于之前没有缓存的情况有明显的提升,并且解决了超卖问题,以及解决了同一个用户只能购买一件同样的商品。
8. 中间件
应用程序(可以是不同编程语言开发)使用某种协议或者说规范与 OS 底层进行通信。这种规范就是中间件,中间件具有跨平台,支持通信,高可用,持久性等特点。
8.1 消息中间件
利用可靠的消息传递机制进行系统和系统直接的通讯,通过提供消息传递和西澳西的排队机制,它可以在分布式系统环境下,扩展进程间的通信。
本质
- 接收数据
- 接受请求
- 存储数据
- 发送请求等功能的技术服务
同时性能要比普通的服务和技术要高。
组成部分
- 消息的协议
- 消息的持久化机制
- 消息的分发策略
- 消息的高可用,高可靠
- 消息的容错机制
应用场景
加入有 10W 的并发请求下订单,我们可以在这些订单入库之前把订单请求堆积到消息队列中,让它稳健可靠的入库和执行。
协议
AMQP,高级消息队列协议
8.2 RabbitMQ
RabbitMQ 中消息传递模型的核心思想是:生产者永远不会直接向队列发送任何消息。生产者常常根本不知道消息是否会被传递到任何队列。
生产者只能将消息发送到交换器(exchange)。
8.2.1 交换器(Exchange)
交换器职责:
- 接收来自生产者的消息
- 将这些消息推送到队列
- 交换器必须确切地知道如何处理它接收到的消息
- 它应该被追加到特定的队列中吗?
- 它应该被追加到许多队列中吗?
- 还是应该被丢弃?
以上规则由交换机类型定义,类型有:direct
,topic
,headers
,fanout
RabbitMQ 默认交换机:
8.2.1.1 Fanout 模式
类似发布订阅,也有点像广播,多个消费者会受到同一个发布者发送的同一个消息。
8.2.1.2 Direct 模式(常用)
有选择地接收信息。
绑定的时候可以采取一个额外的 routingKey
参数。为了避免与 basic_publish 参数相混淆,我们要把它称为绑定键。
Fanout 模式中是将消息传给所有的队列,Direct 模式可以指定不同的路由键的消息发送给指定消费者。
8.2.1.3 Topic 模式(常用)
根据一个模式(主题)接收信息。
可以指定 RoutingKey 包含某个关键词的队列进行发送。带来更多的灵活性。
RoutingKey 中可以有任意多的单词,最多不能超过 255 字节的限制。
*
表示匹配一个词#
表示匹配 0 个或者多个词
当一个队列用 #
绑定键绑定时–它将收到所有的消息,不管 RoutingKey 是什么,就好像在 Fanout 交换机中一样。
8.2.1.4 Header 模式
可以指定哪些指定 key 的 value 值进行发送。(用的不多)
9. 接口优化
- 系统初始化的时候加载全部商品 id 和库存数量到 Redis,并初始化全部商品库存为空的标记全为
false
- 通过 Redis 预减库存,减少数据库的访问,若库存为 0,则将库存为空的标记置为
true
- 请求进入队列缓存,异步下单(告诉客户端正在排队中)
10. 使用 Redis 预减库存,使用消息队列通知数据库处理订单,Jmeter 秒杀压测
设置线程数 1000,每个线程重复请求 10 次。
重复此操作 3 次。
10.1 Windows 测试
QPS:2196.5
10.2 Linux 测试
QPS:1784.7