账单提取GraphQL内部API优化的一个故事

125 阅读16分钟

账单提取。一个关于GraphQL内部API优化的故事

Maciek是一名工程师、Scrum Master和知识共享的倡导者,他对分布式系统、文本处理和编写重要的软件感兴趣。

Toptal工程团队的主要任务之一是向基于服务的架构迁移。该倡议的一个关键因素是账单提取,在这个项目中,我们将账单功能从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平台(单体)到计费的每个请求进行了检测,并记录了详细信息:响应时间、参数、错误,甚至还有关于它们的堆栈跟踪(以了解平台的哪些部分使用计费)。这使我们能够检测到热点--代码中发送许多请求的地方或那些导致缓慢响应的地方。然后,通过堆栈跟踪参数,我们可以在本地重现问题,并有一个简短的反馈回路来进行许多修复。

为了避免在生产中出现令人讨厌的意外,我们增加了另一个层次的功能标志。我们在API中的每个方法都有一个标志,以便从REST转移到GraphQL。我们逐渐启用HTTP,并观察日志中是否出现了 "不好的东西"。

在大多数情况下,"坏东西 "要么是很长(多秒)的响应时间,429 Too Many Requests ,要么是502 Bad Gateway 。我们采用了几种模式来解决这些问题:预装和缓存数据,限制从服务器获取的数据,增加抖动,以及速率限制。

预装和缓存

我们注意到的第一个问题是来自单个类/视图的大量请求,类似于SQL中的N+1问题

Active Record的预加载在服务边界上不起作用,因此,我们有一个单一的页面在每次重新加载时向计费发送约1000个请求。一千个请求来自于一个页面!一些后台工作的情况也没有好多少。我们更愿意做几十个请求,而不是几千个。

其中一个后台工作是获取工作数据(让我们把这个模型称为Product ),并根据计费数据检查产品是否应该被标记为非活动产品(在这个例子中,我们把这个模型称为BillingRecord )。尽管产品是分批提取的,但每次需要时都会要求提供账单数据。每个产品都需要计费记录,所以处理每个产品都会向计费服务发出请求以获取这些记录。这意味着每个产品都有一个请求,导致一个工作的执行需要发送大约1000个请求。

为了解决这个问题,我们增加了计费记录的批量预加载。对于从数据库中获取的每一批产品,我们请求一次计费记录,然后将它们分配给各自的产品。

# 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条,每批向计费服务发出一个请求,我们从每项工作的1000个请求变成了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 vs.where in Rails)。在数据库世界中,这种方法是显而易见的,可作为SELECT 查询中的WHERE 。在这种情况下,它要求我们自己实现查询处理(在Billing中)。

我们部署了过滤器,并等待着看到性能的改善。相反,我们在平台上看到了502个错误(我们的用户也看到了这些错误)。这不是好事。一点都不好!

为什么会发生这种情况?这一变化应该改善响应时间,而不是破坏服务。我们在无意中引入了一个微妙的错误。我们在客户端保留了两个版本的API(GQL和REST)。我们用一个功能标志逐步进行了切换。我们部署的第一个不幸的版本在传统的REST分支中引入了一个回归。我们把测试的重点放在GQL分支上,所以我们错过了REST的性能问题。吸取的教训。如果搜索参数缺失,就返回一个空的集合,而不是你数据库中的所有东西。

看一下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限制器来确保每组作业的处理不突破定义的阈值。

我们又一次部署了它,并等待着周日的到来。我们相信我们最终会解决这个问题--我们确实做到了。错误消失了。

API优化:Nihil Novi Sub Sole

我相信你对我们采用的解决方案并不感到惊讶。分批处理、服务器端过滤、只发送所需字段和速率限制并不是什么新技术。有经验的软件工程师无疑在不同的情况下使用过它们。

预加载以避免N+1?我们在每个ORM中都有。哈希连接?现在连MySQL都有了。SELECT * vs.SELECT field 是一个已知的技巧。分散负载?这也不是一个新概念。

那么我为什么要写这篇文章呢?为什么我们不从一开始就这样做呢?像往常一样,背景是关键。许多技术只有在我们实施后才显得熟悉,或者只有在我们注意到需要解决的生产问题时才显得熟悉,而不是在我们盯着代码的时候。

这有几种可能的解释。大多数时候,我们试图做最简单的事情,以避免过度工程化。我们从一个无聊的REST解决方案开始,然后才转到GQL。我们在一个功能标志后面部署了变化,监测一切在一小部分流量下的表现,并根据真实世界的数据进行了改进。

我们的发现之一是,在重构的时候,性能下降很容易被忽视(提取可以被视为一个重要的重构)。添加一个严格的边界意味着我们切断了为优化代码而添加的纽带。但这并不明显,直到我们测量性能。最后,在某些情况下,我们无法在开发环境中重现生产流量。

我们努力使计费服务的通用HTTP API有一个小的表面。结果,我们得到了一堆通用的端点/查询,这些端点/查询承载着不同用例所需的数据。这意味着在许多用例中,大部分的数据是无用的。这有点像DRY和 YAGNI 之间的权衡 :在DRY中,我们只有一个端点/查询返回计费记录,而在YAGNI中,我们最终在端点中得到了未使用的数据,这只会损害性能。

在与计费团队讨论抖动问题时,我们也注意到了另一个权衡因素。从客户(平台)的角度来看,每个请求都应该在平台需要的时候得到响应。性能问题和服务器过载应该被隐藏在计费服务的抽象背后。从计费服务的角度来看,我们需要找到方法让客户了解服务器的性能特征以承受负载。

同样,这里没有什么是新颖或突破性的。这是关于在不同的背景下识别已知的模式,并理解变化所带来的权衡。我们已经学到了这一点,我们希望我们已经使你免于重复我们的错误。与其重复我们的错误,你们无疑会犯自己的错误,并从中学习。

了解基础知识

什么是内部和外部API?

外部API是一个面向客户端或用户界面的API。内部API用于服务之间的通信。

为什么使用GraphQL?

GraphQL允许实现一个灵活的API层。它支持数据的范围和分组,以便客户端只获取所需的数据,并能在对服务器的单一请求中获取这些数据。

GraphQL比REST好吗?

它们有不同的使用模式,不能直接比较。在我们的案例中,GraphQL被证明是更合适的,但你的里程可能会有所不同。

你如何优化一个API?

这取决于API的情况。预装和缓存数据,限制从服务器获取的数据,增加抖动,以及限制速率,都可以用来优化内部API。