請求の抽出:GraphQL内部API最適化の物語

公開: 2022-03-11

Toptalエンジニアリングチームの主な優先事項の1つは、サービスベースのアーキテクチャへの移行です。 このイニシアチブの重要な要素は、課金機能をToptalプラットフォームから分離して別のサービスとして展開するプロジェクトであるBillingExtractionでした。

過去数か月にわたって、機能の最初の部分を抽出しました。 請求を他のサービスと統合するために、非同期API(Kafkaベース)と同期API(HTTPベース)の両方を使用しました。

この記事は、同期APIの最適化と安定化に向けた取り組みの記録です。

インクリメンタルアプローチ

これが私たちのイニシアチブの最初の段階でした。 完全な請求の抽出への道のりで、私たちは段階的に作業を行い、本番環境に小さな安全な変更を提供するよう努めています。 (このプロジェクトの別の側面についての優れた講演のスライドを参照してください:Railsアプリからのエンジンの増分抽出。)

出発点は、モノリシックなRubyonRailsアプリケーションであるToptalプラットフォームでした。 まず、請求とToptalプラットフォームの間の継ぎ目をデータレベルで特定することから始めました。 最初のアプローチは、Active Record(AR)リレーションを通常のメソッド呼び出しに置き換えることでした。 次に、メソッドによって返されたデータをフェッチする課金サービスへのREST呼び出しを実装する必要がありました。

プラットフォームと同じデータベースにアクセスする小規模な課金サービスを導入しました。 HTTP APIを使用するか、データベースへの直接呼び出しを使用して、請求を照会することができました。 このアプローチにより、安全なフォールバックを実装できました。 何らかの理由(不適切な実装、パフォーマンスの問題、デプロイの問題)でHTTPリクエストが失敗した場合は、直接呼び出しを使用して、正しい結果を呼び出し元に返しました。

移行を安全かつシームレスにするために、機能フラグを使用してHTTP呼び出しと直接呼び出しを切り替えました。 残念ながら、RESTで実装された最初の試みは、許容できないほど遅いことが判明しました。 ARリレーションをリモートリクエストに置き換えるだけで、HTTPが有効になっているときにクラッシュが発生しました。 比較的少数の呼び出しに対してのみ有効にしましたが、問題は解決しませんでした。

根本的に異なるアプローチが必要であることはわかっていました。

請求内部API(別名B2B)

クライアント側の柔軟性を高めるために、RESTをGraphQL(GQL)に置き換えることにしました。 今回の結果を予測できるように、この移行中にデータに基づいた意思決定を行いたかったのです。

そのために、Toptalプラットフォーム(モノリス)からのすべてのリクエストを請求に組み込み、詳細情報(応答時間、パラメーター、エラー、さらにはそれらのスタックトレース(プラットフォームのどの部分が請求を使用しているかを理解するため))をログに記録しました。 これにより、ホットスポット(多くのリクエストを送信するコード内の場所、または応答が遅い場所)を検出できました。 次に、スタックトレースパラメーターを使用して、問題をローカルで再現し、多くの修正のための短いフィードバックループを作成できます。

本番環境での厄介な驚きを避けるために、別のレベルの機能フラグを追加しました。 APIのメソッドごとに1つのフラグがあり、RESTからGraphQLに移行しました。 HTTPを徐々に有効にして、ログに「何か悪い」が表示されるかどうかを監視していました。

ほとんどの場合、「何か悪い」とは、応答時間が長い(数秒)、 429 Too Many Requests 、または502 Bad Gatewayのいずれかでした。 これらの問題を修正するために、いくつかのパターンを採用しました。データのプリロードとキャッシュ、サーバーからフェッチされるデータの制限、ジッターの追加、レート制限です。

プリロードとキャッシング

最初に気付いた問題は、SQLのN + 1の問題と同様に、単一のクラス/ビューから送信されるリクエストの洪水でした。

Active Recordのプリロードはサービスの境界を越えて機能しなかったため、リロードごとに1ページで最大1,000件のリクエストを請求に送信していました。 1ページから1000件のリクエスト! 一部のバックグラウンドジョブの状況はそれほど良くありませんでした。 私たちは、数千ではなく数十のリクエストを行うことを好みました。

バックグラウンドジョブの1つは、ジョブデータをフェッチし(このモデルをProductと呼びます)、請求データに基づいて製品を非アクティブとしてマークする必要があるかどうかを確認しました(この例では、モデルBillingRecordと呼びます)。 製品はバッチでフェッチされましたが、請求データは必要になるたびに要求されました。 すべての製品には請求記録が必要だったため、すべての製品を処理すると、請求サービスにそれらを取得するように要求されました。 これは、製品ごとに1つのリクエストを意味し、1回のジョブ実行から約1,000のリクエストが送信されました。

これを修正するために、請求レコードのバッチプリロードを追加しました。 データベースからフェッチされた製品のバッチごとに、請求レコードを1回要求してから、それぞれの製品に割り当てました。

 # 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つのリクエストにより、ジョブごとのリクエストは最大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にフィルタリング引数を追加することで、この問題に対処しました。 私たちのアプローチは、フィルタリングをアプリレベルからDBクエリに移動することで構成されるよく知られた最適化に似ていました( find_allとRailsのwhere )。 データベースの世界では、このアプローチは明白であり、 SELECTクエリのWHEREとして利用できます。 この場合、(請求で)自分でクエリ処理を実装する必要がありました。

フィルタを展開し、パフォーマンスが向上するのを待ちました。 代わりに、プラットフォームで502のエラーが発生しました(ユーザーもエラーを確認しました)。 良くない。 全然良くない!

なぜそれが起こったのですか? この変更により、サービスが中断されるのではなく、応答時間が改善されたはずです。 不注意で微妙なバグを導入しました。 クライアント側で両方のバージョンのAPI(GQLとREST)を保持しました。 フィーチャートグルで徐々に切り替えていきました。 私たちが展開した最初の不幸なバージョンは、レガシーRESTブランチにリグレッションを導入しました。 テストはGQLブランチに集中したため、RESTのパフォーマンスの問題を見逃しました。 教訓:検索パラメーターが欠落している場合は、データベースにあるすべてのものではなく、空のコレクションを返します。

BillingのNewRelicデータを見てください。 トラフィックが落ち着いているときにサーバー側のフィルタリングを使用して変更を展開しました(プラットフォームの問題が発生した後、課金トラフィックをオフにしました)。 展開後の応答がより速く、より予測可能であることがわかります。

画像:課金サービスのNewRelicデータ。展開後の応答は速くなります。

GQLスキーマにフィルターを追加することはそれほど難しくありませんでした。 GraphQLが本当に輝いたのは、あまりにも多くのオブジェクトではなく、あまりにも多くのフィールドをフェッチした場合でした。 RESTを使用すると、必要になる可能性のあるすべてのデータを送信していました。 汎用エンドポイントを作成すると、プラットフォームで使用されるすべてのデータと関連付けをエンドポイントにパックする必要がありました。

GQLを使用して、フィールドを選択することができました。 複数のデータベーステーブルをロードする必要がある20以上のフィールドをフェッチする代わりに、必要な3〜5個のフィールドのみを選択しました。 これにより、プラットフォームのデプロイ中に請求の使用量が急増するのを防ぐことができました。これは、これらのクエリの一部が、デプロイ中に実行されるElasticSearchReindexingジョブによって使用されたためです。 プラスの副作用として、展開がより速く、より信頼できるものになりました。

最速のリクエストはあなたがしないものです

フェッチされるオブジェクトの数と、すべてのオブジェクトにパックされるデータの量を制限しました。 他に何ができるでしょうか? たぶん、データをまったくフェッチしませんか?

改善の余地がある別の領域に気づきました。プラットフォームで最後の請求レコードの作成日を頻繁に使用しており、そのたびに請求を呼び出して取得していました。 必要になるたびに同期的にフェッチするのではなく、請求から送信されたイベントに基づいてキャッシュできるようにすることにしました。

私たちは事前に計画を立て、タスク(4〜5つ)を準備し、それらの要求が大きな負荷を生成していたため、できるだけ早くタスクを実行するように作業を開始しました。 2週間前に仕事がありました。

幸いなことに、開始して間もなく、問題を再検討し、すでにプラットフォーム上にあるが別の形式のデータを使用できることに気付きました。 Kafkaからのデータをキャッシュするために新しいテーブルを追加する代わりに、請求とプラットフォームからのデータを比較するために数日を費やしました。 また、プラットフォームデータを使用できるかどうかについて、ドメインの専門家に相談しました。

最後に、リモート呼び出しをDBクエリに置き換えました。 これは、パフォーマンスとワークロードの両方の観点から大きな成果でした。 また、1週間以上の開発時間を節約しました。

画像:リモート呼び出しの代わりにDBクエリを使用した場合のパフォーマンスとワークロード。

負荷の分散

これらの最適化を1つずつ実装して展開していましたが、請求が429 Too Many Requestsで応答した場合もありました。 Nginxのリクエスト制限を増やすこともできましたが、通信が期待どおりに動作していないことを示唆しているため、問題をよりよく理解したいと思いました。 ご存知かもしれませんが、エンドユーザーには表示されなかったため(直接呼び出しへのフォールバックのため)、本番環境でこれらのエラーが発生する可能性があります。

このエラーは、毎週日曜日に、プラットフォームが期限切れのタイムシートに関するタレントネットワークメンバーへのリマインダーをスケジュールするときに発生しました。 リマインダーを送信するために、ジョブは数千のレコードを含む関連製品の請求データをフェッチします。 それを最適化するために最初に行ったのは、請求データのバッチ処理とプリロード、および必須フィールドのみのフェッチでした。 どちらもよく知られているトリックなので、ここでは詳しく説明しません。

展開して次の日曜日を待ちました。 私たちは問題を解決したと確信していました。 しかし、日曜日に、エラーが再び表面化しました。

課金サービスは、スケジュール中だけでなく、ネットワークメンバーにリマインダーが送信されたときにも呼び出されました。 リマインダーは(Sidekiqを使用して)別々のバックグラウンドジョブで送信されるため、プリロードは問題外でした。 当初は、すべての製品にリマインダーが必要なわけではなく、リマインダーがすべて一度に送信されるため、問題はないと想定していました。 リマインダーは、ネットワークメンバーのタイムゾーンで午後5時にスケジュールされます。 ただし、重要な詳細を見逃していました。メンバーがタイムゾーン全体に均一に分散されていません。

私たちは何千ものネットワークメンバーにリマインダーをスケジュールしていました。そのうちの約25%は1つのタイムゾーンに住んでいます。 約15%が2番目に人口の多いタイムゾーンに住んでいます。 これらのタイムゾーンで時計が午後5時を刻むと、一度に何百ものリマインダーを送信する必要がありました。 これは、課金サービスへの何百ものリクエストのバーストを意味し、サービスが処理できる以上のものでした。

リマインダーは独立したジョブでスケジュールされているため、請求データをプリロードすることはできませんでした。 すでにその数を最適化していたため、請求から取得できるフィールドの数を減らすことはできませんでした。 ネットワークメンバーを人口の少ないタイムゾーンに移動することも問題外でした。 それで、私たちは何をしましたか? リマインダーを少しだけ移動しました。

すべてのリマインダーがまったく同時に送信される状況を回避するために、リマインダーがスケジュールされた時間にジッターを追加しました。 午後5時に急いでスケジュールする代わりに、午後5時59分から午後6時1分までの2分の範囲内でスケジュールしました。

私たちはサービスを展開し、次の日曜日を待って、最終的に問題を修正したと確信しました。 残念ながら、日曜日にエラーが再び発生しました。

戸惑いました。 私たちの計算によると、リクエストは2分間に分散されているはずです。つまり、1秒あたり最大2つのリクエストがあります。 それはサービスが処理できないものではありませんでした。 請求リクエストのログとタイミングを分析したところ、ジッターの実装が機能していないことがわかりました。そのため、リクエストは依然として緊密なグループに表示されていました。

画像:不適切なジッターの実装が原因で発生したリクエストの数が多い。

その動作の原因は何ですか? これは、Sidekiqがスケジューリングを実装する方法でした。 10〜15秒ごとにredisをポーリングするため、1秒の解像度を提供できません。 リクエストの均一な分散を実現するために、SidekiqEnterpriseが提供するクラスであるSidekiq::Limiterを使用しました。 1秒ウィンドウの移動に対して8回のリクエストを許可するウィンドウリミッターを採用しました。 請求時に1秒あたり10リクエストのNginx制限があったため、この値を選択しました。 粗粒度のリクエスト分散を提供するため、ジッターコードを保持しました。2分間にわたってSidekiqジョブを分散しました。 次に、Sidekiq Limiterを使用して、定義されたしきい値を超えることなく、ジョブの各グループが処理されるようにしました。

もう一度、それを展開して日曜日を待ちました。 私たちは最終的に問題を修正したと確信していました—そして私たちは修正しました。 エラーは消えました。

API最適化:NihilNoviサブソール

私たちが採用したソリューションには驚かなかったと思います。 バッチ処理、サーバー側のフィルタリング、必要なフィールドのみの送信、およびレート制限は、新しい手法ではありません。 経験豊富なソフトウェアエンジニアは、間違いなくさまざまな状況でそれらを使用しています。

N + 1を回避するためのプリロード? すべてのORMにあります。 ハッシュが参加しますか? MySQLでさえ今それらを持っています。 アンダーフェッチ? SELECT * SELECT fieldは既知のトリックです。 負荷を分散しますか? それも新しい概念ではありません。

では、なぜ私はこの記事を書いたのですか? なぜ最初からやらなかったのですか? いつものように、コンテキストが重要です。 これらの手法の多くは、実装した後、またはコードを見つめたときではなく、解決する必要のある本番環境の問題に気付いたときにのみ見慣れたものに見えました。

それについてはいくつかの考えられる説明がありました。 ほとんどの場合、私たちは過剰設計を回避するために機能する最も単純なことをしようとしていました。 退屈なRESTソリューションから始めて、それからGQLに移行しました。 機能フラグの背後に変更を展開し、トラフィックの一部ですべてがどのように動作するかを監視し、実際のデータに基づいて改善を適用しました。

私たちの発見の1つは、リファクタリング時にパフォーマンスの低下が見落とされやすいことでした(そして、抽出は重要なリファクタリングとして扱うことができます)。 厳密な境界を追加するということは、コードを最適化するために追加されたタイをカットすることを意味しました。 ただし、パフォーマンスを測定するまでは明らかではありませんでした。 最後に、開発環境で本番トラフィックを再現できない場合がありました。

私たちは、課金サービスのユニバーサルHTTPAPIの小さな表面を持つように努めました。 その結果、さまざまなユースケースで必要なデータを伝送する多数のユニバーサルエンドポイント/クエリを取得しました。 つまり、多くのユースケースでは、ほとんどのデータが役に立たなかったということです。 DRYとYAGNIの間のトレードオフです。DRYでは、請求レコードを返すエンドポイント/クエリが1つしかないのに対し、YAGNIでは、パフォーマンスを低下させるだけの未使用のデータがエンドポイントに存在します。

また、課金チームとジッターについて話し合うときに、別のトレードオフに気づきました。 クライアント(プラットフォーム)の観点からは、プラットフォームが必要とするときに、すべての要求が応答を受け取る必要があります。 パフォーマンスの問題とサーバーの過負荷は、課金サービスの抽象化の背後に隠されている必要があります。 請求サービスの観点から、負荷に耐えるためにクライアントにサーバーのパフォーマンス特性を認識させる方法を見つける必要があります。

繰り返しますが、ここでは斬新で画期的なものはありません。 それは、さまざまなコンテキストで既知のパターンを識別し、変更によってもたらされるトレードオフを理解することです。 私たちは困難な方法を学びました。私たちはあなたが私たちの過ちを繰り返すことを免れたことを願っています。 私たちの過ちを繰り返す代わりに、あなたは間違いなくあなた自身の過ちを犯し、それらから学ぶでしょう。

私たちの取り組みに参加してくれた同僚やチームメートに特に感謝します。

  • マカール・エルモキン
  • ガブリエレ・レンジ
  • サミュエルベガカバレロ
  • ルカ・ギディ