作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
Maciek Rząsa的头像

Maciek Rząsa

An engineer, Scrum Master, 知识共享的倡导者, Maciek对分布式系统很感兴趣, text processing, 编写重要的软件.

Share

Toptal工程团队的主要优先事项之一是向基于服务的体系结构迁移. 该倡议的一个关键因素是 Billing Extraction, 在这个项目中,我们将计费功能从Toptal平台中分离出来,并将其作为单独的服务部署.

在过去的几个月里,我们提取了该功能的第一部分. 为了将计费与其他服务集成,我们同时使用了两个 asynchronous API (Kafka-based) and a synchronous API (HTTP-based).

本文记录了我们为优化和稳定同步API所做的努力.

Incremental Approach

这是我们倡议的第一阶段. 在我们的全账单提取之旅中, 我们努力以一种渐进的方式为生产提供小而安全的变化. (See 精彩演讲的幻灯片 关于这个项目的另一个方面:从Rails应用程序中增量提取引擎.)

起点是 Toptal platform, 一个独立的Ruby on Rails应用程序. 我们首先在数据级别确定计费和Toptal平台之间的接缝. 第一种方法是替换 Active Record (AR)与常规方法调用的关系. 接下来,我们需要实现对计费服务的REST调用,以获取该方法返回的数据.

我们部署了一个小型计费服务,访问与平台相同的数据库. 我们可以使用HTTP API或直接调用数据库来查询账单. This approach allowed us to implement a safe fallback; in case the HTTP request failed for any reason (incorrect implementation, performance issue, deployment problems), 我们使用了直接调用,并将正确的结果返回给调用者.

确保过渡安全无缝, 我们使用一个特性标志在HTTP和直接调用之间切换. 不幸的是,用REST实现的第一次尝试被证明慢得令人无法接受. 当启用HTTP时,简单地用远程请求替换AR关系会导致崩溃. 尽管我们只对相对较小比例的呼叫启用了它,但问题仍然存在.

我们知道我们需要一个完全不同的方法.

计费内部API(又名B2B)

We decided to 用GraphQL (GQL)代替REST 在客户端获得更大的灵活性. 我们希望在这个过渡期间做出数据驱动的决策,以便能够预测这次的结果.

To do that, 我们检测了来自Toptal平台(整体)的每个请求,并记录了详细信息:响应时间, parameters, errors, 甚至对它们进行堆栈跟踪(以了解平台的哪些部分使用计费). 这允许我们检测热点——代码中发送许多请求或导致缓慢响应的地方. Then, with stacktrace and parameters,我们可以在本地重现问题,并对许多修复有一个简短的反馈循环.

为了避免在生产中出现令人不快的意外,我们添加了另一个级别的特性标志. 我们在API中为每个方法设置了一个标志,用于从REST转向GraphQL. 我们逐渐启用HTTP,并观察日志中是否出现了“不好的东西”.

在大多数情况下,“糟糕的事情”要么是长(多秒)的响应时间, 429 Too Many Requests, or 502 Bad Gateway. 我们采用了几种模式来解决这些问题:预加载和缓存数据, 限制从服务器获取的数据, adding jitter, and rate-limiting.

预加载和缓存

我们注意到的第一个问题是从单个类/视图发送的大量请求,类似于 N+1 problem in SQL.

活动记录的预加载不能在服务边界上工作, as a result, 我们有一个单页发送~1,每次重新加载都有000个计费请求. 一页就有一千个请求! 一些后台工作的情况也好不到哪里去. 我们宁愿提出几十个请求,而不是几千个.

其中一个后台作业正在获取作业数据(我们将此模型称为 Product),并检查是否应该根据账单数据将产品标记为非活动(在本例中), we’ll call the model BillingRecord). 尽管产品是成批提取的, 每次需要时都会请求账单数据. 每个产品都需要账单记录, 因此,处理每个单独的产品都会导致向计费服务请求获取它们. 这意味着每个产品一个请求,结果是大约1个,单个作业执行发送了000个请求.

为了解决这个问题,我们添加了账单记录的批量预加载. 对于从数据库中获取的每批产品, 我们要求一次账单记录,然后将其分配给各自的产品:

获取所有必需的账单记录并将其分配给各自的产品
def cache_billing_records(产品)
    #账单记录数组
    billing_records = Billing::QueryService
       .billing_records_for_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个,每批对计费服务有一个请求, we went from ~1,每个作业000个请求到~10个.

Client-side Joins

当我们有一组产品并且需要它们的账单记录时,批处理请求和缓存账单记录工作得很好. 但是反过来呢:如果我们获取账单记录,然后尝试使用他们各自的产品, 从平台数据库中获取?

正如预期的那样,这导致了另一个N+1问题,这次是在平台端. 当我们使用产品收集N个账单记录时,我们执行了N个数据库查询.

解决办法是立刻把所有需要的产品都取来, 将它们存储为按ID索引的散列, 然后将它们分配到各自的账单记录. 一个简化的实现是:

def product_billing_records(产品)
    Products_by_gid = products.index_by(&:gid)
    product_gid = products_by_gid.keys.compact
    如果product_gids返回[].blank?

    Billing_records = fetch_billing_records(product_gids: product_gids)

    billing_records.每个做|billing_record|       
        billing_record.preload_product!(
            products_by_gid [billing_record.product_gid]
        )
    end
end

如果你认为它类似于a hash join, you’re not alone.

服务器端过滤和欠取

我们击退了最糟糕的请求高峰和平台方面的N+1问题. 不过,我们的反应仍然很慢. 我们确定它们是由于将太多数据加载到平台并在平台上进行过滤(客户端过滤)造成的。. 将数据加载到内存, serializing it, 通过网络发送, 而反序列化只是为了放弃大部分内容是一种巨大的浪费. 它在实现过程中很方便,因为我们有通用的和可重用的端点. 在行动中,它被证明是不可用的. 我们需要更具体的东西.

我们通过 向GraphQL添加过滤参数. 我们的方法类似于一个众所周知的优化,它包括将过滤从应用程序级别移动到数据库查询(find_all vs. where in Rails). 在数据库世界中,这种方法是显而易见的,并且可以作为 WHERE in the SELECT query. 在这种情况下,它要求我们自己实现查询处理(在Billing中)。.

我们部署了过滤器,并等待性能改进. 相反,我们在平台上看到了502个错误(我们的用户也看到了它们)。. Not good. Not good at all!

Why did that happen? 这种改变应该改善了响应时间,而不是破坏了服务. 我们无意中引入了一个微妙的bug. 我们在客户端保留了两个版本的API (GQL和REST). 我们逐渐切换到一个功能标志. 我们部署的第一个不幸的版本在遗留REST分支中引入了一个回归. 我们将测试重点放在GQL分支上,因此忽略了REST中的性能问题. 经验教训:如果搜索参数丢失, 返回一个空集合, 不是数据库中的所有内容.

Take a look at the NewRelic data for Billing. 我们在流量中断期间使用服务器端过滤部署了更改(我们在遇到平台问题后关闭了计费流量)。. 您可以看到,部署后的响应更快,更可预测.

图片:计费服务的NewRelic数据. 部署后的响应速度更快.

向GQL模式添加过滤器并不太难. 在这种情况下 GraphQL 真正引人注目的是我们获取了太多字段,而不是太多对象的情况. 使用REST,我们发送所有可能需要的数据. 创建通用端点迫使我们将其与平台上使用的所有数据和关联打包在一起.

使用GQL,我们可以选择字段. 而不是获取20多个需要加载多个数据库表的字段, 我们只选择了所需的三到五个字段. 这使我们能够在平台部署期间消除计费使用的突然高峰,因为其中一些查询是在部署期间运行的弹性搜索重新排序作业中使用的. 作为一个积极的副作用,它使部署更快、更可靠.

最快的请求是你没有提出的请求

我们限制了获取对象的数量和每个对象中打包的数据量. 我们还能做什么呢? Maybe 根本不获取数据?

我们注意到另一个有待改进的地方:我们经常使用平台上最后一个账单记录的创建日期, 我们打电话给账单来取钱. 我们决定不再在每次需要时同步获取它, 我们可以根据从账单发送的事件缓存它.

We planned ahead, 准备好的任务(四到五个), 然后开始努力让它尽快完成, 因为这些请求产生了很大的负载. 我们有两周的工作要做.

Fortunately, 就在我们出发后不久, 我们重新审视了这个问题,并意识到我们可以使用平台上已经存在的数据,但以不同的形式. 而不是添加新的表来缓存Kafka的数据, 我们花了几天时间比较计费和平台的数据. 我们还就是否可以使用平台数据咨询了领域专家.

最后,我们用一个DB查询替换了远程调用. 从性能和工作负载的角度来看,这都是一个巨大的胜利. 我们还节省了超过一周的开发时间.

图:使用DB查询而不是远程调用的性能和工作负载.

Distributing the Load

我们一个接一个地实现和部署这些优化, 然而,在某些情况下,账单的回应是 429 Too Many Requests. We could have 增加了Nginx的请求限制 但我们想更好地理解这个问题, 因为这是一个暗示,通信不像预期的那样运行. As you may recall, 我们可以在生产中承担这些错误, 因为它们对最终用户不可见(因为回退到直接调用).

这种错误每周日都会发生, 当平台为人才网络成员安排关于过期时间表的提醒时. 发送提醒, 作业获取相关产品的账单数据, 其中包括数千条记录. 我们优化的第一件事是批处理和预加载计费数据, 并且只获取必需的字段. 这两种技巧都是众所周知的,所以我们在这里就不详述了.

我们部署完毕,等待下一个星期天. 我们确信我们已经解决了这个问题. 然而,在周日,这个错误再次出现.

计费服务不仅在调度期间被调用,而且在向网络成员发送提醒时也被调用. 提醒在单独的后台作业中发送(使用 Sidekiq),所以预加载是不可能的. Initially, 我们认为这不会是一个问题,因为不是每个产品都需要提醒,而且提醒都是一次发送的. 提醒时间为网络成员所在时区的下午5点. 但是,我们忽略了一个重要的细节:我们的成员并不是均匀地分布在不同的时区.

我们给成千上万的网络成员安排提醒, 大约25%的人生活在同一个时区. 大约15%的人生活在人口第二多的时区. 在这些时区,当时钟在下午5点滴答作响时,我们不得不同时发送数百条提醒. 这意味着向计费服务发出了数百个请求, 哪个是服务无法处理的.

无法预加载计费数据,因为提醒是在独立的作业中安排的. 我们不能从账单中获取更少的字段,因为我们已经优化了这个数字. 将网络成员转移到人口较少的时区也是不可能的. So what did we do? 我们移动了提醒,只是一点点.

We added jitter 为避免所有提醒都在同一时间发送的情况而安排提醒的时间. 而不是安排在下午5点整, 我们把他们安排在两分钟之内, 在下午5:59到6:01之间.

我们部署了服务,等待下一个星期天, 确信我们最终解决了问题. 不幸的是,在周日,这个错误再次出现.

We were puzzled. 根据我们的计算, 这些请求应该分散在两分钟的时间内, which meant we’d have, at most, 每秒两个请求. 这不是我们处理不了的事. 我们分析了计费请求的日志和计时,我们意识到我们的抖动实现不起作用, 因此,这些请求仍然出现在一个紧密的群体中.

图片:由于抖动实现不足导致的高请求数.

是什么导致了这种行为? 这就是Sidekiq实现调度的方式. It polls redis every 10–15 seconds 正因为如此,它无法提供一秒的分辨率. 为了实现请求的统一分布,我们使用 Sidekiq::Limiter一个由Sidekiq Enterprise提供的类. 我们使用了窗口限制器,允许在一个移动的1秒窗口内发出8个请求. 我们选择这个值是因为我们有一个Nginx限制每秒10个请求计费. 我们保留了抖动代码,因为它提供了粗粒度的请求分散:它在两分钟的时间内分发Sidekiq作业. 然后使用Sidekiq Limiter来确保每组作业的处理不超过定义的阈值.

再一次,我们部署了它,等待周日. 我们确信我们最终解决了这个问题——我们确实解决了. The error vanished.

API优化:Nihil Novi Sub Sole

我相信你对我们采用的解决方案并不感到惊讶. Batching, server-side filtering, 只发送必需的字段, 限速并不是什么新技术. 经验丰富的软件工程师无疑会在不同的环境中使用它们.

预加载以避免N+1? 我们在每个ORM中都有. Hash joins? 甚至MySQL现在也有. Underfetching? SELECT * vs. SELECT field is a known trick. Spreading the load? 这也不是一个新概念.

那么我为什么要写这篇文章呢? Why didn’t we do it 从一开始? 和往常一样,语境是关键. 其中许多技术只有在我们实现它们之后,或者只有在我们注意到需要解决的生产问题时,才看起来很熟悉, 当我们盯着代码看的时候.

对此有几种可能的解释. 大多数时候,我们都在努力做到 这是最简单的方法 为了避免过度工程. 我们从一个无聊的REST解决方案开始,然后才转向GQL. 我们在特性标志后面部署更改, 监控一小部分流量下的所有行为, 并根据实际数据进行改进.

我们的发现之一是,在重构时很容易忽略性能下降(提取可以被视为重要的重构)。. 添加严格的边界意味着我们切断了为优化代码而添加的联系. 不过,直到我们测量了它们的表现,这一点才显现出来. 最后,在某些情况下,我们无法在开发环境中重现生产流量.

我们努力为账单服务提供一个通用HTTP API的小表面. As a result, 我们得到了一堆通用端点/查询,它们承载着不同用例所需的数据. 这意味着在许多用例中,大多数数据都是无用的. It’s a bit of a 在DRY和 YAGNI: With DRY, 使用YAGNI时,我们只有一个返回账单记录的端点/查询, 我们最终在端点中使用未使用的数据,这只会损害性能.

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

同样,这里没有什么新颖或突破性的东西. 它是关于识别不同上下文中的已知模式,并理解由更改引入的权衡. 我们从惨痛的教训中吸取了教训,希望我们不会重蹈覆辙. 而不是重复我们的错误,你无疑会犯自己的错误,并从中吸取教训.

特别感谢参与我们工作的同事和队友:

了解基本知识

  • 什么是内部和外部api?

    外部API是客户端或面向ui的API. 内部API用于服务之间的通信.

  • Why is GraphQL used?

    GraphQL允许实现灵活的API层. 它支持对数据进行范围界定和分组,以便客户端只获取所需的数据,并且可以在向服务器发出的单个请求中检索这些数据.

  • GraphQL比REST好吗?

    它们有不同的使用模式,不能直接比较. 在我们的例子中,GraphQL被证明是更合适的,但您的实际情况可能有所不同.

  • 如何优化API?

    这取决于API. 预加载和缓存数据, 限制从服务器获取的数据, adding jitter, 和速率限制可以用来优化内部API.

就这一主题咨询作者或专家.
Schedule a call

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

Toptal Developers

Join the Toptal® community.