バズった1日で Convex の I/O が 1,385GB・$267 溶けた
2日でユーザーが12倍になり、タイムラインが3回落ちた。3回とも原因は違ったが、構造は同じだった。リアクティブクエリのコストは「1回の重さ」ではなく「read-set × 再実行頻度 × 購読者数」の掛け算だった。
個人開発の iOS アプリが SNS で二段階にバズって、2日でユーザーが 643人から 7,667人になった。広告費はゼロ。
嬉しかったのは最初の数時間だけで、その日タイムラインは3回落ちた。そして翌日、請求を見たら 1日で Database I/O 1,385GB・$267 が溶けていた。
3回の障害は、原因が全部違った。だが構造は全部同じだった。
前提:Convex のクエリは購読である
Convex の query は、一度読んだら終わりではない。そのクエリが読んだデータ(read-set)に変更が入ると、購読しているクライアント全員分が自動で再実行される。
これがリアルタイム更新の正体で、普段はありがたい。いいねが付いた瞬間に全員の画面の数字が変わる。何も書かなくてよい。
だからコストを「1回のクエリが何ms・何MB」で見積もっていた。これが間違いだった。実際のコストはこうだ。
read-set の大きさ × それが1分に何回無効化されるか × 購読者数
平時はどの項も小さいので、掛け算になっていることに気づかない。3つ同時に増えると破裂する。バズはその3つを同時に増やす。
第1波:おすすめタイムラインが、スケルトンのまま固まる
最初に落ちたのは「おすすめ」タブだった。読み込みプレースホルダのまま止まって、いつまでも中身が出てこない。
ログを引くと、recommendFeedPaged が数秒おきにこれを吐いていた。
Your request couldn't be completed. Try again later.
これは Convex の負荷制限だった。ちなみに Convex のログには成功した実行は出ないので、こういうときエラーだけを拾えるのは都合がよかった。
原因は、まだ個人向けのおすすめキャッシュを持っていない閲覧者の経路だった。バズで入ってきたばかりの人は、全員がこの状態にあたる。
その経路は、リクエストのたびに直近240投稿をスコアリングしてランキングを作っていた。1回2〜2.5秒。それだけでも重いが、本当の問題はそこではなかった。
このクエリの read-set が「直近240投稿」だった。
つまり、誰かが1件投稿するか、誰かが1回いいねを押すたびに、その240件を購読しているコールドユーザー全員のクエリが一斉に再実行される。新規ユーザーが増えるほど購読者が増え、投稿といいねが増えるほど再実行の頻度が上がる。両方同時に起きていた。
飽和して負荷制限がかかり、クライアントが再試行して、さらに悪化する。典型的な thundering herd だった。
修正:globalRecommendCache という単一行のテーブルを作り、cron で3分おきに再計算する。コールド読者はその1行を読むだけにした。
read-set が「直近240投稿」から「1行」になる。1行しか読まないので、投稿やいいねでは再実行されない。3分に1回だけ更新される。
これは他でも使っていたパターンの3例目だった(人気タグ、殿堂入り、そしてこれ)。重い集計は cron でキャッシュ行に書き、読者は1行読む。
第2波:自分のデプロイが引き金になった
同じ日の夕方、今度は「フォロー中」タブが落ちた。
followingFeedPaged は、フォローしている人の投稿を新しい順に集めるために、投稿テーブルを新しい順に走査してフィルタしていた。1ページ分が埋まるまで、無制限に。
フォローしている人が多くて活発なユーザーなら、数十件も見れば1ページ埋まる。だがフォローが疎なユーザーだとフィルタにほとんど引っかからず、走査が止まらない。実際どうなっていたかというと、
| 1実行で読む件数 | 約14,900件(Convex の1実行あたり上限 16,384 の 91%) |
| 1実行で読むバイト数 | 約14.2MB(バイト上限の 85%) |
毎回、投稿テーブルをほぼ全件読んでいた。 上限の直前で、ぎりぎり動いていた。
そしてこれが破裂した引き金が、自分だった。16:31 に別件の修正を Convex にデプロイした。その1分後から障害が始まった。
デプロイは、それ自体が「全購読の一斉再実行」イベントだった。
ふだんは何でもない。だが read-set が巨大な購読を大量に抱えている状態でデプロイすると、その全部が同時に走り出す。飽和して、失敗して、リトライの群れが自走する。
修正:走査に maxScan を足して、1実行3,000件で打ち切るようにした。打ち切ってもカーソルを返すので、続きは次のページで読む。機能は変わらない。16:41 にデプロイして、16:44 に収束した。
その日の夜には恒久対応も入れた。テーブルを走査してフィルタするのをやめ、フォローしている相手ごとにインデックスで引いてマージする方式に変えた。読む量がフォロー数×ページサイズで決まるようになり、read-set もフォロー相手の投稿だけになる。無関係な人の投稿では再実行されなくなった。
余談だが、このページングは実装がややこしかった。マージと切り出しは生データのまま行い、可視性フィルタは出力の直前にかける必要がある。先に除外すると、カーソルと各相手の読み進み位置がずれて投稿を取りこぼす。合成データ500ケースで検証した。
第3波:購読すること自体をやめた
夜20時のピークで、3度目。今度はこれが出た。
Too many concurrent requests (256)
同時実行の上限に張り付いていた。
ここで、個別のクエリを速くするアプローチの限界を認めた。問題は個々のクエリではなく、フィードの read-set がホット投稿(=全員が同じものを見ている)であること自体だった。全員が同じデータを購読していれば、1件の書き込みが全員の再実行になる。何をどう速くしても、掛け算の構造は変わらない。
そこで、ホームの3つのフィードから購読をやめた。開いたときに1回取得して、あとは黙っている。引っ張って更新したときだけ取り直す。
考えてみれば、タイムラインに毎秒のリアルタイム性は要らなかった。いいねの数が1秒で反映されることに価値は無い。リアクティブが標準で付いてくるので、要るかどうかを考えていなかっただけだった。
購読として残したのは、read-set が極小の2つだけ。
- 最新投稿の時刻(1件だけ読む・「新着あり」のピルを光らせる用)
- 自分のキャッシュ行1行(引っ張って更新の完了検知用)
このとき踏んだ地雷も書いておく。Convex の api.* という関数参照は Proxy で、アクセスするたびに新しいオブジェクトが返る。これを useEffect の依存配列に入れると毎レンダーで別物と判定され、エフェクトが毎回発火して無限リフレッシュになる。実機で2回やった。
呼び出し回数で犯人を探すと、間違える
後日 Convex のサポートに問い合わせたら、内部の詳細な数字を出してくれた。7日間の累計で、Function Calls 2,060万、DB egress 1.9TB。
内訳が面白かった。
| 関数 | 呼び出し回数 | egress |
|---|---|---|
recommendFeedPaged | 730万回(全呼び出しの50%) | — |
followingFeedPaged | 39.9万回(約1/18) | 1.15TB(全体の61%) |
呼び出し回数が18分の1の関数が、転送量の6割を単独で食っていた。 1回あたり約3MB読んでいたからだ。
呼び出し回数のランキングだけ見ていたら、こちらは目に入らなかった。「何回呼ばれたか」と「いくらかかったか」は別の指標だと、請求書で教わった。
キャッシュ命中率もひどかった。recommendFeedPaged が 9%、followingFeedPaged が 3.5%。Convex はクエリ結果をキャッシュするが、read-set が頻繁に無効化されるとほぼ当たらない。命中率の低さは、read-set が大きすぎることの症状だった。
コードより先に、課金設定が爆弾だった
これは技術の話ではないが、いちばん肝が冷えた。
Convex には Spending Limit があり、上限に達するとプロジェクトが停止する。設定は初期値のまま $500 だった。
障害対応の合間に計算したら、最適化前のペースは1日$110。月初の4〜5日で$500に到達して、全部止まる計算だった。
金額の問題ではなかった。バズの最中に、しかもストア審査の再提出を控えたタイミングで、アプリが全断する。それが怖くて、上限を引き上げた。
平時に確認しておくべきだった。サービスごとに「止まる」か「課金される」かの仕様が違う。無料枠を超えたら止まるもの、黙って課金され続けるもの、警告だけ出るもの。波が来てから調べるのでは遅い。
結果
翌日の数字がこうなった。
| 7/22(障害当日) | 7/23(対策後) | |
|---|---|---|
| Database I/O | 1,385GB | 478.54GB |
| 請求 | $267 | $92.20 |
ユーザー数はむしろ増えている(その後さらに2万人を超えた)。それで I/O は3分の1弱になった。
正直に書いておくと、これで終わりではない。対策後も recommendFeedPaged は I/O の33%を占めていて、いまだに最大の食い手のままだ。プールの事前計算はしたが、ページングして読み出す部分自体がまだ重い。次に絞るならここだと分かっているが、まだ手を付けていない。
学んだこと
リアクティブなクエリのコストは、掛け算で考える。 read-set の大きさ × 無効化の頻度 × 購読者数。設計するときに「この read-set は1分に何回無効化されるか」を見積もる癖がついた。1回のクエリが速いことは、何の保証にもならなかった。
read-set に「直近N件」のような高頻度で変わる集合を入れない。 書き込み1件で全購読者が再実行される。ユーザー数に比例するコストを、リクエスト経路に置いてはいけなかった。
フィルタ一致率が読者によって変わる走査は、隠れた全件スキャンだと思う。 「ページが埋まるまで読む」は、埋まらない読者にとっては「全部読む」と同じだった。最悪ケース(一致ゼロ)で何件読むかを見積もって、上限で有界化する。
デプロイは全購読の一斉再実行イベント。 read-set の大きい購読を抱えたままデプロイすると、それ自体が引き金になる。ピーク時間帯のデプロイは、変更内容と無関係にリスクがある。
リアクティブが要るかどうかを、毎回考える。 標準で付いてくるので、要らない画面にも付けたままにしていた。タイムラインに毎秒の更新は要らなかった。購読をやめるのが、結果的にいちばん効いた対策だった。
上限の80%を超えている関数は、障害予備軍。 Convex の insights は読み取り件数・バイト数が上限に近づくと警告を出す。破裂する前に1,002件出ていた。読んでいなかった。