分布式限流

342 阅读4分钟

问题出现:在一个高并发系统中对流量的把控是非常重要的,当巨大的流量直接请求到我们的服务器上没多久就可能造成接口不可用,不处理的话甚至会造成整个应用不可用。

需求场景:我作为客户端要向kafka生产数据,而kafka的消费者则再源源不断的消费数据,并将消费的数据全部请求到web服务器,虽说做了负载(有4台web服务器)但业务数据的量也是巨大的,每秒钟可能有上万条数据产生。如果生产者直接生产数据的话极有可能把web服务器拖垮。

处理方法:对此就必须要做限流处理,每秒钟生产一定限额的数据到kafka,这样就能极大程度的保证web的正常运转。

限流本质:其实不管处理何种场景,本质都是降低流量保证应用的高可用。

常见算法

  1. 漏桶算法
  2. 令牌桶算法

漏桶算法:比较简单,就是将流量放入桶中,漏桶同时也按照一定的速率流出,如果流量过快的话就会溢出(漏桶并不会提高流出速率)。溢出的流量则直接丢弃。

这种做法简单粗暴。

漏桶算法虽说简单,但却不能应对实际场景,比如突然暴增的流量。这时就需要用到令牌桶算法

令牌桶算法:令牌桶会以一个恒定的速率向固定容量大小桶中放入令牌,当有流量来时则取走一个或多个令牌。当桶中没有令牌则将当前请求丢弃或阻塞。相比之下令牌桶可以应对一定的突发流量。

RateLimiter实现

对于令牌桶的代码实现,可以直接使用Guava包中的RateLimiter。

@Override 
public BaseResponse<UserResVO> getUserByFeignBatch(@RequestBody UserReqVO userReqVO) { 
 //调用远程服务 
 OrderNoReqVO vo = new OrderNoReqVO() ; 
 vo.setReqNo(userReqVO.getReqNo()); 
 RateLimiter limiter = RateLimiter.create(2.0) ; 
 //批量调用 
 for (int i = 0 ;i< 10 ; i++){ 
 double acquire = limiter.acquire(); 
 logger.debug("获取令牌成功!,消耗=" + acquire); 
 BaseResponse<OrderNoResVO> orderNo = orderServiceClient.getOrderNo(vo); 
 logger.debug("远程返回:"+JSON.toJSONString(orderNo)); 
 } 
 UserRes userRes = new UserRes() ; 
 userRes.setUserId(123); 
 userRes.setUserName("张三"); 
 userRes.setReqNo(userReqVO.getReqNo()); 
 userRes.setCode(StatusEnum.SUCCESS.getCode()); 
 userRes.setMessage("成功"); 
 return userRes ; 
} 

详见此。

调用结果如下:

代码可以看出以每秒向桶中放入两个令牌,请求一次消耗一个令牌。所以每秒钟只能发送两个请求。按照图中的时间来看也确实如此(返回值是获取此令牌所消耗的时间,差不多也是每500ms一个)。

使用RateLimiter有几个值得注意的地方:

允许先消费,后付款,意思就是它可以来一个请求的时候一次性取走几个或者是剩下所有的令牌甚至多取,但是后面的请求就得为上一次请求买单,它需要等待桶中的令牌补齐之后才能继续获取令牌。

总结

针对于单个应用的限流 RateLimiter 够用了,如果是分布式环境可以借助 Redis 来完成。

实现原理

实现原理其实很简单。既然要达到分布式全局限流的效果,那自然需要一个第三方组件来记录请求的次数。

其中 Redis 就非常适合这样的场景。

  • 每次请求时将当前时间(精确到秒)作为 Key 写入到 Redis 中,超时时间设置为 2 秒,Redis 将该 Key 的值进行自增。
  • 当达到阈值时返回错误。
  • 写入 Redis 的操作用 Lua 脚本来完成,利用 Redis 的单线程机制可以保证每个 Redis 请求的原子性。

Lua 脚本如下:

--lua 下标从 1 开始-- 限流 keylocal key = KEYS[1]-- 限流大小local limit = tonumber(ARGV[1])-- 获取当前流量大小local curentLimit = tonumber(redis.call('get', key) or "0")if curentLimit + 1 > limit then -- 达到限流大小 返回 return 0;else -- 没有达到阈值 value + 1 redis.call("INCRBY", key, 1) redis.call("EXPIRE", key, 2) return curentLimit + 1end

--lua 下标从 1 开始 
-- 限流 key 
local key = KEYS[1] 
-- 限流大小 
local limit = tonumber(ARGV[1]) 
-- 获取当前流量大小 
local curentLimit = tonumber(redis.call('get', key) or "0") 
if curentLimit + 1 > limit then 
 -- 达到限流大小 返回 
 return 0; 
else 
 -- 没有达到阈值 value + 1 
 redis.call("INCRBY", key, 1) 
 redis.call("EXPIRE", key, 2) 
 return curentLimit + 1 
end 

所以只需要在需要限流的地方调用该方法对返回值进行判断即可达到限流的目的。

当然这只是利用 Redis 做了一个粗暴的计数器,如果想实现类似于上文中的令牌桶算法可以基于 Lua 自行实现。