0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

BigQuery を毎回読むのをやめたら、集計パネルの表示が 7.3秒 → 0.58秒 になった話

0
Posted at

はじめに

社内の広告運用者向けダッシュボードで、画面上部の「集計パネル」の表示が遅いという問題に1ヶ月ほど取り組みました。

細かいチューニングを重ねても 7秒台で止まってしまい、最後は 「リクエストのたびに BigQuery を読む」こと自体をやめる 作りに変えて、0.58秒まで縮めました。

この記事では、次の3つを書きます。

  • 細かい改善が頭打ちになるまでの記録と、何が効いて何が効かなかったか
  • 1日1回作る集計のスナップショットを Cloud Run のメモリに載せておく構成
  • 作り置きにしたことで生まれる「壊れたスナップショットを配ってしまう」リスクへの備え

数字はすべて検証環境(staging)で測ったものです。本番への切り替えはこれからです。

前提:何を表示しているか

広告クリエイティブ(CR)ごとに、1日ごとの成果を見て段階的に判定し、その分布や内訳を集計パネルに出しています。

  • 目標は 3秒以内、合否の判定ラインは 5秒(7回測って全回)
  • 測定は 1年後に見込むデータ量 を再現した合成データで実施しました
    • 同じクリエイティブをまとめたグループが約11万件
    • 日次の行が約28万行、広告の行が約30万行
  • 見えてよいデータは閲覧者ごとに違い、許可された広告アカウントの範囲だけを集計します

改善前の構成

パネルを1回表示するたびに、API サーバが次の処理を順番に行っていました。

  1. 閲覧者に許可されたアカウントで絞った行を BigQuery から読む
  2. 行をクリエイティブ単位のグループにまとめる
  3. 判定してパネルを組み立てる

最初に測ったときは、中央値 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 を裏に持つダッシュボードが遅い」と悩んでいる方の参考になればうれしいです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?