用令牌桶算法完成API接口限流

本文介绍了“令牌桶算法”,和使用lua+redis实现基于令牌桶算法的限流。

1. 限流需求的产生背景

软件开发时偶尔会面临高并发或突发流量,经典的情况是秒杀业务或者是某明星发了爆炸性的微博,很可能因为下游的服务器处理能力不足导致程序异常,甚至造成服务雪崩。

面对高并发或突发流量场景的解决方案之一是“限流”,通过在架构中的网关层进行限制单位时间内的最大请求数量,达到保护后端服务的作用。

熟悉淘宝双11的读者一定见过这个截图,这样的截图一般更容易出现在临近活动结束的时候,访问API的人太多,淘宝会在网关层进行限流。

Java怎么请求api接口不限流_后端

“令牌桶算法”是完成限流功能的常用方法,本人在开发API网关的“限流”模块时,尝试运用了“令牌桶算法”,到现在上线足有一年多,效果较好,鉴于开发成本较低,研发思路可以公式化照搬,且网上并没有类似的实践分享,于是写出此文向大家分享一些心得。

2. “限流”功能的期望效果

限流一般需要设定两个值,一个是“单位时间”,一个是“行动次数”,比如我限制我的后端API最多:

  • “1秒钟/10次”
  • 或者"1分钟/1000次"

如果要实现这种效果最简单的方案是使用“令牌桶算法”完成。

3. 什么是令牌桶算法

令牌桶算法的概念里总共有2个桶:

  • 一个桶叫“令牌桶”,里面装满了令牌,明确标明最多装有多少令牌,这些令牌就是调用的次数,调用一次API会消耗一个令牌,令牌没了将不能调用API。这就是单位时间内能调用多少次API,最多调用的次数即“令牌桶”中令牌的最大承载数。
  • 而另一个桶叫“补桶”,补桶里装有所有的令牌,令牌数等于这个API的“总可调用的次数”,“补桶”定期向“令牌桶”里补令牌,每次补令牌后“令牌桶”中的令牌的总数不能多于令牌桶的最大承载数。因为“令牌桶”用一次就花掉一个令牌,而补桶多久向令牌桶补一次令牌,即是“限流的单位时间”。两个桶进行协调合作就能完成限流。

在对API进行限流时,一般补桶中的令牌可以视为无穷大。

4. 实现令牌桶算法的研发思路

4.1. 技术选型

API调用的特性是高频、多次的,每次调用API前,会检查“令牌桶”中的令牌,并消耗令牌,将令牌数“-1”,所以该信息应该在内存中操作,又由于API网关或流量控制入口经常是集群部署方案,故建议将该信息放入NoSql中,这里选择的是redis进行管理“令牌桶中的令牌数”。

假设在流量高峰时,并发数会极多,故在对令牌桶的令牌数做“检查”和“减少”时,需要注意“原子性”问题。因为redis为了支持原子性的操作,可以允许执行lua脚本,故选择用lua脚本完成“消耗令牌”的操作。

4.2. 什么是“原子性”?

原子是化学反应不可再分的基本微粒。所以原子性指事务的不可分割性,一个事务的所有操作要么不间断地全部被执行,要么一个也没有执行。

比如在进行操作令牌时,大家看下,如果用下面这种方案,是否会出现原子性问题呢?

Java怎么请求api接口不限流_java_02

具体出现原子性问题的情况如下图,这与mysql的事物像:

Java怎么请求api接口不限流_Java怎么请求api接口不限流_03

上图在令牌桶中令牌仅剩1个的情况,同时来了多个请求,因为“检查”和“消耗令牌”的操作是非原子性的,所以我们应该将其作为原子(事物)执行。

4.3. lua in redis 解决原子性问题

lua是十分精巧的脚本语言,设计目的就是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。lua由标准C编写而成,代码简洁优美,几乎在所有操作系统和平台上都可以编译,运行。一个完整的lua解释器不过200k,在目前所有脚本引擎中,Lua的速度是最快的。这一切都决定了lua是作为嵌入式脚本的最佳选择。

比如有些游戏更新时,运行更新器后,更新器会从网络上down一段lua代码并执行,完成程序的更新。

使用redis中执行lua的好处是:

  1. 减少网络开销:可以将多个请求通过脚本的形式一次发送,减少网络时延和请求次数。
  2. 原子性的操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。因此在编写脚本的过程中无需担心会出现竞态条件,无需使用事务。
  3. 代码复用:客户端发送的脚步会永久存在redis中,这样,其他客户端可以复用这一脚本来完成相同的逻辑。

我们使用lua完成redis中的令牌桶操作后,将会解决高并发的问题:

Java怎么请求api接口不限流_Java怎么请求api接口不限流_04

其中因为Redis中的操作为原子性,故不用担心高并发问题。

4.4. 令牌桶设计

我们将api的名称作为redis的key,value为令牌桶中的令牌,限流时间为key的过期时间。这样如果key已过期代表上个限流单位已经过去(redis的特性key过期会不存在),则直接创建key,并扣除一次。如果未过期直接扣除令牌。

假如“api_name()”这个API的限流为“10次/1秒”,我们可以设置Redis的key=api_name,value=10,expires(过期时间)=1。

Java怎么请求api接口不限流_Java怎么请求api接口不限流_05

注意:在这里不用关心value是否会变为0以下,如果变为0以下则代表令牌用光了,则在声明周期内,每次访问会不断进行“减1”,则一直返回被限流状态。而key过期后重新访问,则会重新创建key并完成“补令牌”操作。

4.5. 符合业务的令牌桶优化

限流的单位不止为秒级,偶尔会较长如“10次/分”或“10次/小时”,这样势必面临相邻单位时间会共享次数的问题,举个例子,如果设置为“10次/小时”,而基于4.4节的设计,第一次访问API的时间在10:30,即10:30~11:30之间,最多允许访问10次。但这往往是解释不清的,比如提问:“限流是一小时10次,都从10点进入11点,次数不应该刷新了吗,为什么我还不能访问?”

所以在此基础,建议将redis中的key由“api名称”变为“api名称+当前时间”,如:第一次访问API时,key:“api_name:2021031901” 这样将会在当前小时内完成api的限流。

5. lua代码分享

local flag = redis.call('EXISTS', KEYS[1])
if flag == 0
    then
        redis.call('INCRBY',KEYS[1],ARGV[1]-1)
        redis.call('EXPIRE',KEYS[1],ARGV[2])
        return true
    end
local number =redis.call('DECR', KEYS[1])
if number >= 0
    then return true
    end
return false