秒杀项目
1、项目介绍
本项目基于Springboot+Mysql+Redis+RibbitMQ+MyBatis-Plus等技术,保证了在高并发的情况下秒杀活动的 顺利进行。
2、项目总结
- 首先基本秒杀功能实现
- 用redis进行优化,页面缓存,user对象缓存
- 解决复购和超卖
- 优化秒杀,逐步减少直接操作DB,操作reids
- 解决秒杀安全问题
3、项目功能实现
分布式会话/Session
用户登录
用户登录-基础功能
需求说明
完成用户登录
表结构设计
CREATE TABLE `seckill_user` ( |
密码的设计:两次加盐
- 客户端—-md5(password 明文+salt1)–> 后端(** md5(md5(password 明文+salt1)+salt2)** ==db中存放的 password 是否一致 ?)
- 通过在原始密码(字符串)的前后加上一个字符来生成 md5 加密后的密码(进行两次操作)登录信息、秒杀商品信息设计
package com.hspedu.seckill.util;
import org.apache.commons.codec.digest.DigestUtils;
/**
* MD5Util: 工具类,根据前面密码设计方案提供相应的方法
*/
public class MD5Util {
//最基本的md5加密
public static String md5(String src) {
return DigestUtils.md5Hex(src);
}
//准备一个salt [前端使用盐]
private static final String SALT = "4tIY5VcX";
//加密加盐, 完成的任务就是 md5(password明文+salt1)
public static String inputPassToMidPass(String inputPass) {
System.out.println("SALT.charAt(0)->" + SALT.charAt(0));//c
System.out.println("SALT.charAt(6)->" + SALT.charAt(6));//T
String str = SALT.charAt(0) + inputPass + SALT.charAt(6);
return md5(str);
}
//加密加盐, 完成的任务就是把(MidPass +salt2) 转成DB中的密码
// md5(md5(password明文+salt1)+salt2)
public static String midPassToDBPass(String midPass, String salt) {
System.out.println("salt.charAt(1)->" + salt.charAt(1));//L
System.out.println("salt.charAt(5)->" + salt.charAt(5));//m
String str = salt.charAt(1) + midPass + salt.charAt(5);
return md5(str);
}
//编写一个方法,可以将password明文,直接转成DB中的密码
public static String inputPassToDBPass(String inputPass, String salt) {
String midPass = inputPassToMidPass(inputPass);
String dbPass = midPassToDBPass(midPass, salt);
return dbPass;
}
}/**
* LoginVo: 接收用户登录时,发送的信息(mobile,password)
*/
public class LoginVo {
//对LoginVo的属性值进行,约束
private String mobile;
private String password;
}请求返回信息设计/**
* GoodsVo: 对应就是显示再秒杀商品列表的信息
*/
public class GoodsVo extends Goods {
private BigDecimal seckillPrice; //秒杀价格
private Integer stockCount; //秒杀商品库存
private Date startDate; //秒杀开始时间
private Date endDate; //秒杀结束时间
}
public class RespBean {
private long code; //状态码
private String message; //返回的信息
private Object obj; //返回的数据
//成功后-同时携带数据
public static RespBean success(Object data) {
return new RespBean(RespBeanEnum.SUCCESS.getCode(), RespBeanEnum.SUCCESS.getMessage(), data);
}
//成功后-不携带数据
public static RespBean success() {
return new RespBean(RespBeanEnum.SUCCESS.getCode(), RespBeanEnum.SUCCESS.getMessage(), null);
}
//失败各有不同-返回失败信息时,不携带数据
public static RespBean error(RespBeanEnum respBeanEnum) {
return new RespBean(respBeanEnum.getCode(), respBeanEnum.getMessage(), null);
}
//失败各有不同-返回失败信息时,同时携带数据
public static RespBean error(RespBeanEnum respBeanEnum, Object data) {
return new RespBean(respBeanEnum.getCode(), respBeanEnum.getMessage(), data);
}
}手机号码格式校验/**
* RespBeanEnum: 枚举类如何开发,java基础讲过
*/
public enum RespBeanEnum {
//通用
SUCCESS(200,"SUCCESS"),
ERROR(500,"服务端异常"),
//登录
LOGIN_ERROR(500210,"用户id或者密码错误"),
BING_ERROR(500212,"参数绑定异常~"),
MOBILE_ERROR(500211, "手机号码格式不正确"),
MOBILE_NOT_EXIST(500213,"手机号码不存在"),
PASSWROD_UPDATE_FAIL(500214,"密码更新失败"),
//其它我们在开发过程中,灵活增加即可
//秒杀模块-返回的信息
ENTRY_STOCK(500500,"库存不足"),
REPEAT_ERROR(500501,"该商品每人限购一件"),
REQUEST_ILLEGAL(500502,"请求非法"),
SESSION_ERROR(500503,"用户信息有误.."),
SEK_KILL_WAIT(500504,"排队中..."),
CAPTCHA_ERROR(500505,"验证码错误..."),
ACCESS_LIMIT_REACHED(500506,"访问频繁,请待会再试..."),
SEC_KILL_RETRY(500507,"本次抢购失败,请继续抢购..");
private final Integer code;
private final String message;
}/**
* ValidatorUtil: 完成一些校验工作,比如手机号码格式是否正确..
*/
public class ValidatorUtil {
//校验手机号码的正则表达式
//13300000000 合格
//11000000000 不合格
private static final Pattern mobile_pattern = Pattern.compile("^[1][3-9][0-9]{9}$");
//编写方法, 如果满足规则,返回T, 否则返回F
public static boolean isMobile(String mobile) {
if(!StringUtils.hasText(mobile)) {
return false;
}
//进行正则表达式校验-java基础讲过
Matcher matcher = mobile_pattern.matcher(mobile);
return matcher.matches();
}
}
自定义校验注解
具体实现
记录登录用户Session
有一个CookieUtil,便于操作Cookie,直接拿来使用就行
doLogin
- 在 UserServiceImpl 中根据 LoginVo 到数据库中查
- 查到后用 java.util.UUID 工具类生成票据 Ticket 用来区分 user,存入session中request.getSession().setAttribute**(ticket, user)**;
- 将票据存放到Cookie中 (”userTicket”, ticket)
获取商品列表
- 有了 Cookie 和 Session 就可以在获取商品列表时进行判断了
- 先通过判断请求是否有userTicket,没有则返回登录
- 有则从Session中通过ticket(这个ticket就是userTicket)获取user
- 将user放入到Model传给下一个页面使用(user中包含了user的所有信息)
分布式Session共享
需求说明
Nginx对请求负载均衡到不同的Tomcat,当第二次请求到不同的Tomcat时,Tomcat会认为该用户是第一次购买,就会出现重复购买的问题。所以要进行Session共享。
解决方案:
- Session绑定/粘滞
- Session复制
- 前端存储
- 后端集中存储
SpringSession 实现分布式 Session
描述:将用户 Session 不再存放到各自登录的 Tomcat 服务器,而是统一存在Redis
<!--spring data redis 依赖, 即 spring 整合 redis--> |
spring: |
将user保存到session中改为保存到redis中:
- 配置完后执行这句话 request.getSession().setAttribute(ticket, user); 就把session保存到redis中了
- 是以原始的形式保存的(保存的有session过期时间,最后访问时间等)
直接将用户信息统一放到Redis
**描述: **前面将 Session 统一存放指定 Redis, 是以原生的形式存放, 在操作时, 还需要反序列化,不方便,我们可以直接将登录用户信息统一存放到 Redis, 利于操作
注销掉上面引入的spring-session-data-redis
- 使用 redisTemplate.opsForValue().set(“user:” + ticket, user);
- 在Redis中key是 user: ticket 的格式
- 实现根据userTicket票据的Cookie 到 Redis中获取 user对象
- 刷新Cookie重新计算userTicket的过期时间
GoodController中的toList方法改为直接从redis中获取user,然后放到Model中供下面使用
WebMvcConfiger优化登录
秒杀基本功能开发
商品列表
需求说明
登录成功后可以看到商品列表
表结构设计
CREATE TABLE `t_goods` ( |
重点是id、库存
CREATE TABLE `t_seckill_goods` ( |
goods_id就是 t_goods 的主键
设计GoodsVo,继承了Goods
|
查询GoodsVo商品逻辑
把从Redis中查询到的user放model中后,再把从数据库中查出的商品放到model中
model.addAttribute(“goodsList”, goodsService.findGoodsVo());
<select id="findGoodsVo" resultType="com.hspedu.seckill.vo.GoodsVo"> |
商品表左连接秒杀商品表
商品详情页
需求说明
在商品列表中点击查看详情会看到秒杀商品详情页
实现逻辑
在GoodsMapper中增加通过商品id获取GoodVo
<select id="findGoodsVoByGoodsId" resultType="com.hspedu.seckill.vo.GoodsVo"> |
接入前端,根据秒杀开始时间和结束时间,在前端秒杀商品详情页有不同的显示:
- 秒杀倒计时
- 秒杀进行中
- 秒杀结束
秒杀基本实现
需求说明
- 点击进行秒杀,更新库存,保存普通订单,秒杀订单
- 秒杀完成后进入秒杀订单页
- 只是最基本的功能,高并发下存在超卖的问题
功能1-秒杀倒计时
在GoodsController的 toDetail 加入
根据查到的GoodVo的开始结束时间来鉴定秒杀状态
//返回秒杀商品详情的同时返回该商品秒杀的状态和秒杀剩余的时间 |
功能2-秒杀按钮
前端实现的,就是按钮不到秒杀期间不能点
功能3-点击按钮可以秒杀
秒杀成功后秒杀按钮变为立即支付,重复秒杀,库存不足都会秒杀失败(判断复购,库存)
表设计
CREATE TABLE `t_order` ( |
CREATE TABLE `t_seckill_order` ( |
秒杀逻辑
用户id,商品 id 的唯一索引,解决同一个用户多次抢购’ **
SeckillController 中的 doSeckill 方法版本1**:
由商品列表页带来的user,供后面保存订单来使用
根据商品id直接从数据库中取出商品,看商品库存是否大于0
根据商品id和userId到数据库秒杀商品表中查询,进而判断该用户是否已经秒杀了该商品
通过了上面两个判断之后进入**orderService.seckill(user, goodsVo)**秒杀商品,生成订单
//处理用户抢购请求/秒杀
//说明:我们先完成一个V1.0版本,后面在高并发的情况下, 还要做优化
public String doSeckill(Model model, User user, Long goodsId) {
System.out.println("------秒杀V1.0 开始-------");
if (user == null) {//用户没有登录
return "login";
}
//将user放入到model, 下一个模板可以使用
model.addAttribute("user", user);
//获取到goodsVo
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
//判断库存
if (goodsVo.getStockCount() < 1) {//没有库存
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail";//错误页面
}
//判断用户是否复购-判断当前购买用户id和购买商品id是否已经在 商品秒杀表存在了
SeckillOrder seckillOrder = seckillOrderService.getOne(
new QueryWrapper<SeckillOrder>().eq("user_id", user.getId())
.eq("goods_id", goodsId));
if (seckillOrder != null) {
model.addAttribute("errmsg", RespBeanEnum.REPEAT_ERROR.getMessage());
return "secKillFail";//错误页面
}
//抢购
Order order = orderService.seckill(user, goodsVo);
if (order == null) { //有可能执行过程中出错
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail";//错误页面
}
//进入到订单页
model.addAttribute("order", order);
model.addAttribute("goods", goodsVo);
System.out.println("------秒杀V1.0 结束-------");
return "orderDetail"; //进入订单详情页
}这个seckill方法是用来更新库存,保存普通订单,商品订单的
public Order seckill(User user, GoodsVo goodsVo) {
//认为能进到这个代码中就是可以秒杀
//查询秒杀商品的库存,并-1
SeckillGoods seckillGoods = seckillGoodsService.
getOne(new QueryWrapper<SeckillGoods>().eq("goods_id", goodsVo.getId()));
//完成一个基本的秒杀操作[这块不具原子性],后面在高并发的情况下,还会优化, 不用急
seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);
seckillGoodsService.updateById(seckillGoods);版本1的问题
从数据库拿出秒杀商品,减库存后更新到数据库,这两步操作不具备原子性
查用户,查秒杀订单判断是否复购都是从数据库中查的
秒杀压测Jmeter
多用户测试
测试方法
生成多用户测试脚本
/** |
使用Jmeter模拟多用户并发请求,发现出现超卖–后续解决
页面优化
redis页面缓存
需求说明
- 在2.1 2.2中, 多用户在查看商品列表和商品详情页的时候, 每一个用户都需要到DB 查询
- 对 DB 查询的压力很大
- 但是我们商品信息并不会频繁的变化, 所以你查询回来的结果都是一样的
- 我们可以通过 Redis 缓存页面来进行优化, 这样可以将 1 分钟内多次查询DB, 优化成1次查询, 减少 DB 压力
WebContext就是上下文内容,当作一个常规的用法,知道怎么去使用就行
设置过期时间60s
//进入到商品列表单-改进的到redis中查询 |
//进入商品详情页-根据goodsId |
对象缓存
需求说明
- 1.2.2中当用户登录成功后, 就会将用户对象缓存到 Redis
- 好处是解决了分布式架构下的 Session 共享问题
- 但是也带来新问题, 如果用户信息改变, DB用户信息和Redis 缓存用户对象不一致问题-也就是对象缓存问题
解决思路
- 编写方法, 当用户信息变化时, 就更新用户在 DB 的信息, 同时删除该用户在Redis的缓存对象
- 这样用户就需要使用新密码重新登录, 从而更新用户在 Redis 对应的缓存对象
复购和超卖
超卖
需求说明
解决2.3.3中doSeckill 方法版本1的超卖问题
出现超卖问题的原因
- 在SeckillController 中判断复购,库存之后就进入到OrderServiceImpl中的seckill方法进行秒杀
- 而在高并发下可能同时200个请求同时执行判断库存的语句,都通过了库存判断
- 然后都冲入到OrderServiceImpl中的seckill方法,而该方法中减库存的操作不具备原子性
- 可能多个请求才减去1个商品,而冲到seckill中的请求都会生成订单
解决思路
- Mysql在默认的事务隔离级别(可重复读)下,执行update语句时,会在事务中锁定要更新的行
- 这可以防止其他会话在同一行上执行 UPDATE 或DELETE操作 使用update代替下面原来减库存的语句(Mybatis-plus)
public Order seckill(User user, GoodsVo goodsVo) {
//认为能进到这个代码中就是可以秒杀
//查询秒杀商品的库存,并-1
SeckillGoods seckillGoods = seckillGoodsService.
getOne(new QueryWrapper<SeckillGoods>().eq("goods_id", goodsVo.getId()));
//完成一个基本的秒杀操作[这块不具原子性],后面在高并发的情况下,还会优化, 不用急
// seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);
// seckillGoodsService.updateById(seckillGoods);
//1. Mysql在默认的事务隔离级[REPEATABLE-READ]别下
//2. 执行update语句时,会在事务中锁定要更新的行
//3. 这样可以防止其它会话在同一行执行update,delete
System.out.println("执行update==>" + user.getId());
//只要在更新成功时,返回true,否则返回false, 即更新后,受影响的行数>=1为T
boolean update = seckillGoodsService.update(new UpdateWrapper<SeckillGoods>()
.setSql("stock_count=stock_count-1")
.eq("goods_id", goodsVo.getId()).gt("stock_count", 0));
if (!update) {//如果更新失败,说明已经没有库存了
//把这个秒杀失败的信息-记录到Redis
redisTemplate.opsForValue().set("seckillFail:" + user.getId() + ":" + goodsVo.getId(), 0);
return null;
}
//生成普通订单
Order order = new Order();
order.setUserId(user.getId());
order.setGoodsId(goodsVo.getId());
order.setDeliveryAddrId(0L);//老师就设置一个初始值
order.setGoodsName(goodsVo.getGoodsName());
order.setGoodsCount(1);
order.setGoodsPrice(seckillGoods.getSeckillPrice()); //秒杀的价格
order.setOrderChannel(1);//老师就设置一个初始值
order.setStatus(0);//老师就设置一个初始值-未支付
order.setCreateDate(new Date());//设置成now
//保存order
orderMapper.insert(order);
//生成秒杀商品订单-
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setGoodsId(goodsVo.getId());
//这里秒杀商品订单对应的order_id 是从上面添加 order后获取到的
seckillOrder.setOrderId(order.getId());
seckillOrder.setUserId(user.getId());
//保存seckillOrder 用mapper也行
seckillOrderService.save(seckillOrder);
//将生成的秒杀订单,存入到Redis, 这样在查询某个用户是否已经秒杀了这个商品时,
//直接到Redis中查询,起到优化效果
//设计秒杀订单的key => order:用户id:商品id
redisTemplate.opsForValue().set("order:" + user.getId() + ":" + goodsVo.getId(), seckillOrder);
return order;
}import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
public class SeckillGoodsServiceImpl extends ServiceImpl<SeckillGoodsMapper, SeckillGoods>
implements SeckillGoodsService {
}这样减库存的操作就是串行执行的了,库存小于1会更新失败,也不会生成订单了boolean update = seckillGoodsService.update(new UpdateWrapper<SeckillGoods>()
.setSql("stock_count=stock_count-1")
.eq("goods_id", goodsVo.getId()).gt("stock_count", 0));当然了,这样解决仍有大量的请求进入seckill然后到DB中执行sql,后面会优化if (!update) {
//如果更新失败,说明已经没有库存了,把这个秒杀失败的信息-记录到Redis
redisTemplate.opsForValue()
.set("seckillFail:" + user.getId() + ":" + goodsVo.getId(), 0);
return null;
}
复购
需求说明
- 对版本1进行优化,不去数据库看是否存在该用户对此商品的秒杀订单
- 而是把生成的订单放到redis中,后面直接从redis中判断是否复购
- 就是seckill最后生成秒杀订单后设置到redis中
秒杀优化
预减库存+Decrement
目的就是减少去到数据库中的操作
需求说明
- 在2.3.3中防止超卖,SeckillController 是直接到数据库查出商品的 goodsService.findGoodsVoByGoodsId(goodsId); 初步判断的库存,并发下不准确
- 大量的并发请求都去到数据库中尝试减库存的操作,虽然控制了超卖,但容易把数据库压垮
解决思路
- 使用 Redis 完成预减库存,如果没有库存了, 直接返回, 减小对DB的压力
- 如果预减库存后,库存小于0就不再去orderService.seckill()中了
- 结果就是库存有多少就进去几个请求
- 依赖decrement具有原子性,redisTemplate.opsForValue().decrement(“seckillGoods:” + goodsId);**前奏:SeckillController的初始化方法,从数据库查出所有的秒杀商品,然后存到redis中 seckillGoods : id **
//库存预减, 如果在Redis中预减库存,发现秒杀商品已经没有了,就直接返回
//从而减少去执行 orderService.seckill() 请求,防止线程堆积, 优化秒杀/高并发
// decrement()是具有原子性!!
Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
if (decrement < 0) {//说明这个商品已经没有库存
//恢复库存为0
redisTemplate.opsForValue().increment("seckillGoods:" + goodsId);
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail";//错误页面
}//这个方法是在类的所有属性,都初始化后,自动执行的
//这里把所有秒杀商品的库存量加载到redis
public void afterPropertiesSet() throws Exception {
//查询所有的秒杀商品
List<GoodsVo> list = goodsService.findGoodsVo();
if (CollectionUtils.isEmpty(list)) {
return;
}
//遍历List,然后将秒杀商品的库存量放入到redis
//秒杀商品库存量对应key: seckillGoods:商品id
list.forEach(goodsVo -> {
redisTemplate.opsForValue().set("seckillGoods:" + goodsVo.getId(), goodsVo.getStockCount());
//初始化map
//如果goodsId : false 表示有库存
//如果goodsId : true 表示没有库存
entryStockMap.put(goodsVo.getId(), false);
});
}
加入内存标记
目的就是减少到redis中预减库存
需求说明
- 如果某个商品库存已经为空了,我们仍然是到 Redis 去查询的,还可以进行优化
- 给商品进行内存标记,如果库存为空,直接返回,避免总是到Redis 查询库存
解决思路
操作本机JVM快于操作Redis
添加属性 private HashMap<Long, Boolean> entryStockMap = new HashMap<>();
- 在到redis中预减库存之前,先查看entryStockMap,如果标记为true,则表示redis中的库存已经为0
- 不再去redis中预减
- 如果是redis中库存第一次不足,预减后设置标记为true
//处理用户抢购请求/秒杀
//说明:我们先完成一个V4.0版本,加入内存标记优化秒杀
public String doSeckill(Model model, User user, Long goodsId) {
System.out.println("------秒杀V4.0 开始-------");
if (user == null) {
return "login";
}
model.addAttribute("user", user);
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
//判断库存
if (goodsVo.getStockCount() < 1) {//没有库存
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail";//错误页面
}
//判断用户是否复购
SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
if (seckillOrder != null) {
model.addAttribute("errmsg", RespBeanEnum.REPEAT_ERROR.getMessage());
return "secKillFail";//错误页面
}
//对map进行判断[内存标记],如果商品在map已经标记为没有库存,则直接返回,无需进行Redis预减
if (entryStockMap.get(goodsId)) {
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail";//错误页面
}
//库存预减, 如果在Redis中预减库存,发现秒杀商品已经没有了,就直接返回
Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
if (decrement < 0) {//说明这个商品已经没有库存
//就是在这设置了内存标记之后,相当于直接告诉后来的请求说已经没有库存了,不用再去redis中预减了。截断的效果
entryStockMap.put(goodsId, true); //定义的true是代表没有库存了
//恢复库存为0
redisTemplate.opsForValue().increment("seckillGoods:" + goodsId);
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail";//错误页面
}
//抢购
Order order = orderService.seckill(user, goodsVo);
if (order == null) { //有可能执行过程中出错
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail";//错误页面
}
//进入到订单页
model.addAttribute("order", order);
model.addAttribute("goods", goodsVo);
System.out.println("------秒杀V4.0 结束-------");
return "orderDetail"; //进入订单详情页
}
加入消息队列,秒杀异步请求
目的是把执行seckill交给消息队列,赶紧返回客户端消息,防止线程堆积
需求说明
- 前面秒杀, 没有实现异步机制, 是完成下订单后, 再返回的
- 当有大并发请求下订单操作时, 数据库来不及响应, 容易造成线程堆积
解决思路
- 加入消息队列,实现秒杀的异步请求
- 接收到客户端秒杀请求后,服务器立即返回 正在秒杀中.., 有利于流量削峰
- 客户端进行轮询秒杀结果, 接收到秒杀结果后,在客户端页面显示即可
使用的是RabbotMQ的Topic主题模式(Direct路由模式下的一种扩展)
把秒杀操作seckill移到消息消费者,然后消费者执行
/** |
秒杀消息组成:
- 用户 user
- 商品id goodsId
/**
* RabbitMQSeckillConfig: 配置类,创建消息队列和交换机
*/
public class RabbitMQSeckillConfig {
//定义消息队列名和交换机名
private static final String QUEUE = "seckillQueue";
private static final String EXCHANGE = "seckillExchange";
//创建队列
public Queue queue_seckill() {
return new Queue(QUEUE);
}
//创建交换机-Topic
public TopicExchange topicExchange_seckill() {
return new TopicExchange(EXCHANGE);
}
//将队列绑定到交换机,并指定路由
public Binding binding_seckill() {
return BindingBuilder.bind(queue_seckill())
.to(topicExchange_seckill()).with("seckill.#");
}
}发送的是String/**
* MQSenderMessage: 消息的生产者/发送者[秒杀消息]
*/
public class MQSenderMessage {
//装配RabbitTemplate
private RabbitTemplate rabbitTemplate;
//方法:发送秒杀消息
public void sendSeckillMessage(String message) {
log.info("发送消息-->" + message);
rabbitTemplate.convertAndSend("seckillExchange", "seckill.message", message);
}
}用工具类JSONUtil把String转为SeckillMessage/**
* MQReceiverMessage: 消息的接收者/消费者, 这里调用seckill方法()
*/
public class MQReceiverMessage {
//装配需要的组件/对象
private GoodsService goodsService;
private OrderService orderService;
//接收消息,并完成下单
public void queue(String message) {
log.info("接收到的消息是-->" + message);
//解读,这里我们从队列中取出的是string
//但是我们需要的是SeckillMessage, 因此需要一个工具类JSONUtil
//在hutool依赖
SeckillMessage seckillMessage =
JSONUtil.toBean(message, SeckillMessage.class);
//秒杀用户对象
User user = seckillMessage.getUser();
//秒杀的商品id
Long goodsId = seckillMessage.getGoodsId();
//通过商品id,得到对应的GoodsVo
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
//下单操作
orderService.seckill(user, goodsVo);
}
}
SeckillController 中抢购的代码变化//抢购
Order order = orderService.seckill(user, goodsVo);
if (order == null) { //有可能执行过程中出错
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail";//错误页面
}//抢购,向消息队列发送秒杀请求,实现了秒杀异步请求
//这里我们发送秒杀消息后,立即快速返回结果[临时结果] - "比如排队中.."
//客户端可以通过轮询,获取到最终结果
//创建SeckillMessage
SeckillMessage seckillMessage = new SeckillMessage(user, goodsId);
mqSenderMessage.sendSeckillMessage(JSONUtil.toJsonStr(seckillMessage));
model.addAttribute("errmsg", "排队中...");
return "secKillFail";
秒杀安全
秒杀接口地址隐藏
需求说明
- 前面我们处理高并发, 是按照正常业务逻辑处理的, 也就是用户正常抢购
- 还需要考虑抢购安全性, 当前程序, 抢购接口是固定, 如果泄露, 会有安全隐患, 比如抢购未开始或者已结束, 还可以使用脚本发起抢购
解决思路
- 隐藏抢购接口
- 用户抢购时, 先生成一个唯一的抢购路径, 返回给客户端
- 客户端抢购时会携带生成的抢购路径, 服务端做校验, 如果校验成功, 才走下一步, 否则直接返回
解决过程
OrderServiceImpl生成的秒杀路径放到redis中,便于后面到redis中校验
set(“seckillPath:”+ user.getId() + “:” + goodsId,path, 60, TimeUnit.SECONDS);
//方法: 生成秒杀路径/值(唯一) |
在OrderController中增加getPath,createPath后返回给客户端
然后客户端带着新路径再次发出秒杀请求
//方法:获取秒杀路径 |
在SeckillController做地址校验,路径正确了再忘下一步走
//处理用户抢购请求/秒杀 |
验证码防止脚本攻击
需求说明
- 在一些抢购活动中, 可以通过验证码的方式, 防止脚本攻击, 比如12306
解决思路
- 使用验证码 happyCaptcha ![image.png././duskimg/seckill/img6.png)
解决过程
在SeckillController中增加方法生成验证码,保存到reids,便于后面校验
//生成验证码-happyCaptcha |
在OrderServiceImpl中增加校验验证码的方法
//方法: 验证用户输入的验证码是否正确 |
秒杀接口限流-防刷
需求说明
- 完成接口限流-防止某个用户频繁的请求秒杀接口
- 比如在短时间内,频繁点击立即秒杀
解决思路
解决过程
简单接口限流
- 使用简单的 Redis 计数器, 完成接口限流防刷
- 除了计数器算法,也有其它的算法来进行接口限流, 比如漏桶算法和令牌桶算法
- 添加在获取路径代码之前
//增加业务逻辑: 加入Redis计数器, 完成对用户的限流防刷
//比如:5秒内访问次数超过了5次, 我们就认为是刷接口
//这里先把代码写在方法中,后面我们使用注解提高使用的通用性
//uri就是 localhost:8080/seckill/path 的 /seckill/path
String uri = request.getRequestURI();
ValueOperations valueOperations = redisTemplate.opsForValue();
String key = uri + ":" + user.getId();
Integer count = (Integer) valueOperations.get(key);
if (count == null) {//说明还没有key,就初始化,值为1, 过期时间为5秒
valueOperations.set(key, 1, 5, TimeUnit.SECONDS);
} else if (count < 5) {//说明正常访问
valueOperations.increment(key);
} else {//说明用户在刷接口
return RespBean.error(RespBeanEnum.ACCESS_LIMIT_REACHED);
}
通过接口限流
- 自定义注解@AccessLimit, 提高接口限流功能通用性 , 减少冗余代码, 同时也减少业务代码入侵
自定义注解@AccessLimit,时间范围,访问最大次数,是否登录
/** |
写到getPath方法里
//方法:获取秒杀路径 |
//用来存储拦截器获取的 user 对象 |
属实看不懂这个拦截器
/** |
|
/** |