はじめに
社内の広告運用者向けダッシュボードで、画面上部の「集計パネル」の表示が遅いという問題に1ヶ月ほど取り組みました。
細かいチューニングを重ねても 7秒台で止まってしまい、最後は 「リクエストのたびに BigQuery を読む」こと自体をやめる 作りに変えて、0.58秒まで縮めました。
この記事では、次の3つを書きます。
- 細かい改善が頭打ちになるまでの記録と、何が効いて何が効かなかったか
- 1日1回作る集計のスナップショットを Cloud Run のメモリに載せておく構成
- 作り置きにしたことで生まれる「壊れたスナップショットを配ってしまう」リスクへの備え
数字はすべて検証環境(staging)で測ったものです。本番への切り替えはこれからです。
前提:何を表示しているか
広告クリエイティブ(CR)ごとに、1日ごとの成果を見て段階的に判定し、その分布や内訳を集計パネルに出しています。
- 目標は 3秒以内、合否の判定ラインは 5秒(7回測って全回)
- 測定は 1年後に見込むデータ量 を再現した合成データで実施しました
- 同じクリエイティブをまとめたグループが約11万件
- 日次の行が約28万行、広告の行が約30万行
- 見えてよいデータは閲覧者ごとに違い、許可された広告アカウントの範囲だけを集計します
改善前の構成
パネルを1回表示するたびに、API サーバが次の処理を順番に行っていました。
- 閲覧者に許可されたアカウントで絞った行を BigQuery から読む
- 行をクリエイティブ単位のグループにまとめる
- 判定してパネルを組み立てる
最初に測ったときは、中央値 13.6秒、3秒以内は 9回中 0回でした。時間の大半は BigQuery からの読み取り(約10秒)です。
細かい改善の記録
内訳を計測ログに出し、大きいところから順に削りました。
| 段階 | やったこと | 中央値 |
|---|---|---|
| 0 | 最初(BigQuery の REST API で結果を取得) | 13.6秒 |
| 1 | BigQuery Storage Read API で読む | 11.2秒 |
| 2 | 週ごとの集計を、グループごとに1回のループで作る | 8.5秒 |
| 3 | 受け取ったデータの復号を、行単位から列単位に変える | 8.5秒 |
| 4 | グループ化の索引に JSON.stringify の文字列キーを使うのをやめる |
7.3秒 |
段階1と2の間で、合成データの中身(文字列の長さなど)を本番の実測に合わせて作り直しました。そのため、段階0の 13.6秒と後の段階は厳密には同じ条件ではありません。同じデータで比べられるのは、段階4(7.3秒)と最終形(0.58秒)です。
効いたもの
- Storage Read API:クエリ結果をストリームで受け取れます。REST API でページを順に取るより速くなりました
- 列単位の復号:復号だけを取り出して試すと、表によって約1,000ms → 約250ms、約1,250ms → 約90ms に縮みました
-
文字列キーをやめる:グループ化の索引を作る時間の約6割が、
JSON.stringifyで複合キーを作る処理でした
効かなかったもの
- ストリームの並列化:8本を要求しても、実際に返ってくるのは毎回1本でした
- V8 の GC 設定の調整:0.4〜0.8秒しか縮みませんでした(GC は1リクエストで 44〜53回、合計 0.7〜2.0秒)
- 転送する列を減らす:0.4〜0.7秒しか縮みませんでした
なぜ頭打ちになったか
一番速い条件(キャッシュが効き、プロセスも温まっている状態)でも、次の時間が毎回かかっていました。
- BigQuery との往復だけで約2〜3秒(データ量によらず)
- そのあとの受け取りとグループ化
リクエストのたびに BigQuery を読む限り、3秒には届きません。 ここで、細かく削る作業は止めて、作りそのものを比べ直すことにしました。
作りの比較
| 案 | 内容 | 判断 |
|---|---|---|
| A | 1日1回作る集計のスナップショットを、専用サーバのメモリに載せておく | 採用 |
| B | 判定までバッチで先に済ませておく | 判定自体は約11万グループで 39〜103ms と軽く、速さにほぼ効かない |
| C | 目標の3秒を見直す | 目標は変えない方針 |
| A' | A と同じことを、既存の API サーバのプロセスで行う | 下記の理由で不採用 |
A' を採らなかった理由は2つです。
- 同じプロセスに大きなデータを載せると、ほかの処理が 8〜21%遅くなり、GC の一時停止(完全 GC)が 10〜30ms から 410〜490ms に伸びました
- 既存の API サーバは負荷に応じて最大95台まで増えるので、全台が同じデータを抱えることになります
改善後の構成
スナップショットの作り方と公開
- 毎日1回、集計バッチ(Cloud Scheduler から起動)が集計結果を BigQuery の表に書きます
- 書き終えて検査に通ったら、公開の登録表に公開時刻を入れます。これが「公開」です
- 古いスナップショットは30分残し、最新の公開済みのものは消しません
公開と取り消し(後述)は、どちらも登録表の公開時刻の列を書き換えるだけで行います。
専用サーバでの読み込みと差し替え
専用サーバは、既存の API サーバと同じコンテナイメージを、別の起動エントリで動かしています。ロジックは共通の TypeScript 関数で、従来の経路と応答の JSON が完全に一致することをテストで確かめています。
差し替えの流れを簡略化すると、次のようになります。
// 簡略化したイメージ
let current: Snapshot | null = null;
let loading = false;
async function watch(): Promise<void> {
if (loading) return;
const latestId = await fetchLatestPublishedId(); // 登録表を読むだけ
if (latestId === null || latestId === current?.id) return;
loading = true;
try {
const rows = await readAllWithStorageRead(latestId);
validate(rows); // 通らなければ差し替えない
const next = await buildInChunks(rows); // 小さな塊ごとに await して、イベントループを止めない
current = next; // 1回の代入で差し替える
} finally {
loading = false;
}
}
setInterval(() => void watch(), 30_000);
ポイントは3つです。
- 読み込み中は古いスナップショットで答えるので、切り替えの間も応答は止まりません
- 組み立てを小さな塊に分けることで、読み込み中もリクエストを処理できます
- 差し替えは参照の代入1回で行うので、途中まで組み立てた状態は見えません
リクエストが来たら、閲覧者に許可されたアカウントの集合を使って、メモリ上の各グループを次の3つに分けます。
- 全部見える:そのまま使う
- 一部だけ見える:見える部分だけで組み立て直す
- 見えない:除外する
そのあと判定してパネルを組み立てます。BigQuery への問い合わせは1回もありません。
メモリと設定
| 項目 | 値 |
|---|---|
| スナップショット1つのヒープ使用量 | 約136MiB(手元で測定) |
| 切り替え中のメモリのピーク | 手元で約0.7GB(データ量が3倍でも約1.5GB) |
| 読み込み時間 | 起動時 7.7〜9.3秒、稼働中の切り替え 9.9〜13.9秒 |
| Cloud Run | CPU 1、メモリ 2GiB、同時処理 4、CPU 常時割り当て、起動時 CPU ブースト |
| Node.js(V8)のヒープ上限 | 1536MB |
自動スケーリングをやめて2台固定にした
最初は自動スケーリング(最小1台・最大3台)にしていました。ところが、1台の状態でリクエストがほぼ同時に2本届くと、新しい台が起動することがありました。その場合、1本が約15秒待たされました(6回中4回)。内訳は、コンテナの起動が4〜5秒、スナップショットの読み込みが9〜10秒です。
起動のたびに大きなデータを読み込む作りでは、「台が増える瞬間」の遅さがそのまま利用者に見えます。そこで、手動スケーリングで2台に固定しました。
- 128本を同時に投げても新しい台の起動は0回、応答は最大2.8秒
- 費用は2台で月約$115(BigQuery の読み取りは月$1未満)
作り置きにした分の備え
作り置きにすると、壊れたスナップショットを公開したとき、全員に同じ間違った数字が出ます。そこで、次の4つを入れました。
1. 公開前の検査
公開する前に、次のような整合性を確かめます。
- キーの一意性
- 日次の行が必ずどこかのグループに属していること
- すべての行が対象のスナップショットの値であること
- NULL や空のアカウントを隔離すること
通らなければ公開しません。専用サーバも、読み込むときに同じ検査をもう一度行います。
2. 公開後も15分ごとに検査し直して、自動で取り消す
公開後も15分ごとに検査し直します。通らなければ、登録表の公開時刻を NULL に戻して公開を取り消します。専用サーバは30秒ごとの見張りでこれに気づき、メモリ上のスナップショットを捨てて、前の正常なスナップショットを読み込み直します。
検証環境で、わざと壊したスナップショットを公開して試しました。
| 経過 | 起きたこと |
|---|---|
| 0秒 | 再検査が壊れたスナップショットを見つけ、公開を取り消す(取り消しの処理は 15.1秒) |
| 約9秒後 | 専用サーバ2台が、壊れたスナップショットを捨てる |
| 約12秒後 | 前の正常なスナップショットを読み込み直す |
3. 専用サーバが答えられないときは従来の経路へ
Web サーバは専用サーバを先に呼び、5秒待っても答えがなければ、従来の API サーバ(BigQuery を読む経路)に切り替えます。連続5回失敗したら、30秒間は専用サーバを呼ばずに従来の経路を使います(いわゆるサーキットブレーカー)。この値はいまのところ仮置きです。
4. 設定で段階的に有効にする
専用サーバを使うかどうかは、インフラの設定(Pulumi の stack 設定)で環境ごとに切り替えます。Web サーバの向け先を空にすれば、すぐ従来の経路に戻せます。
結果
| 条件 | 中央値 | 最大 | 3秒以内 |
|---|---|---|---|
| 改善前の最終段階(段階4) | 7.3秒 | - | 0/7 |
| スナップショットをメモリに載せた後(1年後のデータ量) | 0.58秒 | 1.46秒 | 7/7 |
| 現在のデータ量、目標CPAを入力した状態 | 0.52秒 | 0.74秒 | 7/7 |
| 1年後のデータ量、目標CPAを入力した状態 | 2.0秒 | 2.5秒 | 7/7 |
最後の行が遅い主な原因は、応答の JSON が約1.9MB と大きいことです。
残っている課題
- 負荷が高いときの Web サーバ側:1秒に約3本のペースで投げると、専用サーバ自身は最大1.2秒・CPU 最大24%と余裕があります。一方、Web サーバの応答は上位5%で 3.3〜13.7秒に伸びました。原因はまだ特定できていません
- 鮮度:スナップショットは1日1回なので、日中の更新は反映されません
- 値だけ書き換えられた場合:公開後に、スナップショットの番号を変えずに中身だけ書き換えられると、いまの検査では見つけられません
まとめ
-
内訳を測ってから削る。 一番効いたのは、計測ログに内訳を出し続けたことでした。
JSON.stringifyのキーが重いことも、ストリームが1本しか返らないことも、測って初めて分かりました - 改善が逓減してきたら、作りの下限を測る。 「暖まった最良の状態でも BigQuery との往復だけで2〜3秒」と分かった時点で、細かい改善では届かないことがはっきりしました。細かい改善の効きが小さくなってきた時点で、先にこの下限を測るべきでした
- 作り置きにするなら、壊れたときの戻し方まで一緒に作る。 速さの代わりに「全員に同じ間違いを配る」リスクを抱えるので、公開前の検査・公開後の再検査・自動の取り消し・従来経路への切り替えまでをセットにしました
同じように「BigQuery を裏に持つダッシュボードが遅い」と悩んでいる方の参考になればうれしいです。