B站秒杀项目
GPT摘要
这是一个秒杀系统的学习项目,核心目标是实现高性能、高并发的秒杀功能,同时解决超卖和重复抢购问题。项目基于Spring Boot框架,整合了MyBatis、Redis、RabbitMQ等技术,并逐步优化性能。 主要内容包括: 1. 秒杀功能实现 - 防止超卖:通过数据库乐观锁(update原子操作)和Redis预减库存(利用Redis原子性)实现。 - 防止重复抢购:通过唯一索引(用户+商品ID)或Redis缓存验证。 2. 优化策略 - 减少数据库访问:使用Redis缓存库存、订单信息等。 - 优化并发瓶颈:引入内存标记减少Redis访问,利用MQ异步处理请求,降低数据库压力。 - 静态页面优化:采用前后端分离(页面静态化),通过Ajax加载动态数据,减少服务端渲染压力。 3. 技术实现 - 分布式会话管理:使用Redis存储用户Session或Token,支持多服务共享登录状态。 - 压测与调优:JMeter模拟高并发请求,分析并优化QPS(从637提升至3209)。 - 安全措施:验证码、接口限流(令牌桶算法)、隐藏秒杀地址等,防止恶意请求。 4. 关键问题与解决方案 - 超卖问题:Redis预减库存+数据库乐观锁双重保障,确保库存不超扣。 - 性能瓶颈:本地化Redis(避免网络延迟)、MQ削峰填谷、内存标记优化。 - 分布式锁:通过Redis+Lua脚本实现原子操作,避免并发问题。 5. 系统扩展 - 支持动态调整商品库存(如Nacos配置下发)。 - 网关层限流(如Sentinel)保护后端服务。 该项目从基础实现逐步优化至高并发方案,涵盖数据库设计、缓存策略、异步处理、安全防护等核心要点,适用于学习分布式系统与高并发场景的解决方案。
B站秒杀项目
- 视频地址
- 代码:https://github.com/Goinggoinggoing/seckill-study
- 项目来源:程序员来了666 https://gitee.com/guizhizhe/seckill_demo , 添加了一些中间过程接口以及注释
介绍
一个简单的秒杀项目,并不断的优化
在该项目中核心就是秒杀的实现:不能超卖、不能重复抢
- 不能超卖在doSeckill1中通过update的排他性实现(乐观锁)。而在doSeckill2中通过redis预减库存(redis的原子性实现)
- 不能重复抢通过唯一索引实现,默认建表时没有添加,压测可以把用户加少点商品多一点就可以复现重复购买
优化不过就是把数据库的重复访问,能放到redis就放到redis;而如果访问redis太多了就再加一层内存标记
redis和mysql要么都在远程,要么都在本地,否则可能会出现redis缓存优化了但QPS没提升
秒杀的接口有三个,先在goodsdetail中启用doSeckill1
doSeckill1: 对应到 P43,update排他+唯一索引实现秒杀(没有做order页面静态化)
1
2
3
4
5
6
7
8
9
10
11
12// 到这里为了实现不超卖(减少库存的同时判断库存数量)且单一下单(唯一索引)
boolean seckillGoodsResult = itSeckillGoodsService.update(new UpdateWrapper<TSeckillGoods>()
.setSql("stock_count = " + "stock_count-1")
.eq("goods_id", goodsVo.getId())
.gt("stock_count", 0) //大于零才下单
);
// 防超卖,直接结束,很关键
if (!seckillGoodsResult) {
return null; // 否则下单事务直接结束。update是排他锁,一定不会超卖
}
// 同时做了一些优化: 下单后存入redis,加快是否重复下单判断。doSeckill2:对应到 P53, order界面静态化 + redis预减库存 + 内存标记 + MQ
1
2
3
40.页面静态化(在前后端分离项目里默认就做了)
1.用redis预减库存(减少数据库访问),redis是原子操作,可以防止超卖;
2.满足还有库存后进入MQ队列;
3.我还想减少redis访问次数:引入内存标记。doSeckill :最终秒杀方案 一些安全上的优化
对应到发起请求界面static\goodsDetail.html 52~67行
前后端结合项目,两种处理页面方式,二者对比可以看orderDetail页面
- 前端页面在template下,通过controller返回访问,并
model.add添加数据
。h:text="${goods.goodsName}"
页面取出数据, 不可直接访问页面 - 在static下的页面可直接访问,并在页面加载时ajax请求返回json数据,
$("#goodsName").text(goods.goodsName);
根据id注入数据。(代码中的方式,相当于静态化了,视频中一开始是上面的方式,后面才做的静态化)
视频内容
- 项目框架搭建
- SpringBoot环境搭建
- 集成Thymeleaf,RespBean
- MyBatis
- 分布式会话
- 用户登录
- 设计数据库
- 明文密码二次MD5加密
- 参数校验+全局异常处理
- 共享Session
- SpringSession
- Redis
- 用户登录
- 功能开发
- 商品列表
- 商品详情
- 秒杀
- 订单详情
- 系统压测
- JMeter
- 自定义变量模拟多用户
- JMeter命令行的使用
- 正式压测
- 商品列表
- 秒杀
- 页面优化
- 页面缓存+URL缓存+对象缓存
- 页面静态化,前后端分离
- 静态资源优化
- CDN优化
- 接口优化
- Redis预减库存减少数据库的访问
- 内存标记减少Redis的访问
- RabbitMQ异步下单
- SpringBoot整合RabbitMQ
- 交换机
- 安全优化
- 秒杀接口地址隐藏
- 算术验证码
- 接口防刷
- 主流的秒杀方案
软件架构
技术 | 版本 | 说明 |
---|---|---|
Spring Boot | 2.6.4 | |
MySQL | 8 | |
MyBatis Plus | 3.5.1 | |
Swagger2 | 2.9.2 | Swagger-models2.9.2版本报错,使用的是1.5.22 |
Kinfe4j | 2.0.9 | 感觉比Swagger UI漂亮的一个工具,访问地址是ip:端口/doc.html |
Spring Boot Redis |
快(高性能) 准(一致性) 稳(高可用)
项目构建
初始化
spring 模板,并添加相应依赖,再添加mybatisplus
配置mybatis-plus datasource log
创建controller service mapper 和mapper.xml, @MapperScan Dao层
新建测试接口测试
创建用户
创建数据库表以及mapper service controller层
pass = MD5(MD5(pass名为+salt) + salt2),前端传过来的时候也加密一次。这里salt2是存在数据库里的
创建一个项目作为逆向生成工具项目,勾选spring web,添加mybatis plus,官网代码生成器
用户登录
/doLogin
导入登录界面,前端传密码前用md5加密一下
添加通用返回类以及枚举对象
1 |
|
校验在service中,还可以导入spring-boot-starter-validation
包然后通过注解@NotNull
实现校验。
service如果出现异常,抛出并且
全局异常处理
登录成功后存一个uuid到session,并用cookie(直接返回也可以)返回给前端,前端每次都带上这个;或者返回一个token,前端每次携带
添加
spring-session-data-redis
依赖后可以将session自动存入redis中实现分布式session,或者直接存数据到redis, key为uuid,通过cookie传来。之后相当于每次通过uuid拿到用户信息。导入
redis
包,配置 ip port等,配置类实现redis序列化,object序列化为json使用配置MVC,继承
WebMvcConfigurer
实现mvc的配置 配置自定义参数配置, 每次取出user传入参数(这里只做了取出参数User,没有拦截请求)
还可以配置拦截器,添加拦截器,拦截哪些请求。(也可以拦截器直接实现参数,自己代码)
数据库表
界面:商品列表 商品详情 订单
表:商品表 订单表 秒杀商品表(秒杀活动很多,添加一个标识字段不合适) 秒杀订单表
秒杀表
:商品ID、秒杀库存、开始结束时间
商品列表
名称 图片 价格 秒杀价 秒杀库存
由于需要显示的数据包含商品表
和秒杀商品表
,添加vo继承商品表添加额外信息
toList
接口返回商品列表,还需要添加mvc静态资源映射 :addResourceHandlers
商品详情页
需要知道秒杀是否开始结束,后端通过时间判断返回给前端一个状态(未开始、进行中、已结束)
秒杀功能
/doSeckill1 传统秒杀
传入user,goodsId
- 判断该goodsId是否还有库存,库存是看秒杀商品表,
进一步:redis预减
- 判断该userId是否购买过goodId(查看秒杀订单表):==优化:查询redis==
- 都没问题时,减库存,生成订单,生成秒杀订单
进一步:加入队列
代码中,前端页面需要将接口改为doSeckill1
小结
- 建表,需要额外秒杀商品表(价格、库存、开始结束时间)、秒杀订单(商品id、订单id)
- 登录,存入信息到redis,key为时间戳,访问通过cookie携带
- 全局异常处理处理业务异常,拦截器拦截未登录用户(cookie时间戳不合理),静态资源配置
- 商品列表、商品详情页,秒杀功能
压测
环境配置
jmeter
- QPS:每秒请求次数
- TPS:每秒事务(吞吐量)次数
- 一个页面一个TPS可能多次QPS
但windos和linux相差可能很多
配置mysql:为了安全性创建新用户xx,打开阿里云安全组,关闭防火墙
1 |
|
配置redis (使用docker),并配置远程访问以及持久化
1 |
|
安装jmeter,配置encodin,导入配置,放到bin目录下
1 |
|
部署java 到docker容器中,但我mysql redis都装在宿主机,需要合并网络不好访问,所以还是部署出来(或者用dockercompose部署)
1 |
|
问题:postman可以携带cookie请求成功,但浏览器不可以(跨域)
现在没有拦截未登录用户,如果未携带cookie会导致User空指针异常
小结
- 本地项目数据库、redis都用云的,并打包一份项目放到云上
- 云上部署,本地运行压测程序进行压测(标准应该云上压测,但比较麻烦)
配置文件导入多用户
再创建一个用户,登录后把uuid保存下来,放到文件里逗号分隔
csv数据文件设置 定义变量名称
userTicket
,${userTicket}
取出测试/user/info接口,看下是不是 返回不同用户
商品列表:5000 10 :460
生成100个用户,并且登录返回ticket,这里也可以生成用户用java,请求用python
1 |
|
测试秒杀接口
/doSeckill2
存在超卖问题!!
优化
1.对象缓存redis
通过uuid缓存User对象,数据跟新后要删除redis
2.页面缓存redis
把整个页面缓存到redis
本来是return ”goodsList“
返回页面 - - 》》优化成 渲染出整个页面
再返回,并缓存整个页面。toList
1 |
|
3.页面静态化
前后端分离
- 静态页面放到static下,可以直接/path.html访问,加载页面时请求返回json数据
goodsDetail2
->detail
- 之前跳转是
/goods/toDetail?goodsId=1
访问接口,现在是/Detail.html?goodsId=1
直接访问页面,但加载页面时多一步ajax请求数据 - 前端加载数据 根据id注入:
$("#goodsName").text(goodsVo.goodsName);
本来是Thymeleafth:text= "${goodsVo.goodname}"
orderDetial页面同理:
- 原来发起doseckill1请求,成功加载数据return detaii跳转页面,失败返回到错误页面(代码就是这样)
- 现在发起doseckill2请求,成功后弹框问是否跳转到static下的detaii.html,然后再发起ajax请求加载detail
4.静态资源缓存
配置后static下的goodsDetail.html
将被缓存,
1 |
|
5.问题解决
判断是否重复抢购,存入redis中
1 |
|
单纯在减库存时判断商品库存是否为负,为负不再继续,解决超卖。update会加行级别排他锁
==影响并发量==
1 |
|
订单表加唯一索引(user, goodid)防止单一用户多抢,@Transactional
。
至此:单一购买以及超卖问题都解决了
进一步优化
doseckill2
redis预减库存(原子)
内存标记减少redis访问
用队列进行缓冲
静态化 + MQ + redis: QPS:
637->571
反而下降了分析原因:数据库在本地但redis在云上,导致redis读取过慢,redis本地后**
1933 -> 3209
**,p55
1.预减库存
- 加载时加入库存量 ;redis是==原子操作==,减库存时不会有并发问题,保证进入MQ的都是有库存的
1 |
|
2.内存标记:
在访问redis前,使用一个map标记商品是否还有库存,减少redis访问(分布式会不会有问题??)
1 |
|
3.消息队列:
有库存要加入mq,传入参数中
- 配置文件定义队列和交换机
- MQSender文件封装发送方法
- 返回给前端排队中状态码
- MQReceiver完成下单,再判断下库存、重复抢购
4.前端轮询:
下单后等待,添加一个接口查询是否下单成功
lua脚本:
// 减少和增加不是原子的,但会有问题吗??:redis库存可能负数,但不会超卖
setIfAbsent
:setnx实现加锁,存在以下问题:
- 异常了锁不会销毁:增加一个5s超时时间
- 如果处理时间超过了5s,会导致删别人的锁:value是版本号,保证删的是自己加的版本
- (获取版本号 比较 删除)不是原子操作:lua脚本实现redis原子化
1 |
|
安全
- 验证码,存入redis
.set("captcha:" + tUser.getId() + ":" + goodsId, captcha.text() )
- 对同一用户和商品生成一个唯一地址,拿地址再下单。获取地址需要验证码,地址同样redis
- 限流 (网关)
单接口简单限流:直接redis,存在5s最后临界问题 ; 漏桶算法;令牌桶算法(令牌不断生成到桶里)
1 |
|
通用:拦截器+注解
1 |
|
拦截器:去出注解中的参数进行判断。同时把user参数的写入也加进来,存入ThreadLocal
1 |
|
企业
网关过滤
2s 100w请求,20w商品。令牌桶。没获得令牌的直接失败
快速生成订单:redis (分片) ,再mq
超卖:分布式锁redisson。加锁解锁消耗:集群
nacos动态下发商品数量
- 框架搭建
- Thymeleaf, RespBean
- 设计数据库
- 全局异常 、通用返回、通用参数
- 开发
- 商品列表
- 商品详情
- 订秒杀
- 订单详情
- 压测
- 配置环境
- 多用户
- 优化 页面、对象
- 进一步优化Redis预减、内存标记、MQ
总结
- 框架搭建
- Thymeleaf, RespBean
- 设计数据库
- 全局异常 、通用返回、通用参数
- 开发
- 商品列表
- 商品详情
- 订秒杀
- 订单详情
- 压测
- 配置环境
- 多用户
- 优化 页面、对象
- 进一步优化Redis预减、内存标记、MQ