本文是秒杀系统的第二篇,主要讲解接口限流措施。接口限流其实定义也非常广,接口限流本身也是系统安全防护的一种措施,在面临高并发的请购请求时,我们如果不对接口进行限流,可能会对后台系统造成极大的压力,尤其是对于下单的接口,过多的请求打到数据库会对系统的稳定性造成影响。所以对于秒杀系统:
- 会尽量选择独立于公司其他后端系统之外进行单独部署,以免秒杀业务崩溃影响到其他系统
- 除了独立部署秒杀业务之外,我们能够做的就是尽量让后台系统稳定优雅的处理大量请求。
列举几种容易理解的接口限流的措施:
- 令牌桶限流
- 单用户访问频率限流
- 抢购接口隐藏
因为篇幅会比较长,所以会分两篇文章来进行讲解,本篇主要讲令牌桶限流,后面两种我们一并在后面的一篇文章介绍。
令牌桶限流
令牌桶限流算法
令牌桶算法最初来源于计算机网络。在网络传输数据时,为了防止网络拥塞,需限制流出网络的流量,使流量以比较均匀的速度向外发送。令牌桶算法就实现了这个功能,可控制发送到网络上数据的数目,并允许突发数据的发送。
如图,大小固定的令牌桶可自行以恒定的速率源源不断地产生令牌。如果令牌不被消耗,或者被消耗的速度小于产生的速度,令牌就会不断地增多,直到把桶填满。后面再产生的令牌就会从桶中溢出。最后桶中可以保存的最大令牌数永远不会超过桶的大小。如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。
令牌桶算法与漏桶算法
漏桶算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率。
漏桶算法与令牌桶算法在表面看起来类似,很容易将两者混淆。但事实上,这两者具有截然不同的特性,且为不同的目的而使用。漏桶算法与令牌桶算法的区别在于:
- 漏桶算法能够强行限制数据的传输速率,令牌桶算法能够在限制数据的平均传输速率的同时还允许某种程度的突发传输;
- 在某些情况下,漏桶算法不能够有效地使用网络资源。因为漏桶的漏出速率是固定的,所以即使网络中没有发生拥塞,漏桶算法也不能使某一个单独的数据流达到端口速率。因此,漏桶算法对于存在突发特性的流量来说缺乏效率。而令牌桶算法则能够满足这些具有突发特性的流量。
并不能说明令牌桶一定比漏洞好,它们使用场景不一样:
- 令牌桶可以用来保护自己,主要用来对调用者频率进行限流,为的是让自己不被打垮。所以如果自己本身有处理能力的时候,如果流量突发(实际消费能力强于配置的流量限制),那么实际处理速率可以超过配置的限制。
- 漏桶算法用来保护他人,也就是保护他所调用的系统。主要场景是,当调用的第三方系统本身没有保护机制,或者有流量限制的时候,我们的调用速度不能超过他的限制,由于我们不能更改第三方系统,所以只有在主调方控制。这个时候,即使流量突发,也必须舍弃。
限流工具类RateLimiter
Google开源工具包Guava提供了限流工具类RateLimiter,该类基于令牌桶算法来完成限流,非常易于使用。我们利用它在之前讲过的乐观锁抢购接口上增加该令牌桶限流代码:
@Controller
public class OrderController {
private static final Logger LOGGER = LoggerFactory.getLogger(OrderController.class);
@Autowired
private StockService stockService;
@Autowired
private OrderService orderService;
//每秒放行10个请求
RateLimiter rateLimiter = RateLimiter.create(10);
/**
* 乐观锁更新库存 + 令牌桶限流
* @param sid
* @return
*/
@RequestMapping("/createOptimisticOrder/{sid}")
@ResponseBody
public String createOptimisticOrder(@PathVariable int sid) {
// 阻塞式获取令牌
//LOGGER.info("等待时间" + rateLimiter.acquire());
// 非阻塞式获取令牌
if (!rateLimiter.tryAcquire(1000, TimeUnit.MILLISECONDS)) {
LOGGER.warn("你被限流了,真不幸,直接返回失败");
return "购买失败,库存不足";
}
int id;
try {
id = orderService.createOptimisticOrder(sid);
LOGGER.info("购买成功,剩余库存为: [{}]", id);
} catch (Exception e) {
LOGGER.error("购买失败:[{}]", e.getMessage());
return "购买失败,库存不足";
}
return String.format("购买成功,剩余库存为:%d", id);
}
}
在代码中做了相关的解释。使用RateLimiter rateLimiter = RateLimiter.create(10)初始化令牌桶类,每秒放行10个请求。使用rateLimiter 获取令牌的方式主要有两种:
- 阻塞式获取令牌:使用rateLimiter.acquire()实现。请求进来后,若令牌桶里没有足够的令牌,就在这里阻塞住,等待令牌的发放;
- 非阻塞式获取令牌:使用rateLimiter.tryAcquire(1000, TimeUnit.MILLISECONDS)实现。请求进来后,若令牌桶里没有足够的令牌,会尝试等待设置好的时间(这里写了1000ms),其会自动判断在1000ms后,这个请求能不能拿到令牌,如果不能拿到,直接返回抢购失败。如果timeout设置为0,则等于阻塞时获取令牌。
我们使用JMeter设置200个线程,来同时抢购数据库里库存100个的iphone。相关结构和数据可以看教你从0到1搭建秒杀系统-防超卖。
令牌桶算法实践
首先使用非阻塞式获取令牌的方式进行操作,请求完以后来看看购买结果:
有数据可以看到,最终只有11个被卖出去了。在这种情况下请求能够没被限流的比率在11%左右。
可以看到,200个请求中没有被限流的请求里,由于乐观锁的原因,会出现一些并发更新数据库失败的问题,导致商品没有被卖出。我们再试一试令牌桶算法的阻塞式使用,我们将代码换成rateLimiter.acquire();,然后将数据库恢复成100个库存,订单表清零。开始请求:
可以看到,100个全部卖出。这里首先看一下操作结果和打印日志:
对照着请求的打印日志,有几个问题需要说明一下:
- 首先,所有请求进入了处理流程,但是被限流成每秒处理10个请求。
- 在刚开始的请求里,令牌桶里一下子被取了10个令牌,所以出现了第二张图中的,乐观锁并发更新失败,然而在后面的请求中,由于令牌一旦生成就被拿走,所以请求进来的很均匀,没有再出现并发更新库存的情况。这也符合“令牌桶”的定义,可以应对突发请求(只是由于乐观锁,所以购买冲突了)。而非“漏桶”的永远恒定的请求限制。
- 200个请求,在乐观锁的情况下,卖出了全部100个商品,如果没有该限流,而请求又过于集中的话,会卖不出去几个。
令牌桶限流算法说完了,我们再回头思考超卖的问题,在海量请求的场景下使用乐观锁,会导致大量的请求返回抢购失败,用户体验极差。然而使用悲观锁,比如数据库事务,则可以让数据库一个个处理库存数修改,修改成功后再迎接下一个请求,所以在不同情况下,应该根据实际情况使用悲观锁和乐观锁。两种锁各有优缺点,不能单纯的定义哪个好于哪个:
- 乐观锁比较适合数据修改比较少,读取比较频繁的场景,即使出现了少量的冲突,这样也省去了大量的锁的开销,故而提高了系统的吞吐量;
- 但是如果经常发生冲突(写数据比较多的情况下),上层应用不不断的retry,这样反而降低了性能,对于这种情况使用悲观锁就更合适。
悲观锁实践
我们为了在高流量下,能够更好更快的卖出商品,我们实现一个悲观锁(事务for update更新库存),看看悲观锁的结果如何。在Controller中,增加一个悲观锁卖商品接口:
/**
* 事务for update更新库存
* @param sid
* @return
*/
@RequestMapping("/createPessimisticOrder/{sid}")
@ResponseBody
public String createPessimisticOrder(@PathVariable int sid) {
int id;
try {
id = orderService.createPessimisticOrder(sid);
LOGGER.info("购买成功,剩余库存为: [{}]", id);
} catch (Exception e) {
LOGGER.error("购买失败:[{}]", e.getMessage());
return "购买失败,库存不足";
}
return String.format("购买成功,剩余库存为:%d", id);
}
在Service中,给该卖商品流程加上事务:
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED)
@Override
public int createPessimisticOrder(int sid){
//校验库存(悲观锁for update)
Stock stock = checkStockForUpdate(sid);
//更新库存
saleStock(stock);
//创建订单
int id = createOrder(stock);
return stock.getCount() - (stock.getSale());
}
/**
* 检查库存 ForUpdate
* @param sid
* @return
*/
private Stock checkStockForUpdate(int sid) {
Stock stock = stockService.getStockByIdForUpdate(sid);
if (stock.getSale().equals(stock.getCount())) {
throw new RuntimeException("库存不足");
}
return stock;
}
/**
* 更新库存
* @param stock
*/
private void saleStock(Stock stock) {
stock.setSale(stock.getSale() + 1);
stockService.updateStockById(stock);
}
/**
* 创建订单
* @param stock
* @return
*/
private int createOrder(Stock stock) {
StockOrder order = new StockOrder();
order.setSid(stock.getId());
order.setName(stock.getName());
int id = orderMapper.insertSelective(order);
return id;
}
这里使用Spring的事务,@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED),如果遇到回滚,则返回Exception,并且事务传播使用PROPAGATION_REQUIRED–支持当前事务,如果当前没有事务,就新建一个事务。
我们依然设置100个商品,清空订单表,开始用JMeter更改请求的接口/createPessimisticOrder/1,发起200个请求:
可以看到,200个请求,100个返回了抢购成功,100个返回了抢购失败。并且商品卖给了前100个进来的请求,十分的有序。所以,悲观锁在大量请求的请求下,有着更好的卖出成功率。但是需要注意的是,如果请求量巨大,悲观锁会导致后面的请求进行了长时间的阻塞等待,用户就必须在页面等待,很像是“假死”,可以通过配合令牌桶限流,或者是给用户显著的等待提示来优化。
猜你感兴趣:
教你从0到1搭建秒杀系统-防超卖
教你从0到1搭建秒杀系统-限流
教你从0到1搭建秒杀系统-抢购接口隐藏与单用户限制频率
教你从0到1搭建秒杀系统-缓存与数据库双写一致
教你从0到1搭建秒杀系统-Canal快速入门(番外篇)
教你从0到1搭建秒杀系统-订单异步处理
更多文章请点击:更多…