我们将Github API缩放了Redis中的分片,复制的速率限制器

2021-04-09 00:01:43

大约一年前,我们迁移了一个旧的速率限制器,以便为更多的流量提供服务,并适应更具弹性的平台架构。我们采用了一个带客户端分级的复制redis后端。最后,它的效果很棒,但我们沿途学到了一些教训。

在memcached中,将该键的值递增,如果没有任何当前值,将其设置为1

此外,如果尚未存在一个,请使用相关键(例如,“#{key}:reset_at”)设置“在”memcached中的值“重置”值。

递增时,如果过去的“重置”值,请忽略现有值并设置新的“复位”

在每个请求的开头,如果密钥的值高于限制,并且将来“重置为”,然后拒绝请求

(可能对它有更多细微差别,但这是主要的想法。)

我们的Memcached架构是由于变化。由于它大多数用作缓存层,因此我们将从单个共享MEMCACHED从单个共享的MEMCACHE切换到每个数据中心的一个MEMCACHED。虽然适用于应用程序缓存,但如果客户要求被路由到不同的数据中心,则会导致我们的速率限制器非常奇怪。

Memcached“持久性”不适合我们。 Memcached后端由速率限制器和其他应用程序缓存共享,这意味着,当它填满时,它有时会阻止速率限制器数据,即使它仍然处于活动状态。 (结果,客户端将获得“新鲜”的速率限制窗口。有时,只有一个关键就会被驱逐 - 他们会保持相同的“使用”价值,但获得新的未来,“重置”价值观!)

使用Redis,因为它具有更合适的持久性系统和简单的分片和复制设置

应用程序内的碎片:该应用程序将为每个密钥选择,redis群集读取和写入

要缓解REDIS的CPU绑定性质,请在每个群集中放置单个主要(编写)和几个副本(对于读取)

而不是在数据库中写入“重置”,而是在不再适用的情况下使用redis到期来使值消失

在Lua实现存储逻辑,保证操作的原子性(这是对以前的设计改进)

我们考虑的一个选项,但决定使用我们的MySQL支持的KV商店(GitHub :: kV)进行存储。我们不想向已经忙碌的MySQL原序添加流量:通常,我们使用副本获取请求,但速率限制更新需要对主要的写访问权限。通过选择不同的存储后端,我们可以避免向MySQL提供额外的(和实质性)。

使用Redis的另一个优点是它是一个良好的路径。我们可以从两个优秀的现有资源中获取灵感:

Stripe的技术博客文章“用速率限制仪缩放您的API”,其中包括Ruby和Redis示例实现

要推出此更改,我们将当前的持久性逻辑分离为MemcachedBackend类,并为速率限制器构建了一个新的RedisBackend类。我们使用一个功能标志来门访问新的后端。这允许我们使用新的后端逐步增加客户的百分比。我们可以改变没有部署的百分比,这意味着,如果出现问题,我们可以快速切换回旧的实现。

该发布顺利进行,当完成后,我们删除了功能标志和MemcachedBackend类,并直接与委托给它的Throttler类集成了RedisBackend。

很多集成商密切关注他们的速率限制使用。在我们发布后的几周内,我们有两个非常有趣的错误报告:

一些客户观察到他们的x-RATELIMIT-RESET标题值“摆动” - 它可能会显示一个请求的10:00:00,但另一个请求(带一个)第二区别)。

一些客户的要求拒绝过度限制,但响应标题表示X-RATELIMIT - 剩余时间:5000.这没有意义:如果他们在他们面前有一个全额限制窗口,为什么请求被拒绝?

我们对使用Redis的内置时间到Live(TTL)持乐观态度,以实现我们的“重置”功能。但事实证明,我的实现导致上面描述的“摆动”。

Lua脚本返回了客户端的速率限制值的TTL,然后在Ruby中,它被添加到Time.Now.to_I获取X-RATELIMIT复位标题的时间戳。问题是,时间在TTL(redis中)和time.now.to_i(在Ruby)之间传递。根据需要多少时间,并且在时钟的第二个边界上落下的位置,所产生的时间戳可能是不同的。例如,考虑以下呼叫:

在这种情况下,由于第二个边界发生在TTL和Time.Now之间,因此得到的时间戳比前一个更大的时间。

我们本可以尝试提高此操作的精度(例如,Redis PTTL),但即使它被大大降低,仍然存在一些摆动。

另一种可能性是仅使用Redis计算时间,而不是混合Ruby和Redis呼叫来创建它。 redis的时间命令可能被用作真理来源。 (旧的redis版本没有时间在Lua脚本中允许时间,但redis 5+确实如此。)我们避免了这种设计,因为它会更加难以测试:通过使用Ruby的时间作为真理的来源,我可以在我的时间里旅行使用TimeCop进行测试,断言已过期密钥,无需实际等待Redis对系统时钟返回True,未来时间的呼叫。 (我仍然不得不等待Redis来测试基于过期的数据库清理,但是由于Expires_at来自Ruby-Land,我可以注入非常短的到期窗口来简化测试。)

相反,我们决定持续到数据库中的Ruby的“重置”时间。这样,我们可以确定它不会摆动。 (Wobbling是计算的效果 - 但从数据库中的读取将保证稳定的值。)而不是从Redis读取TTL,我们在数据库中存储了另一个值(有效地加倍我们的存储空间,但是确定)。

我们仍然将TTL应用于速率限制键,但在“在”时间“时,它们被设置为一秒钟。这样,我们可以使用Redis的自己的语义清理“死亡”速率限制窗口。

奇怪地,许多客户报告拒绝包含X-RATELIMIT的拒绝:5000个标题。这是怎么回事!?

在请求的开头,检查客户的当前速率限制值。如果它超过最大允许的限制,则准备拒绝响应。

在提供响应之前,将当前速率限制值递增,并使用响应填充X-RATELIMIT -...标题。

嗯,事实证明,上面的步骤1击中了Redis副本,因为它是读取操作。读取操作返回了有关客户端上一个窗口的信息,并且应用程序准备了拒绝响应。

然后,步骤2将击中redis primary。在该数据库调用期间,Redis将过期前一个窗口数据并返回新速率限制的数据。这是一个已知的redis限制:副本不会过期数据,直到他们从原初级接收到这样做,并且初选不会到期键(GitHub问题)直到它们才会过期(实际上,Primaries不时会随机采样键,适当到期,请参阅“Redis如何到期键”。)

基本上,如上所述,相同的修复:而不是依赖于Redis的TTL来过期旧速率限制窗口,而是需要在应用程序中管理该功能。 (应用程序应准备好从副本阅读陈旧数据,然后忽略它。)

即使在修复之后,还需要更好的设计:在速率有限的请求的情况下,我们应该避免对数据库的第二个调用。客户端的窗口可能会在两个呼叫之间过期,从而导致上述响应的类型。此修复程序需要改进编写响应的Ruby代码,以便上面步骤1的响应填充X-Ratelimit -...标题。

- Rate_script: - 计算客户端的请求 - 并返回客户端的当前状态 - 重命名输入的输入下调以下本地incly_key =键[1]本地increntment_amount = tonumber(argv [1])local next_expires_at = tonumber (argv [2])本地current_time = tonumber(argv [3])本地expires_at_key = paters_limit_key ..":exp" local affires_at = tonumber(redis.call(" get" expires_kate_key ))如果没有expires_at或affires_at< thount_time然后 - 这是一个全新的窗口, - 或者这个窗口已经关闭,但是redis hasn' t清理了钥匙 - (Redis会再次清除它) - 初始化一个新的速率限制窗口redis.call(" set" hate_limit_key,0)redis.call(" set" affires_at_key,nexp_expires_at) - 告诉Redis清除_one _到期后_one _one - 时间。 - 这种方式,Ruby和Redis之间的时钟差异Won' t导致数据消失。 - (Redis只会清理这些钥匙"窗口已经过去)rediS.call(" expireat",pater_limit_key,next_expires_at + 1)redis.call(&#34 ; expireat",affires_at_key,nexp_expires_at + 1) - 由于更新了数据库,因此返回新值expires_at = next_expires_atend--现在窗口已知已存在_or_刚刚初始化, - 增量计数器( `Incby`返回一个数字)本地Current = redis.call(" incrby" ratury_limit_key,increntment_amount)return {current,appires_at} - check_script: - 获得键的值和到期根据需要的算法需要以原子方式ran--因此脚本.--重命名输入的输入,以便清楚地下降至本地rate_key_key = keys [1] local expires_at_key = rate_limit_key ..":exp"当地current_time = tonumber(argv [1])本地尝试= tonumber(redis.call(" get" pare_limit_key))local appires_at = nil - 可能被覆盖以下原因,而不是尝试 - thi S客户端HASN' T初始化了一个窗口 - 让这一点才能返回{nil,nil}, - 在应用程序提供defaultselse的地方 - 我们发现了许多尝试,现在检查 - 如果这个窗口是实际上已过期expires_at = tonumber(redis.call(" get" get" expires_at_key)),如果没有expires_at或affires_at

我们从这种新方法学到了很多,但我们仍然存在一个缺点:当前的实现不会递增“当前”速率限制值,直到请求完成后。我们这样做是因为我们不向客户收取304的客户端,而不是修改的响应(当客户端提供电子标签时可能会发生)。更好的实现可能会在请求开始时递增值,然后如果响应为304,则会退回客户端。这将阻止客户端在仍在处理最终允许的请求时客户端可以超过其限制的边缘情况。

在解决这篇文章中描述的问题之后,新的速率限制器已经很好。它具有改进的可靠性,固定客户的问题,并减少了我们的支持负载(最终😉),并且该架构已准备好进行我们的下一波平台改进。