1、项目介绍

本项目基于Springboot+Mysql+Redis+RibbitMQ+MyBatis-Plus等技术,保证了在高并发的情况下秒杀活动的 顺利进行。

2、项目总结

  • 首先基本秒杀功能实现
  • 用redis进行优化,页面缓存,user对象缓存
  • 解决复购和超卖
  • 优化秒杀,逐步减少直接操作DB,操作reids
  • 解决秒杀安全问题

3、项目功能实现

分布式会话/Session

用户登录

用户登录-基础功能

需求说明
完成用户登录
表结构设计

CREATE TABLE `seckill_user` (
`id` BIGINT(20) NOT NULL COMMENT '用户 ID, 设为主键, 唯一手机号', --是手机号
`nickname` VARCHAR(255) NOT NULL DEFAULT '',
`password` VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'MD5(MD5(pass 明文+固定salt)+salt)',
`slat` VARCHAR(10) NOT NULL DEFAULT '',
`head` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '头像',
`register_date` DATETIME DEFAULT NULL COMMENT '注册时间',
`last_login_date` DATETIME DEFAULT NULL COMMENT '最后一次登录时间',
`login_count` INT(11) DEFAULT '0' COMMENT '登录次数',
PRIMARY KEY (`id`)
) ENGINE=INNODB DEFAULT CHARSET=utf8mb4;

密码的设计:两次加盐

  • 客户端—-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)
    */
    @Data
    public class LoginVo {
    //对LoginVo的属性值进行,约束
    @NotNull
    @IsMobile
    private String mobile;

    @NotNull
    @Length(min = 32)
    private String password;
    }
    /**
    * GoodsVo: 对应就是显示再秒杀商品列表的信息
    */
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class GoodsVo extends Goods {
    private BigDecimal seckillPrice; //秒杀价格
    private Integer stockCount; //秒杀商品库存
    private Date startDate; //秒杀开始时间
    private Date endDate; //秒杀结束时间
    }
    请求返回信息设计
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    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基础讲过
    */
    @Getter
    @ToString
    @AllArgsConstructor
    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-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.4.5</version>
</dependency>
<!--pool2 对象池依赖-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.9.0</version>
</dependency>
<!--实现分布式 session, 即将 Session 保存到指定的 Redis-->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
spring:
#配置redis
redis:
host: 192.168.200.130
port: 6379
database: 0
timeout: 10000ms
lettuce:
pool:
max-active: 8 #最大连接数,默认是8
max-wait: 10000ms #最大连接等待时间,默认是-1 一直等待
max-idle: 200 #最大空闲连接,高并发的情况下会来回切换连接
min-idle: 5 #最小空闲连接数,默认是0

将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` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '商品 id',
`goods_name` VARCHAR(16) not null DEFAULT '',
`goods_title` VARCHAR(64) not null DEFAULT '' COMMENT '商品标题',
`goods_img` VARCHAR(64) not null DEFAULT '' COMMENT '商品图片',
`goods_detail` LONGTEXT not null COMMENT '商品详情',
`goods_price` DECIMAL(10,2) DEFAULT '0.00' COMMENT '商品价格',
`goods_stock` INT(11) DEFAULT '0' COMMENT '商品库存',
PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4;

重点是id、库存

CREATE TABLE `t_seckill_goods` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
`goods_id` BIGINT(20) DEFAULT 0,
`seckill_price` DECIMAL(10,2) DEFAULT '0.00',
`stock_count` INT(10) DEFAULT 0,
`start_date` DATETIME DEFAULT NULL,
`end_date` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4;

goods_id就是 t_goods 的主键
设计GoodsVo,继承了Goods

@Data
@AllArgsConstructor
@NoArgsConstructor
public class GoodsVo extends Goods {

private BigDecimal seckillPrice;
private Integer stockCount;
private Date startDate;
private Date endDate;
}

查询GoodsVo商品逻辑
把从Redis中查询到的user放model中后,再把从数据库中查出的商品放到model中
model.addAttribute(“goodsList”, goodsService.findGoodsVo());

<select id="findGoodsVo" resultType="com.hspedu.seckill.vo.GoodsVo">
SELECT g.id,
g.goods_name,
g.goods_title,
g.goods_img,
g.goods_detail,
g.goods_price,
g.goods_stock,
sg.seckill_price,
sg.stock_count,
sg.start_date,
sg.end_date
FROM t_goods g
LEFT JOIN t_seckill_goods sg
ON g.id = sg.goods_id
</select>

商品表左连接秒杀商品表

商品详情页

需求说明
在商品列表中点击查看详情会看到秒杀商品详情页
实现逻辑
在GoodsMapper中增加通过商品id获取GoodVo

<select id="findGoodsVoByGoodsId" resultType="com.hspedu.seckill.vo.GoodsVo">
SELECT
g.id,
g.goods_name,
g.goods_title,
g.goods_img,
g.goods_detail,
g.goods_price,
g.goods_stock,
sg.seckill_price,
sg.stock_count,
sg.start_date,
sg.end_date
FROM t_goods g
LEFT JOIN t_seckill_goods sg
ON g.id = sg.goods_id
WHERE g.id=#{goodsId}
</select>

接入前端,根据秒杀开始时间和结束时间,在前端秒杀商品详情页有不同的显示:

  • 秒杀倒计时
  • 秒杀进行中
  • 秒杀结束

秒杀基本实现

需求说明

  • 点击进行秒杀,更新库存,保存普通订单,秒杀订单
  • 秒杀完成后进入秒杀订单页
  • 只是最基本的功能,高并发下存在超卖的问题

功能1-秒杀倒计时

在GoodsController的 toDetail 加入
根据查到的GoodVo的开始结束时间来鉴定秒杀状态

//返回秒杀商品详情的同时返回该商品秒杀的状态和秒杀剩余的时间
//为了配合前端展示秒杀商品的状态 - 这里依然有一个业务设计
//1. 变量 secKillStatus 秒杀状态 0:秒杀未开始 1: 秒杀进行中 2: 秒杀已经结束
//2. 变量 remainSeconds 剩余秒数: >0: 表示还有多久开始秒杀: 0: 秒杀进行中 -1: 表示秒杀已经结束
Date startDate = goodsVo.getStartDate();
Date endDate = goodsVo.getEndDate();
Date nowDate = new Date();
//秒杀状态
int secKillStatus = 0;
//秒杀距离开始的剩余时间(单位是秒)
int remainSeconds = 0;

if (nowDate.before(startDate)) {
secKillStatus = 0;
//得到还有多少秒开始秒杀
remainSeconds = (int) ((startDate.getTime() - nowDate.getTime()) / 1000);
} else if (nowDate.after(endDate)) {
secKillStatus = 2;
remainSeconds = -1;
} else {
secKillStatus = 1;
remainSeconds = 0;
}
//将secKillStatus 和 remainSeconds放入到model ,携带给模板页使用
model.addAttribute("secKillStatus", secKillStatus);
model.addAttribute("remainSeconds", remainSeconds);

功能2-秒杀按钮

前端实现的,就是按钮不到秒杀期间不能点

功能3-点击按钮可以秒杀

秒杀成功后秒杀按钮变为立即支付,重复秒杀库存不足都会秒杀失败(判断复购,库存)
表设计

CREATE TABLE `t_order` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
`user_id` BIGINT(20) NOT NULL DEFAULT 0,
`goods_id` BIGINT(20) NOT NULL DEFAULT 0,
`delivery_addr_id` BIGINT(20) NOT NULL DEFAULT 0,
`goods_name` VARCHAR(16) NOT NULL DEFAULT '',
`goods_count` INT(11) NOT NULL DEFAULT '0',
`goods_price` DECIMAL(10,2) NOT NULL DEFAULT '0.00',
`order_channel` TINYINT(4) NOT NULL DEFAULT '0' COMMENT '订单渠道1pc,2Android,3ios',
`status` TINYINT(4) NOT NULL DEFAULT '0' COMMENT '订单状态:0 新建未支付1已支付2 已发货 3 已收货 4 已退款 5 已完成',
`create_date` DATETIME DEFAULT NULL, `pay_date` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=600 DEFAULT CHARSET=utf8mb4;
CREATE TABLE `t_seckill_order` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT,
`user_id` BIGINT(20) NOT NULL DEFAULT 0,
`order_id` BIGINT(20) NOT NULL DEFAULT 0,
`goods_id` BIGINT(20) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `seckill_uid_gid` (`user_id`,`goods_id`) USING BTREE COMMENT ' 用户id,商品 id 的唯一索引,解决同一个用户多次抢购' )
ENGINE=INNODB AUTO_INCREMENT=300 DEFAULT CHARSET=utf8mb4;

秒杀逻辑
用户id,商品 id 的唯一索引,解决同一个用户多次抢购’ **
SeckillController 中的 doSeckill 方法
版本1**:

  • 由商品列表页带来的user,供后面保存订单来使用

  • 根据商品id直接从数据库中取出商品,看商品库存是否大于0

  • 根据商品id和userId到数据库秒杀商品表中查询,进而判断该用户是否已经秒杀了该商品

  • 通过了上面两个判断之后进入**orderService.seckill(user, goodsVo)**秒杀商品,生成订单

    //处理用户抢购请求/秒杀
    //说明:我们先完成一个V1.0版本,后面在高并发的情况下, 还要做优化
    @RequestMapping("/doSeckill")
    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方法是用来更新库存,保存普通订单,商品订单的

    @Override
    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

多用户测试

测试方法
image.png
生成多用户测试脚本

/**
* UserUtil: 生成多用户测试脚本
* 1. 创建多个用户,保存到seckill_user表
* 2. 模拟http请求,生成jmeter压测的脚本
*/
//生成多用户工具类
//创建用户,并且去登录得到userticket,得到的userTicket写入到config.txt文件内
public class UserUtil {
public static void create(int count) throws Exception {
List<User> users = new ArrayList<>(count);
//count表示你要创建的用户个数
for (int i = 0; i < count; i++) {
User user = new User();
user.setId(13300000100L + i);
user.setNickname("user" + i);
//小伙伴也可以生成不同的盐,这里老师就是有一个
user.setSlat("ptqtXy16");//用户数据表的slat,由程序员设置
//?是用户原始密码,比如12345 , hello等
//小伙伴也可以生成不同的密码
user.setPassword(MD5Util.inputPassToDBPass("12345", user.getSlat()));
users.add(user);
}
System.out.println("create user");
//将插入数据库-seckill_user
Connection connection = getConn();
String sql = "insert into seckill_user(nickname,slat,password,id) values(?,?,?,?)";
PreparedStatement preparedStatement = connection.prepareStatement(sql);
for (int i = 0; i < users.size(); i++) {
User user = users.get(i);
preparedStatement.setString(1, user.getNickname());
preparedStatement.setString(2, user.getSlat());
preparedStatement.setString(3, user.getPassword());
preparedStatement.setLong(4, user.getId());
preparedStatement.addBatch();
}
preparedStatement.executeBatch();
preparedStatement.clearParameters();//关闭
connection.close();
System.out.println("insert to do");
//模拟登录,发出登录拿到userTicket
String urlStr = "http://localhost:8080/login/doLogin";
File file = new File("D:\\aHspJava\\projects\\iSecKill\\tool\\temp\\config.txt");
if (file.exists()) {
file.delete();
}
RandomAccessFile raf = new RandomAccessFile(file, "rw");
raf.seek(0);
for (int i = 0; i < users.size(); i++) {
User user = users.get(i);
//请求
URL url = new URL(urlStr);
//使用HttpURLConnection 发出http请求
HttpURLConnection co = (HttpURLConnection) url.openConnection();
co.setRequestMethod("POST");
//设置输入网页密码(相当于输出到页面)
co.setDoOutput(true);
OutputStream outputStream = co.getOutputStream();
String params = "mobile=" + user.getId() + "&password=" + MD5Util.inputPassToMidPass("12345");
outputStream.write(params.getBytes());
outputStream.flush();
//获取网页输出,(得到输入流,把结果得到,再输出到ByteArrayOutputStream内)
InputStream inputStream = co.getInputStream();
ByteArrayOutputStream bout = new ByteArrayOutputStream();
byte[] bytes = new byte[1024];
int len = 0;
while ((len = inputStream.read(bytes)) >= 0) {
bout.write(bytes, 0, len);
}
inputStream.close();
bout.close();
//把ByteArrayOutputStream内的东西转换为respBean对象
String response = new String(bout.toByteArray());
ObjectMapper mapper = new ObjectMapper();
RespBean respBean = mapper.readValue(response, RespBean.class);
//得到userTicket
String userTicket = (String) respBean.getObj();
System.out.println("create userTicket" + userTicket);
String row = user.getId() + "," + userTicket;
//写入指定文件
raf.seek(raf.length());
raf.write(row.getBytes());
raf.write("\r\n".getBytes());
System.out.println("write to file:" + user.getId());
}
raf.close();
System.out.println("over");
}

private static Connection getConn() throws Exception {
String url = "jdbc:mysql://127.0.0.1:3306/seckill?useSSL=false&useUnicode=false&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai";
String username = "root";
String password = "19419";
String driver = "com.mysql.cj.jdbc.Driver";
Class.forName(driver);
return DriverManager.getConnection(url, username, password);
}

public static void main(String[] args) throws Exception {
create(2000);//这里创建了2000个用户.
}
}

使用Jmeter模拟多用户并发请求,发现出现超卖–后续解决

页面优化

redis页面缓存

需求说明

  • 在2.1 2.2中, 多用户在查看商品列表商品详情页的时候, 每一个用户都需要到DB 查询
  • 对 DB 查询的压力很大
  • 但是我们商品信息并不会频繁的变化, 所以你查询回来的结果都是一样的
  • 我们可以通过 Redis 缓存页面来进行优化, 这样可以将 1 分钟内多次查询DB, 优化成1次查询, 减少 DB 压力


WebContext就是上下文内容,当作一个常规的用法,知道怎么去使用就行
设置过期时间60s

	//进入到商品列表单-改进的到redis中查询
@RequestMapping(value = "/toList", produces = "text/html;charset=utf-8") //返回的编码
@ResponseBody
public String toList(Model model, User user, HttpServletRequest request, HttpServletResponse response) {
if (user == null) {
return "login";
}

//如果redis中有就直接返回,没有的话再继续往下走
ValueOperations valueOperations = redisTemplate.opsForValue();
String html = (String) valueOperations.get("goodsList");
if(StringUtils.hasText(html)){
return html;
}

//将user放入到model,给下一个模板使用
model.addAttribute("user", user);
//将商品列表信息,放入到model,携带该下一个模板使用
model.addAttribute("goodsList", goodsService.findGoodsVo());

//如果从redis中没有获取到页面,就手动渲染然后存到redis中 WebContext就是上下文内容,当作一个常规的用法,知道怎么去使用就行
//说白了就是把WebContext上下文获取到,并且把model传进去
WebContext webContext =
new WebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap());
//处理了一个模板页,取了个名称叫goodsList,内容是从webContext中来的
//上面没从redis中拿到html,就从这里给html赋值
//goodsList来自templates中的goodsList.html,之前是走流程渲染的,现在是手动渲染到goodsList.html页面
html = thymeleafViewResolver.getTemplateEngine().process("goodsList", webContext);

if(StringUtils.hasText(html)){ //有内容说明渲染成功
//将页面保存到redis,设置没60秒更新一次,该页面60秒失效,redis会清除这个页面,因为期间页面可能会变化
//这个goodsList代表在redis中缓存的时候给key取得名称
valueOperations.set("goodsList", html, 60, TimeUnit.SECONDS);

}

return html;
// return "goodsList";
}
//进入商品详情页-根据goodsId
//user是从自定义参数解析器完成带来的
@RequestMapping(value = "/toDetail/{goodsId}", produces = "text/html;charset=utf-8")
@ResponseBody
public String toDetail(Model model, User user, @PathVariable("goodsId") Long goodsId,
HttpServletRequest request, HttpServletResponse response) {
if (user == null) {
return "login";
}

//如果redis中有就直接返回,没有的话再继续往下走
ValueOperations valueOperations = redisTemplate.opsForValue();
String html = (String) valueOperations.get("goodsDetail:" + goodsId);
if(StringUtils.hasText(html)){
return html;
}

//放到model中,带给前端页面会用
model.addAttribute("user", user);

GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
model.addAttribute("goods", goodsVo);

//返回秒杀商品详情的同时返回该商品秒杀的状态和秒杀剩余的时间
//为了配合前端展示秒杀商品的状态 - 这里依然有一个业务设计
//1. 变量 secKillStatus 秒杀状态 0:秒杀未开始 1: 秒杀进行中 2: 秒杀已经结束
//2. 变量 remainSeconds 剩余秒数: >0: 表示还有多久开始秒杀: 0: 秒杀进行中 -1: 表示秒杀已经结束
Date startDate = goodsVo.getStartDate();
Date endDate = goodsVo.getEndDate();
Date nowDate = new Date();
//秒杀状态
int secKillStatus = 0;
//秒杀距离开始的剩余时间(单位是秒)
int remainSeconds = 0;

if (nowDate.before(startDate)) {
secKillStatus = 0;
//得到还有多少秒开始秒杀
remainSeconds = (int) ((startDate.getTime() - nowDate.getTime()) / 1000);
} else if (nowDate.after(endDate)) {
secKillStatus = 2;
remainSeconds = -1;
} else {
secKillStatus = 1;
remainSeconds = 0;
}
//将secKillStatus 和 remainSeconds放入到model ,携带给模板页使用
model.addAttribute("secKillStatus", secKillStatus);
model.addAttribute("remainSeconds", remainSeconds);

//如果从redis中没有获取到页面,就手动渲染商品详情页然后存到redis中
//说白了就是把WebContext上下文获取到,并且把model传进去
WebContext webContext =
new WebContext(request, response, request.getServletContext(), request.getLocale(), model.asMap());
//上面没从redis中拿到html,就从这里给html赋值
html = thymeleafViewResolver.getTemplateEngine().process("goodsDetail", webContext);

if(StringUtils.hasText(html)){ //有内容说明渲染成功
//将页面保存到redis,设置没60秒更新一次,该页面60秒失效,redis会清除这个页面,因为期间页面可能会变化
//这个goodsList代表在redis中缓存的时候给key取得名称
valueOperations.set("goodsDetail:" + goodsId, html, 60, TimeUnit.SECONDS);
}

return html;
}

对象缓存

需求说明

  • 1.2.2中当用户登录成功后, 就会将用户对象缓存到 Redis
  • 好处是解决了分布式架构下的 Session 共享问题
  • 但是也带来新问题, 如果用户信息改变, DB用户信息和Redis 缓存用户对象不一致问题-也就是对象缓存问题

解决思路

  • 编写方法, 当用户信息变化时, 就更新用户在 DB 的信息, 同时删除该用户在Redis的缓存对象
  • 这样用户就需要使用新密码重新登录, 从而更新用户在 Redis 对应的缓存对象

复购和超卖

超卖

需求说明
解决2.3.3中doSeckill 方法版本1的超卖问题
出现超卖问题的原因

  • 在SeckillController 中判断复购,库存之后就进入到OrderServiceImpl中的seckill方法进行秒杀
  • 而在高并发下可能同时200个请求同时执行判断库存的语句,都通过了库存判断
  • 然后都冲入到OrderServiceImpl中的seckill方法,而该方法中减库存的操作不具备原子性
  • 可能多个请求才减去1个商品,而冲到seckill中的请求都会生成订单

image.png
解决思路

  • Mysql在默认的事务隔离级别(可重复读)下,执行update语句时,会在事务中锁定要更新的行
  • 这可以防止其他会话在同一行上执行 UPDATE 或DELETE操作
    @Override
    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;
    }
    使用update代替下面原来减库存的语句(Mybatis-plus)
    import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
    @Service
    public class SeckillGoodsServiceImpl extends ServiceImpl<SeckillGoodsMapper, SeckillGoods>
    implements SeckillGoodsService {
    }
    boolean update = seckillGoodsService.update(new UpdateWrapper<SeckillGoods>()
    .setSql("stock_count=stock_count-1")
    .eq("goods_id", goodsVo.getId()).gt("stock_count", 0));
    这样减库存的操作就是串行执行的了,库存小于1会更新失败,也不会生成订单了
    if (!update) {
    //如果更新失败,说明已经没有库存了,把这个秒杀失败的信息-记录到Redis
    redisTemplate.opsForValue()
    .set("seckillFail:" + user.getId() + ":" + goodsVo.getId(), 0);
    return null;
    }
    当然了,这样解决仍有大量的请求进入seckill然后到DB中执行sql,后面会优化

复购

需求说明

  • 对版本1进行优化,不去数据库看是否存在该用户对此商品的秒杀订单
  • 而是把生成的订单放到redis中,后面直接从redis中判断是否复购
  • 就是seckill最后生成秒杀订单后设置到redis中

秒杀优化

预减库存+Decrement

目的就是减少去到数据库中的操作
需求说明

  • 在2.3.3中防止超卖,SeckillController 是直接到数据库查出商品的 goodsService.findGoodsVoByGoodsId(goodsId); 初步判断的库存,并发下不准确
  • 大量的并发请求都去到数据库中尝试减库存的操作,虽然控制了超卖,但容易把数据库压垮

解决思路

  • 使用 Redis 完成预减库存,如果没有库存了, 直接返回, 减小对DB的压力
  • 如果预减库存后,库存小于0就不再去orderService.seckill()中了
  • 结果就是库存有多少就进去几个请求
  • 依赖decrement具有原子性,redisTemplate.opsForValue().decrement(“seckillGoods:” + goodsId);
    //库存预减, 如果在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";//错误页面
    }
    **前奏:SeckillController的初始化方法,从数据库查出所有的秒杀商品,然后存到redis中 seckillGoods : id **
    //这个方法是在类的所有属性,都初始化后,自动执行的
    //这里把所有秒杀商品的库存量加载到redis
    @Override
    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版本,加入内存标记优化秒杀
    @RequestMapping("/doSeckill")
    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移到消息消费者,然后消费者执行

/**
* SeckillMessage: 秒杀消息对象
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SeckillMessage {
private User user;
private Long goodsId;
}

秒杀消息组成:

  • 用户 user
  • 商品id goodsId
    /**
    * RabbitMQSeckillConfig: 配置类,创建消息队列和交换机
    */
    @Configuration
    public class RabbitMQSeckillConfig {

    //定义消息队列名和交换机名
    private static final String QUEUE = "seckillQueue";
    private static final String EXCHANGE = "seckillExchange";

    //创建队列
    @Bean
    public Queue queue_seckill() {
    return new Queue(QUEUE);
    }

    //创建交换机-Topic
    @Bean
    public TopicExchange topicExchange_seckill() {
    return new TopicExchange(EXCHANGE);
    }

    //将队列绑定到交换机,并指定路由
    @Bean
    public Binding binding_seckill() {
    return BindingBuilder.bind(queue_seckill())
    .to(topicExchange_seckill()).with("seckill.#");
    }
    }
    /**
    * MQSenderMessage: 消息的生产者/发送者[秒杀消息]
    */
    @Service
    @Slf4j
    public class MQSenderMessage {

    //装配RabbitTemplate
    @Resource
    private RabbitTemplate rabbitTemplate;

    //方法:发送秒杀消息
    public void sendSeckillMessage(String message) {
    log.info("发送消息-->" + message);
    rabbitTemplate.convertAndSend("seckillExchange", "seckill.message", message);
    }
    }
    发送的是String
    /**
    * MQReceiverMessage: 消息的接收者/消费者, 这里调用seckill方法()
    */
    @Service
    @Slf4j
    public class MQReceiverMessage {

    //装配需要的组件/对象
    @Resource
    private GoodsService goodsService;
    @Resource
    private OrderService orderService;


    //接收消息,并完成下单
    @RabbitListener(queues = "seckillQueue")
    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);
    }

    }
    用工具类JSONUtil把String转为SeckillMessage
    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";

秒杀安全

秒杀接口地址隐藏

需求说明

  • 前面我们处理高并发, 是按照正常业务逻辑处理的, 也就是用户正常抢购
  • 还需要考虑抢购安全性, 当前程序, 抢购接口是固定, 如果泄露, 会有安全隐患, 比如抢购未开始或者已结束, 还可以使用脚本发起抢购

解决思路

  • 隐藏抢购接口
  • 用户抢购时, 先生成一个唯一的抢购路径, 返回给客户端
  • 客户端抢购时会携带生成的抢购路径, 服务端做校验, 如果校验成功, 才走下一步, 否则直接返回

解决过程
image.png
OrderServiceImpl生成的秒杀路径放到redis中,便于后面到redis中校验
set(“seckillPath:”+ user.getId() + “:” + goodsIdpath, 60, TimeUnit.SECONDS);

//方法: 生成秒杀路径/值(唯一)
@Override
public String createPath(User user, Long goodsId) {
//生成秒杀路径/值
String path = MD5Util.md5(UUIDUtil.uuid());
//将随机生成的路径保存到Redis, 设置一个超时时间60s
//key的设计: seckillPath:userId:goodsId
redisTemplate.opsForValue().set("seckillPath:"+ user.getId() + ":" + goodsId,
path, 60, TimeUnit.SECONDS);
return path;
}

//方法: 对秒杀路径进行校验
@Override
public boolean checkPath(User user, Long goodsId, String path) {
if (user == null || goodsId < 0 || !StringUtils.hasText(path)) {
return false;
}

//取出该用户秒杀该商品的路径
String redisPath = (String) redisTemplate.opsForValue()
.get("seckillPath:" + user.getId() + ":" + goodsId);
return path.equals(redisPath);
}

在OrderController中增加getPath,createPath后返回给客户端
然后客户端带着新路径再次发出秒杀请求

//方法:获取秒杀路径
@RequestMapping("/path")
@ResponseBody
@AccessLimit(second = 5, maxCount = 5, needLogin = true)
public RespBean getPath(User user, Long goodsId, String captcha, HttpServletRequest request){
if (user == null || goodsId < 0 || !StringUtils.hasText(captcha)) {
return RespBean.error(RespBeanEnum.SESSION_ERROR);
}

String path = orderService.createPath(user, goodsId);
return RespBean.success(path);
}

在SeckillController做地址校验,路径正确了再忘下一步走

//处理用户抢购请求/秒杀
//说明:我们先完成一个V6.0版本,加入秒杀安全,直接返回RespBean,不再去下一个页面了
@RequestMapping("/{path}/doSeckill") //每一个秒杀的路径都不一样
@ResponseBody
public RespBean doSeckill(@PathVariable String path, Model model, User user, Long goodsId) {

System.out.println("------秒杀V6.0 开始-------");
if (user == null) {//用户没有登录
return RespBean.error(RespBeanEnum.SESSION_ERROR);
}

//这里增加一个逻辑,校验用户携带的path是否正确
boolean checkPath = orderService.checkPath(user, goodsId, path);
if(!checkPath){
return RespBean.error(RespBeanEnum.REQUEST_ILLEGAL);
}

验证码防止脚本攻击

需求说明

  • 在一些抢购活动中, 可以通过验证码的方式, 防止脚本攻击, 比如12306

解决思路

  • 使用验证码 happyCaptcha ![image.png././duskimg/seckill/img6.png)

解决过程
在SeckillController中增加方法生成验证码,保存到reids,便于后面校验

//生成验证码-happyCaptcha
@RequestMapping("/captcha")
public void happyCaptcha(HttpServletRequest request, HttpServletResponse response, User user, Long goodsId) {
//生成验证码,并输出
//注意,该验证码,默认就保存到session中, key是 happy-captcha
HappyCaptcha.require(request, response)
.style(CaptchaStyle.ANIM) //设置展现样式为动画
.type(CaptchaType.NUMBER) //设置验证码内容为数字
.length(6) //设置字符长度为6
.width(220) //设置动画宽度为220
.height(80) //设置动画高度为80
.font(Fonts.getInstance().zhFont()) //设置汉字的字体
.build().finish(); //生成并输出验证码

//把验证码的值,保存Redis [考虑项目分布式], 设置了验证码的失效时间100s
//key: captcha:userId:goodsId
redisTemplate.opsForValue().set("captcha:" + user.getId() + ":" + goodsId
, (String) request.getSession().getAttribute("happy-captcha"), 100, TimeUnit.SECONDS);
}

在OrderServiceImpl中增加校验验证码的方法

//方法: 验证用户输入的验证码是否正确
@Override
public boolean checkCaptcha(User user, Long goodsId, String captcha) {
if (user == null || goodsId < 0 || !StringUtils.hasText(captcha)) {
return false;
}

//从Redis取出验证码
String redisCaptcha = (String) redisTemplate.opsForValue().get("captcha:" + user.getId() + ":" + goodsId);

return captcha.equals(redisCaptcha);
}

秒杀接口限流-防刷

需求说明

  • 完成接口限流-防止某个用户频繁的请求秒杀接口
  • 比如在短时间内,频繁点击立即秒杀

解决思路
image.png
解决过程

简单接口限流

  • 使用简单的 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, 提高接口限流功能通用性 , 减少冗余代码, 同时也减少业务代码入侵

image.png
自定义注解@AccessLimit,时间范围,访问最大次数,是否登录

/**
* AccessLimit: 自定义的注解
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AccessLimit {
int second();//时间范围
int maxCount();//访问的最大次数
boolean needLogin() default true;//是否登录
}

写到getPath方法里

//方法:获取秒杀路径
@RequestMapping("/path")
@ResponseBody
/**
* @AccessLimit(second = 5,maxCount = 5,needLogin = true)
* 1. 使用注解的方式完成对用户的限流防刷-通用性和灵活性提高
* 2. second = 5,maxCount = 5 说明是在5秒内可以访问的最大次数是5次
* 3. needLogin = true 表示用户是否需要登录
*/
@AccessLimit(second = 5, maxCount = 5, needLogin = true)
public RespBean getPath(User user, Long goodsId, String captcha, HttpServletRequest request){
if (user == null || goodsId < 0 || !StringUtils.hasText(captcha)) {
return RespBean.error(RespBeanEnum.SESSION_ERROR);
}

// //增加业务逻辑: 加入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);
// }

//增加一个业务逻辑-校验用户输入的验证码是否正确
boolean check = orderService.checkCaptcha(user, goodsId, captcha);
if (!check) {//如果校验失败
return RespBean.error(RespBeanEnum.CAPTCHA_ERROR);
}

String path = orderService.createPath(user, goodsId);
return RespBean.success(path);
}
//用来存储拦截器获取的 user 对象
public class UserContext {

//每个线程都有自己的ThreadLocal, 把共享数据存放到这里,保证线程安全
private static ThreadLocal<User> userHolder = new ThreadLocal<>();

public static void setUser(User user) {
userHolder.set(user);
}

public static User getUser() {
return userHolder.get();
}
}

属实看不懂这个拦截器

/**
* AccessLimitInterceptor: 自定义的拦截器
*/
@Component
public class AccessLimitInterceptor implements HandlerInterceptor {

//装配需要的组件/对象
@Resource
private UserService userService;
@Resource
private RedisTemplate redisTemplate;

//这个方法完成1. 得到user对象,并放入到ThreadLoacl 2. 去处理@Accesslimit
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

if (handler instanceof HandlerMethod) {
//这里我们就先获取到登录的user对象
User user = getUser(request, response);
//存入到ThreadLocal
UserContext.setUser(user);

//把handler 转成 HandlerMethod
HandlerMethod hm = (HandlerMethod) handler;
//获取到目标方法的注解
AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class);
if (accessLimit == null) {//如果目标方法没有@AccessLimit说明该接口并没有处理限流防刷
return true; //继续
}
//获取注解的值
int second = accessLimit.second();//获取到时间范围
int maxCount = accessLimit.maxCount();//获取到最大的访问次数
boolean needLogin = accessLimit.needLogin();//获取是否需要登录
if (needLogin) {//说明用户必须登录才能访问目标方法/接口
if (user == null) {//说明用户没有登录
//返回一个用户信息错误的提示...一会再单独处理...
render(response, RespBeanEnum.SESSION_ERROR);
return false;//返回
}

}
String uri = request.getRequestURI();
String key = uri + ":" + user.getId();
ValueOperations valueOperations = redisTemplate.opsForValue();
Integer count = (Integer) valueOperations.get(key);
if (count == null) {//说明还没有key,就初始化,值为1, 过期时间为5秒
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;
}

//方法:构建返回对象-以流的形式返回
private void render(HttpServletResponse response,
RespBeanEnum respBeanEnum) throws IOException {

response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
PrintWriter out = response.getWriter();
//构建RespBean
RespBean error = RespBean.error(respBeanEnum);
out.write(new ObjectMapper().writeValueAsString(error));
out.flush();
out.close();
}

//单独编写方法,得到登录的user对象-userTicket
private User getUser(HttpServletRequest request, HttpServletResponse response) {
String ticket = CookieUtil.getCookieValue(request, "userTicket");
if (!StringUtils.hasText(ticket)) {
return null;//说明该用户没有登录,直接返回null
}
return userService.getUserByCookie(ticket, request, response);
}
}

@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {

//装配
@Resource
private UserArgumentResolver userArgumentResolver;

@Resource
private AccessLimitInterceptor accessLimitInterceptor;


// 注册自定义拦截器,这样就可以生效
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(accessLimitInterceptor);
}

//静态资源加载
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**").addResourceLocations("classpath:/static/");
}

//这里加入我们自定义的解析器到 HandlerMethodArgumentResolver列表中
//这样自定义的解析器工作
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(userArgumentResolver);
}
}
/**
* UserArgumentResolver: 自定义的一个解析器
*/
@Component
public class UserArgumentResolver implements HandlerMethodArgumentResolver {

//装配UserService
@Resource
private UserService userService;

//判断你当前要解析的参数类型是不是你需要的?
@Override
public boolean supportsParameter(MethodParameter parameter) {
//获取参数是不是user类型
Class<?> aClass = parameter.getParameterType();
//如果为t, 就执行resolveArgument
return aClass == User.class;
}

//如果上面supportsParameter,返回T,就执行下面的resolveArgument方法
//到底怎么解析,是由程序员根据业务来编写
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {

// HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
// HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
//
// String ticket = CookieUtil.getCookieValue(request, "userTicket");
// if (!StringUtils.hasText(ticket)) {
// return null;
// }
// //从Redis来获取用户
// User user = userService.getUserByCookie(ticket, request, response);
//
// return user;
return UserContext.getUser();
}
}