分散キューという名の苦しみ
TL;DR 分散システムにおいてキューを導入する場合、本当にキューが必要なのか再考すべき。そこが地獄の入り口だから。
システムの抽象
コンピュータの世界は、本来は0と1の信号の羅列が飛び交う無機質なものである。でも人類は信号だけですべてを語らず、様々な喩えを定義してきた。それはデスクトップ・ウィンドウ・マウスカーソルといったグラフィカルな表現に留まらず、パケットやカプセル化といった用語にロック・キュー・リスト・木などのアルゴリズムやデータ構造の世界にも自然に溶け込んでいる。これらはすべて人間の理解を助けるための喩え話に過ぎず、この喩えこそが人間のより直感的な理解をもたらす一方で、発想の制約を生み出してきた。
人間が大きなシステムを作るときも何らかの喩えを用いてシステム全体を整理する。アーキテクチャの「ポンチ絵」を描いて情報共有をするのは企業に勤めていれば経験した人も多いだろう。パワーポイントで円柱を描いて「MySQL」とか書かれた図形や、そこに向かって矢印が刺さったり、そこから矢印を伸ばしたりといったありがたい図がやりとりされるのは別に珍しくない。複雑なシステムを頭の中に押し込むには多かれ少なかれ何らかの抽象化を用いるのは人間の正しい姿勢である。
キューの登場
しかし、その抽象化は時として大きな設計ミスを生み出す事がある。その典型例がキューである。
キューとは待ち行列であり、積まれたタスクのメタファーであり、作業途中のデータの置き場でもある。パワポに横向きの筒を描いて発生したタスクを矢印で伸ばして投入すれば直感的なシステムアーキテクチャの出来上がりだ。実際に分散システムにおいてキューの出現頻度は高く、AWSならAmazon SQSというサービスも展開されている。
分散システムでキューを使って単純化するメリットは大きい。データを追加する側(プロデューサー)はキューにデータを登録できればそこで手放しができるし、データを受け取る側(コンシューマー)は自分のペースでデータを取り出して処理すれば良い。手の空いたワーカーがデータを受け取っていくので、タスクの粒度がバラバラでも自然とロードバランスが理想に近い形で実現できる。プロデューサーとコンシューマーどちらかの負荷が高いのならワーカーをクラウド上に立てて追加すれば処理性能もスケールする。それは理想的なクラウドのデザインパターンの一つでもある。FIFOなキューとPub/Subはまた違うものであるが、どちらも大差ないと言わんばかりにRabbitMQとかMQTTとかKafkaとかそんな名前で分散システムのポンチ絵の中で横向きの円筒が頻出している。
しかし、このキューを用いたパターンはパワポの絵から実体化して運用に乗せるとびっくりするほど多くの地雷を踏むことになる。人類がやりがちなミスというのはよくパターン化するもので、この手の抽象化において踏み抜くミスとは「人間はひとたびキューをシステム内に置くと、それが際限なく伸びると仮定しがち」というものである。無意識に面白いぐらいこのアンチパターンにハマる。そうして発生する「キューの利用時あるある」を以下に列挙する。
流入速度に追いつかず破綻
まず流出ペースを少しでも流入ペースが上回った場合、キューに貯まるタスクは青天井に増え続ける。システムにアラートが上がって様子を見に行ったら未完了タスクが30万件とか積み上がってて絶望する事になる。コンシューマー側は余裕を持ってスケールするよう設計する必要がある。また、コンシューマーがどうしても別の理由で一定以上スケールできないのであれば、プロデューサー側がそれ以上タスクをエンキューしないよう自制しなくてはならない。
下手なリトライ戦略による破綻
次に、コンシューマーが作業途中で離脱した場合、分散システムでは「これから離脱するよ」といったシグナルが送られることは期待できないので自然とタイムアウトの形で離脱を検知しキューに戻す事になる。キューに戻すというのをエンキューにより実現するのは直感的だが実はやってはいけないパターンである。その挙動は無意識に際限ないサイズのキュー長を仮定しており、その再エンキュー自体が待たされてコンシューマーが動けなくなる可能性を考慮の外に置いてしまっている。そういった事態を回避するにはコンシューマーは単純にキューのエントリを消費するのではなく、Ackをキューに投げ込むまでの間はエントリーを借りているに過ぎず、データを保持する責任はキュー側が一貫して持ち続けるというアーキテクチャを取る必要がある。まともなキューはそれをサポートしている。
失敗を誤検知してリトライしての破綻
更にはこのブログの中で繰り返し言っているが、タイムアウト検知によるリトライというのは直感に反して非常に難しい。分散システムにおいて、過負荷による返答の遅れと故障とを明確に切り分けるのは至難の技である。故障だと勘違いして他のワーカーがタスクを引き継いだら、実は生きていた元のワーカーと同じデータによる異なる更新でレースコンディションなんてスケジュールを考えたくないのが人情である。無事タスクが終わったのでとキューにAckを返そうとしたらタッチの差で遅延と見做され他のワーカーにデータを再送されたりといった可能性を考えだすとこのデザインパターンはコーナーケースがいくつも出てくる。コンシューマーの操作は冪等になるよう設計するぐらいしか迂回しようがない。Amazon SQSだと失敗続きのキューエントリは別の場所に放り込んで破綻の回避や原因分析に役立てるデッドレターキューという仕組みがある。
Push通知に命預けすぎ問題
そして、Push通知を受け取る事でコンシューマーの通信負荷を下げようというパターンもよくあるがPush通知自体は相手の都合を考えずに送るものであるので相手の負荷状況によっては無視されることもザラにありうる。Push通知は高負荷時には当てにならないと考えてクリティカルなものなら定期的にポーリングする方が確実である。Exactly OnceとかAtleast Once対応なんて喧伝されていることはよくあるが、仮にそのような信頼性をミドルウェアのレイヤーだけで解決しているとしたら、そのミドルウェア自体がSPoF(単一障害点)となるだろう。
このように、絵的に直感的に見えるアーキテクチャの影には数々の落とし穴が隠れている。キューにデータの永続性や負荷のロードバランスやシステムの単純さをしわ寄せしたのだから当然と言える。
対策
ではどうすれば良いか。まず、アプリケーションの要件として本当にキューを必要としている場合にはおとなしくキューを使うべきである。例えばTCPのプロトコルスタックを実装するならキューを使うのは普通である。しかし、キューは抽象化の一つに手段に過ぎず、少し頭をひねってみれば問題によっては必ずしもキューという抽象化が正しいとは限らないパターンに気付けることもある。
おすすめのパターンの一つはRDBに入れてしまう事である。取り組むべき対象をDBの1行にして、更新時刻と現在何を待っているかを書き表す。例えばとあるデータに対してA→B→Cという3つの操作を順番に行う場合、stateとでも名づけた属性の上で 「A待ち」「B待ち」「C待ち」「完了」という状態の上をトランザクションで遷移させる。その際、例えばAの操作を行うワーカーが消失したら時刻の経過を理由に他のワーカーがAを引き継いでB,Cと処理を遷移させて完了にした後で、最初のワーカーが戻ってきて「B待ち」に状態を戻さないように適切に冪等化しなくてはならない。
また、同じ操作は何度やっても同じ結果か、より適切なデータによる上書きしか行われないよう、例えばUUIDなどを予め決定してリトライ時の副作用を適切に設計しなくてはならない。
Exactly Onceな実行というのは何か特定のミドルウェアを使えば魔法のようにいつのまにか解決されるものではなく、アプリ層まで深く食い込んで設計されて初めてなし得る物である。
他にも、結局欲しいのは永続化されてて入力速度と出力速度の緩衝装置に過ぎない順序付きのストレージに過ぎない事も良くある。S3なりDynamoDBなり信頼できるストレージに放り込んで順序に関しては別の手段で解決するほうが、困難を分割して対処できるようになるので手頃でおすすめである。
まとめ
分散システムにおいてキューという抽象化で設計を行うと、一気にいろんな問題を解決したように見える一方、発生する大量の問題を無意識の内に都合の良い架空の無限で壊れないキューの中にしわ寄せしたに過ぎない状態に陥っている事が良くある。キューという喩えに惑わされず、解くべき課題をアプリのレイヤーから考えなおしてみれば、自然と強固な分散システムに到れるはずである。
ykanazawa1999氏の画像をCC BY-NC-SA 2.0ライセンスのもと使用しています。
Traffic Jam under JR Higashi-kanagawa Station | There is a t… | Flickr