# SmallSecKill
**Repository Path**: mqliu/small-sec-kill
## Basic Information
- **Project Name**: SmallSecKill
- **Description**: 小型秒杀项目。采用乐观锁防止超卖+令牌桶算法限流+md5签名+单用户频率访问限制
- **Primary Language**: Java
- **License**: Apache-2.0
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 3
- **Created**: 2021-09-23
- **Last Updated**: 2021-09-23
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# SamllSecKill
#### 介绍
小型秒杀项目。采用乐观锁防止超卖+令牌桶算法限流+md5签名+单用户频率访问限制
> 参考: [如何基于springboot优雅设计一个秒杀系统乐观锁解决超卖、Redis缓存、令牌桶桶限流等方案,已完结!](https://www.bilibili.com/video/BV13a4y1t7Wh)
### 前期准备
#### 在数据库创建两张表
- 库存表 `stock`
```sql
DROP TA`seckill`BLE IF EXISTS `stock`;
CREATE TABLE `stock`(
`id` int(11) unsigned not null auto_increment,
`name` varchar(50) not null default '' comment '名称',
`count` int(11) not null comment '库存',
`sale` int(11) not null comment '已售',
`version` int(11) not null comment '版本号',
primary key(`id`)
)engine=InnoDB DEFAULT CHARSET=utf8;
```
- 订单表 `order`
```sql
DROP TABLE IF EXISTS `stock_order`;
CREATE TABLE `stock_order`(
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`sid` INT(11) NOT NULL COMMENT '库存ID',
`name` VARCHAR(30) NOT NULL DEFAULT '' COMMENT '商品名称',`stock_order`
`create_name` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY(`id`)
)ENGINE=INNODB DEFAULT CHARSET=utf8;
```
#### 安装依赖
- `mysql、mybatis`等
```xml
org.mybatis.spring.boot
mybatis-spring-boot-starter
2.1.3
mysql
mysql-connector-java
5.1.47
org.projectlombok
lombok
1.18.8
true
com.alibaba
druid
1.1.21
```
#### 创建 controller、dao、entity、service包,编写相关文件
- 具体参考视频即可。
#### 安装jmeter工具
- 具体参考视频。
- 运行命令:`jmeter -n -t [jmx file] -l [results file] -e -o [Path to web report folder]`
### 超卖问题及解决方法
- 出现原因:并发的线程数量远远高于实际的库存数量,在不加锁的情况下,会出现超卖问题。
- 秒杀代码:
```java
@Service
@Transactional
public class OrderServiceImpl implements OrderService{
@Autowired
private StockDAO stockDAO;
@Autowired
private OrderDAO orderDAO;
@Override
public int seckill(Integer id) {
//根据商品id校验库存
Stock stock = stockDAO.checkStock(id);
if(stock.getSale().equals(stock.getCount())){
throw new RuntimeException("库存不足");
}else{
//扣除库存
stock.setSale(stock.getSale()+1);
stockDAO.updateSale(stock);
//创建订单
Order order = new Order();
order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
orderDAO.createOrder(order);
return order.getId();
}
}
}
```
#### 悲观锁解决超卖
- 使用悲观锁方式可以解决超卖。但不能在秒杀的方法上加,因为synchronized的作用域小于@Transactional注解,这样导致解锁之后,事务还没来得及提交,另外一个线程的事务读到数据库中未更新的值,出现了超卖问题。
- 原因分析:
- 库存表中某件商品`count`(库存)为100件,`sale`(已售)0件。
- 线程1启动一个事务,执行完synchronized修饰的`seckill`方法后,还未来得及提交(数据库没有被修改)。
- 此时线程2启动一个事务,进入synchronized修饰的`seckill`方法,此时读取到的`sale=0`。
- 线程1提交事务,修改`sale=1`,在订单表中新增了一条数据。
- 线程2执行完`seckill`后,修改`sale=1`,并在订单表中新增了一条数据。
- 最后导致1件商品被卖出了两次,即超卖现象。

- 正确添加方式是在外部的controller方法中添加。悲观锁只能让线程串行执行,严重降低效率,不推荐使用。
```java
@RestController
@RequestMapping("/stock")
public class StackController {
@Autowired
private OrderService orderService;
@GetMapping("/kill")
public String secKill(Integer id){
try {
//使用悲观锁
synchronized (this) {
int orderId = orderService.seckill(id);
return "秒杀成功,订单id为:" + String.valueOf(orderId);
}
} catch (Exception e) {
e.printStackTrace();
return e.getMessage();
}
}
}
```
#### 乐观锁解决超卖
- 秒杀业务代码:
```java
@Service
@Transactional
public class OrderServiceImpl implements OrderService{
@Autowired
private StockDAO stockDAO;
@Autowired
private OrderDAO orderDAO;
//秒杀
@Override
public int seckill(Integer id) {
Stock stock = checkStock(id);
updateSale(stock);
return createOrder(stock);
}
//校验库存
private Stock checkStock(Integer id){
Stock stock = stockDAO.checkStock(id);
if(stock.getSale().equals(stock.getCount())) {
throw new RuntimeException("库存不足");
}
return stock;
}
//扣除库存
private void updateSale(Stock stock){
//stock.setSale(stock.getSale()+1);
//在sql层面完成销量的+1,和版本号的+1,并根据商品id和版本号同时查询更新的商品。
int result = stockDAO.updateSale(stock);
if(result==0){
throw new RuntimeException("抢购失败,请重试");//必须要抛异常,事务可以回滚,否则继续执行下去
}
}
//创建订单
private Integer createOrder(Stock stock){
Order order = new Order();
order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
orderDAO.createOrder(order);
return order.getId();
}
}
```
- dao文件:
- `StockDAO`
```java
public interface StockDAO {
//根据商品id查询库存信息
Stock checkStock(Integer id);
//根据商品id扣除库存
int updateSale(Stock stock);
}
```
- `OrderDAO`
```java
public interface OrderDAO {
/**
* 生成订单
* @param order
*/
void createOrder(Order order);
}
```
- mapper文件:
- `StockDAOMapper.xml`
```xml
update stock set
sale=sale+1,
version=version+1
where
id=#{id}
and
version = #{version}
```
- `OrderDAOMapper.xml`
```xml
insert into stock_order values(#{id},#{sid},#{name},#{createDate})
```
### 接口限流
- 限流指对某一时间窗口内的请求进行限制,保持系统可用性和稳定性,防止因流量暴增而导致系统运行缓慢或宕机。
#### 接口限流
- 在面临高并发的抢购请求时,如果不对接口进行限流,可能会对后台系统造成极大的压力。大量的请求抢购成功时需要调用下单的接口,过多的请求打到数据库会对系统的稳定性造成影响。
#### 解决办法
- 常用的限流算法有令牌桶和漏桶算法。在开发高并发系统时,有三把利器保护系统:**缓存**、**降级**和**限流**。
- 缓存:缓存的目的是提升系统访问速度和增大系统处理容量。
- 降级:降级是当服务器压力剧增的情况下,根据当前业务情况及流量对一些服务和页面有策略的降级,以释放服务器资源保证核心业务的正常运行。
- 限流:限流的目的是通过对并发访问请求进行限速,一旦达到限制速率则可以拒绝服务、排队或等待、降级等处理。
#### 令牌桶和漏桶算法
> 各种限流算法的介绍请参考:[图解+代码|常见限流算法以及限流在单机分布式场景下的思考](https://www.jianshu.com/p/40d3f44122b2)
- 漏桶算法:漏桶算法思路比较简单,请求先流入到漏桶里,漏桶以一定速度出水,当水流入速度过大会直接溢出,可以看出**漏桶算法能强行限制数据的传输速率**。

- 令牌桶算法:大小固定的令牌桶自行以恒定速率源源不断产生令牌,如果令牌不被消耗,或者被消耗的速度小于产生的速度,令牌就会不断增加,直到把桶填满。后面再产生的令牌就会从桶中溢出。最后桶中可以保存的最大令牌数就不会超过桶的大小。**这意味着,面对瞬间大流量,该算法可以在短时间内请求拿到大量令牌。**

#### 使用令牌桶算法实现乐观锁+限流
- 引入依赖
```xml
com.google.guava
guava
28.2-jre
```
- 测试令牌桶
- jemeter工具,设置并发请求为1000,运行之后可以发现某些请求被限流,直接抛弃。
```java
public class StackController {
//创建令牌桶示例
private RateLimiter rateLimiter = RateLimiter.create(40); //每秒产生40个token
@GetMapping("/testToken")
public String testTokenBucket(Integer id){
//1.没有获取到令牌就一直阻塞,返回等待时间
// log.info("等待时间"+rateLimiter.acquire());
//2.设置一个等待时间,如果在等待时间内获取到了令牌就处理业务,否则抛弃该请求
if(!rateLimiter.tryAcquire(2, TimeUnit.SECONDS)){
System.out.println("当前请求被限流,直接抛弃");
return "失败";
}
System.out.println("处理业务");
return "成功";
}
}
```
- 使用令牌桶实现限流
- 不能保证商品被全部售完。因为部分请求由于限流会被抛弃。
```java
//创建令牌桶示例
private RateLimiter rateLimiter = RateLimiter.create(40); //每秒产生40个token
@GetMapping("/tokenKill")
public String secTokenKill(Integer id){
//令牌桶限流
if(!rateLimiter.tryAcquire(2,TimeUnit.SECONDS)){
log.info("抢购失败,当前秒杀活动过于火爆,请重试");
return "抢购失败,当前秒杀活动过于火爆,请重试";
}
try{
int orderId = orderService.seckill(id);
log.info("秒杀成功,订单id为:" + String.valueOf(orderId));
return "秒杀成功,订单id为:" + String.valueOf(orderId);
}catch (Exception e){
// e.printStackTrace();
return e.getMessage();
}
}
```
### 隐藏秒杀接口
- 需要考虑的一些问题:
- 应该在一定时间内进行秒杀处理,如何加入时间验证?-- 限时抢购
- 如何隐藏秒杀地址? -- 秒杀接口隐藏
- 秒杀后,如何限制单个用户的请求频率? --单用户限制频率
#### 限时抢购的实现
- 使用redis来记录秒杀商品的时间,对秒杀过期的请求进行拒绝处理。
- 引入依赖,并配置redis
```xml
org.springframework.boot
spring-boot-starter-data-redis
```
- 修改后的秒杀代码:
```java
@Service
@Transactional
@Slf4j
public class OrderServiceImpl implements OrderService{
@Autowired
private StockDAO stockDAO;
@Autowired
private OrderDAO orderDAO;
@Autowired
private StringRedisTemplate redisTemplate;
/**
在redis中设置key
**/
@PostConstruct
public void init(){
redisTemplate.opsForValue().set("kill1","1",10, TimeUnit.SECONDS);//设置商品的过期时间为10s
}
/**
引入redis实现限时抢购
**/
@Override
public int seckill(Integer id) {
//校验redis中秒杀商品是否超时
if(!redisTemplate.hasKey("kill"+id)){
log.info("该商品的秒杀活动已经结束了");
throw new RuntimeException("该商品的秒杀活动已经结束了");
}
Stock stock = checkStock(id);
updateSale(stock);
return createOrder(stock);
}
//校验库存
private Stock checkStock(Integer id){
Stock stock = stockDAO.checkStock(id);
if(stock.getSale().equals(stock.getCount())) {
throw new RuntimeException("库存不足");
}
return stock;
}
//扣除库存
private void updateSale(Stock stock){
//stock.setSale(stock.getSale()+1);
//在sql层面完成销量的+1,和版本号的+1,并根据商品id和版本号同时查询更新的商品。
int result = stockDAO.updateSale(stock);
if(result==0){
throw new RuntimeException("抢购失败,请重试");//必须要抛异常,事务可以回滚,否则继续执行下去
}
}
//创建订单
private Integer createOrder(Stock stock){
Order order = new Order();
order.setSid(stock.getId()).setName(stock.getName()).setCreateDate(new Date());
orderDAO.createOrder(order);
return order.getId();
}
}
```
- 部分秒杀结果:
```bash
2020-10-20 20:33:33.249 INFO 16116 --- [io-8080-exec-36] com.qmh.controller.StackController : 秒杀成功,订单id为:2889
2020-10-20 20:33:33.510 INFO 16116 --- [io-8080-exec-41] com.qmh.controller.StackController : 秒杀成功,订单id为:2890
2020-10-20 20:33:33.614 INFO 16116 --- [io-8080-exec-48] com.qmh.service.OrderServiceImpl : 该商品的秒杀活动已经结束了
2020-10-20 20:33:33.668 INFO 16116 --- [io-8080-exec-49] com.qmh.service.OrderServiceImpl : 该商品的秒杀活动已经结束了
2020-10-20 20:33:33.724 INFO 16116 --- [io-8080-exec-50] com.qmh.service.OrderServiceImpl : 该商品的秒杀活动已经结束了
2020-10-20 20:33:33.778 INFO 16116 --- [io-8080-exec-51] com.qmh.service.OrderServiceImpl : 该商品的秒杀活动已经结束了
2020-10-20 20:33:33.778 INFO 16116 --- [io-8080-exec-52] com.qmh.service.OrderServiceImpl : 该商品的秒杀活动已经结束了
2020-10-20 20:33:33.815 INFO 16116 --- [io-8080-exec-45] com.qmh.controller.StackController : 秒杀成功,订单id为:2891
```
#### 抢购接口隐藏
- 抢购接口隐藏(接口加盐)的具体做法:
- 每次点击秒杀按钮,先从服务器获取一个秒杀验证值。
- redis以缓存用户ID和商品ID为key,秒杀地址为value缓存验证值。
- 用户请求秒杀商品的时候,要带上秒杀验证值进行校验。
- 添加用户表:
```sql
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user`(
`id` INT(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` VARCHAR(80) DEFAULT NULL COMMENT '用户名',
`password` VARCHAR(40) DEFAULT NULL COMMENT '用户密码',
PRIMARY KEY(`id`)
);
```
- 控制器中添加生成`md5`的方法:用户请求该接口获得md5,携带md5进行秒杀
```java
@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;
}
```
- md5实现方法:
```java
@Override
public String getMd5(Integer id, Integer userId) {
//验证userId
User user = userDAO.findById(userId);
if(user==null) throw new RuntimeException("用户信息不存在");
log.info("用户信息:[{}]",user.toString());
//验证id
Stock stock = stockDAO.checkStock(id);
if(stock==null) throw new RuntimeException("商品信息不合法");
log.info("商品信息:[{}]",stock.toString());
//生成hashKey
String hashKey = "KEY_"+userId+"_"+id;
//生成md5, 其中!jskf是盐
String key = DigestUtils.md5DigestAsHex((userId+id+"!jskf").getBytes());
//放入redis中
redisTemplate.opsForValue().set(hashKey,key,120,TimeUnit.SECONDS);
return key;
}
```
- 修改秒杀方法
```java
@GetMapping("/tokenKill")
public String secTokenKill(Integer id,Integer userId,String md5){
//令牌桶限流
if(!rateLimiter.tryAcquire(3,TimeUnit.SECONDS)){
log.info("抢购失败,当前秒杀活动过于火爆,请重试");
return "抢购失败,当前秒杀活动过于火爆,请重试";
}
try{
int orderId = orderService.seckill(id,userId,md5);
log.info("秒杀成功,订单id为:" + String.valueOf(orderId));
return "秒杀成功,订单id为:" + String.valueOf(orderId);
}catch (Exception e){
// e.printStackTrace();
return e.getMessage();
}
}
```
```java
@Override
public int seckill(Integer id, Integer userId, String md5) {
//校验redis中秒杀商品是否超时
if(!redisTemplate.hasKey("kill"+id)){
log.info("该商品的秒杀活动已经结束了");
throw new RuntimeException("该商品的秒杀活动已经结束了");
}
//验证签名
String hashKey = "KEY_"+userId+"_"+id;
String s = redisTemplate.opsForValue().get(hashKey);
if(s==null) throw new RuntimeException("没有携带签名");
if(!md5.equals(s)){
throw new RuntimeException("当前请求数据不合法");
}
Stock stock = checkStock(id);
updateSale(stock);
return createOrder(stock);
}
```
#### 单用户限制频率
- 用redis对每个用户做访问统计,甚至带上商品id,对单个商品进行访问统计。
- 具体实现:在用户申请下单时,检查用户的访问次数,超过访问次数就不让他下单。
- 新增`userService`接口:
```java
public interface UserService {
//向redis中写入用户访问次数
int saveUserCount(Integer userId);
//判断单位时间调用次数
boolean getUserCount(Integer userId);
}
```
- `userServiceImpl`实现类:
```java
@Slf4j
public class UserServiceImpl implements UserService{
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public int saveUserCount(Integer userId) {
//根据不同用户id生成调用次数的key
String limitKey = "LIMIT"+"_"+userId;
//获取调用次数
String limitNum = redisTemplate.opsForValue().get(limitKey);
int limit = -1;
if(limitNum==null){
redisTemplate.opsForValue().set(limitKey,"0",3600, TimeUnit.SECONDS);
}else{
limit = Integer.parseInt(limitNum)+1;
redisTemplate.opsForValue().set(limitKey,String.valueOf(limit),3600,TimeUnit.SECONDS);
}
return limit;
}
@Override
public boolean getUserCount(Integer userId) {
//根据不同用户id生成调用次数的key
String limitKey = "LIMIT"+"_"+userId;
//获取调用次数
String limitNum = redisTemplate.opsForValue().get(limitKey);
if(limitNum==null){
log.error("该用户没有访问,疑似异常");
return true;
}
return Integer.parseInt(limitNum)>10; //一个用户一小时内只能调用10次
}
}
```
- 修改秒杀的controller代码:
```java
/**
* 乐观锁防止超卖+令牌桶算法限流+md5签名+单用户频率访问限制
* @param id
* @param userId
* @param md5
* @return
*/
@GetMapping("/kill")
public String seckill(Integer id,Integer userId,String md5){
//令牌桶限流
if(!rateLimiter.tryAcquire(3,TimeUnit.SECONDS)){
log.info("抢购失败,当前秒杀活动过于火爆,请重试");
return "抢购失败,当前秒杀活动过于火爆,请重试";
}
try{
//单用户调用接口频率限制
int count = userService.saveUserCount(userId);
log.info("用户目前的访问次数为:[{}]",count);
boolean isBanned = userService.getUserCount(userId);
if(isBanned){
log.info("购买失败,超过频率限制");
return "购买失败,超过频率限制";
}
//调用秒杀业务
int orderId = orderService.seckill(id,userId,md5);
log.info("秒杀成功,订单id为:" + String.valueOf(orderId));
return "秒杀成功,订单id为:" + String.valueOf(orderId);
}catch (Exception e){
// e.printStackTrace();
return e.getMessage();
}
}
```
- 秒杀结果:
```bash
2020-10-20 22:15:24.470 INFO 19792 --- [io-8080-exec-63] com.qmh.controller.StackController : 用户目前的访问次数为:[10]
2020-10-20 22:15:24.470 INFO 19792 --- [io-8080-exec-64] com.qmh.controller.StackController : 用户目前的访问次数为:[10]
2020-10-20 22:15:24.521 INFO 19792 --- [io-8080-exec-22] com.qmh.service.OrderServiceImpl : 该商品的秒杀活动已经结束了
2020-10-20 22:15:24.521 INFO 19792 --- [io-8080-exec-65] com.qmh.controller.StackController : 用户目前的访问次数为:[10]
2020-10-20 22:15:24.574 INFO 19792 --- [io-8080-exec-63] com.qmh.controller.StackController : 购买失败,超过频率限制
2020-10-20 22:15:24.574 INFO 19792 --- [io-8080-exec-67] com.qmh.controller.StackController : 用户目前的访问次数为:[11]
2020-10-20 22:15:24.574 INFO 19792 --- [io-8080-exec-23] com.qmh.service.OrderServiceImpl : 该商品的秒杀活动已经结束了
2020-10-20 22:15:24.574 INFO 19792 --- [io-8080-exec-66] com.qmh.controller.StackController : 用户目前的访问次数为:[11]
2020-10-20 22:15:24.574 INFO 19792 --- [io-8080-exec-64] com.qmh.controller.StackController : 购买失败,超过频率限制
```