今天,Mailgun很高兴能够开源高性能的分布式限速微服务--Gubernator。 Gubernator会做什么?
Features
- Gubernator在整个集群中平均分配速率限制请求,这意味着您可以通过简单地添加更多节点来扩展系统。
- Gubernator不依赖于Memcache或Redis之类的外部缓存,因此不存在与相关服务的部署同步。这使得在kubernetes或nomad等编排系统中动态增加或缩小群集。
- Gubernator不会在磁盘上保持任何状态,客户端会根据每个请求将其配置传递给它。
- Gubernator提供对其API的GRPC和HTTP访问。
- 可以作为需要速率限制的服务的辅助工具运行,也可以作为单独的服务运行。
- 可用作库,以实现特定于域的速率限制服务。
- 支持用于极高吞吐量环境的可选最终一致的速率限制分配。
现在,我们确信您对我们为什么决定开源Gubernator仍有很多疑问。因此,在深入探讨Gubernator的工作方式之前,让我们先回答其中几个问题。您要问的最大的问题可能是...
为什么不使用 Redis?
在评估Redis时,考虑到下面几点:
- 即使使用 pipelining ,使用基本Redis速率限制实现也将导致额外的网络往返。
- 我们可以使用 https://redis.io/commands/eval 并编写一个LUA脚本来减少往返行程,但我们需要为所实现的每种算法维护该脚本。
- 每个单独的请求将导致至少两次往返Redis。再加上至少1次往返于我们的微服务,这意味着每个请求至少2次往返于我们的服务。
Redis的最佳解决方案是编写一个实现速率限制算法的LUA脚本。然后将该脚本存储在Redis服务器上,并针对每个速率限制请求调用该脚本。在这种情况下,大部分工作都是由Redis完成的,而我们的微服务基本上是访问Redis的代理。在这种情况下,我们有两个选择:
- 将Gubernator创建为可访问Redis的速率限制库。每个需要速率限制的服务都将使用该库。
- 消除Redis,并在具有速率限制的微服务中使用瘦GRPC客户端实现分发,缓存和限制算法。
为什么是 微服务 ?
Mailgun是一家使用python和golang的多语言公司,构成了我们大多数代码库。如果我们选择将速率限制实现为库,则需要最少python和golang版本。在内部使用同一库的python和golang版本之前,我们已经走了这条路线。根据我们的经验,跨服务的共享库具有以下缺点。
- 库的bug和功能更新最多只能对依赖项进行更新。最糟糕的是,它要求以所有受支持的语言对使用该库的所有服务进行修改。
- 开发人员很少要使用两种语言维护或编写新功能。通常,这会导致库的一个版本比其他版本具有更多功能或得到更好的维护。
随着我们生态系统中微服务和语言的数量持续增长,这些问题变得越来越严重。相比之下,可以轻松为需要访问Gubernator的每种语言创建和维护GRPC和HTTP库。
对于微服务,可以在不中断相关服务的情况下添加错误更新和新功能。只要不允许对API进行重大更改,相关服务就可以选择新功能,而无需更新所有相关服务。
Gubernator作为微服务的杀手级功能是,它为进入系统的许多请求创建了一个同步点。彼此之间在几微秒之内收到的请求可以进行优化和协调,从而减少服务在高负载下使用的总带宽和往返延迟。在单个主机上运行的所有服务都具有在各自进程中运行的同一个库,这些服务不具有此功能。
为什么 Gubernator 是无状态 ?
Gubernator无状态,因为它不需要磁盘空间即可操作。从来没有配置或缓存数据同步到磁盘,这是因为对Gubernator的每个请求都包含速率限制的配置。
起初,您可能认为这对每个请求来说都是不必要的开销。但是,实际上,速率限制配置仅由4个64位整数组成。该配置由“限制”,“持续时间”,“算法”和“行为”组成(有关其工作原理的详细信息,请参见下文)。正是由于这种简单的配置,Gubernator可以用于提供客户可以使用的多种速率限制用例。其中一些用例是:
- Ingress limiting - 基于HTTP的典型402太多请求类型限制
- Traffic Shedding - 当您的API处于错误状态时,仅拒绝新的或未经身份验证的请求
- Egress limiting - 用数百万条消息轰炸外部SMTP服务器不是No Bueno
- Queue Processing - 知道何时可以立即处理请求,或应按接收顺序将其排队和处理
- API Capacity Management - 对全局API系统可以处理的请求总数设置全局限制。拒绝或排队请求破坏了系统的正常运行能力
除了上述用例之外,无配置设计对微服务设计和部署也有重要意义:
- 部署时无配置同步。部署使用Gubernator的服务时,没有速率限制配置需要预先部署到Gubernator。
- 使用Gubernator的服务拥有其问题空间的速率限制域模型。这样可以将特定领域的知识排除在Gubernator之外,因此Gubernator可以专注于其最擅长的工作-速率限制!
除了这些问题之外,让我们从整个工作原理开始,全面讨论Gubernator。
Gubernator 工作原理
Gubernator设计为对等体的分布式群集运行,该对等体利用所有当前活动速率限制的内存中缓存,因为这样就不会将任何数据同步到磁盘。由于大多数基于网络的速率限制持续时间仅保留几秒钟,因此在重新启动或计划的停机时间期间丢失内存高速缓存并不是什么大问题。对于Gubernator,我们选择性能而不是准确性,因为在高速缓存丢失的情况下,一小部分流量在短时间内(通常是几秒钟)过度请求是可以接受的。
当向Gubernator发出速率限制请求时,该请求将被加密,并应用一致的哈希算法来确定哪个对等方将成为速率限制请求的所有者。为速率限制选择单个所有者,可以使计数的原子增量非常快,并且避免了在对等群集之间一致地分配计数时所涉及的复杂性和延迟。
尽管简单高效,但是由于单个协调器可能负责成千上万个请求,而速率限制,因此该设计可能会受到大量请求的影响。
为了解决这个问题,客户端可以请求“Behaviour = BATCHING”,该行为允许对等方在指定窗口(默认值为500微秒)内接收多个请求,并将请求分批处理为单个对等方请求,从而减少通过有线方式进行请求的总数。
为了确保集群中的每个对等方准确地为速率限制密钥计算正确的哈希值,必须以及时且一致的方式将集群中的对等方列表分配给集群中的每个对等方。当前,Gubernator支持使用etcd或kubernetes端点API来发现Gubernator对等体。
Gubernator 操作
当客户端或服务向Gubernator发出请求时,客户端将为每个请求提供速率限制配置。然后,将速率限制配置与当前速率限制状态一起存储在速率限制所有者的本地缓存中。速率限制及其存储在本地缓存中的配置仅在速率限制配置的指定持续时间内存在。
持续时间到期后,如果在持续时间内未再次请求速率限制,则将其从缓存中删除。随后对相同名称和unique_key对的请求将在缓存中重新创建配置和速率限制,并且该循环将重复。另一方面,具有不同配置的后续请求将覆盖先前的配置,并立即应用新的配置。
通过GRPC发送的速率限制请求示例可能如下所示:
rate_limits:
# Scopes the request to a specific rate limit
- name: requests_per_sec
# A unique_key that identifies this rate limit request
unique_key: account_id=123|source_ip=172.0.0.1
# The number of hits we are requesting
hits: 1
# The total number of requests allowed for this rate limit
limit: 100
# The duration of the rate limit in milliseconds
duration: 1000
# The algorithm used to calculate the rate limit
# 0 = Token Bucket
# 1 = Leaky Bucket
algorithm: 0
# The behavior of the rate limit in gubernator.
# 0 = BATCHING (Enables batching of requests to peers)
# 1 = NO_BATCHING (Disables batching)
# 2 = GLOBAL (Enable global caching for this rate limit)
behavior: 0
一个示例响应为:
rate_limits:
# The status of the rate limit. OK = 0, OVER_LIMIT = 1
- status: 0,
# The current configured limit
limit: 10,
# The number of requests remaining
remaining: 7,
# A unix timestamp in milliseconds of when the rate limit will reset,
# or if OVER_LIMIT is set it is the time at which the rate limit
# will no longer return OVER_LIMIT.
reset_time: 1551309219226,
# Additional metadata about the request the client might find useful
metadata:
# This is the name of the node that owns this request
"owner": "api-n03.staging.us-east-1.mailgun.org:9041"
GLOBAL行为
由于Gubernator速率限制是由集群中的单个对等方散列和处理的,因此,适用于数据中心中每个请求的速率限制将导致单个对等方对整个数据中心的速率限制请求进行处理。
例如,考虑一个速率限制,其名称为name = requests_per_datacenter,而unique_id = us-east-1。现在想象一下,对于每个进入us-east-1数据中心的HTTP请求,都以该速率限制向Gubernator发送了一个请求。这可能是成千上万,甚至每秒可能有数百万个请求,这些请求全部由集群中的单个对等方散列和处理。由于存在潜在的扩展问题,因此Gubernator引入了一种称为GLOBAL的可配置行为。
当将速率限制配置为behavior = GLOBAL
时,从客户端收到的速率限制请求将不会转发到拥有对等方。相反,它将从接收请求的对等方处理的内部缓存中进行应答。达到速率限制的匹配将由接收对等方批处理,并异步发送到所属对等方,在此对匹配进行总计并计算OVER_LIMIT。然后,拥有对等方的责任是使用速率限制的当前状态更新群集中的每个对等方,以使对等方内部缓存定期从所有者那里以最新速率限制状态进行更新。
GLOBAL行为的副作用
由于匹配数是批量处理的,并且异步转发给所有者,因此对客户端的即时响应将不包括最准确的剩余计数。仅在对所有者对等方的异步调用完成并且拥有对等方有时间更新集群中的所有对等方之后,该计数才会更新。结果,使用GLOBAL可以扩大规模,但要以保持一致性为代价。如果群集足够大,则使用GLOBAL可以增加每个速率限制请求的流量。 GLOBAL仅应用于与传统非GLOBAL行为无法很好扩展的极高速度限制。
Gubernator 性能
在生产环境中,对于我们的API的每个请求,我们都会向Gubernator发送2个限速请求,以进行限速评估;一个用于对HTTP请求进行评分,另一个用于对用户在特定持续时间内也可以发送电子邮件的收件人数量进行评分。在这种设置下,一个超过2,000个的Gubernator节点字段每秒请求一次,并且大多数批处理响应在1毫秒内返回。
由于我们许多面向公众的API都是使用python编写的,因此我们在单个节点上运行了许多python解释器实例。这些python实例会将请求本地转发到Gubernator实例,然后再将其批处理并将请求转发到拥有的节点。
Gubernator允许用户选择非分批行为,这将进一步减少客户端速率限制请求的延迟。但是,由于吞吐量要求,我们的生产环境使用Behaviour = BATCHING
和默认的500微秒窗口。在生产中,我们观察到API使用高峰期间的批量为1,000。流量需求不一样的其他用户可能会禁用批量处理,并会降低等待时间,但会降低吞吐量。
Gubernator 作为库使用
如果您使用的是Golang,则可以将Gubernator用作库。如果您希望在自己的公司特定模型之上实施限流服务,这将很有用。我们在Mailgun的内部进行此操作,并提供了一个我们创造性地称为ratelimits的服务,该服务跟踪每个帐户所施加的限制。这样,您可以利用Gubernator的功能和速度,但仍可以分层业务逻辑,并将特定于域的问题集成到您的限速服务中。
使用库时,您的服务将成为集群的完整成员,与独立的Gubernator服务器一样,将参与相同的一致哈希和缓存。您所需要做的就是提供GRPC服务器实例,并告诉Gubernator集群中的对等节点位于何处。
结论
将Gubernator用作通用限速服务使我们能够依赖微服务体系结构,而不会损害服务独立性和通用限速解决方案所需的重复工作。我们希望通过将该项目开源,其他人可以合作并从我们在这里开始的工作中受益。
PS: 本文属于翻译,原文
有疑问加站长微信联系(非本文作者)