B站秒杀项目

B站秒杀项目

介绍

一个简单的秒杀项目,并不断的优化

  1. 在该项目中核心就是秒杀的实现:不能超卖、不能重复抢

    • 不能超卖在doSeckill1中通过update的排他性实现(乐观锁)。而在doSeckill2中通过redis预减库存(redis的原子性实现)
    • 不能重复抢通过唯一索引实现,默认建表时没有添加,压测可以把用户加少点商品多一点就可以复现重复购买
  2. 优化不过就是把数据库的重复访问,能放到redis就放到redis;而如果访问redis太多了就再加一层内存标记

  3. redis和mysql要么都在远程,要么都在本地,否则可能会出现redis缓存优化了但QPS没提升

秒杀的接口有三个,先在goodsdetail中启用doSeckill1

  1. 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,加快是否重复下单判断。
  2. doSeckill2:对应到 P53, order界面静态化 + redis预减库存 + 内存标记 + MQ

    1
    2
    3
    4
    0.页面静态化(在前后端分离项目里默认就做了)
    1.用redis预减库存(减少数据库访问),redis是原子操作,可以防止超卖;
    2.满足还有库存后进入MQ队列;
    3.我还想减少redis访问次数:引入内存标记。
  3. doSeckill :最终秒杀方案 一些安全上的优化

对应到发起请求界面static\goodsDetail.html 52~67行

前后端结合项目,两种处理页面方式,二者对比可以看orderDetail页面

  1. 前端页面在template下,通过controller返回访问,并model.add添加数据h:text="${goods.goodsName}"页面取出数据, 不可直接访问页面
  2. 在static下的页面可直接访问,并在页面加载时ajax请求返回json数据,$("#goodsName").text(goods.goodsName);根据id注入数据。(代码中的方式,相当于静态化了,视频中一开始是上面的方式,后面才做的静态化)

视频内容

  1. 项目框架搭建
    1. SpringBoot环境搭建
    2. 集成Thymeleaf,RespBean
    3. MyBatis
  2. 分布式会话
    1. 用户登录
      1. 设计数据库
      2. 明文密码二次MD5加密
      3. 参数校验+全局异常处理
    2. 共享Session
      1. SpringSession
      2. Redis
  3. 功能开发
    1. 商品列表
    2. 商品详情
    3. 秒杀
    4. 订单详情
  4. 系统压测
    1. JMeter
    2. 自定义变量模拟多用户
    3. JMeter命令行的使用
    4. 正式压测
      1. 商品列表
      2. 秒杀
  5. 页面优化
    1. 页面缓存+URL缓存+对象缓存
    2. 页面静态化,前后端分离
    3. 静态资源优化
    4. CDN优化
  6. 接口优化
    1. Redis预减库存减少数据库的访问
    2. 内存标记减少Redis的访问
    3. RabbitMQ异步下单
      1. SpringBoot整合RabbitMQ
      2. 交换机
  7. 安全优化
    1. 秒杀接口地址隐藏
    2. 算术验证码
    3. 接口防刷
  8. 主流的秒杀方案

软件架构

技术 版本 说明
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,没有拦截请求)

    image-20230315100229300

    ​ 还可以配置拦截器,添加拦截器,拦截哪些请求。(也可以拦截器直接实现参数,自己代码)

数据库表

界面:商品列表 商品详情 订单

:商品表 订单表 秒杀商品表(秒杀活动很多,添加一个标识字段不合适) 秒杀订单表

秒杀表:商品ID、秒杀库存、开始结束时间

商品列表

名称 图片 价格 秒杀价 秒杀库存

由于需要显示的数据包含商品表秒杀商品表,添加vo继承商品表添加额外信息

toList接口返回商品列表,还需要添加mvc静态资源映射addResourceHandlers

image-20230307190839362

商品详情页

需要知道秒杀是否开始结束,后端通过时间判断返回给前端一个状态(未开始、进行中、已结束)

image-20230307193103679

秒杀功能

/doSeckill1 传统秒杀

传入user,goodsId

  • 判断该goodsId是否还有库存,库存是看秒杀商品表, 进一步:redis预减
  • 判断该userId是否购买过goodId(查看秒杀订单表):==优化:查询redis==
  • 都没问题时,减库存,生成订单,生成秒杀订单 进一步:加入队列

代码中,前端页面需要将接口改为doSeckill1

小结

  • 建表,需要额外秒杀商品表(价格、库存、开始结束时间)、秒杀订单(商品id、订单id)
  • 登录,存入信息到redis,key为时间戳,访问通过cookie携带
  • 全局异常处理处理业务异常,拦截器拦截未登录用户(cookie时间戳不合理),静态资源配置
  • 商品列表、商品详情页,秒杀功能

压测

环境配置

jmeter

  • QPS:每秒请求次数
  • TPS:每秒事务(吞吐量)次数
  • 一个页面一个TPS可能多次QPS

image-20230314094536441

但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<>();
//生成用户
// for (int i = 0; i < 100; i++) {
// TUser tUser = new TUser();
// tUser.setId(1233L + i);
// tUser.setNickname("user" + i);
// tUser.setSalt("1a2b3c");
// tUser.setPassword("05314c6fbe1d0cdb5eab4e80f1bda30a");
// list.add(tUser);
// }
// tUserService.saveBatch(list);
// System.out.println("create user");

//读取用户
list = tUserService.list();

//登录,生成UserTicket
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 // 之前是返回字符串走mvc,现在是直接返回html文本

// 先看redis有没有
ValueOperations valueOperations = redisTemplate.opsForValue();
String html = (String) valueOperations.get("goodsList");
if (!StringUtils.isEmpty(html)) {
return html;
}
// 数据还是先放到model
model.addAttribute("user", user);
model.addAttribute("goodsList", itGoodsService.findGoodsVo());
// 手动渲染goodsList这个界面
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
#启用压缩资源(gzip,brotil)解析,默认禁用
compressed: true
#启用h5应用缓存,默认禁用
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

  • redis预减库存(原子)

  • 内存标记减少redis访问

  • 用队列进行缓冲

  • 静态化 + MQ + redis: QPS:637->571 反而下降了

  • 分析原因:数据库在本地但redis在云上,导致redis读取过慢,redis本地后**1933 -> 3209**,p55

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
//内存标记,减少Redis的访问
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) {
// 参数User
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