Webサービスにおける非同期処理の勘所

株式会社カミナシで VPoE を務めている pospome です。 (´・ω・`)

最近非同期処理について考えることがあったので、そのときに考えたことをサクッとまとめようと思います。 非同期処理について体系的にまとめたものではなく、あくまで "その時に考えた限定的な内容" になります。

Webサービスにおける非同期処理とは

本記事を読み進めるにあたって、 "非同期処理" の認識を合わせておきましょう。

Webサービスにおける非同期処理は AWS SQS, Google Cloud Pub/Sub などを利用するアレのことです。 以下のようにバックエンドサーバが AWS SQS などのメッセージキューにメッセージをキューイングして、ワーカーがそれを処理する・・・みたいなやつです。

クライアント -----> バックエンドサーバ -----> AWS SQS -----> ワーカー

Webサービスを開発する場合、多くの処理が同期的なものとして作られるのではないでしょうか。 しかし、たまーに非同期処理にしなければいけないことがあります。 本記事では、そのような非同期処理を扱う際に考慮するポイントを簡単にまとめます。

非同期処理はめんどくさい

基本的に非同期処理は面倒です。 可能であればやりたくありません。 なので、非同期処理は "同期処理では要件を満たせない時" に採用するのがいいと思っています。

同期処理では要件を満たせないケース

同期処理では要件を満たせないケースを説明します。

  • 巨大なCSVファイルのダウンロード処理
    これは非同期処理の王道ではないでしょうか。これを同期処理として実装すると利用者を待たせることになったり、DBに対する負荷が高くなってしまいます。同じようなケースとして "CSVのアップロード & DBへの登録処理" があります。

  • RPSの高い環境でレイテンシの高い外部APIを複数回叩く
    こちらも王道な気がしますね。バックエンドサーバに "レイテンシの高い外部APIを複数回叩くエンドポイント" があり、そのエンドポイントに数万RPSのリクエストが来るケースです。これを同期処理として実装すると、バックエンドサーバに通信が滞留することになり、バックエンドサーバのリソースを圧迫してしまいます。コネクションプールが枯渇したり、バックエンドサーバのスケール効率が悪かったりしますね。

こんな感じでユースケースを挙げていくとキリがないですが、 自分は "同期処理 or 非同期処理" の意思決定をする際に、それぞれのメリデメを網羅的に整理することはないです。 というのも、大体のケースで "同期処理だと厳しいから非同期処理を使う" というロジックで判断すればいいからです。 "同期処理だと要件を満たせないポイント" さえ適切に押さえていれば、「要件を満たすには非同期処理を使うしかないよね」で済みます。

そして、重要なのは "非同期処理を扱う際に何を考慮するか" です。 前述した通り、非同期処理は面倒です。 扱いを間違えるとリリース後の運用がめちゃくちゃ面倒になるので、ここを抑えておきましょう。

補足:
非同期処理は実装自体はそんなに難しくないですし、問題が発生することも少ないです。 「なんか意外と簡単だな」で終わるでしょう。 しかし、非同期処理を扱うにあたって、抑えなければいけないポイントを抑えていない場合、リリース後の運用で問題が発生します。 あとになって困るわけですね。

非同期処理の面倒なポイント

ここでは非同期処理の面倒なポイントを説明します。 面倒なポイントを抑えることで、適切に非同期処理を扱うことができます。 ここで挙げたポイントは実装に入る前の設計フェーズ(Design Docを記載するタイミングなど)で具体的に考慮しておいた方がいいです。

1. メッセージキュー/タスクキューに対する理解

非同期処理をするには、AWS SQS, Google Cloud Pub/Sub など、非同期処理を実現するための仕組みを利用する必要があります。 当たり前ですが、利用する仕組みの仕様はしっかりと認識しておきましょう。 クラウドサービスを利用する場合、意外といろんな機能、オプション、制限があるので、勉強するのが大変です。

クラウドサービスの仕様以外にも、例えば "メッセージキューに溜まったメッセージ数が多くなった際にワーカー数を手動でスケールさせる必要がある" という運用作業を想定していなかったり、それをアラート化できることを知らなかったり(それに必要なメトリクスが取れる)、いろいろありますね。とはいえ、ネット上にそれっぽい情報が転がっていたり、ChatGPTに質問すれば回答してくれたりするので、サボらず頑張って調べればいいだけだと思います。

ここをサボって「メッセージキューにメッセージを放り込めたぞ、よかった、よかった、リリースしよう」 みたいな開発をしていると、何かしらトラブります。

2. ワーカー処理が失敗したときのリカバリ方法を考える

メッセージキューにメッセージを放り込むと、ワーカーがそのメッセージを受け取り、特定の処理を開始します。 しかし、その処理が100%成功するとは限りません。 プログラミングミスで例外が発生して処理が途中で止まったり、ワーカーがOOMで死んだり、デッドレターキューに入ったり、原因はいろいろです。 非同期処理では、ワーカーの処理が失敗した場合のリカバリ方法を考える必要があります。

一番シンプルなリカバリ方法はエンジニアが手動でリカバリする方法です。 メッセージキューにメッセージを再度投げ込んであげましょう。 ジョブの失敗頻度が低い場合は、これで十分かもしれません。

しかし、この方法は意外と面倒です。 メッセージのペイロードがどのようなものになるのかをログなどから確認しなければいけないですし、 多くのケースでDBのレコードによるタスクの状態管理がされているので、DBの値も適切なものに設定し直す必要があるかもしれません。 メッセージキューとDBをあれこれ操作するのは意外と複雑な作業になるので、手順書を作ることになるでしょう。 しかし、たまにしか使われない手順書は最新のシステム仕様との整合性が取れていない可能性があります。 いざというときに実行すると "curl の送信したリクエストがエラーになる" みたいなことがあるかもしれません。 うーん・・・面倒ですね・・・。

「じゃあ自動化すればいいじゃないか」という発想になります(ワーカー処理が頻度高く失敗しても対応できますね)。 失敗した処理をDBに記録しておき、定期実行されるバッチでリトライしましょう。 しかし、これはこれで実装に手間がかかりますし、そのバッチが失敗した場合は対応が必要になります。 一度のバッチで処理できるタスク数を考えて設計する必要性もありそうですね・・・考えることは少なくないですね。

非同期処理は、このようなリカバリ処理をどう設計するかを考える必要があります。

3. ボトルネックの確認と対策

非同期処理を採用すると、バックエンドサーバはメッセージキューにメッセージをポンポン投げ込むだけなので、一見なんのボトルネックや負荷もなく、上手く動いてくれそうです。しかし、そのメッセージは最終的にどこかで処理されます。非同期処理を採用したからといって、ボトルネックや負荷がなくなるわけではないことに注意しましょう。

具体例として "DBから多くのデータを取得して、巨大なCSVファイルを作る" というユースケースで考えてみましょう。 バックエンドサーバはメッセージキューに "CSVファイルを作ってね" というメッセージをポンポン投げ込むだけです。 しかし、そのメッセージは最終的にワーカーが受取り、CSVを作成するのに必要なデータをDBから取得し、CSVファイルを作ります。 このとき、DBへのクエリが複雑であればあるほど、発行するクエリが多ければ多いほど、DBの負荷が高くなり、利用者に影響が出ます(巨大なCSVを作るという性質上、負荷の高いクエリを投げる可能性が高い)。利用者に影響はなくとも、メッセージが特定の時間帯に集中し、ワーカー数がクオータ上限に達してアラートが発火する・・・みたいなこともあるかもしれないですね。

ということで、何も考えずに、メッセージをメッセージキューに放り込むと、どこかがボトルネックになり、問題が発生する可能性があります。そもそもメッセージキュー自体にもクオータが設定されていますからね。ということで、非同期処理ではボトルネックの確認と対策が必要になります。

上記のCSVダウンロードのボトルネックがDBだとして、パッと思いつくだけでも以下のように色々な対策があります。 それぞれの対策には明確にメリデメがあります。 どの対策を採用するのかを考えるのも面倒ですね・・・。

  • 同時に実行できるワーカー数を制限する。
  • ワーカー用のDBインスタンスを用意する(いわゆる分析用DBみたいなやつ)。
  • メッセージキューにキューイングできるメッセージ数を制限する。
  • ワーカー処理に sleep を入れて、クエリ実行間隔を長くする。

非同期だからといって無限にトラフィックに対処できるわけではありません。何がボトルネックになり、それをどのように解決するのかを考えましょう。

補足:
非同期処理を導入する場合、上記の例のようにRDBボトルネックになる可能性が高いです。 非同期処理はハイトラフィックな環境で利用される事が多いので、RDBよりもNoSQLやNewSQLのような Read/Write がスケールするDBとの相性が良いです。 必要に応じて導入を検討すると良いと思います(とはいえ、そういったDBを導入するのも大変なので困りものですね・・・)。

補足:非同期処理を上手く扱えるようになろう

本記事では「非同期処理は面倒なので、可能な限り同期処理の方がいいよ」というスタンスですが、 非同期処理によって実現できるシステムのスケール性能やサービス間の疎結合化はとても魅力的です。 というか、ある程度の規模のシステムになると非同期処理を利用するのが当たり前になります。 なので、システムのスケールに合わせて非同期処理を上手く扱える人になったり、そういった組織を作っていったり、我々自身がそういった変化へ投資をしていくタイミングが来ると思います。そのときは覚悟を決めて頑張りましょう。

まとめ

本記事では非同期処理の勘所についてまとめました。 限定的な内容になっていますが(例えば分散トランザクション問題とかを取り上げていなかったり)、それでも何かの役に立てば嬉しいです。

宣伝

株式会社カミナシではエンジニアをすごーーーーく募集集しています。 興味のある方は応募してみてください。