又拍云网关速率限制实践

速率限制 (Rate Limit) 经过限制调用 API 的频率防止 API 过分使用,保护 API 免受意外或恶意的使用,在诸多业务场景中获得普遍应用。日前,又拍云系统开发工程师陈卓受邀在 Open Talk 公开课上做了题为《又拍云网关速率限制实践》的分享,详细解读当前经常使用的算法以及基于网关 nginx/openresty 的实现和配置细节。如下是直播分享内容整理,查看视频请点击阅读原文。html

网关速率限制是一种防护服务性措施,公共服务须要借其保护本身免受过分使用,使用速率限制主要有三个好处:nginx

  • 提高用户体验:用户在使用公共服务时总会面临一些资源加强和共享的问题,例如 CPU,当一个用户无论是有意或无心地过分使用 API 时,势必会对其余的用户形成一些影响。
  • 更加安全:咱们的服务、CPU、内存其实都是有必定的限制,过分访问势必会影响到服务的稳定性。假若有四个服务,每一个服务能承载 100 个请求,当其中一个服务超过 100 个请求时就可能会宕机,其它三个服务在接收到超过 100 个服务请求时,也会接着连续宕机,这会形成服务不可用。
  • 减小开销:如今不少服务都是放到公有云上,内存、CPU 和流量都是有成本的,有一些按量计费,使用多少花多少钱,这种状况会产生一些没必要要的开销。

RateLimit 的几种算法

首先介绍四种速率限制的算法,分别是漏桶(Leaky Bucket)、令牌桶(Token Bucket)、固定窗口(Fixed Windows)、滑动窗口(Sliding Windows),不少限制措施都是基于这些算法进行的。漏桶和令牌桶虽然直观理解看似不太同样,可是在底层实现中这两种算法很是类似,达到的效果差很少。固定窗口和滑动窗口属于另一类,滑动窗口是基于固定窗口作的。git

漏桶(Leaky Bucket)github

如上图所示,用户请求都被放进桶里,当桶满了之后,请求会被拒绝掉。桶的底部有一个孔,请求会以必定的速率被放过,好比说如今是限制每分钟 10 个请求,意味着每隔 6 秒钟就会有一个请求经过。漏桶算法的特色在于其经过请求的速率是恒定的,能够将流量整形的很是均匀,即使同时有 100 个请求也不会一次性经过,而是按必定间隔慢慢放行,这对后端服务迎接突发流量很是友好。golang

令牌桶(Token Bucket)redis

令牌桶,顾名思义桶里放的是一些令牌,这些令牌会按必定的速率往桶里放,假如每分钟限制 10 个请求,那么每分钟就往桶里放 10 个令牌,请求进来的时候须要先在令牌桶里拿令牌,拿到令牌则请求被放行,桶为空拿不到则意味着该请求被拒绝掉。算法

须要说明的是,令牌的个数是按必定的速率投放的,每分钟放 10 个令牌,那么能经过的请求确定也是每分钟 10 个。假如匀速放令牌, 6 秒钟放一个令牌,最终结果和每分钟放 10 个令牌是同样的。apache

漏桶(Leaky Bucket)算法实现后端

因为令牌桶跟漏桶的实现效果差很少,这里主要细讲漏桶的算法和实现。先假设速率限制是每分钟 3 个请求,即每 20 秒钟放行一个请求。如图所示,假设第 10 秒进来第一个请求,由于以前一直都没有请求进入,因此该请求被容许经过。记录下最后一次的访问时间,即为本次请求经过时间点。api

如今第 20 秒又过来一个请求,20 秒相对于 10 秒钟通过了 10 秒钟,按照计算只容许被经过 0.5 个请求,那请求就被拒绝掉了。这个 last 值仍是保持最后一次一个请求经过的时间。第 30 秒又来了一个请求:若是将 30 秒看做是最后一次更新时间,至关因而 30 秒减 10 秒,也就是通过了 20 秒,而咱们的限制是每 20 秒容许 1 个请求,那么这个请求会被放过去,last 值如今已经变成了 30 秒。

经过上述分析能够发现,漏桶限制很是严格,即使请求是第 29 秒进来也不能被经过,由于必需要通过 20 秒才容许经过一个请求,这可能会给业务带来一个问题:例如如今每分钟容许经过 3 个请求,用户可能须要在前 10 秒钟把三个请求发完,这种需求在这种算法下不会被容许。由于从发掉第一个请求到发第二个请求必需要间隔 20 秒才能够,为了弥补这种缺陷,须要引用另一个参数 burst(爆发),容许忽然爆发的请求。以下图中所示,40 秒距离 30 秒实际上只通过了 10 秒钟,按照以前的算法计算只被容许访问 0.5 个请求,实际上应该被拒绝掉,可是咱们容许它提早多访问一个请求(burst 为1),算下来就是 0.5+1=1.5 个请求。

须要注意的是,虽然咱们当前时间是 40 秒,但咱们最后须要更新请求时间到 50 秒,这是由于如今已经超量使用进入到下一个时间段了,至关因而提早放行一个请求,最后一个 last 时间是 30 秒,应该加 20 秒到 50 秒。这也是该算法实现的一个特色,不少算法也都有 burst 的功能,即容许提早访问。

45 秒又来了一个请求,尽管这个请求来时,咱们也容许它提早访问。但因为上一次最后访问时间已是 50 秒了,并且在经过计算得出不到一个请求时,这一个请求也就被拒绝掉了,时间戳 last 仍是 50 秒。

漏桶算法核心的地方在于咱们在实现的时候保存最后一次的经过时间,新请求来的时候,用当前的时候减去以前的时间,而后拿到能够容许经过的请求个数。若是能经过,就把最后一次请求时间改为当前的时间;不能经过,当前最后一次请求时间仍是不变。若是咱们要添加 burst 的功能,即提早容许它访问多少个请求的时候,last 时间可能再也不是最后一次放过去的时间,而是相对于以前最后一次请求的时间,它增加了多少个请求的时间,而这个 last 时间可能会超过请求的时候,总的来看主要核心的变量就是 last 的时间戳和 burst。

漏桶/令牌桶算法开源库

漏桶跟令牌桶的开源库也是特别多,下列几个库很是经典,各个语言和各个包都有实现,再加上由于我从事的工做主要是对 lua 和 golang 比较熟悉,这里主要讲他们:

  • nginx

https://www.nginx.com/blog/ra...

  • openresty/lua-resty-limit-traffic (两个变量)

https://github.com/openresty/...

  • uber-go/ratelimit

https://github.com/uber-go/ra...

Nginx 使用漏桶实现的,这个你们有兴趣去看一看,咱们稍后会讲 Nginx 如何配置限制。Openresty 是基于 Nginx 之上使用 lua 编写模块的一个框架,它的实现里主要有两个参数,第一个参数是刚刚说的 rate,即每秒容许多少个请求;另外一个参数是 burst ,指的是容许提早范围多少个,好比说每秒钟容许请求 5 个,这里还能够容许它提早放过去 5 个请求。

Uber 是 Uber 公司内部用 go 语言实现的一个 rate 限制。与前面 lua 代码不加锁不一样的是,这个算法加了一个自选锁。我认为在高并发场景中,自选锁是一个挺好的选择,由于这会有一个 get 和 set 的操做,为了保证准确确定要加锁,你们也能够去看看。

Nginx 配置

Nginx 配置中先考虑限制维度。例如每一个用户每分钟只被容许访问两次就是按照用户纬度来限制,或者按照 ip 和 host 来限制,还有就是按照一个 Server,好比一个 Sever 最大能承载每秒钟 10000 个,超过 10000 个可能要被弹掉了。

以上提到的这些限制维度在 Nginx 里都能实现,实现方式主要依赖于 Nginx 的两个模块:ngx_http_limit_req_module 和 ngx_http_limit_conn_module ,即限制请求数和限制链接数。

固定窗口(Fixed Windows)

固定窗口是最好理解的一个算法,应用在分布式限制场景中很是容易实现,由于它不须要加锁。

如图是一个时间戳的窗口,咱们如今规定每分钟 50 个请求。30 秒来了第 1 个请求,40 秒的时候来了 49 个请求,如今一分钟的时候来了 50 个请求,因为已经达到每秒 50 个限制,当 50 秒再来一个请求时会被直接弹掉。等到下一分钟时,即使一会儿来了 50 个请求也会被放过,由于它已经到了下一分钟了。

经过分析,你们能看到固定窗口算法是真的很是简单,你的程序只须要存储着当前时间窗口内已经有了多少个请求。至于不加锁,则是由于咱们直接原子操做增长变量,增长完了之后须要注意有没有超过 50,超过 50,请求被拒绝;没有超过 50,请求会被接收,因此这里不会出现 get 跟 set 的状况。

固然这个也有一个弊端,如上图所示的 00:30 到 01:30 ,也算是一个 60 秒的时间范围,但它有 100 个请求了,和咱们限制的要求是不同的,会出现流量高峰的问题。于是这种算法只能保证在一个固定窗口请求不会超过 50 个,若是是随机一个非固定窗口以内,它的请求就颇有可能超过 50 个,针对这种状况又提出了滑动窗口的概念。

滑动窗口(Sliding Windows)

如图所示,每分钟有 50 个请求,滑动窗口的一分钟指的的是当前时间往前的一分钟有多少个请求,例如 01:15 以前至关于从 0:15 到 01:15 。

已知 01:00 到 01:15 有 18 个请求,但 00:15 到 01:00 这个时间段是多少个请求呢?咱们如今知道的是 00:00 到 01:00 是有 42 个请求,而滑动窗口算法的特色在于按比例。能够将这一分钟分红两段时间,前 15 秒和 后 45 秒,它按这个比例计算 00:15 到 01:00 大约有多少个请求。按比例算不是很精准,由于它只记录了总数。经过计算

rate=42((60-15)/60)+18=42 0.75 + 18=49.5 requests

算下来是 49.5 个请求,当前这个请求应该是被拒绝掉的。

经过上述操做能够发现滑动窗口经过比例来保证每一个分钟内经过值和限制值相近。固然这种不许的状况能够经过减少窗口时间改进,例如如今窗口是 1 分钟,你能够减少到 10 秒钟,这样发生错误的几率就会下降,不过减少到 10 秒窗口带来的额外存储成本就会很高。虽然这个算法有一些缺点,可是也有很多的公司在用。

滑动窗口(Sliding Windows)是否准确的问题

Cloudflare 对来自 270000 个不一样来源的 4 亿个请求的分析显示:

  • 0.003% 的请求被错误地容许或限制了速率
  • 实际利率与近似利率之间的平均差别为 6%
  • 尽管产生了略高于阈值的流量(误报),实际发生率比阈值率高出不到 15%

不少大公司也在使用滑动窗口算法,若是你的限制每分钟 50 个,你能容忍它每分钟 40 个或者 60个的话,这种算法方案也是可行的。

固定窗口/滑动窗口应用

刚刚咱们已经讲到了滑动窗口限制算法不须要加锁,使用原子操做便可,因此实现也很是简单。

  • openresty/lua-resty-limit-traffic (atomic原子操做)

https://github.com/openresty/...

只有 100 的代码,这里用了一个 increment 的原子操做,不须要加锁,对多线程、多进程的实现比较友好,开销很是少。

  • kong

https://github.com/Kong/kong/...

这是 Kong 实现的滑动窗口应用,不过代码比较多,你们有兴趣的看看,一样是 lua 的代码,这个滑动窗口的实现比较全。

分布式 Rate Limit

不少时候网关可能不止一台,有两台机器的时候就要执行同步操做,例如在漏桶算法中要同步 last 值。同步的策略可使用 DB 库。不过 DB 库同步适合请求量比较小的场景。面对请求量特别大的时候,可使用 redis 这种高速的内存库,同步比较快。固然以上两种都是限制比较精准的时候可使用,若是不是特别精准,只须要防止服务不被冲垮,我以为可使用 local 限制。

local 的限制是什么呢?咱们刚刚提到每分钟限制 50 个请求,若是你有两个 Node,能够平均分配每一个 Node 25 个,这个方案是可行的。若是有权重是 10% 的流量往一边走,90% 的流量往另外一边走,能够相对调大其中一个 Node 的权重,改为一个 Node 每分钟限制 45 个,另外一个每分钟限制 5 个,这样能够避免再接入一些 DB 的中间件。

面对这种分布式的业务场景,APISIX 实现的还不错 (https://github.com/apache/inc...),它是基于 openresty 作的一个库,直接使用了 redis 做为同步,经过固定窗口方法实现。另外一个不错的是goredis(https://github.com/rwz/redis-...),基于 golang 的库实现了漏桶算法。goredis 的成本稍微高一些,若是是分布式的话,用固定窗口和滑动窗口的成本会低不少。

分布式 Rate Limit 性能优化

前面提到每一个请求过来时都要读取 redis 或者是 DB 类的数据,例如固定窗口读取 count 值,去redis 把 count 减 1 会增长延迟,这种状况下就带来一些多余的开销。为解决此问题,一些开源的企业级方案推崇不实时同步数值的作法。假如如今每分钟有两个请求,Node1 接到一个请求时,并非立刻执行去 redis 执行 last 减 1 的操做,而是等待一段时间,例如 1 秒钟同步一次,而后同步到 redis ,这样就减小了同步的次数。

可是这种操做也会带来一个新的问题。假如如今的限制是每秒容许 2 两个请求,Node1 和 Node2 在一秒内同时来了两个请求,由于尚未到一秒,只是在本地计数,因此这 4 个请求被放过。当到了 1 秒的时候,去 redis 减值的时候,才会发现已经有 4 个请求被放过。不过这种能够经过限制它下一秒一个请求都不能被经过来补偿。

固然这种状况要看你的容忍程度,这也算是一种解决方案,不过这种解决方案实现的仍是比较少。Kong 算其中一个,它是基于 openresty 开发的一个网关产品,实现了咱们讲的定时同步,不须要实时同步 count 值的功能。还有一个是 Cloudflare,也是用滑动窗口去解决性能的问题,不过它没有开源。

总结

  • 漏桶跟令牌桶很是经典,你们能够找到本身熟悉的语言算法实现,限制比较准确。
  • 固定窗口实现更简单,无锁,适于分布式,可是会有流量高峰的问题。在一些限制不须要那么平滑的的场景中可使用,限制相对准确。
  • 滑动窗口实现简单,也适用于分布式,且不会有高峰流量的问题,可是限制会有误差,若是要使用须要容忍限制误差的问题。

以上是陈卓在又拍云 Open Talk 公开课上的主要分享内容,演讲视频和 PPT 详见下方连接:

Open Talk 公开课

推荐阅读

企业如何高效平稳实现数据迁移

三分钟了解 Python3 的异步 Web 框架 FastAPI

相关文章
相关标签/搜索