数千rpsをさばくために高頻度カウンタだけ Postgres から Redis に逃がした話
クライアントのプロダクト開発支援で、数千rps規模のリアルタイムなデータ同期を支える機能を見ています。多くの利用者が短時間に一斉に送るリアクション(「いいね」「拍手」のようなカウント)を集計して、別の画面へリアルタイムに反映する、という類のものです。
ピーク時には、この書き込みが一瞬に集中します。想定ピークはだいたい 3,500 リクエスト/秒です。この負荷にバックエンドが耐えられるか、負荷テストをすることになりました。
結論から言うと、集計を持っていた Postgres が数百rpsで頭打ちになり、その1機能だけを Redis に逃がしました。犯人だと思っていたものが実は違った、という切り分けが面白かったので残しておきます。
もとの作り:種類ごとに1行のカウンタ
リアクションの集計は Supabase(Postgres)に、こういう1テーブルで持っていました。種類ごとに1行だけを持つ、ただのカウンタです。DB は Supabase の一番小さいプラン(Micro)で動かしていて、費用はできるだけ抑えたい前提でした。
-- kind ごとに1行だけ。1スタンプ=1行ではない
create table reaction_tally (
kind text primary key,
count bigint not null default 0
);
送信側は、クライアントで 700ms ごとにタップ数をまとめてから Server Action を1回呼び、サーバは受け取った件数をインクリメントします。誰がいつ押したかのログは残さず、種類別の総数だけが積み上がる設計です。
-- 受け取った件数 n を該当 kind の行に足すだけ
insert into reaction_tally(kind, count) values (p_kind, p_n)
on conflict (kind) do update set count = reaction_tally.count + excluded.count;
この作りだと行が増えず、読み取りも速くなります。表示側はこのカウンタを数秒ごとに読むだけです。
負荷テスト:数百rpsで詰まった
このカウンタに向けて、rps を段階的に上げていきました。すると、目標の 3,500rps のはるか手前、300〜440rps あたりで頭打ちになりました。
しかも、そこから rps を上げても実効スループットは伸びず、レイテンシだけが中央値で約2秒まで膨らみ、15〜20% が失敗します。典型的な飽和です。想定ピークの1割ほどしか捌けていません。
犯人は「ホット行のロック競合」だと思った
真っ先に疑ったのは、行ロックの競合です。実際に画面へ出す種類は3つに絞っていて、書き込みはその3行に集中します。同じ行を同時に更新できるのは常に1トランザクションなので、ここで直列化して詰まっているのだろう、と考えました。
そこで、集計カウンタをやめて「1スタンプ = 1行 INSERT」の追記型に変えた版を用意し、同じ条件で叩いてみました。追記なら同じ行を奪い合わないので、ロック競合が犯人ならここで大きく伸びるはずです。
ところが、伸びたのは 1.5倍ほど。数百rpsが数百rpsのままで、桁は変わりませんでした。行ロックは主犯ではなかった、ということです。
疑いは「1リクエスト = 1コミット」へ
追記型に変えても頭打ちは同じでした。UPSERT でも INSERT でも共通しているのは「1リクエストにつき Postgres へ1回コミットする」ことです。ここが効いているのでは、と見立てました。
コミットのたびに、Postgres は「書いたデータが消えないよう、ディスクに確実に書き切る」処理をします(WAL への fsync)。1回なら一瞬ですが、毎スタンプごとに走ると、この確定処理の積み重ねが効いてきます。書き方(UPSERT / INSERT)を変えても「毎スタンプ1コミット」である限り、同じ天井に当たる、という理屈です。
だとすると効くのは書き方ではなく、このホットパスを Postgres のコミットから外すことのはず。そう見当をつけました。
集計カウンタだけを Redis に逃がす
そこで、リアクションのカウンタを Upstash の Redis に移しました。Upstash はHTTPで叩けるサーバレスの Redis で、Vercel とも繋ぎやすいマネージドサービスです。
やることは、加算を Postgres の UPSERT から Redis の INCRBY に置き換えるだけです。インメモリのアトミックな加算なので、コミット(fsync)が要りません。
// Postgres の UPSERT をやめて Redis の INCRBY に
await redis.incrby(`reaction_tally:${kind}`, n);
表示側は種類ぶんのキーを MGET でまとめて読みます。ここで大事なのは、全部を Redis に移したわけではないことです。認証も本来の業務データも、消えては困るものは Supabase のまま。移したのは「大量に来て、消えても構わない集計値」だけです。適材適所の部分オフロードです。
結果
Redis に移したあと、同じ負荷をかけ直しました。DB を直接叩く経路で、成功率を保ったまま 約 3,000rps・レイテンシ数十ms 台まで伸びました。しかもこの 3,000rps は負荷をかけた単一マシン側の頭打ちで、Redis 自体はまだ余裕がありそうでした。数百rpsで詰まっていたのが、想定ピークの 3,500rps に迫るところまで一気に改善しました。
Redis 側(Upstash)も 429 は出ず、この rps を問題なく処理しています。
まとめ
高頻度で叩かれるカウンタは、単一行のロック競合より、「1リクエスト = 1コミット」のほうが効いてくるようでした。書き方(UPSERT / INSERT)を変えても頭打ちは変わらず、ホットパスをインメモリの Redis に逃がして初めて桁が変わりました。
消えても構わない集計値なら、ホットパスをインメモリの Redis に逃がすのが素直で、効き方も桁で変わります。全部を移すのではなく、「大量・揮発でよい」ものだけを選んで逃がすと、既存のデータ基盤はそのまま活かせます。
コスト面でも都合がよく、DB を上のプランに上げれば費用がかかりますが、この用途なら Upstash の無料プランで足りそうでした。追加のお金をかけずにピークをさばける見込みが立ったのは、ありがたい点です。
もう一つの収穫は、ボトルネックは思い込みで決めず実験で潰す、という当たり前でした。「ホット行のロックだろう」という最初の仮説は、追記型で叩いてみて初めて否定できました。
Anycloudでは一緒に働くメンバーを募集しています!
Anycloudは、ユーザーの心を動かす体験を届けることを大切にしています。フルリモート・フルフレックスの環境のもと、ライフスタイルに合わせた働き方を実現しながら挑戦したい方を歓迎します。詳細はこちらをご覧ください。