计费提取:GraphQL 内部 API 优化的故事
已发表: 2022-03-11Toptal 工程团队的主要优先事项之一是向基于服务的架构迁移。 该计划的一个关键要素是计费提取,在该项目中,我们将计费功能与 Toptal 平台隔离开来,将其部署为单独的服务。
在过去的几个月里,我们提取了功能的第一部分。 为了将计费与其他服务集成,我们同时使用了异步API(基于 Kafka)和同步API(基于 HTTP)。
本文记录了我们为优化和稳定同步 API 所做的努力。
增量法
这是我们倡议的第一阶段。 在我们全面提取账单的过程中,我们努力以渐进的方式工作,为生产提供小的和安全的更改。 (参见关于该项目另一个方面的精彩演讲的幻灯片:从 Rails 应用程序中增量提取引擎。)
起点是Toptal 平台,一个单一的 Ruby on Rails 应用程序。 我们首先在数据级别识别计费和 Toptal 平台之间的接缝。 第一种方法是用常规方法调用替换 Active Record (AR) 关系。 接下来,我们需要实现对计费服务的 REST 调用,以获取该方法返回的数据。
我们部署了一个小型计费服务,访问与平台相同的数据库。 我们能够使用 HTTP API 或直接调用数据库来查询帐单。 这种方法使我们能够实现安全的回退; 如果 HTTP 请求由于任何原因(不正确的实现、性能问题、部署问题)失败,我们使用直接调用并将正确的结果返回给调用者。
为了使转换安全无缝,我们使用功能标志在 HTTP 和直接调用之间切换。 不幸的是,第一次尝试用 REST 实现的速度慢得让人无法接受。 启用 HTTP 时,简单地用远程请求替换 AR 关系会导致崩溃。 尽管我们只为相对较小比例的调用启用了它,但问题仍然存在。
我们知道我们需要一种完全不同的方法。
计费内部 API(又名 B2B)
我们决定用 GraphQL (GQL) 替换 REST,以便在客户端获得更大的灵活性。 我们希望在此过渡期间做出数据驱动的决策,以便能够预测这次的结果。
为此,我们检测了来自 Toptal 平台(单体)的每个请求以进行计费并记录详细信息:响应时间、参数、错误,甚至它们的堆栈跟踪(以了解平台的哪些部分使用计费)。 这使我们能够检测热点——代码中发送许多请求或导致响应缓慢的地方。 然后,使用stacktrace和parameters ,我们可以在本地重现问题,并为许多修复提供一个简短的反馈循环。
为了避免生产中出现令人讨厌的意外,我们添加了另一个级别的功能标志。 我们在 API 中的每个方法都有一个标志,以便从 REST 迁移到 GraphQL。 我们逐渐启用 HTTP 并观察日志中是否出现“有问题”。
在大多数情况下,“有问题”要么是较长的(多秒)响应时间、 429 Too Many Requests或502 Bad Gateway 。 我们采用了几种模式来解决这些问题:预加载和缓存数据、限制从服务器获取的数据、添加抖动和速率限制。
预加载和缓存
我们注意到的第一个问题是从单个类/视图发送的大量请求,类似于 SQL 中的 N+1 问题。
Active Record 预加载无法在服务边界上工作,因此,我们有一个页面在每次重新加载时发送约 1,000 个计费请求。 来自单个页面的一千个请求! 一些后台工作的情况也好不到哪里去。 我们宁愿提出数十个请求,而不是数千个。
其中一个后台作业是获取作业数据(我们将此模型称为Product )并检查是否应根据计费数据将产品标记为非活动状态(对于本示例,我们将调用模型BillingRecord )。 尽管产品是分批获取的,但每次需要时都会请求计费数据。 每个产品都需要计费记录,因此处理每个产品都会导致计费服务请求获取它们。 这意味着每个产品一个请求,并且导致从单个作业执行发送大约 1,000 个请求。
为了解决这个问题,我们添加了账单记录的批量预加载。 对于从数据库中提取的每批产品,我们请求一次计费记录,然后将它们分配给相应的产品:
# fetch all required billing records and assign them to respective products def cache_billing_records(products) # array of billing records billing_records = Billing::QueryService .billing_records_for_products(*products) indexed_records = billing_records.group_by(&:product_gid) products.each do |p| e.cache_billing_records!(indexed_records[p.gid].to_a) } end end以 100 个批次和每批次对计费服务的一个请求,我们从每个作业约 1,000 个请求增加到约 10 个。
客户端连接
当我们有一系列产品并且我们需要它们的计费记录时,批处理请求和缓存计费记录效果很好。 但是反过来呢:如果我们获取账单记录,然后尝试使用他们各自的产品,从平台数据库中获取?
正如预期的那样,这导致了另一个 N+1 问题,这次是在平台方面。 当我们使用产品收集 N 条计费记录时,我们正在执行 N 条数据库查询。
解决方案是一次获取所有需要的产品,将它们存储为按 ID 索引的哈希值,然后将它们分配给各自的计费记录。 一个简化的实现是:
def product_billing_records(products) products_by_gid = products.index_by(&:gid) product_gids = products_by_gid.keys.compact return [] if product_gids.blank? billing_records = fetch_billing_records(product_gids: product_gids) billing_records.each do |billing_record| billing_record.preload_product!( products_by_gid[billing_record.product_gid] ) end end如果您认为它类似于哈希联接,那么您并不孤单。
服务器端过滤和提取不足
我们在平台方面战胜了最严重的请求高峰和 N+1 问题。 不过,我们的反应仍然很慢。 我们确定它们是由于向平台加载过多数据并在那里过滤(客户端过滤)引起的。 将数据加载到内存、对其进行序列化、通过网络发送以及反序列化只是为了丢弃大部分数据,这是一种巨大的浪费。 这在实现过程中很方便,因为我们有通用且可重用的端点。 在操作过程中,它被证明无法使用。 我们需要更具体的东西。
我们通过向 GraphQL 添加过滤参数来解决这个问题。 我们的方法类似于众所周知的优化,包括将过滤从应用程序级别移动到数据库查询( find_all与 Rails 中的where )。 在数据库世界中,这种方法是显而易见的,并且可以用作SELECT查询中的WHERE 。 在这种情况下,它需要我们自己实现查询处理(在 Billing 中)。
我们部署了过滤器并等待看到性能改进。 相反,我们在平台上看到了 502 错误(我们的用户也看到了)。 不好。 一点都不好!
为什么会这样? 这种变化应该提高响应时间,而不是中断服务。 我们无意中引入了一个微妙的错误。 我们在客户端保留了 API 的两个版本(GQL 和 REST)。 我们使用功能标志逐渐切换。 我们部署的第一个不幸的版本在遗留 REST 分支中引入了回归。 我们将测试集中在 GQL 分支上,因此我们错过了 REST 中的性能问题。 经验教训:如果缺少搜索参数,则返回一个空集合,而不是数据库中的所有内容。
查看 Billing 的NewRelic数据。 我们在流量停滞期间通过服务器端过滤部署了更改(我们在遇到平台问题后关闭了计费流量)。 您可以看到部署后响应更快且更可预测。

将过滤器添加到 GQL 模式并不难。 GraphQL 真正大放异彩的情况是我们获取了太多字段而不是太多对象的情况。 使用 REST,我们发送了所有可能需要的数据。 创建一个通用端点迫使我们将其与平台上使用的所有数据和关联打包在一起。
使用 GQL,我们能够选择字段。 我们没有获取需要加载多个数据库表的 20 多个字段,而是只选择了需要的三到五个字段。 这使我们能够在平台部署期间消除突然的计费使用高峰,因为其中一些查询被部署期间运行的弹性搜索重新索引作业使用。 作为一个积极的副作用,它使部署更快、更可靠。
最快的请求是你不提出的请求
我们限制了获取对象的数量和打包到每个对象中的数据量。 我们还能做什么? 也许根本不获取数据?
我们注意到另一个有改进空间的地方:我们经常使用平台中最后一个计费记录的创建日期,并且每次都调用计费来获取它。 我们决定,与其在每次需要时同步获取它,不如根据计费发送的事件缓存它。
我们提前计划,准备好任务(其中四到五个),并开始努力尽快完成,因为这些请求会产生很大的负载。 我们还有两周的工作要做。
幸运的是,在我们开始后不久,我们重新审视了这个问题,并意识到我们可以使用平台上已经存在但形式不同的数据。 我们没有添加新表来缓存来自 Kafka 的数据,而是花了几天时间比较来自计费和平台的数据。 我们还咨询了领域专家是否可以使用平台数据。
最后,我们用数据库查询替换了远程调用。 从性能和工作负载的角度来看,这是一个巨大的胜利。 我们还节省了一周多的开发时间。
分配负载
我们正在一一实施和部署这些优化,但仍然存在计费响应429 Too Many Requests的情况。 我们本可以增加对 Nginx 的请求限制,但我们想更好地理解这个问题,因为它暗示通信没有按预期运行。 您可能还记得,我们可以承受生产中的这些错误,因为它们对最终用户不可见(因为回退到直接调用)。
该错误发生在每个星期天,当平台安排提醒人才网络成员有关过期时间表时。 为了发送提醒,作业会获取相关产品的计费数据,其中包括数千条记录。 我们优化它的第一件事是批处理和预加载计费数据,并仅获取必填字段。 两者都是众所周知的技巧,因此我们不会在这里详细介绍。
我们部署并等待下一个星期天。 我们确信我们已经解决了这个问题。 然而,在周日,错误再次出现。
计费服务不仅在日程安排期间被调用,而且在向网络成员发送提醒时也被调用。 提醒在单独的后台作业中发送(使用 Sidekiq),因此预加载是不可能的。 最初,我们认为这不是问题,因为并非每个产品都需要提醒,而且提醒都是一次性发送的。 提醒安排在网络成员所在时区的下午 5 点。 但是,我们错过了一个重要的细节:我们的成员并非均匀地分布在不同时区。
我们为成千上万的网络成员安排了提醒,其中大约 25% 的人生活在一个时区。 大约 15% 的人生活在人口第二多的时区。 当时钟在这些时区下午 5 点滴答作响时,我们不得不一次发送数百条提醒。 这意味着向计费服务发出数百个请求,这超出了该服务的处理能力。
无法预加载帐单数据,因为提醒是在独立作业中安排的。 我们无法从计费中获取更少的字段,因为我们已经优化了该数字。 将网络成员转移到人口较少的时区也是不可能的。 那么我们做了什么? 我们移动了提醒,只是一点点。
我们在安排提醒的时间添加了抖动,以避免所有提醒将在完全相同的时间发送的情况。 我们没有安排在下午 5 点整,而是安排在下午 5:59 到下午 6:01 之间的两分钟范围内。
我们部署了服务并等待下周日,确信我们最终解决了问题。 不幸的是,周日,错误再次出现。
我们很困惑。 根据我们的计算,请求应该分布在两分钟的时间内,这意味着我们每秒最多有两个请求。 这不是服务无法处理的事情。 我们分析了计费请求的日志和时间,我们意识到我们的抖动实现不起作用,所以请求仍然出现在一个紧凑的组中。
是什么导致了这种行为? 这是 Sidekiq 实现调度的方式。 它每 10-15 秒轮询一次 redis,因此,它无法提供一秒的分辨率。 为了实现请求的均匀分布,我们使用Sidekiq::Limiter ——Sidekiq Enterprise 提供的一个类。 我们使用了窗口限制器,该窗口限制器允许八个请求移动一秒窗口。 我们选择该值是因为我们的 Nginx 限制为每秒 10 个请求的计费。 我们保留了抖动代码,因为它提供了粗粒度的请求分散:它在两分钟内分发 Sidekiq 作业。 然后使用 Sidekiq Limiter 来确保每组作业的处理都不会超出定义的阈值。
我们再次部署它并等待周日。 我们有信心最终解决了这个问题——我们做到了。 错误消失了。
API 优化:Nihil Novi Sub Sole
我相信您对我们采用的解决方案并不感到惊讶。 批处理、服务器端过滤、仅发送必填字段和速率限制都不是新技术。 经验丰富的软件工程师无疑会在不同的环境中使用它们。
预加载以避免 N+1? 我们在每个 ORM 中都有它。 哈希连接? 甚至 MySQL 现在也有它们。 不足取? SELECT * vs. SELECT field是一个已知的技巧。 分担负载? 这也不是一个新概念。
那么我为什么要写这篇文章呢? 为什么我们一开始就没有做好呢? 像往常一样,上下文是关键。 许多这些技术只有在我们实现它们之后或者只有在我们注意到需要解决的生产问题时才看起来很熟悉,而不是在我们盯着代码时。
对此有几种可能的解释。 大多数时候,我们都在尝试做最简单的事情来避免过度设计。 我们从一个无聊的 REST 解决方案开始,然后才转向 GQL。 我们在功能标志后面部署了更改,监控了一小部分流量的所有行为,并根据实际数据应用了改进。
我们的一个发现是重构时性能下降很容易被忽视(提取可以被视为重要的重构)。 添加严格的边界意味着我们切断了为优化代码而添加的联系。 不过,在我们测量性能之前,这一点并不明显。 最后,在某些情况下,我们无法在开发环境中重现生产流量。
我们努力为计费服务提供一个通用 HTTP API 的小表面。 结果,我们得到了一堆通用端点/查询,这些端点/查询承载了不同用例所需的数据。 这意味着在许多用例中,大部分数据都是无用的。 这是 DRY 和 YAGNI 之间的一个折衷:使用 DRY,我们只有一个端点/查询返回计费记录,而使用 YAGNI,我们最终会在端点中得到只会损害性能的未使用数据。
在与计费团队讨论抖动时,我们还注意到另一个权衡。 从客户端(平台)的角度来看,每个请求都应该在平台需要时得到响应。 性能问题和服务器过载应该隐藏在计费服务的抽象背后。 从计费服务的角度来看,我们需要想办法让客户端了解服务器的性能特征来承受负载。
同样,这里没有什么是新颖或开创性的。 它是关于识别不同上下文中的已知模式并理解变化带来的权衡。 我们已经从艰难的道路上学到了这一点,我们希望我们已经让您免于重复我们的错误。 毫无疑问,您将自己犯错误并从中吸取教训,而不是重复我们的错误。
特别感谢参与我们工作的同事和队友:
- 马卡尔·埃尔莫欣
- 加布里埃尔·伦齐
- 塞缪尔·维加·卡瓦列罗
- 卢卡·圭迪
