SpringBoot简单设计一个秒杀系统流程
一、秒杀系统
1.1 秒杀的场景
- 淘宝限量抢购商品
- 火车票(12306系统)
1.2 保护的方法
- 乐观锁防止超卖
- 令牌桶限流
- Redis 缓存
- MD5加密,隐藏秒杀接口
- …
二、防止超卖
1.数据库的设计
创建两张表 商品信息stock 和 stock_order 订单表
DROP TABLE IF EXISTS `stock`;
CREATE TABLE `stock` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL COMMENT '名称',
`count` int(11) NOT NULL COMMENT '库存',
`sale` int(11) NOT NULL COMMENT '已售',
`version` int(11) NOT NULL COMMENT '乐观锁,版本号',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
DROP TABLE IF EXISTS `stock_order`;
CREATE TABLE `stock_order` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`sid` int(11) NOT NULL COMMENT '库存ID',
`name` varchar(255) NOT NULL COMMENT '商品名称',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=596 DEFAULT CHARSET=utf8;
2.pojo实体层
Order 订单
@Data
@AllArgsConstructor
@NoArgsConstructor
@Accessors(chain = true)
public class Order {
private Integer id;
private Integer sid;
private String name;
private Date createTime;
}
Stock 商品信息
@Data
@AllArgsConstructor
@NoArgsConstructor
@Accessors(chain = true)
public class Stock {
private Integer id;
private String name;
private Integer count;
private Integer sale;
private Integer version;
}
3.Mapper.xml
StockMapper.xml 商品信息
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.xizi.miaosha.mapper.StockMapper">
<select id="checkStock" parameterType="INT" resultType="com.xizi.miaosha.pojo.Stock">
select id,name,count,sale,version from stock
where id=#{id}
</select>
<update id="updateStock" parameterType="com.xizi.miaosha.pojo.Stock" >
update stock set
sale=#{sale}
where
id=#{id}
</update>
</mapper>
Ordermapper.xml 创建订单
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.xizi.miaosha.mapper.OrderMapper">
<insert id="createOrder" parameterType="com.xizi.miaosha.pojo.Order" useGeneratedKeys="true" keyProperty="id">
insert into stock_order values (#{id},#{sid},#{name},#{createTime});
</insert>
</mapper>
4.Mapper层
订单的mapper
package com.xizi.miaosha.mapper;
import com.xizi.miaosha.pojo.Order;
import org.springframework.stereotype.Repository;
@Repository
public interface OrderMapper {
/*
创建订单
*/
int createOrder(Order order);
}
商品信息的mapper
package com.xizi.miaosha.mapper;
import com.xizi.miaosha.pojo.Stock;
import org.springframework.stereotype.Repository;
@Repository
public interface StockMapper {
//根据商品id查询库存信息的方法
Stock checkStock(Integer id);
/*
根据商品扣除库存
*/
int updateStock(Stock stock);
}
5.Service层
OrderService接口
package com.xizi.miaosha.service;
/**
* 订单业务
*/
public interface OrderService {
/*
处理秒杀的下单方法
*/
Integer kill(Integer id);
}
OrderServiceImpl 实现类
package com.xizi.miaosha.service.impl;
@Service
@Transactional
@Slf4j
public class OrderServiceImpl implements OrderService {
@Autowired
private StockMapper stockMapper;
@Autowired
private OrderMapper orderMapper;
@Override
public Integer kill(Integer id) {
//根据商品id效验ku库存
Stock stock = checkStock(id);
//扣除库存
updateSale(stock);
//创建订单
Integer orderId = createOrder(stock);
return orderId;
}
//效验库存
private Stock checkStock(Integer id){
Stock stock = stockMapper.checkStock(id);
if(stock.getSale().equals(stock.getCount())){
throw new RuntimeException("库存不足!!!");
}
return stock;
}
//扣除库存
//在业务层进行商品售卖+1操作
private void updateSale(Stock stock){
stock.setSale(stock.getSale()+1);
int updateRows = stockMapper.updateStock(stock);
if(updateRows==0){
throw new RuntimeException("抢购失败,请重试!!!");
}
}
//创建订单
private Integer createOrder(Stock stock){
Order order = new Order();
order.setSid(stock.getId()).setName(stock.getName()).setCreateTime(new Date());
orderMapper.createOrder(order);
return order.getId();
}
}
6.Controller层
@RestController
@RequestMapping("/stock")
@Slf4j
public class StockController {
@Autowired
private OrderService orderService;
//开发秒杀方法
@RequestMapping(value = "/kill0",method = RequestMethod.GET)
public String kill0(Integer id){
try {
System.out.println("秒杀商品的id: "+id);
//根据秒杀的商品id 去调用秒杀业务
Integer orderId = orderService.kill(id);
return "秒杀成功,订单id为:"+orderId;
} catch (Exception e) {
e.printStackTrace();
return e.getMessage();
}
}
}
7.正常测试
正常测试没有问题
8.使用Jmeter进行压力测试
配置当前接口 配置了2000个线程数
- jmeter压力测试 jmeter -n -t [文件地址]
出现超卖现象 卖出了143 大于实际的20
9.解决超卖现象
1.第一种:悲观锁 加同步代码块 效率低
//开发秒杀方法 使用悲观锁防止超卖
@RequestMapping(value = "/kill0",method = RequestMethod.GET)
public String kill0(Integer id){
try {
//悲观锁 同步代码块 同步执行 效率降低
//保证当前线程得执行比事务大
synchronized(this){
System.out.println("秒杀商品的id: "+id);
//根据秒杀的商品id 去调用秒杀业务
Integer orderId = orderService.kill(id);
return "秒杀成功,订单id为:"+orderId;
}
} catch (Exception e) {
e.printStackTrace();
return e.getMessage();
}
}
Jmeter测试 2000线程数 解决商品超卖
注意事项
如果使用synchronized方法进行同步处理 该业务上使用了注解 @Transactional 会出现超卖问题异常问题 @Transactional 事务注解带有同步得功能 当前事务同步能力大于 synchronized方法
2. 第二种:乐观锁 Version版本 效率高
实际上是把主要防止超卖问题交给数据库解决,利用数据库中定义的version字段以及数据库中的事务实现在并发情况下商品的超卖问题
在OrderServiceImpl 修改扣除库存方法
//扣除库存
private void updateSale(Stock stock){
//在sql层面完成销量的+1 和版本号的+1 并且根据商品id和版本号同时查询更新的商品
int updateRows = stockMapper.updateStock(stock);
if(updateRows==0){
throw new RuntimeException("抢购失败,请重试!!!");
}
}
修改StockMapper.xml 更新库存方法
<update id="updateStock" parameterType="com.xizi.miaosha.pojo.Stock" >
update stock set
sale=sale+1 ,
version=version+1
where
id=#{id} and
version=#{version}
</update>
添加新的接口进行测试
//开发秒杀方法 使用乐观锁防止超卖
@RequestMapping(value = "/kill",method = RequestMethod.GET)
public String kill(Integer id){
try {
//悲观锁 同步代码块 同步执行 效率降低
//保证当前线程得执行比事务大
// synchronized(this){
System.out.println("秒杀商品的id: "+id);
//根据秒杀的商品id 去调用秒杀业务
Integer orderId = orderService.kill(id);
return "秒杀成功,订单id为:"+orderId;
// }
} catch (Exception e) {
e.printStackTrace();
return e.getMessage();
}
}
测试结果 解决商品超卖问题
三 .接口限流
限流:是对某一时间窗口内的请求数进行限制,保持系统的可用性和稳定性,防止因流量暴增而导致的系统运行缓慢或宕机
- 1 接口限流
在面临高并发的抢购请求时,我们如果不对接口进行限流,可能会对后台系统造成极大的压力。大量的请求抢购成功时需要调用下单的接口,过多的请求打到数据库会对系统的稳定性造成影响。
- 2 如何解决接口限流
常用的限流算法有令牌桶和和漏桶(漏斗算法),而Google开源项目Guava中的RateLimiter使用的就是令牌桶控制算法。在开发高并发系统时有三把利器用来保护系统:缓存、降级和限流
- 缓存:缓存的目的是提升系统访问速度和增大系统处理容量
- 降级:降级是当服务器压力剧增的情况下,根据当前业务情况及流量对一些服务和页面有策略的降级,以此释放服务器资源以保证核心任务的正常运行
- 限流:限流的目的是通过对并发访问/请求进行限速,或者对一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务、排队或等待、降级等处理。
- 3 令牌桶和漏斗算法
- 漏斗算法:漏桶算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率。
- 令牌桶算法:最初来源于计算机网络。在网络传输数据时,为了防止网络拥塞,需限制流出网络的流量,使流量以比较均匀的速度向外发送。令牌桶算法就实现了这个功能,可控制发送到网络上数据的数目,并允许突发数据的发送。大小固定的令牌桶可自行以恒定的速率源源不断地产生令牌。如果令牌不被消耗,或者被消耗的速度小于产生的速度,令牌就会不断地增多,直到把桶填满。后面再产生的令牌就会从桶中溢出。最后桶中可以保存的最大令牌数永远不会超过桶的大小。这意味,面对瞬时大流量,该算法可以在短时间内请求拿到大量令牌,而且拿令牌的过程并不是消耗很大的事情。
3.4 令牌桶简单使用
- 导入依赖
<!--google开源工具类 RateLimter令牌桶的实现-->
<!--使用令牌桶算法导入依赖-->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>28.2-jre</version>
</dependency>
在Controller层添加方法
//创建令牌桶的实例
//每次令牌桶放行10个请求
private RateLimiter rateLimiter= RateLimiter.create(10);
//开发秒杀方法 使用乐观锁防止超卖+令牌桶算法限流
@RequestMapping(value = "/killtoken",method = RequestMethod.GET)
public String killtoken(Integer id){
//1.没有获取到token请求一直知道获取到token 令牌
//log.info("等待的时间: "+ rateLimiter.acquire());
//加入令牌桶的限流措施
//2.设置一个等待时间,如果在等待的时间内获取到了token 令牌,则处理业务,如果在等待时间内没有获取到响应token则抛弃
//设置超过两秒没有拿到令牌 抛弃请求
if(!rateLimiter.tryAcquire(2,TimeUnit.SECONDS )){
log.info("抛弃的请求:抢购失败,当前秒杀活动过于火爆,请重试");
return "抢购失败,当前秒杀活动过于火爆,请重试";
}
System.out.println("秒杀商品的id: "+id);
try {
//根据秒杀的商品id 去调用秒杀业务
Integer orderId = orderService.kill(id);
System.out.println("秒杀成功,订单id为:"+orderId);
return "秒杀成功,订单id为:"+orderId;
} catch (Exception e) {
e.printStackTrace();
return e.getMessage();
}
}
测试 令牌桶算法限流
四. 限时抢购的实现
使用Redis来记录秒杀商品的时间,对秒杀过期的请求进行拒绝处理!
- 启动 Redis 服务
- 将秒杀商品放入Redis并设置超时
这里我们使用String类型 以kill + 商品id作为key 以商品id作为value,设置180秒超时(可随意设置时间)
127.0.0.1:6379> set kill1 1 EX 180
OK
- 导入Redis依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
- 修改配置连接redis
spring.redis.port=6379
spring.redis.host=localhost
spring.redis.database=0
- 使用redis控制抢购超时的请求 OrderServiceImpl层中修改
@Service
@Transactional
@Slf4j
public class OrderServiceImpl implements OrderService {
@Autowired
private StockMapper stockMapper;
@Autowired
private OrderMapper orderMapper;
@Autowired
private UserMapper userMapper;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public Integer kill(Integer id) {
// 校验redis中秒杀商品是否超时
if(!stringRedisTemplate.hasKey("kill"+id)){
throw new RuntimeException("当前商品的抢购活动已经结束了----");
}
//根据商品id效验ku库存
Stock stock = checkStock(id);
//扣除库存
updateSale(stock);
//创建订单
Integer orderId = createOrder(stock);
return orderId;
}
测试 在设置的180s后秒杀接口关闭
五. 抢购接口隐藏(MD5加密)
防止抢购的链接被不断地请求,只要稍微写点爬虫代码,模拟一个抢购请求,就可以不通过点击下单按钮,直接在代码中请求我们的接口,完成下单,可以写一些脚本抢购各种秒杀商品。
将抢购接口进行隐藏,抢购接口隐藏(接口加盐)的具体做法:
- 每次点击秒杀按钮,先从服务器获取一个秒杀验证值(接口内判断是否到秒杀时间)。
- Redis以缓存用户ID和商品ID为Key,秒杀地址为Value缓存验证值
- 用户请求秒杀商品的时候,要带上秒杀验证值进行校验。
1.创建用户表
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
`password` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
2. User实体层
@Data
@AllArgsConstructor
@NoArgsConstructor
@Accessors(chain = true)
public class User {
private Integer id;
private String name;
private String password;
}
3. UserMapper
package com.xizi.miaosha.mapper;
import com.xizi.miaosha.pojo.User;
import org.springframework.stereotype.Repository;
@Repository
public interface UserMapper {
//通过id 查询用户
User findById(Integer id);
}
4.UserMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.xizi.miaosha.mapper.UserMapper">
<!--//通过id 查询用户-->
<select id="findById" parameterType="Integer" resultType="User">
select id,name,password from user where id=#{id}
</select>
</mapper>
5.OrderServiceImpl 生成MD5签名
@Service
@Transactional
@Slf4j
public class OrderServiceImpl implements OrderService {
@Autowired
private StockMapper stockMapper;
@Autowired
private OrderMapper orderMapper;
@Autowired
private UserMapper userMapper;
@Autowired
private StringRedisTemplate stringRedisTemplate;
// //生成md5签名得方法
@Override
public String getMd5(Integer id, Integer userId) {
//验证userId 用户是否存在
User user = userMapper.findById(userId);
if(user==null){
throw new RuntimeException("用户信息不存在");
}
log.info("用户的信息:[{}]",user.toString());
//验证商品id 存放商品信息
Stock stock = stockMapper.checkStock(id);
if(stock==null){
throw new RuntimeException("商品信息不存在");
}
log.info("商品的信息:[{}]",stock.toString());
//生成md5签名 放入redis 服务
String hashKey="KEY_"+userId+id;
//随机盐
String key = DigestUtils.md5DigestAsHex((userId + id + "!XIZIzz").getBytes());
stringRedisTemplate.opsForValue().set(hashKey, key,240, TimeUnit.SECONDS);
log.info("Redis写入:[{}] [{}]",hashKey,key);
return key;
}
2. Controller层添加生成MD5方法
@RestController
@RequestMapping("/stock")
@Slf4j
public class StockController {
@Autowired
private OrderService orderService;
//生成md5值得方法
@RequestMapping("md5")
public String getMd5(Integer id,Integer userId){
String md5;
try {
md5=orderService.getMd5(id,userId);
} catch (Exception e) {
e.printStackTrace();
return "获取md5失败: "+e.getMessage();
}
return "获取md5信息为: "+md5;
}
3. 测试MD5
4.Controller创建一个携带md5下单接口
//开发秒杀方法 使用乐观锁防止超卖+令牌桶算法限流+md5加密(id+userId)
// 抢购接口隐藏 不能直接访问 必须先进行MD5加密存入redis 在请求接口的时候比较MD5是否相等
@RequestMapping(value = "/killtokenmd5",method = RequestMethod.GET)
public String killtokenmd5(Integer id,Integer userId,String md5){
System.out.println("秒杀商品的id: "+id);
//加入令牌桶的限流措施
if(!rateLimiter.tryAcquire(2,TimeUnit.SECONDS )){
log.info("抛弃的请求:抢购失败,当前秒杀活动过于火爆,请重试");
return "抢购失败,当前秒杀活动过于火爆,请重试";
}
try {
//根据秒杀的商品id 去调用秒杀业务
Integer orderId = orderService.kill(id,userId,md5);
System.out.println("秒杀成功,订单id为:"+orderId);
return "秒杀成功,订单id为:"+orderId;
} catch (Exception e) {
e.printStackTrace();
return e.getMessage();
}
}
5. OrderService层重载kill()方法
/**
* 订单业务
*/
public interface OrderService {
/*
处理秒杀的下单方法
*/
Integer kill(Integer id);
//生成md5签名得方法
String getMd5(Integer id, Integer userId);
//用来处理秒杀的下单方法 并返回订单id 加入 md5接口
Integer kill(Integer id, Integer userId, String md5);
}
6.OrderServiceImpl 实现层
//用来处理秒杀的下单方法 并返回订单id 加入 md5接口
@Override
public Integer kill(Integer id, Integer userId, String md5) {
// 校验redis中秒杀商品是否超时
if(!stringRedisTemplate.hasKey("kill"+id)){
throw new RuntimeException("当前商品的抢购活动已经结束了----");
}
//先验证签名
String hashKey="KEY_"+userId+id;
String s = stringRedisTemplate.opsForValue().get(hashKey);
if(s==null){
throw new RuntimeException("没有携带验证签名,请求不合法");
}
if(!s.equals(md5)){
throw new RuntimeException("当前请求数据不合法,请稍后再试!");
}
//根据商品id效验ku库存
Stock stock = checkStock(id);
//扣除库存
updateSale(stock);
//创建订单
Integer orderId = createOrder(stock);
return orderId;
}
7. 测试
六. 单用户限制频率
-
用redis给每个用户做访问统计,甚至是带上商品id,对单个商品做访问统计,这都是可行的。
-
我们先实现一个对用户的访问频率限制,我们在用户申请下单时,检查用户的访问次数,超过访问次数,则不让他下单!
1.UserService接口
package com.xizi.miaosha.service;
public interface UserService {
//向redis中写入用户访问次数
int saveUserCount(Integer userId);
//判断单位时间调用次数
boolean getUserCount(Integer userId);
}
2.UserServiceImpl 实现类
package com.xizi.miaosha.service.impl;
import com.xizi.miaosha.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
public class UserServiceImpl implements UserService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
//根据不同用户id生成调用次数的key
@Override
public int saveUserCount(Integer userId) {
String limitKey="LIMIT"+"_"+userId;
//获取redis中指定key的调用次数
String limitNum = stringRedisTemplate.opsForValue().get(limitKey);
int limit=-1;
if(limitNum==null){
//第一次调用放入redis中设置为0
stringRedisTemplate.opsForValue().set(limitKey, "0",3600, TimeUnit.SECONDS);
}else{
//不是第一次调用每次+1
limit= Integer.parseInt(limitNum) + 1;
stringRedisTemplate.opsForValue().set(limitKey, String.valueOf(limit),3600, TimeUnit.SECONDS);
}
return limit; //返回调用次数
}
@Override
public boolean getUserCount(Integer userId) {
String limitKey="LIMIT"+"_"+userId;
String limitNum = stringRedisTemplate.opsForValue().get(limitKey);
if(limitKey==null){
log.error("该用户没有申请验证值记录,异常");
return true;
}
return Integer.parseInt(limitNum)>10;
}
}
3.Controler创建接口
//开发秒杀方法 使用乐观锁防止超卖+令牌桶算法限流+md5加密(id+userId)+单用户次数调用频率
@RequestMapping(value = "/killtokenmd5limit",method = RequestMethod.GET)
public String killtokenmd5limit(Integer id,Integer userId,String md5){
System.out.println("秒杀商品的id: "+id);
//加入令牌桶的限流措施
if(!rateLimiter.tryAcquire(2,TimeUnit.SECONDS )){
log.info("抛弃的请求:抢购失败,当前秒杀活动过于火爆,请重试");
return "抢购失败,当前秒杀活动过于火爆,请重试";
}
try {
//加入单用户
int count = userService.saveUserCount(userId);
log.info("用户截止该次访问次数为:[{}]",count);
//进行判断
boolean userCount = userService.getUserCount(id);
if(userCount){
log.info("购买失败,超过频率限制!");
return "购买失败,超过频率限制!";
}
//根据秒杀的商品id 去调用秒杀业务
Integer orderId = orderService.kill(id,userId,md5);
System.out.println("秒杀成功,订单id为:"+orderId);
return "秒杀成功,订单id为:"+orderId;
} catch (Exception e) {
e.printStackTrace();
return e.getMessage();
}
}
4.Jmeter压力测试
七. GitHub源码
https://github.com/Y960303802/SpringBoot-Simple-