秒杀项目过程笔记
Table of Contents generated with DocToc (opens new window)
# Java秒杀方案
# 1、页面优化
# 1.1、缓存
使用redis存储渲染后的html的String,存入redis,过期时间60ms。
访问时先判断redis中是否存在页面的缓存,如果存在,使用
ThymeleafViewResolver
进行解析,然后返回解析后的字符串。注意:这样返回需要加上
@ResponseBody
注解,否则会报错
@ApiOperation(value = "商品页面")
@GetMapping(value = "/toList",produces = "text/html;charset=utf-8")
@ResponseBody
public String toList(Model model, User user){
//因为使用的了HandlerMethodArgumentResolver
//就能省略 方法参数获取cookie,再通过cookie找User,再转换的过程
//而直接将User作为入参进行判断即可
if (user == null){
return "login";
}
String html = redisUtil.get("goodsList");
//获取页面 如果不为空 直接返回
if (isOk(html)){
return html;
}
//获取页面 如果为空 手动渲染 存入redis 再返回
model.addAttribute("user", user);
model.addAttribute("goodsList", goodsService.findGoodsVo());
//最后一个map的参数 作为要返回的属性 就是model的attribute
WebContext context = new WebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap());
//进行解析
html = thymeleafViewResolver.getTemplateEngine().process("goodsList", context);
if (isOk(html)){
//存入redis
redisUtil.set("goodsList", html,60);
}
return html;
}
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
商品详情页面同理:
@ApiOperation(value = "商品详情页")
@GetMapping(value = "/toDetail",produces = "text/html;charset=utf-8")
@ResponseBody
public String toDetail(Model model,User user,Long goodsId){
if (user == null){
return "login";
}
//如果存在 直接返回
String html = redisUtil.get("goodsDetails:"+goodsId);
if (isOk(html)){
return html;
}
//为空 渲染 缓存 返回
GoodsVo goodsVo = goodsService.findGoodsVoById(goodsId);
Date startDate = goodsVo.getStartDate();
Date endDate = goodsVo.getEndDate();
Date now = new Date();
int secKillStatus;
//秒杀倒计时秒数
long remainSeconds;
//秒杀持续时间
long seckillSeconds = 0;
//秒杀未开始
if (now.before(startDate)){
secKillStatus = 0;
remainSeconds = (startDate.getTime()-now.getTime())/1000;
} else if (now.after(endDate)){
//秒杀已结束
secKillStatus = 2;
remainSeconds = -1;
} else{
//秒杀进行中
seckillSeconds = (endDate.getTime()-now.getTime())/1000;
secKillStatus = 1;
remainSeconds = 0;
}
model.addAttribute("seckillSeconds", seckillSeconds);
model.addAttribute("secKillStatus", secKillStatus);
model.addAttribute("remainSeconds", remainSeconds);
model.addAttribute("user", user);
model.addAttribute("goods", goodsVo);
WebContext context = new WebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap());
html = thymeleafViewResolver.getTemplateEngine().process("goodsDetail", context);
if (isOk(html)){
redisUtil.set("goodsDetails:"+goodsId, html,60);
}
return html;
}
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
压测
在此配置下:8核8线程i7-9700的windows1000个线程 重复10次,测三次 相当于30000
- 优化前QPS:1835.9/sec
- 页面缓存优化后QPS:4448.4/sec
有2.4倍的提升
# 1.2、静态化分离
即把页面的静态部分写死,然后通过Ajax获取动态数据,然后通过jQuery显示
# 2、服务优化
# 2.1、RabbitMQ消息队列
# 2.2、接口优化
redis判重复秒杀
redis储存成功的秒杀订单,判断用户是否已秒杀成功过
redis预减库存
在Bean的属性加载完成后,将库存加载到redis中,秒杀时使用redis预减库存
/** 实现 InitializingBean接口 重写方法 * 在Bean属性注入后 将商品的库存加载到redis中 * @throws Exception */ @Override public void afterPropertiesSet() throws Exception { List<GoodsVo> goodsVo = goodsService.findGoodsVo(); if (!goodsVo.isEmpty()){ for (GoodsVo vo : goodsVo) { redisUtil.set("seckillGoods:"+vo.getId(), vo.getStockCount()); //内存标记 防止库存为0但大量请求仍去查询redis获取库存 //这里使用本地内存标记库存的是否为0 emptyStockMap.put(vo.getId(), false); } } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16//使用redis的原子decr操作进行预减库存 Long stock = redisUtil.decr("seckillGoods:" + goodsId);
1
2内存标记
库存进行内存标记,防止库存为0时仍有大量请求访问redis
//使用一个map,key为秒杀商品的id,value为true则表示库存为0 //在进行预减库存的时候 如果减去后的库存<0了,就把这个商品id对应的值改成true private final Map<Long,Boolean> emptyStockMap = new ConcurrentHashMap<>();
1
2
3//进行秒杀前 先判断内存标记 if (emptyStockMap.get(goodsId)){ return ApiResp.error(ApiRespEnum.REPEAT_ERROR); } //如果库存被减为负数了,把库存加回来 修改内存标记为true 返回库存不足 if (stock<0){ emptyStockMap.put(goodsId, true); redisUtil.incr("seckillGoods:" + goodsId); return ApiResp.error(ApiRespEnum.REPEAT_ERROR); }
1
2
3
4
5
6
7
8
9
10RabbitMQ异步下单
在同步的情况下,用于对商品秒杀时,会在同一时间有大量更新库存、插入订单的请求流量直接到达数据库进行操作,对数据库的压力是巨大的,所以我们可以通过RabbitMQ减轻数据库压力:
当用户秒杀成功后,生产者发送一条消息,加入到消息队列中,然后即可立马返回给用户抢购成功,发送的消息由监听此队列的消费者处理,可根据数据库性能设置prefetch最大处理消息数量来进一步优化
。实际上用户并不关心能马上看到自己的订单,只关心是否成功,对于生成订单、更新库存可以通过异步处理写入数据库,比起多线程同步修改数据库的操作,大大缓解了数据库的连接压力,最主要的好处就表现在数据库连接的减少- 同步方式:大量请求快速占满数据库框架开启的数据库连接池,同时修改数据库,导致数据库读写性能骤减
- 一条条消息以顺序的方式写入数据库,连接数几乎不变(当然,也取决于消息队列消费者的数量)
# 完整代码:
controller接口:
@ApiOperation(value = "秒杀接口")
@PostMapping("/doSeckill2")
@ResponseBody
@SuppressWarnings("all")
public ApiResp doSeckill2(User user, Long goodsId){
if (user == null){
return ApiResp.error(ApiRespEnum.SESSION_ERROR);
}
// TODO: 2022/5/18 优化2 内存标记防止库存为0仍访问redis
//通过内存标记减少对redis的访问 如果内存标记库存直接为0 返回错误
if (emptyStockMap.get(goodsId)){
return ApiResp.error(ApiRespEnum.REPEAT_ERROR);
}
//查redis判断该用户是否已秒杀成功过
String seckillOrder = redisUtil.get("seckillOrder:" + user.getId() +":"+ goodsId);
if (isOk(seckillOrder)){
return ApiResp.error(ApiRespEnum.REPEAT_ERROR);
}
// TODO: 2022/5/18 优化2 redis预减库存
//进行预减库存
Long stock = redisUtil.decr("seckillGoods:" + goodsId);
//如果库存被减为负数了,把库存加回来 修改内存标记 返回库存不足
if (stock<0){
emptyStockMap.put(goodsId, true);
redisUtil.incr("seckillGoods:" + goodsId);
return ApiResp.error(ApiRespEnum.REPEAT_ERROR);
}
// TODO: 2022/5/18 异步下单
//RabbitMQ异步下单
orderMessageProducer.sendOderMessage(new SeckillOderMessage(user, goodsId));
//返回正在排队的结果0给用户
return ApiResp.success(0);
}
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
service:
@Transactional(rollbackFor = Exception.class)
@Override
public ApiResp seckill(User user, GoodsVo goodsVo) {
//一开始是先查一遍seckillGoods 然后更新时 set库存=seckillGoods.getStockCount-1;
//优化: 不查,扣库存使用stock_count = stock_count - 1并且判断当前stock_count>0
//扣库存-1
boolean result = seckillGoodsService.update(new UpdateWrapper<SeckillGoods>()
.setSql("stock_count = stock_count - 1")
.eq("goods_id", goodsVo.getId())
.gt("stock_count", 0));
if (!result){
return ApiResp.error(ApiRespEnum.ERROR);
}
//下单
Order order = new Order();
order.setUserId(user.getId());
order.setGoodsId(goodsVo.getId());
order.setDeliveryAddrId(0L);
order.setGoodsName(goodsVo.getGoodsName());
order.setGoodsCount(1);
order.setGoodsPrice(goodsVo.getSeckillPrice());
order.setOrderChannel(1);
order.setStatus(0);
order.setCreateDate(new Date());
int insert = orderMapper.insert(order);
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setUserId(user.getId());
seckillOrder.setOrderId(order.getId());
seckillOrder.setGoodsId(goodsVo.getId());
boolean save = seckillOrderService.save(seckillOrder);
if (insert>0&&save){
//秒杀订单存入redis 加速判断重复秒杀
redisUtil.set("seckillOrder:"+user.getId()+":"+goodsVo.getId(), seckillOrder);
}
return ApiResp.success(order);
}
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
# 2.3、分布式锁
使用redis的setIfAbsent操作,可以设置分布式锁,这个锁在分布式环境下是唯一的,可以保证操作的原子性。
线程1先set成功锁,执行业务,此时线程2进来,在锁没有过期的情况下,线程2不能set成功,无法执行业务,就保证了原子性,等线程1执行完后,删除这个锁,线程2即可正常进行
但是这也会出现一个问题,如果由于网络原因或者系统自己宕机等,线程1没有执行删除锁操作,那么后面的线程就无法获取到锁,造成了死锁。因此我们可以为锁设置一个过期时间,一般要大于线程执行业务的时间,哪怕出现了问题,没有删除锁,锁也会过期,不会影响后面的线程set锁。
因为删除时根据key去删除的
,假设有以下情况:线程1 set的锁为5秒过期,但是它执行了7秒才执行完,此时它会执行一个删除操作,而由于此时已经是第7秒,属于线程2拿到锁了,线程1删除时相当于把线程2的锁释放了,然后如果持续这样,线程2释放线程3的锁,这些线程的任务在一定程度上存在并行执行,而我们想要的是一个线程执行完另一个线程才能接着执行,所以要对删除操作进行优化为了解决3的问题,我们将锁的value值设置为一个随机字符串,每次释放锁的时候,都去比较随机字符串是否一致,如果一致再去释放锁,否则不释放。而释放锁时要进行:
查锁获取value,比较value是否正确,释放锁
,这三个步骤,不具备原子性,所以最终,我们使用lua脚本来保证操作的原子性
在上一步接口优化中,对redis库存的扣减,其实是有问题的,
在减少的时候并没有去判断它的值是否还能减
。假设同一个用户来了10个请求线程同时到达这个位置,就会直接将redis库存减到0,而后来的线程还会继续减为负数,而原来我们的处理是,减完后,判一下库存是否小于0了,小于0则加回去,这样虽然能够解决问题,但在难以保证在极端情况下还能正确。我们减redis库存也需要先比较是否能减,然后再减,所以要保证这两步操作的原子性 使用lua脚本
lua脚本代码:
--如果传入的key存在 执行下面的操作 不存在返回-1
if (redis.call('exists', KEYS[1]) == 1) then
--先获取库存数量
local stock = tonumber(redis.call('get', KEYS[1]));
--如果库存大于0 则减库存
if (stock > 0) then
redis.call('incrby', KEYS[1], -1);
return stock;
end ;
--没库存 返回-1
return -1;
end ;
return -1;
2
3
4
5
6
7
8
9
10
11
12
13
执行的工具方法(RedisUtil中)
/**
* 执行lua脚本
* @param script
* @param key
* @param args
* @return
* @param <T>
*/
public <T> T execute(RedisScript<T> script, String key, Object ...args){
return redisTemplate.execute(script, Collections.singletonList(key), Arrays.asList(args));
}
2
3
4
5
6
7
8
9
10
11
完整代码修改:
@ApiOperation(value = "秒杀接口")
@PostMapping("/doSeckill")
@ResponseBody
@SuppressWarnings("all")
public ApiResp doSeckill(User user, Long goodsId){
if (user == null){
return ApiResp.error(ApiRespEnum.SESSION_ERROR);
}
// TODO: 2022/5/18 优化2 内存标记防止库存为0仍访问redis
//通过内存标记减少对redis的访问 如果内存标记库存直接为0 返回错误
if (emptyStockMap.get(goodsId)){
return ApiResp.error(ApiRespEnum.REPEAT_ERROR);
}
//查redis判断该用户是否已秒杀成功过
String seckillOrder = redisUtil.get("seckillOrder:" + user.getId() +":"+ goodsId);
if (isOk(seckillOrder)){
return ApiResp.error(ApiRespEnum.REPEAT_ERROR);
}
// TODO: 2022/5/19 优化3 lua脚本预减库存 解决redis库存负数问题
//lua脚本进行预减库存
Long stock = redisUtil.execute(stockLuaScript, "seckillGoods:" + goodsId, Collections.EMPTY_LIST);
if (stock<0){
emptyStockMap.put(goodsId, true);
return ApiResp.error(ApiRespEnum.REPEAT_ERROR);
}
// TODO: 2022/5/18 异步下单
//RabbitMQ异步下单
orderMessageProducer.sendOderMessage(new SeckillOderMessage(user, goodsId));
//返回正在排队的结果0给用户
return ApiResp.success(0);
}
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
# 3、安全优化
# 3.1、隐藏秒杀地址
将秒杀接口包装一层,在点击秒杀的时候,会先发起一次获取秒杀地址的请求,通过比对验证码后,返回一串随机UUID字符md5加密的秒杀地址的标识
@Override public String createPath(User user, Long goodsId) { String str = Md5Util.md5(UUID.randomUUID().toString()); //将秒杀地址的随机串存入redis redisUtil.set("seckillPath:"+user.getId()+":"+goodsId, str,60); return str; } @Override public Boolean checkPath(User user, Long goodsId,String path) { if (goodsId<0|| StringUtils.isBlank(path)){ return false; } String redisPath = redisUtil.get("seckillPath:" + user.getId() + ":" + goodsId); return redisPath != null && redisPath.equals(path); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15@ApiOperation(value = "获取秒杀地址") @GetMapping("/path") @ResponseBody public ApiResp path(User user,Long goodsId,String captcha){ if (user == null){ return ApiResp.error(ApiRespEnum.SESSION_ERROR); } Boolean checkCaptcha = orderService.checkCaptcha(user, goodsId, captcha); if (!checkCaptcha){ return ApiResp.error(ApiRespEnum.ERROR_CAPTCHA); } String path = orderService.createPath(user,goodsId); return ApiResp.success(path); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14秒杀时先检查传来的该用户的随机秒杀path与redis中是否相等
# 3.2、验证码
在点击秒杀之前,添加验证码校验,使用easy-captcha
<!--验证码依赖--> <dependency> <groupId>com.github.whvcse</groupId> <artifactId>easy-captcha</artifactId> <version>1.6.2</version> </dependency>
1
2
3
4
5
6
后端接口生成一个算术验证码
@ApiOperation(value = "获取验证码")
@GetMapping("/captcha")
public void captcha(User user,Long goodsId){
if (user == null){
throw new GlobalException(ApiRespEnum.SESSION_ERROR);
}
//设置请求头为输出图片的类型
response.setContentType("image/jpg");
response.setHeader("Pargam", "No-cache");
response.setHeader("Cache-Control", "no-cache");
response.setDateHeader("Expires", 0);
//生成算术验证码
ArithmeticCaptcha captcha = new ArithmeticCaptcha(130,32,3);
//验证码存入redis 设置5分钟失效
redisUtil.set("captcha:"+user.getId()+":"+goodsId,captcha.text(),300);
try {
captcha.out(response.getOutputStream());
} catch (IOException e) {
log.error("用户:{} 验证码生成失败", user.getId());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
前端:
如果在秒杀进行中,则调一次刷新验证码方式,将验证码所在块改为显示,否则改为隐藏
在获取秒杀路径时,将验证码传过去,后端与存在redis中的验证码比对
@Override public Boolean checkCaptcha(User user, Long goodsId, String captcha) { if (StringUtils.isBlank(captcha)){ return false; } String redisCaptcha = redisUtil.get("captcha:" + user.getId() + ":" + goodsId); return captcha.equals(redisCaptcha); }
1
2
3
4
5
6
7
8
# 3.3、接口限流
高并发系统用于保护系统有三种利器:缓存、降级、限流服务端限流的方案可以归纳为两窗两桶(固定窗口,滑动窗口,漏桶算法,令牌桶算法)
# 固定窗口法
- 固定时间周期划分时间为多个时间窗口,如:每10秒为一个时间窗口
- 在每个时间窗口内,每有一个请求,计数器加一
- 当计数器超过限制,丢弃本窗口之后的所有请求
- 当下一时间窗口开始,重置计数器
# 缺点
通过请求量为允许限制的两倍
假设限制1秒内最多通过10个请求,在第一个窗口的最后半秒内通过了10个请求,第二个窗口的前半秒内又通过了10个请求。这样看来就是在1秒内通过了20个请求
一旦流量进入速度有所波动,要么计数器会被提前计满,导致这个周期内剩下时间段的请求被限制。要么就是计数器计不满,导致资源无法充分利用
# 滑动窗口法
- 以当前时间为截止时间,往前取一定的时间作为时间窗口,比如:往前取 60s 的时间
- 当有新的请求进入时,删除时间窗口之外的请求,对时间窗口之内的请求进行计数统计,若未超过限制,则进行放行操作;若超过限制,则拒绝本次服务
# 缺点
当时间区精度越高,占用的空间资源就越大
# 漏桶算法
- 将每个请求视为水滴加入漏桶进行存储
- 漏桶以固定速率匀速出水(处理请求)
- 若桶满则抛弃请求
# 缺点
不适合
突发请求场景
:当短时间内有大量的突发请求时,即便此时服务器没有任何负载,每个请求也都得在队列中等待一段时间才能被响应。
# 令牌桶算法
- 以恒定的速度往令牌桶中放入令牌
- 当有请求过来则从令牌桶中获取令牌进行后续请求
- 当获取令牌失败后则进行友好处理。
# 分布式场景
- 网关限流
- Nginx限流
- 基于IP地址和基于服务器的访问请求限流
- 并发量(连接数)限流
- 下行带宽速率限制
# 4、分布式会话
# 4.1、用户登录
# 登录逻辑
系统是使用mobile作为id的,密码是经过了
MD5(MD5(pass明文+固定salt)+salt)
两次md5加密的;第一次是前端通过固定salt先对密码加密一次
//salt var g_passsword_salt="1a2b3c4d" let inputPass = $("#password").val(); let salt = g_passsword_salt; let str = salt.charAt(0) + salt.charAt(2) + inputPass + salt.charAt(5) + salt.charAt(4); let password = md5(str);
1
2
3
4
5
6
7第二次是后端再通过这个用户注册时生成的salt再对密码加密一次(注册没有写 逻辑是这样的)
所以登录的时候,先通过手机号判断是否有这个用户,有的话再看他的密码与使用他的salt加密后的 输入密码 是否相等,不等则抛出对应GlobalException。
验证成功以后,为这个用户生成一个形式为UUID的凭证ticket,并把它存入redis并设置过期时间,然后封装为一个cookie返回给前端
# 参数校验
为了避免写太多的StringUtils.xxx这种冗余代码,我们可以引入validation的包来处理。下面引入依赖
<!-- validation组件--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
1
2
3
4
5
方式1:使用组件自带的校验
首先,如果要校验的参数是对象,则将
@Valid
加对象前面(如果是String、Integer这些,直接使用对应的@NotNull
等注解即可)。//这里注意,必须要在Controller的方法之中使用 //测试如果在service的方法中使用,不会生效 @PostMapping("/doLogin") @ResponseBody public ApiResp toLogin(@Valid LoginVo loginVo) { log.info("loginVo:{}",loginVo); return userService.doLogin(loginVo); }
1
2
3
4
5
6
7
8在类的属性上加上对应的注解即可,还可以在注解中指定校验失败时的message
@Data public class LoginVo { @NotBlank(message = "手机号不能为空") private String mobile; @NotBlank(message = "密码不能为空") @Length(min = 32) /** * 可以用Pattern正则对手机号进行验证,如果用了,在不满足时,会抛出BindException * 此时需要我们手动处理(使用@RestControllerAdvice),否则前端就得不到具体的响应 * * 当然 也可以自定义注解 */ @Pattern(regexp = "[1]([3-9])[0-9]{9}$") private String password; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
方式2:通过自定义注解实现自定义的校验
参考
@NotNull
注解定义我们的手机号格式校验注解@Target({METHOD,FIELD,ANNOTATION_TYPE,CONSTRUCTOR,PARAMETER,TYPE_USE}) @Retention(RUNTIME) @Documented //这里是指定由哪个类来具体进行校验 //这个类需实现ConstraintValidator<A extends Annotation,T>接口并实现所有方法 @Constraint(validatedBy = {IsMobileValidator.class}) public @interface IsMobile { //标识这个属性是必填的 boolean required() default true; //默认错误信息 String message() default "手机号码格式错误"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16定义
ConstraintValidator<A extends Annotation,T>接口
实现类,实现它的两个方法public class IsMobileValidator implements ConstraintValidator<IsMobile,String> { private boolean required = false; //具体的校验逻辑 @Override public boolean isValid(String value, ConstraintValidatorContext context) { //如果不是必填的 并且为空 直接返回true if (!required && StringUtils.isBlank(value)){ return true; } //如果必填 或者非必填时它填了 就需要校验 return ValidatorUtil.isMobile(value); } //在这个方法实现对required的初始化 @Override public void initialize(IsMobile constraintAnnotation) { required = constraintAnnotation.required(); } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20校验工具类:
public class ValidatorUtil { private static final Pattern MOBILE_PATTEN = Pattern.compile("[1]([3-9])[0-9]{9}$"); /** * 手机号码校验 * @param mobile * @return */ public static boolean isMobile(String mobile) { if (StringUtils.isEmpty(mobile)) { return false; } Matcher matcher = MOBILE_PATTEN.matcher(mobile); return matcher.matches(); } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16在字段上加上自定义注解即可
@Data public class LoginVo { @NotBlank(message = "手机号不能为空") @IsMobile private String mobile; @NotBlank(message = "密码不能为空") @Length(min = 32) private String password; }
1
2
3
4
5
6
7
8
9
10
这样我们就完成了参数的校验,但是我们会发现,此时虽然参数校验成功了,后端代码并没有往下执行了,但前端并任何提示,这其实是因为,参数校验不通过时,Validator抛出了
BindException
(大都是抛出它的子类MethodArgumentNotValidException
),而我们并没有对这个异常进行处理,所以前端没有获取到对应的响应信息,所以我们需要添加一个全局异常的处理类来进行处理
# 参数校验的全局异常处理
自定义全局异常类。一定要继承RuntimeException类,而不是Exception类,如果是Exception类的话,抛出异常是需要在方法上throws的
/** * @author zdk * @date 2022/5/15 18:13 * 全局异常类 */ @Data @AllArgsConstructor @NoArgsConstructor public class GlobalException extends RuntimeException{ private ApiRespEnum apiRespEnum; }
1
2
3
4
5
6
7
8
9
10
11这里选择使用
@RestControllerAdvice
类型的全局异常处理类@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) public ApiResp handle(Exception e){ //处理自定义的异常 if (e instanceof GlobalException){ GlobalException exception = (GlobalException) e; return ApiResp.error(exception.getApiRespEnum()); } //处理使用的Validator组件的异常 else if(e instanceof BindException) { BindException bindException = (BindException) e; ApiResp respBean = ApiResp.error(ApiRespEnum.BIND_ERROR); respBean.setMessage("参数校验异常:" + bindException.getBindingResult().getAllErrors().get(0).getDefaultMessage()); return respBean; } e.printStackTrace(); log.error("异常信息:{}",e.getMessage()); return ApiResp.error(ApiRespEnum.ERROR); } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
总结
# 4.2、共享Session
# 一些解决方案
- Session复制
- 优点
- 无需修改代码,只需要修改Tomcat配置
- 缺点
- Session同步传输占用内网带宽
- 多台Tomcat同步时,性能指数级下降
- Session占用内存,无法有效水平扩展
- 优点
- 前端存储
- 优点
- 不占用服务端内存
- 缺点
- 存在安全风险
- 数据大小受cookie限制
- 占用外网带宽
- 优点
- Session粘滞(一致性Hash)
- 优点
- 无需修改代码
- 服务端可以水平扩展
- 缺点
- 增加新机器,会重新Hash,导致重新登录
- 应用重启,需要重新登录
- 优点
- 后端集中存储
- 优点
- 安全
- 容易水平扩展
- 缺点
- 增加复杂度
- 需要修改代码
- 优点
# Spring Session实现分布式Session
添加依赖
<!--Spring Boot Redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!--Spring的分布式session--> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency> <!--对象池依赖 Redis的lettuce对象池可能会用到--> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15配置redis
#redis配置 redis: host: 211.69.238.77 password: port: 6379 database: 1 #连接超时时间 timeout: 10000ms lettuce: pool: #最大连接数 默认8 max-active: 8 #最大连接阻塞时间 默认-1 max-wait: 1000ms #最大空闲连接,默认8 max-idle: 200
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16上面的步骤执行以后,
set到Session的值 会被自动加入到redis中
request.getSession().setAttribute("user:"+ticket, user);
1
# Redis存储用户信息
直接根据生成的ticket存储即可
//生成一个ticket 并设置过期时间
String ticket = UUID.randomUUID().toString();
//存入redis
redisUtil.set("user:"+ticket, user, 60*60);
CookieUtil.setCookie(request, response, "userTicket", ticket,60*60);
2
3
4
5
# 4.3、优化登录
在优化前,我们在需要User信息的Controller方法中,都需要在方法入参上加入
@CookieValue("userTicket") ticket
,然后在方法代码中通过判断ticket是否存在来跳转,存在时还要通过它去redis获取用户信息,过于重复和繁琐,所以我们使用HandlerMethodArgumentResolver
优化
首先创建一个类,实现
HandlerMethodArgumentResolver
接口/** * @author zdk * @date 2022/5/16 18:19 * 对Controller中的方法中的 User类型的参数做统一判断 */ @Component public class UserHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver { @Autowired private UserService userService; /** * 如果参数的类型是User 才交由resolveArgument方法处理 * @param parameter * @return */ @Override public boolean supportsParameter(MethodParameter parameter) { Class<?> clazz = parameter.getParameterType(); return clazz == User.class; } @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); String userTicket = CookieUtil.getCookieValue(request, "userTicket"); if (StringUtils.isBlank(userTicket)){ return null; } return userService.getUserByCookie(userTicket); } }
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这里面就通过cookie去获取了User对象,所以我们在Controller的方法参数上只需要写一个
User user
,然后在代码中对它判断一次即可还要进行MVC的配置,添加MVC配置类
/** * @author zdk * @date 2022/5/16 18:09 */ @Configuration public class WebMvcConfig implements WebMvcConfigurer { @Autowired private UserHandlerMethodArgumentResolver userHandlerMethodArgumentResolver; @Override public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { //User类型的入参判断 resolvers.add(userHandlerMethodArgumentResolver); } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16在方法中直接填入User参数即可
@GetMapping("/toList") public String toList(Model model,User user){ //因为使用的了HandlerMethodArgumentResolver //就能省略 方法参数获取cookie,再通过cookie找User,再转换的过程 //而直接将User作为入参进行判断即可 if (user == null){ return "login"; } model.addAttribute("user", user); return "goodsList"; }
1
2
3
4
5
6
7
8
9
10
11
# 5、功能开发
# 5.1、商品列表
简单的显示秒杀商品的列表,显示图片、原价、秒杀价、秒杀库存数量
# 5.2、商品详情
主要是显示秒杀商品的开始时间、开始前的倒计时(秒)、正在秒杀状态、时间结束后的秒杀结束状态
思路
后端提供两个信息:秒杀状态和秒杀开始倒计时
- 秒杀商品表含有秒杀开始时间和结束时间,在后端查出秒杀商品信息后,用当前时间与秒杀开始时间和结束时间作对比
- 如果当前时间在
秒杀开始时间之前
,则为秒杀未开始状态,需要计算倒计时并渲染到前端 - 如果在
秒杀结束时间
之后,则为秒杀已结束状态,将按钮变更为不可点击 - 如果在两者之间,证明秒杀正在进行,这里前端其实还需要对
秒杀结束时间-当前时间
进行倒计时,倒计时结束后显示秒杀已结束,然后变更按钮状态,但教程中并未实现,只能通过刷新一次才能变成秒杀已结束状态。试着实现了一下,前端太拉了,没成功
# 5.3、秒杀
简略版
用户一下单,查数据库判断库存,不足则返回错误;再判断是否重复秒杀(看秒杀订单里是否该用户已秒杀了该商品),如果重复,返回错误;然后正常下单,下单时,扣库存、新增普通订单和秒杀订单数据
简单优化
进入秒杀service中后,通过一开始不查商品,直接扣库存,扣库存使用stock_count = stock_count - 1并且判断当前stock_count>0
来扣库存、订单插入增加user_id和goods_id的唯一索引防止超卖(仅在单体中不超卖,分布式系统这种方式不能解决超卖问题),同时在判断是否重复秒杀时,将秒杀订单存在redis,避免查数据库,提高速度
# 5.4、订单详情
# 6、系统压测
# 6.1、JMeter入门
修改jmeter.properties:添加language=zh_CN、sampleresult.default.encoding=UTF-8
这两个属性
一些概念
QPS:(
Queries Per Second
) 是每秒查询率,是一台服务器每秒能够响应的查询次数,是对一个特定的查询服务器在规定时间内所处理的流量多少的衡量标准,即每秒的响应请求数,也即是最大吞吐量 (opens new window)。TPS (
Transactions Per Second
) 也就是事务数/秒。一个事物是指一个客户机向服务器发送请求然后服务器做出反应的过程。客户机在发送请求时开始计时,收到服务器响应后结束计时,以此来计算使用的时间和完成的事务个数QPS和TPS区别:TPS即每秒处理事务数,包括了
用户请求服务器
、服务器自己的内部处理
、服务器返回给用户
这三个过程,每秒能够完成N个这一组过程,TPS就是N;QPS基本类似于TPS,但是不同的是,对于一个页面的一次访问,形成一个TPS
;但一次页面请求,可能产生多次对服务器的请求,服务器对这些请求,就可计入"QPS"之中
并发数(并发度):指系统同时能处理的请求数量,同时反应了系统的负载能力。这个数值可以分析机器1S内的访问日志数量来得到
吞吐量:吞吐量是指系统在单位时间内处理请求的数量,TPS、QPS都是吞吐量的常用量化指标。
系统吞吐量要素:一个系统的吞吐量(承压能力)与request(请求)对cpu的消耗,外部接口,IO等等紧密关联。
单个request对cpu消耗越高,外部系统接口,IO影响速度越慢,系统吞吐能力越低,反之越高
重要参数:QPS、TPS、并发数、响应时间
关系:QPS(TPS)=并发数/平均响应时间