一、什么是秒杀
秒杀最直观的定义:在高并发场景下而下单某一个商品,这个过程就叫秒杀
【秒杀场景】
- 火车票抢票
- 双十一限购商品
- 热度高的明星演唱会门票
- …
二、为什么使用秒杀
早起的12306购票,刚被开发出来使用的时候,12306会经常出现 超卖 这种现象,也就是说车票只剩10张了,却被20个人买到了,这种现象就是超卖!
还有在高并发的情况下,如果说没有一定的保护措施,系统会被这种高流量造成宕机
【为什么使用秒杀】
- 严格防止超卖
- 库存100件 你卖了120件 等着辞职吧!
- 防止黑客
- 假如我们网站想下发优惠给群众,但是被黑客利用技术将下发给群众的利益收入囊中
- 保证用户体验
- 高并发场景下,网页不能打不开、订单不能支付 要保证网站的使用!
三、非并发情况下秒杀
创建数据库
create database stockdb;
use stockdb;
create table stock(
id int primary key auto_increment,
`name` varchar(50),
`count` int,
sale int,
`version` int
);
create table stock_order(
id int primary key auto_increment,
sid int,
`name` varchar(50),
`create_time` timestamp
);
insert stock value('0','iPhone 13 Pro',15,0,0);
- 创建SpringBoot项目,添加以下依赖
<dependencies>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.2.6</version>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.2</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.48</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
【配置文件】
server.port=8989
server.servlet.context-path=/ms
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/stockdb
spring.datasource.username=root
spring.datasource.password=ok
mybatis.mapper-locations=classpath:mapper/*.xml
mybatis.type-aliases-package=com.jiabin.pojo
logging.level.root=info
logging.level.com.jiabin.mapper:debug
- 创建实体类
【实体类】
/**
* @Author 嘉宾
* @Data 2022/3/23 20:06
* @Version 1.0
* @Desc 订单
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Accessors(chain = true)
public class Order {
private Integer id;
private Integer sid;
private String name;
private Date createDate;
}
/**
* @Author 嘉宾
* @Data 2022/3/23 19:57
* @Version 1.0
* @Desc 商品
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Accessors(chain = true)
public class Stock {
private Integer id;
private String name;
private Integer count; //库存
private Integer sale; //已售
private Integer version; //版本号
}
- 创建mapper层,根据业务编写接口
【mapper】
/**
* @Author 嘉宾
* @Data 2022/3/23 19:56
* @Version 1.0
* @Desc 商品
*/
@Mapper
public interface StockMapper {
/**
* 根据商品id查询库存数量
*/
Stock checkStock(Integer id);
/**
* 根据商品id减少库存
*/
void updateSale(Stock stock);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.jiabin.mapper.StockMapper">
<!-- 根据商品id查询库存数量 -->
<select id="checkStock" resultType="Stock" parameterType="int">
select * from stock where id = #{id}
</select>
<!-- 根据商品id扣除库存 -->
<update id="updateSale" parameterType="Stock" >
update stock set sale = #{sale} where id = #{id}
</update>
</mapper>
/**
* @Author 嘉宾
* @Data 2022/3/23 20:07
* @Version 1.0
* @Desc 订单
*/
@Mapper
public interface OrderMapper {
/**
* 创建订单
*/
void createOrder(Order order);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.jiabin.mapper.OrderMapper">
<!-- 创建订单 -->
<insert id="createOrder" parameterType="Order" useGeneratedKeys="true" keyProperty="id">
insert stock_order value(#{id},#{sid},#{name},#{createDate});
</insert>
</mapper>
- 创建service,添加订单信息
/**
* @Author 嘉宾
* @Data 2022/3/23 19:51
* @Version 1.0
*/
public interface OrderService {
/**
* 处理秒杀下单方法,返回订单id
* @param id
* @return
*/
Integer kill(Integer id);
}
/**
* @Author 嘉宾
* @Data 2022/3/23 19:53
* @Version 1.0
*/
@Service
@Transactional //控制事务
public class OrderServiceImpl implements OrderService {
@Autowired
private StockMapper stockMapper;
@Autowired
private OrderMapper orderMapper;
//在非并发情况下无问题
@Override
public Integer kill(Integer id) {
//根据商品id校验库存是否还存在
Stock stock = stockMapper.checkStock(id);
//当已售和库存相等就库存不足了
if(stock.getSale().equals(stock.getCount())){
throw new RuntimeException("库存不足!");
}else{
//扣除库存 (已售数量+1)
stock.setSale(stock.getSale()+1);
stockMapper.updateSale(stock); //更新信息
//创建订单
Order order = new Order();
order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
orderMapper.createOrder(order); //创建订单
return order.getId(); //mybatis主键生成策略 直接返回创建的id
}
}
}
- 创建StockController 提供访问路径
/**
* @Author 嘉宾
* @Data 2022/3/23 19:49
* @Version 1.0
*/
@RestController
@RequestMapping("/stock")
public class StockController {
@Autowired
private OrderService orderService;
//开发秒杀方法
@GetMapping("/kill/{id}")
public String kill(@PathVariable("id") Integer id){
System.out.println("秒杀商品的ID=====================>"+id);
try {
//根据秒杀商品id调用秒杀业务
Integer orderId = orderService.kill(id);
return "秒杀成功,订单ID为:"+String.valueOf(orderId);
}catch (Exception e){
e.printStackTrace();
return e.getMessage();
}
}
}
- 访问项目地址
http://localhost:8989/ms/stock/kill/1
进行测试
【第一次下单,可以看到一套事务完成,对应订单信息也随着创建】 【当我们连续购买多次,商品库存达到了极限后,提示我们库存不足!此时订单是无法创建的!】
以上只是可以在非高并发场景下可以使用,如果说大量的请求涌向我们的接口,可能会出现问题,下面我们测试一下高并发场景下会出现什么问题!
四、高并发测试工具 JMeter
- 打开我们的测试工具
JMeter
,创建线程组设置请求数
【设置1000个线程足够!】 - 配置访问的基础路径配置
【ip、端口、请求类型、访问路径】 - 创建监听,监听线程执行的输出
- 配置完毕!启动我们的测试工具 高并发访问系统
【清空数据库数据】
#清空数据库数据
truncate table stock;
truncate table stock_order;
insert stock value('0','iPhone 13 Pro',15,0,0);
select * from stock_order;
【启动压力测试工具】
【查看监听输出】
【查看数据库订单表,发现已经严重超卖,高并发场景下是无法抵御的!】
五、解决商品超卖问题
1、使用悲观锁解决商品超卖
上述章节我们可以看到,高并发场景下我们的商品已经严重超卖了,公司中是严重不允许出现该问题的,下面我们看一下如何解决该问题!
【使用 synchronized 悲观锁】
- 顾名思义十分悲观,它总是认为会出问题,无论干什么都会上锁!再去操作!
- synchronized中文意思是同步,也被称之为”同步锁“。
- synchronized的作用是保证在同一时刻, 被修饰的代码块或方法只会有一个线程执行,以达到保证并发安全的效果。
- synchronized是Java中解决并发问题的一种最常用的方法,也是最简单的一种方法。
- 我们使用synchronized 修饰我们的业务代码
/**
* @Author 嘉宾
* @Data 2022/3/23 19:53
* @Version 1.0
*/
@Service
@Transactional //控制事务
public class OrderServiceImpl implements OrderService {
@Autowired
private StockMapper stockMapper;
@Autowired
private OrderMapper orderMapper;
/**
* 秒杀商品
**/
@Override
public synchronized Integer kill(Integer id) {
//根据商品id校验库存是否还存在
Stock stock = stockMapper.checkStock(id);
//当已售和库存相等就库存不足了
if(stock.getSale().equals(stock.getCount())){
throw new RuntimeException("库存不足!");
}else{
//扣除库存 (已售数量+1)
stock.setSale(stock.getSale()+1);
stockMapper.updateSale(stock); //更新信息
//创建订单
Order order = new Order();
order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
orderMapper.createOrder(order); //创建订单
return order.getId(); //mybatis主键生成策略 直接返回创建的id
}
}
}
【继续清空数据库数据】
#清空数据库数据
truncate table stock;
truncate table stock_order;
insert stock value('0','iPhone 13 Pro',15,0,0);
select * from stock_order;
- 继续使用压力测试工具进行测试
【测试后发现确实可以帮我们解决大量超卖问题,但是仔细观察还是存在超卖现象】
注意:这里存在一个坑!
- 由于我们的业务实体类中我们添加了事务 @Transactional ,添加了事务后会导致我们的事务也存在 线程同步,而事务的线程同步要比我们 synchronized 的线程同步 范围更大
- 我们的 synchronized 代码块确实可以帮我们实现线程同步,但是当我们代码块流程结束后事务可能还没有结束,就例如当前线程A的锁已经释放了 而事务还没有提交, 此时下一个线程B来了,来了之后事务开始提交,当线程B开始执行,线程B一执行数据库也跟着提交,这样可能会出现多提交这种问题!
- 所以说在这里添加 synchronized 也会出现超卖问题!以后不要在业务方法上添加 synchronized !
【解决办法,在控制层中添加 synchronized 】
/**
* @Author 嘉宾
* @Data 2022/3/23 19:49
* @Version 1.0
*/
@RestController
@RequestMapping("/stock")
public class StockController {
@Autowired
private OrderService orderService;
//开发秒杀方法
@GetMapping("/kill/{id}")
public String kill(@PathVariable("id") Integer id){
System.out.println("秒杀商品的ID=====================>"+id);
try {
//使用悲观锁
synchronized (this){
//根据秒杀商品id调用秒杀业务
Integer orderId = orderService.kill(id);
return "秒杀成功,订单ID为:"+String.valueOf(orderId);
}
}catch (Exception e){
e.printStackTrace();
return e.getMessage();
}
}
}
【测试结果(前提继续清空数据库)】
【总结:】
- synchronized 要在控制层中添加!
- 注意事务 和 synchronized 存在的问题!
【悲观锁缺点:】
- 会造成线程阻塞,线程排队问题
- 对用户的体验不是很好
注意:我们不推荐使用 悲观锁
解决该问题,因为使用悲观锁
会出现线程排队问题!下面介绍乐观锁方式
2、使用乐观锁解决商品超卖 推荐乐观锁
上述章节我们使用悲观锁
解决了商品超卖问题,但是存在一定的缺陷,就是会出现线程一个个排队问题,会造成线程阻塞,给用户体验也不是很好!我们可以使用乐观锁解决该问题
【使用乐观锁】
- 顾名思义十分乐观,它总是认为不会出现问题,无论干什么都不去上锁!如果出现问题,再次更新值测试
- 乐观锁相对悲观锁而言,乐观锁机制采取了更加宽松的加锁机制。
- 乐观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性
【代码重构】
- 我们将服务层代码重构,将一个个业务抽取成方法
/**
* @Author 嘉宾
* @Data 2022/3/23 19:53
* @Version 1.0
*/
@Service
@Transactional //控制事务
public class OrderServiceImpl implements OrderService {
@Autowired
private StockMapper stockMapper;
@Autowired
private OrderMapper orderMapper;
// 秒杀
@Override
public Integer kill(Integer id) {
// 校验库存
Stock stock = checkStock(id);
// 存在扣住库存 未抛出异常则满足!
updateSale(stock);
// 创建订单
return createOrder(stock);
}
/**
* 校验库存
* @param id
* @return
*/
public Stock checkStock(Integer id){
//根据商品id校验库存是否还存在
Stock stock = stockMapper.checkStock(id);
//当已售和库存相等就库存不足了
if(stock.getSale().equals(stock.getCount())){
throw new RuntimeException("库存不足!");
}
return stock; //满足情况下返回商品信息
}
/**
* 扣除库存
* @param stock
*/
public void updateSale(Stock stock){
//扣除库存 (已售数量+1)
stock.setSale(stock.getSale()+1);
stockMapper.updateSale(stock); //更新信息
}
/**
* 创建订单
* @param stock
* @return
*/
public Integer createOrder(Stock stock){
//创建订单
Order order = new Order();
order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
orderMapper.createOrder(order); //创建订单
return order.getId(); //mybatis主键生成策略 直接返回创建的id
}
}
- 控制层
/**
* @Author 嘉宾
* @Data 2022/3/23 19:49
* @Version 1.0
*/
@RestController
@RequestMapping("/stock")
public class StockController {
@Autowired
private OrderService orderService;
//开发秒杀方法
@GetMapping("/kill/{id}")
public String kill(@PathVariable("id") Integer id){
System.out.println("秒杀商品的ID=====================>"+id);
try {
//根据秒杀商品id调用秒杀业务
Integer orderId = orderService.kill(id);
return "秒杀成功,订单ID为:"+String.valueOf(orderId);
}catch (Exception e){
e.printStackTrace();
return e.getMessage();
}
}
}
- 启动项目测试下确保无问题!
【使用乐观锁解决商品超卖】
- 修改我们扣除库存的方法,通过版本号控制抵挡高并发涌入
/**
* 扣除库存
* @param stock
*/
public void updateSale(Stock stock){
//在sql层面完成销量+1 和 版本号 +1 并且根据商品id和版本号同时查询更新的商品
Integer updRows = stockMapper.updateSale(stock); //更新信息
if(updRows == 0){ //代表没有拿到版本号
throw new RuntimeException("抢购失败,请重试!");
}
}
- 修改mapper文件中的映射,我们修改 销量的同时 也修改版本号 并且需要根据 id + 版本号进行修改
<!-- 根据商品id扣除库存 -->
<update id="updateSale" parameterType="Stock" >
update stock set sale = sale + 1,version = version + 1 where id = #{id} and version = #{version}
</update>
- 其余代码我们没有任何改动,启动项目进行测试
【依旧清空数据库数据】
#清空数据库数据
truncate table stock;
truncate table stock_order;
insert stock value('0','iPhone 13 Pro',15,0,0);
select * from stock_order;
【启动压力测试工具,高并发访问我们的请求,发现无任何问题】
【每秒杀一件商品都会修改一次版本号】
总结:
- 相对悲观锁而言乐观锁保证了一定的效率,而不像悲观锁那样会造成线程阻塞
- 使用乐观锁需要使用版本号,在操作数据的时候要对版本号进行更新
商品超卖总体流程
以上已经解决了我们商品超卖的问题,我们还需要解决前端请求限流的问题,我们目前只是1000个请求访问,如果说有更大的请求过来 我们的后端绝对是承受不住的 我们需要在请求做一定的限流处理!
六、使用令牌桶+乐观锁限流
1、什么是令牌桶
最初来源于计算机网络,在网络传输时,为了限制网络的拥塞,而限制网络的流量,使流量比较均匀的速度向外发送。
令牌桶允许请求的突发,它是以恒定的速度向桶中生成令牌,就比如我们向桶中生产100个令牌,它会以一定的速度生产令牌。当请求过来的时候会先向桶中拿取令牌,拿到了令牌才能进行业务处理,没有拿到令牌的请求可以暂时等待,直到令牌生产完毕后再拿到令牌去做业务处理,另外一种方式就是给请求一定的时间,在一定时间内拿到令牌就进行业务处理,拿不到的话就抛弃请求。
2、测试令牌桶
- 导入依赖
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>28.2-jre</version>
</dependency>
- 创建令牌桶实例,编写测试访问接口
/**
* @Author 嘉宾
* @Data 2022/3/23 19:49
* @Version 1.0
*/
@RestController
@RequestMapping("/stock")
@Slf4j
public class StockController {
@Autowired
private OrderService orderService;
//创建令牌桶实例
private RateLimiter rateLimiter = RateLimiter.create(20); //放行20个请求
/**
* 基础令牌桶demo
* @param id
* @return
*/
@GetMapping("/sale/{id}")
public String sale(@PathVariable("id") Integer id){
//1、没有获取到令牌的阻塞,直到获取到令牌token
log.info("等待的时间:"+rateLimiter.acquire());
System.out.println("========================>处理业务!");
return "抢购成功!";
}
...
}
- 启动项目测试,使用压力测试工具访问
【我们设置2000个线程进行访问,可以发现每个请求逐个等待执行,令牌桶会没隔断时间发放令牌,只有得到令牌才能访问业务】【修改代码,设置线程需要在5秒内获取令牌,若获取不到就抛弃该请求】
/**
* 基础令牌桶demo
* @param id
* @return
*/
@GetMapping("/sale/{id}")
public String sale(@PathVariable("id") Integer id){
//1、没有获取到令牌的阻塞,直到获取到令牌token
//log.info("等待的时间:"+rateLimiter.acquire());
///2、设置超时时间,如果在等待时间内获取到了令牌则处理业务,如果在等待时间外没有获取到令牌 则抛弃请求
if(!rateLimiter.tryAcquire(5, TimeUnit.SECONDS)){ //5秒内能获取
log.info("当期请求被限流,被抛弃了,无法调用后续秒杀业务!");
return "抢购失败!";
}
System.out.println("========================>处理业务!");
return "抢购成功!";
}
【启动线程组测试,可以看到大部分请求均被抛弃】
3、使用令牌桶+乐观锁限流
开发步骤
- 在控制器中添加业务代码
/**
* @Author 嘉宾
* @Data 2022/3/23 19:49
* @Version 1.0
*/
@RestController
@RequestMapping("/stock")
@Slf4j
public class StockController {
@Autowired
private OrderService orderService;
//创建令牌桶实例
private RateLimiter rateLimiter = RateLimiter.create(20); //放行20个请求 20个令牌
/**
* 乐观锁 + 令牌桶算法限流 version2.0
* @param id
* @return
*/
@GetMapping("/killtoken/{id}")
public String killtoken(@PathVariable("id") Integer id){
System.out.println("秒杀商品的ID=====================>"+id);
//加入令牌桶的限流措施
if(rateLimiter.tryAcquire(2,TimeUnit.SECONDS)){
System.out.println("抢购失败,当前秒杀活动过于火爆,请重试!");
return "抢购失败,当前秒杀活动过于火爆,请重试!";
}
try { //2秒内能拿到令牌才能进入
//根据秒杀商品id调用秒杀业务
Integer orderId = orderService.kill(id);
return "秒杀成功,订单ID为:"+String.valueOf(orderId);
}catch (Exception e){
e.printStackTrace();
return e.getMessage();
}
}
}
- 清空数据库
#清空数据库数据
truncate table stock;
truncate table stock_order;
insert stock value('0','iPhone 13 Pro',15,0,0);
select * from stock_order;
- 启动项目测试,通过压力测试工具测试 2000个线程
【订单已经生成15个,2秒内未拿到令牌的线程会被抛弃】
七、其它问题
在上面章节中,我们完成了防止商品超卖以及限流,以及可以防止高并发情况下服务器宕机了,本章节中我们会继续完成一些问题
- 秒杀都是在一个限定的时间内可以秒杀的,而不是每时每刻用户都可以秒杀,我们需要对秒杀系统加入限时处理!限时抢购
- **如果说黑客通过抓包的方式获取到了我们秒杀接口的地址,通过脚本抢购我们的地址怎么办? **隐藏接口
- 秒杀开始之后 一个用户请求频率过高 如何单位时间内限制访问次数? 单用户限制频率
1、Redis限时抢购
使用Redis完成商品秒杀时间限制,商品只有有效时间内可以秒杀
# 设置redis键存在时间
set Key Value EX 有效时间
# 我们设置商品的过期时间
set kill商品编号 value Ex 有效时间
开发步骤
- 在项目中加入整合redis依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
- 在配置文件中加入配置
# redis
spring.redis.host=192.168.171.134
spring.redis.port=6379
spring.redis.database=0
- 在秒杀接口中添加redis响应代码
/**
* @Author 嘉宾
* @Data 2022/3/23 19:53
* @Version 1.0
*/
@Service
@Transactional //控制事务
public class OrderServiceImpl implements OrderService {
@Autowired
private StockMapper stockMapper;
@Autowired
private OrderMapper orderMapper;
@Autowired
private StringRedisTemplate stringRedisTemplate;
//秒杀!
@Override
public Integer kill(Integer id) {
// 验证redis中的秒杀商品是否超时
if(!stringRedisTemplate.hasKey("kill"+id)){ //当redis不存在秒杀的商品 当我们设置的限时字段过期了就不存在了,不存在就代表秒杀结束了!
throw new RuntimeException("当前商品的抢购活动已经结束了!");
}
// 校验库存
Stock stock = checkStock(id);
// 存在扣住库存 未抛出异常则满足!
updateSale(stock);
// 创建订单
return createOrder(stock);
}
....
}
- 启动redis
# 后台启动
redis-server jconfig/redis.conf
【设置一个商品有效秒杀时间】
# 单位为秒
set kill1 1 EX 60
- 启动项目,通过压力测测试工具进行测试!
【清空数据库数据】
#清空数据库数据
truncate table stock;
truncate table stock_order;
insert stock value('0','iPhone 13 Pro',15,0,0);
select * from stock_order;
【在商品有效时间内秒杀商品,秒杀正常】
【清空数据库数据,在商品无效时间内秒杀商品,提示当前商品秒杀已经结束,数据库也不会创建对应订单信息】
2、隐藏接口
在以上章节中,我们实现了Redis限时抢购等问题,虽然解决了限时抢购问题,但是我们系统还存在一定的问题,就比如我们的秒杀接口,如果说有不法人员利用某种技术手段获取到了我们的接口信息,在我们秒杀还没有开启的时候,不法人员提前进行对我们接口的连续访问,这样就对我们的用户不公平了,或许直接通过脚本访问接口 通过不进行按钮点击完成抢购,这样就成就了成千上万的薅羊毛军团!
我们需要对秒杀接口进行隐藏处理,抢购接口隐藏(接口加盐)的具体做法:
- 每次点击秒杀接口,先从服务器获取一个秒杀验证值(接口内判断是否是秒杀时间)
- Redis以缓存用户ID和商品的ID为Key(MD5-用户id-商品id),秒杀地址为 redis中存的秒杀验证值
- 用户请求秒杀的时候,需要带上秒杀验证进行校验
- 具体流程:
- 即使黑客获取到了我们生成md5的接口,我们生成md5的时候为它指定一个随机盐,并且再进行md5的限时处理,这样就有效的防止了脚本
代码实现
- 在我们的数据库中添加新的表
user
create table `user`(
uid int primary key auto_increment,
uname varchar(100),
upwd varchar(50)
);
insert `user` values('0','jiabin','123');
- 在controller中创建对应生成md5的方法
/**
* @Author 嘉宾
* @Data 2022/3/23 19:49
* @Version 1.0
*/
@RestController
@RequestMapping("/stock")
@Slf4j
public class StockController {
@Autowired
private OrderService orderService;
@Autowired
private UserService userService;
//创建令牌桶实例
private RateLimiter rateLimiter = RateLimiter.create(20); //放行20个请求
/**
* 生成MD5
* @param id
* @param uid
* @return
*/
@GetMapping("/md5/{id}/{uid}")
public String getMD5(@PathVariable("id")Integer id,@PathVariable("uid") Integer uid){
String md5;
try {
md5 = orderService.getMD5(id,uid);
}catch (Exception e){
e.printStackTrace();
return "获取MD5失败:"+e.getMessage();
}
return "获取到的MD5信息为:"+md5;
}
...
}
- 在业务层实现我们具体的业务
【OrderServiceImpl】
/**
* @Author 嘉宾
* @Data 2022/3/23 19:53
* @Version 1.0
*/
@Service
@Transactional //控制事务
public class OrderServiceImpl implements OrderService {
@Autowired
private StockMapper stockMapper;
@Autowired
private OrderMapper orderMapper;
@Autowired
private UserMapper userMapper;
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 根据商品ID与用户ID生成MD5
* @param id
* @param uid
* @return
*/
@Override
public String getMD5(Integer id, Integer uid) {
// 验证uid 用户是否存在
User user = userMapper.findUserById(uid);
if(null == user) throw new RuntimeException("用户信息不存在!");
// 验证id 商品是否存在
Stock stock = stockMapper.checkStock(id);
if(null == stock) throw new RuntimeException("商品信息不存在!");
// 生成MD5存入Redis
String hashKey = "KEY_"+uid+"_"+id;
// 生成MD5 !JiaBin16是一个盐
String key = DigestUtils.md5DigestAsHex((uid+id+"!JiaBin16").getBytes());
// 存入redis key value 时间
stringRedisTemplate.opsForValue().set(hashKey,key,120, TimeUnit.SECONDS);
return key;
}
...
}
- 创建对应的实体类以及校验用户接口
【User】
/**
* @Author 嘉宾
* @Data 2022/3/25 19:25
* @Version 1.0
* @Description
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@Accessors(chain = true)
@ToString
public class User {
private Integer uid;
private String uname;
private String upwd;
}
【UserMapper】
/**
* @Author 嘉宾
* @Data 2022/3/25 19:25
* @Version 1.0
* @Description
*/
@Mapper
public interface UserMapper {
/**
* 根据用户id查询用户
*/
User findUserById(Integer uid);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.jiabin.mapper.UserMapper">
<!-- 根据用户id查询用户 -->
<select id="findUserById" resultType="User" parameterType="int">
select * from user where uid = #{uid}
</select>
</mapper>
- 这里可以启动测试一下是否可以获取到md5
- 改造秒杀接口
【复制秒杀方法,进行改造 controller】
/**
* 乐观锁 + 令牌桶算法限流 + MD5签名 version3.0
* @param id
* @return
*/
@GetMapping("/killtokenMD5/{id}/{uid}/{md5}")
public String killtokenMD5(@PathVariable("id") Integer id,@PathVariable("uid")Integer uid,@PathVariable("md5")String md5){
System.out.println("秒杀商品的ID=====================>"+id);
//加入令牌桶的限流措施
if(rateLimiter.tryAcquire(5,TimeUnit.SECONDS)){
System.out.println("抢购失败,当前秒杀活动过于火爆,请重试!");
return "抢购失败,当前秒杀活动过于火爆,请重试!";
}
try { //2秒内能拿到产品才能进入
//根据秒杀商品id调用秒杀业务
Integer orderId = orderService.killMD5(id,uid,md5);
return "秒杀成功,订单ID为:"+String.valueOf(orderId);
}catch (Exception e){
e.printStackTrace();
return e.getMessage();
}
}
【复制秒杀方法,进行改造 service-----------这里我们将redis限时注释掉了 利于我们测试】
/**
* 用来处理秒杀下单方法 返回订单id 加入md5签名 (接口隐藏)
* @param id
* @param uid
* @param md5
* @return
*/
@Override
public Integer killMD5(Integer id, Integer uid, String md5) {
// 验证redis中的秒杀商品是否超时
// if(!stringRedisTemplate.hasKey("kill"+id)){ //当redis不存在秒杀的商品 当我们设置的限时字段过期了就不存在了,不存在就代表秒杀结束了!
// throw new RuntimeException("当前商品的抢购活动已经结束了!");
// }
// 验证签名
String hashKey = "KEY_"+uid+"_"+id;
// 通过redis
String md5DB = stringRedisTemplate.opsForValue().get(hashKey);
if(null == md5DB){
throw new RuntimeException("没有携带签名,请求不合法!");
}
if(!md5DB.equals(md5)){ //请求的md5与数据库中的md5作比较
throw new RuntimeException("当前请求数据不合法,请稍后再试!");
}
// 校验库存
Stock stock = checkStock(id);
// 存在扣住库存 未抛出异常则满足!
updateSale(stock);
// 创建订单
return createOrder(stock);
}
- 启动项目进行测试
【首先我们要生成md5,携带我们的md5进行访问,并且保证商品有一定的库存!】
【携带正确md5情况下,可以秒掉的原因是因为我注释掉了令牌桶,不然抢不到令牌访问】 【携带非法令牌情况下】 【解开令牌桶注释,进行压力工具测试,记得清空数据库数据保留库存以及令牌的持久性!】
【携带正确md5情况下,一切秒杀成功!500个线程组】 【携带非法md5情况下,清空数据库保证库存数量,500个线程,秒杀失败!】
3、单用户限制频率
假设我们已经做好了接口隐藏,但是总会有一些无聊的人会再去写一个获取md5的脚本,先获取md5的加密值再去请求购买,如说过你的抢购按钮做的很差,需要在开启0.5秒后才能请求成功,那么脚本可能就会在用户请求之前就请求成功。
我们需要做一个保护措施,用来限制单用户的抢购频率,每个用户在一定时间内只能请求一定的次数!
实现思路:
- 使用redis对每个用户进行访问统计,统计的字段名包含商品的id以及用户的id
- 写一个对用户访问效率限制,在用户下单申请的时候,检查用户的访问次数是否超过了我们指定的次数,如果超过了就抛弃该请求
- 具体流程:
开发步骤
- 开发controller代码
/**
* @Author 嘉宾
* @Data 2022/3/23 19:49
* @Version 1.0
*/
@RestController
@RequestMapping("/stock")
@Slf4j
public class StockController {
@Autowired
private OrderService orderService;
@Autowired
private UserService userService;
//创建令牌桶实例
private RateLimiter rateLimiter = RateLimiter.create(20); //放行20个请求
/**
* 乐观锁 + 令牌桶算法限流 + MD5签名 + 单用户访问频率限制 version4.0
* @param id
* @return
*/
@GetMapping("/killtokenMD5limit/{id}/{uid}/{md5}")
public String killtokenMD5limit(@PathVariable("id") Integer id,@PathVariable("uid")Integer uid,@PathVariable("md5")String md5){
//加入令牌桶的限流措施
// if(rateLimiter.tryAcquire(5,TimeUnit.SECONDS)){
// System.out.println("抢购失败,当前秒杀活动过于火爆,请重试!");
// return "抢购失败,当前秒杀活动过于火爆,请重试!";
// }
try { //2秒内能拿到产品才能进入
//单用户调用接口频率限制
Integer readCount = userService.addUserReadCount(uid);
log.info("===>当前该用户"+uid+"访问次数:"+readCount);
Boolean isBannd = userService.getUserCount(uid);
if (isBannd){ //判断是否超过指定访问次数
log.info("购买失败,您当前超过了频率限制!");
return "购买失败,您当前超过了频率限制!";
}
//根据秒杀商品id调用秒杀业务
Integer orderId = orderService.killMD5(id,uid,md5);
return "秒杀成功,订单ID为:"+String.valueOf(orderId);
}catch (Exception e){
e.printStackTrace();
return e.getMessage();
}
}
...
}
- Service代码
【UserService】
/**
* @Author 嘉宾
* @Data 2022/3/25 19:33
* @Version 1.0
* @Description
*/
public interface UserService {
/**
* 向reids中写入访问次数
*/
Integer addUserReadCount(Integer uid);
/**
* 判断单位时间调用次数
*/
Boolean getUserCount(Integer uid);
}
【UserServiceImpl】
/**
* @Author 嘉宾
* @Data 2022/3/25 19:34
* @Version 1.0
* @Description
*/
@Service
@Slf4j
public class UserServiceImpl implements UserService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public Integer addUserReadCount(Integer uid) {
// 根据不同uid生成调用次数的key
String readKey = "READ_"+uid;
// 获取redis中key的调用次数
String readCount = stringRedisTemplate.opsForValue().get(readKey);
int read = -1;
if(readCount == null){ // 第一次调用
// 存入redis中设置0
System.out.println("=======================>第一次调用");
stringRedisTemplate.opsForValue().set(readKey,"0",3600, TimeUnit.SECONDS);
}else{ // 不是第一次
// 每次调用+1
System.out.println("=======================>不是第一次调用");
read = Integer.parseInt(readCount) + 1;
stringRedisTemplate.opsForValue().set(readKey,String.valueOf(read),3600,TimeUnit.SECONDS);
}
return read; //返回调用次数 (如果返回-1就代表没有调用过)
}
@Override
public Boolean getUserCount(Integer uid) {
// 根据用户id生成key
String readKey = "READ_"+uid;
// 根据当前key获取用户的调用次数
String readCount = stringRedisTemplate.opsForValue().get(readKey);
if(readCount==null){ // 基本不会出现
//为空直接抛弃说明key出现异常
log.error("该用户没有访问申请验证值记录,疑似异常!");
return true;
}
return Integer.parseInt(readCount)>10; //大于10代表超过:true超过 false 没超过
}
}
- 启动项目进行测试
【这里我们注释掉了令牌桶,方便我们的测试】
【第一次访问,秒杀成功,控制台输出对应信息 我们定义的第一次访问就会返回-1】 【当我们连续进行秒杀,访问超过10次后就会限制用户,控制台打印对应信息】