B站秒杀项目
介绍
一个简单的秒杀项目,并不断的优化
在该项目中核心就是秒杀的实现:不能超卖、不能重复抢
- 不能超卖在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; }
|
doSeckill2:对应到 P53, order界面静态化 + redis预减库存 + 内存标记 + MQ
1 2 3 4
| 0.页面静态化(在前后端分离项目里默认就做了) 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 2 3 4 5 6 7 8 9 10
| public class RespBean { public static RespBean success() { return new RespBean(RespBeanEnum.SUCCESS.getCode(), RespBeanEnum.SUCCESS.getMessage(), null); } } public enum RespBeanEnum { SUCCESS(200, "SUCCESS"), ERROR(500, "服务端异常"), }
|
校验在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 2 3 4 5 6
| CREATE USER 'xx'@'%' IDENTIFIED BY '123456'; grant all on *.* to 'xx'@'%';
sudo firewall-cmd --list-ports sudo firewall-cmd --add-port=3306/tcp --permanent sudo firewall-cmd --reload
|
配置redis (使用docker),并配置远程访问以及持久化
1 2 3 4 5 6 7 8 9 10 11 12 13
| # 持久化 mkdir -p /root/docker/redis/data mkdir -p /root/docker/redis/conf vi /root/docker/redis/conf/redis.conf # bind 127.0.0.1 protected-mode no appendonly yes requirepass 123123
docker run --name my_redis -p 6379:6379 -v /root/docker/redis/data:/data -v /root/docker/redis/conf/redis.conf:/etc/redis/redis.conf -d redis redis-server /etc/redis/redis.conf
redis-server /etc/redis/redis.conf:在容器内执行的命令,启动 Redis 服务,并使用 /etc/redis/redis.conf 作为配置文件。
|
安装jmeter,配置encodin,导入配置,放到bin目录下
1 2
| ./jmeter.sh -n -t first.jmx -l result.jtl result.jtl 拿到win 聚合报告下查看
|
部署java 到docker容器中,但我mysql redis都装在宿主机,需要合并网络不好访问,所以还是部署出来(或者用dockercompose部署)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| # 使用 openjdk 作为基础镜像 FROM openjdk:8-jdk-alpine
# 设置工作目录 WORKDIR /app
# 将打包好的jar复制到容器中 COPY target/seckill-demo-0.0.1-SNAPSHOT.jar /app/seckill-demo-0.0.1-SNAPSHOT.jar
# 暴露应用程序端口,起提示作用 EXPOSE 8080
# 启动应用程序 CMD ["java", "-jar", "/app/seckill-demo-0.0.1-SNAPSHOT.jar"]
docker build -t myapp:v1 . docker run -p 8080:8080 myapp:v1
|
问题:postman可以携带cookie请求成功,但浏览器不可以(跨域)
现在没有拦截未登录用户,如果未携带cookie会导致User空指针异常
小结
- 本地项目数据库、redis都用云的,并打包一份项目放到云上
- 云上部署,本地运行压测程序进行压测(标准应该云上压测,但比较麻烦)
配置文件导入多用户
再创建一个用户,登录后把uuid保存下来,放到文件里逗号分隔
csv数据文件设置 定义变量名称userTicket
, ${userTicket}
取出
测试/user/info接口,看下是不是 返回不同用户
商品列表:5000 10 :460
生成100个用户,并且登录返回ticket,这里也可以生成用户用java,请求用python
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
| @GetMapping("/createuser") @ApiOperation("压测创建配置文件") public void CreateUser() throws IOException { List<TUser> list = new ArrayList<>();
list = tUserService.list();
String urlString = "http://localhost:8080/login/doLogin"; File file = new File("C:\\Users\\13000\\Desktop\\config.txt"); if (file.exists()) { file.delete(); } RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rw"); randomAccessFile.seek(0); for (int i = 0; i < list.size(); i++) { TUser tUser = list.get(i); URL url = new URL(urlString); HttpURLConnection httpURLConnection = (HttpURLConnection) url.openConnection(); httpURLConnection.setRequestMethod("POST"); httpURLConnection.setDoOutput(true); OutputStream outputStream = httpURLConnection.getOutputStream(); String params = "mobile=" + tUser.getId() + "&password=c38dc3dcb8f0b43ac8ea6a70b5ec7648"; outputStream.write(params.getBytes()); outputStream.flush(); InputStream inputStream = httpURLConnection.getInputStream(); ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); byte[] buff = new byte[1024]; int len = 0; while ((len = inputStream.read(buff)) >= 0) { byteArrayOutputStream.write(buff, 0, len); } inputStream.close(); byteArrayOutputStream.close(); String respone = new String(byteArrayOutputStream.toByteArray()); ObjectMapper mapper = new ObjectMapper(); RespBean respBean = mapper.readValue(respone, RespBean.class); String userTicket = (String) respBean.getObject(); System.out.println("create userTicket:" + tUser.getId()); String row = tUser.getId() + "," + userTicket; randomAccessFile.seek(randomAccessFile.length()); randomAccessFile.write(row.getBytes()); randomAccessFile.write("\r\n".getBytes()); System.out.println("write to file :" + tUser.getId()); } randomAccessFile.close(); System.out.println(); }
|
测试秒杀接口
/doSeckill2
存在超卖问题!!
优化
1.对象缓存redis
通过uuid缓存User对象,数据跟新后要删除redis
2.页面缓存redis
把整个页面缓存到redis
本来是return ”goodsList“
返回页面 - - 》》优化成 渲染出整个页面
再返回,并缓存整个页面。toList
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @RequestMapping(value = "/toList", produces = "text/html;charset=utf-8", method = RequestMethod.GET) @ResponseBody
ValueOperations valueOperations = redisTemplate.opsForValue(); String html = (String) valueOperations.get("goodsList"); if (!StringUtils.isEmpty(html)) { return html; } model.addAttribute("user", user); model.addAttribute("goodsList", itGoodsService.findGoodsVo()); WebContext webContext = new WebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap()); html = thymeleafViewResolver.getTemplateEngine().process("goodsList", webContext);
if (!StringUtils.isEmpty(html)) { valueOperations.set("goodsList", html, 60, TimeUnit.SECONDS); } return html;
|
3.页面静态化
前后端分离
- 静态页面放到static下,可以直接/path.html访问,加载页面时请求返回json数据
goodsDetail2
-> detail
- 之前跳转是
/goods/toDetail?goodsId=1
访问接口,现在是/Detail.html?goodsId=1
直接访问页面,但加载页面时多一步ajax请求数据
- 前端加载数据 根据id注入:
$("#goodsName").text(goodsVo.goodsName);
本来是Thymeleaf th:text= "${goodsVo.goodname}"
orderDetial页面同理:
- 原来发起doseckill1请求,成功加载数据return detaii跳转页面,失败返回到错误页面(代码就是这样)
- 现在发起doseckill2请求,成功后弹框问是否跳转到static下的detaii.html,然后再发起ajax请求加载detail
4.静态资源缓存
配置后static下的goodsDetail.html
将被缓存,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| spring: resources: add-mappings: true cache: cachecontrol: max-age: 3600 chain: cache: true enabled: true compressed: true html-application-cache: true static-locations: classpath:/static/
|
5.问题解决
判断是否重复抢购,存入redis中
1
| TSeckillOrder tSeckillOrder = (TSeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
|
单纯在减库存时判断商品库存是否为负,为负不再继续,解决超卖。update会加行级别排他锁
==影响并发量==
1 2 3 4 5 6 7 8
| 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; }
|
订单表加唯一索引(user, goodid)防止单一用户多抢,@Transactional
。
至此:单一购买以及超卖问题都解决了
进一步优化
doseckill2
1.预减库存
- 加载时加入库存量 ;redis是==原子操作==,减库存时不会有并发问题,保证进入MQ的都是有库存的
1 2 3 4 5 6
| Long v = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId); if (v < 0){ EmptyStockMap.put(goodsId, true); redisTemplate.opsForValue().increment("seckillGoods:" + goodsId); return RespBean.error(RespBeanEnum.EMPTY_STOCK); }
|
2.内存标记:
在访问redis前,使用一个map标记商品是否还有库存,减少redis访问(分布式会不会有问题??)
1 2 3 4
| if (EmptyStockMap.get(goodsId)) { return RespBean.error(RespBeanEnum.EMPTY_STOCK); }
|
3.消息队列:
有库存要加入mq,传入参数中
- 配置文件定义队列和交换机
- MQSender文件封装发送方法
- 返回给前端排队中状态码
- MQReceiver完成下单,再判断下库存、重复抢购
4.前端轮询:
下单后等待,添加一个接口查询是否下单成功
lua脚本:
// 减少和增加不是原子的,但会有问题吗??:redis库存可能负数,但不会超卖
setIfAbsent
:setnx实现加锁,存在以下问题:
- 异常了锁不会销毁:增加一个5s超时时间
- 如果处理时间超过了5s,会导致删别人的锁:value是版本号,保证删的是自己加的版本
- (获取版本号 比较 删除)不是原子操作:lua脚本实现redis原子化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| public void testLock3() { ValueOperations valueOperations = redisTemplate.opsForValue(); String value = UUID.randomUUID().toString(); Boolean isLock = valueOperations.setIfAbsent("k1", value, 5, TimeUnit.SECONDS); if (isLock) { valueOperations.set("name", "xxx"); String name = (String) valueOperations.get("name"); System.out.println("name=" + name); System.out.println(valueOperations.get("k1")); Boolean result = (Boolean) redisTemplate.execute(redisScript, Collections.singletonList("k1"), value); System.out.println(result); } else { System.out.println("有线程在使用,请稍后再试"); } }
lua: if redis.call("get",KEYS[1])==ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
|
安全
- 验证码,存入redis
.set("captcha:" + tUser.getId() + ":" + goodsId, captcha.text() )
- 对同一用户和商品生成一个唯一地址,拿地址再下单。获取地址需要验证码,地址同样redis
- 限流 (网关)
单接口简单限流:直接redis,存在5s最后临界问题 ; 漏桶算法;令牌桶算法(令牌不断生成到桶里)
1 2 3 4 5 6 7 8 9 10 11
| 制访问次数,5秒内访问5次 String uri = request.getRequestURI(); captcha = "0"; Integer count = (Integer) valueOperations.get(uri + ":" + tuser.getId()); if (count == null) { valueOperations.set(uri + ":" + tuser.getId(), 1, 5, TimeUnit.SECONDS); } else if (count < 5) { valueOperations.increment(uri + ":" + tuser.getId()); } else { return RespBean.error(RespBeanEnum.ACCESS_LIMIT_REACHED); }
|
通用:拦截器+注解
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 需要限制的接口 @AccessLimit(second = 5, maxCount = 5, needLogin = true) void f()
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface AccessLimit {
int second();
int maxCount();
boolean needLogin() default true; }
|
拦截器:去出注解中的参数进行判断。同时把user参数的写入也加进来,存入ThreadLocal
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (handler instanceof HandlerMethod) { TUser tUser = getUser(request, response); UserContext.setUser(tUser); HandlerMethod hm = (HandlerMethod) handler; AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class); if (accessLimit == null) { return true; } int second = accessLimit.second(); int maxCount = accessLimit.maxCount(); boolean needLogin = accessLimit.needLogin();
String key = request.getRequestURI(); if (needLogin) { if (tUser == null) { render(response, RespBeanEnum.SESSION_ERROR); } key += ":" + tUser.getId(); } ValueOperations valueOperations = redisTemplate.opsForValue(); Integer count = (Integer) valueOperations.get(key); if (count == null) { valueOperations.set(key, 1, second, TimeUnit.SECONDS); } else if (count < maxCount) { valueOperations.increment(key); } else { render(response, RespBeanEnum.ACCESS_LIMIT_REACHED); return false; } } return true; }
|
企业
网关过滤
2s 100w请求,20w商品。令牌桶。没获得令牌的直接失败
快速生成订单:redis (分片) ,再mq
超卖:分布式锁redisson。加锁解锁消耗:集群
nacos动态下发商品数量
- 框架搭建
- Thymeleaf, RespBean
- 设计数据库
- 全局异常 、通用返回、通用参数
- 开发
- 压测
- 优化 页面、对象
- 进一步优化Redis预减、内存标记、MQ
总结
- 框架搭建
- Thymeleaf, RespBean
- 设计数据库
- 全局异常 、通用返回、通用参数
- 开发
- 压测
- 优化 页面、对象
- 进一步优化Redis预减、内存标记、MQ