项目介绍: 小型秒杀项目。采用乐观锁防止超卖+令牌桶算法限流+md5签名+单用户频率访问限制
项目地址:SmallSecKill
前期准备
在数据库创建两张表
-
库存表
stock
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
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
等<dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.1.3</version></dependency><dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.47</version></dependency><dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.8</version> <optional>true</optional></dependency><!--数据源--><dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.1.21</version></dependency>
创建 controller、dao、entity、service包,编写相关文件
- 具体参考视频即可。
安装jmeter工具
-
具体参考视频。
-
运行命令:
jmeter -n -t [jmx file] -l [results file] -e -o [Path to web report folder]
超卖问题及解决方法
-
出现原因:并发的线程数量远远高于实际的库存数量,在不加锁的情况下,会出现超卖问题。
-
秒杀代码:
@Service@Transactionalpublic 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方法中添加。悲观锁只能让线程串行执行,严重降低效率,不推荐使用。
@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(); } }}
乐观锁解决超卖
-
秒杀业务代码:
@Service@Transactionalpublic 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
public interface StockDAO { //根据商品id查询库存信息 Stock checkStock(Integer id); //根据商品id扣除库存 int updateSale(Stock stock);}
-
OrderDAO
public interface OrderDAO { /** * 生成订单 * @param order */ void createOrder(Order order);}
-
-
mapper文件:
-
StockDAOMapper.xml
<!--根据秒杀商品的id查询库存--> <select id="checkStock" parameterType="int" resultType="Stock"> select * from stock where id = #{id} </select> <!--根据商品id更新库存--> <update id="updateSale" parameterType="Stock"> update stock set sale=sale+1, version=version+1 where id=#{id} and version = #{version} </update>
-
OrderDAOMapper.xml
<mapper namespace="com.qmh.dao.OrderDAO"> <!--创建订单--> <insert id="createOrder" parameterType="Order" useGeneratedKeys="true" keyProperty="id"> insert into stock_order values(#{id},#{sid},#{name},#{createDate}) </insert> </mapper>
-
接口限流
- 限流指对某一时间窗口内的请求进行限制,保持系统可用性和稳定性,防止因流量暴增而导致系统运行缓慢或宕机。
接口限流
- 在面临高并发的抢购请求时,如果不对接口进行限流,可能会对后台系统造成极大的压力。大量的请求抢购成功时需要调用下单的接口,过多的请求打到数据库会对系统的稳定性造成影响。
解决办法
-
常用的限流算法有令牌桶和漏桶算法。在开发高并发系统时,有三把利器保护系统:缓存、降级和限流。
-
缓存:缓存的目的是提升系统访问速度和增大系统处理容量。
-
降级:降级是当服务器压力剧增的情况下,根据当前业务情况及流量对一些服务和页面有策略的降级,以释放服务器资源保证核心业务的正常运行。
-
限流:限流的目的是通过对并发访问请求进行限速,一旦达到限制速率则可以拒绝服务、排队或等待、降级等处理。
-
令牌桶和漏桶算法
各种限流算法的介绍请参考:图解+代码|常见限流算法以及限流在单机分布式场景下的思考
-
漏桶算法:漏桶算法思路比较简单,请求先流入到漏桶里,漏桶以一定速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率。
-
令牌桶算法:大小固定的令牌桶自行以恒定速率源源不断产生令牌,如果令牌不被消耗,或者被消耗的速度小于产生的速度,令牌就会不断增加,直到把桶填满。后面再产生的令牌就会从桶中溢出。最后桶中可以保存的最大令牌数就不会超过桶的大小。这意味着,面对瞬间大流量,该算法可以在短时间内请求拿到大量令牌。
使用令牌桶算法实现乐观锁+限流
-
引入依赖
<dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>28.2-jre</version> </dependency>
-
测试令牌桶
-
jemeter工具,设置并发请求为1000,运行之后可以发现某些请求被限流,直接抛弃。
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 "成功"; } }
-
-
使用令牌桶实现限流
-
不能保证商品被全部售完。因为部分请求由于限流会被抛弃。
//创建令牌桶示例 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
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
-
修改后的秒杀代码:
@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(); } }
-
部分秒杀结果:
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缓存验证值。
-
用户请求秒杀商品的时候,要带上秒杀验证值进行校验。
-
-
添加用户表:
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进行秒杀@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实现方法:
@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; }
-
修改秒杀方法
@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(); } } @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
接口:public interface UserService { //向redis中写入用户访问次数 int saveUserCount(Integer userId); //判断单位时间调用次数 boolean getUserCount(Integer userId); }
-
userServiceImpl
实现类:@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代码:
/** * 乐观锁防止超卖+令牌桶算法限流+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(); } }
-
秒杀结果:
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 : 购买失败,超过频率限制