02パフォーマンス5 min

管理画面の一覧が真っ先に落ちる

社内向けの管理画面は、最初は行が四つしかないので上限なしで書かれます。そしてプロダクトが育ったとき、まさにそこが吹き飛びます。

著者: Salva Castells

カバー画像: Marijovanni, CC BY-SA 4.0.
この記事の内容

管理画面はいつも同じ始まり方をします。テーブルがひとつ、テスト用の行が四つ、そして where のない select。動きます。動くので、誰も二度と見ません。そのあいだにユーザーのテーブルは四行から四万行になり、誰も計測していない社内ページがプロダクトでいちばん遅いページになります。

これを四つの別々のプロジェクトで直す羽目になりました。毎回、理由は同じ三つです。

誰も見ないので管理画面は雑に書かれる

公開ページには初日からページングがあります。誰かがレビューし、誰かが計測し、遅ければ Core Web Vitals の指標が叫びます。管理画面にはそれが何もありません。使うのは二人だけで、その二人は「管理画面だから」遅いのだと思っています。

運用している文字起こし API では、サーバーアクションの getAdminUsers と getAdminFeedback が limit をパラメータで受け取り、そのままクエリに渡していました。上限なしです。UI からは常に 10 が来るので、何か月も誰も触っていませんでした。問題は UI ではありません。サーバーアクションはエンドポイントだ、ということです。管理者セッションを持つ誰か、あるいは任意のスクリプトが limit=1000000 を要求して、テーブル全体をワーカーのメモリに展開できてしまいます。

修正はセキュリティのまとめ作業のなかで三行でした。

export const ADMIN_LIST_MAX_LIMIT = 1000;

const cappedLimit = Math.min(Math.max(limit, 1), ADMIN_LIST_MAX_LIMIT);

Math.max(limit, 1) は無駄な心配性ではありません。負の limit やゼロも、ドライバによっては妙なことをします。大事なのは上限がどこに住んでいるかです。コンポーネントでもフックでもなく、リクエストを受け取る層です。それより上にあるものは全部ただの提案です。

タダに見えてタダではない count

古典的なページングは何ページあるかを知る必要があり、そのために SELECT COUNT(*) が要ります。count は安いと頭が思い込んでいるので、何も考えずに書いてしまいます。安くありません。Postgres で正確な count はテーブル全体かインデックス全体を走査しますし、そうしない方法はありません。

社内の文書管理ツールでは、ユーザー管理画面がページ読み込みのたびに三つのことを並行してやっていました。プロフィールのページを取得し、セレクタ用に全会社を取得し、プロフィールの総数を数える。pageSize は 5 です。つまり「47 ページ中 3 ページ目」と描くためにテーブル全体を走査していました。

count を外し、ページングの契約を変えました。

setHasMore(profilesData.length === pageSize);

「47 ページ中」は失われます。得られるのは、クエリが常にページサイズに比例することです。次へのボタンは currentPage < totalPages ではなく hasMore を見るようになり、表示する範囲は手元にあるものだけで、何も聞かずに計算します。

const endIndex = startIndex + companies.length - 1;

総数が見えないことに誰も文句を言いませんでした。社内の管理画面では何ひとつ決定しないデータです。本当に必要なら pg_class の reltuples がゼロコストで推定値をくれます。「ユーザーはだいたい 4 万人」にはそれで十分です。

オブジェクト全体ではなくメタデータを取る

三つめのパターンはもう少し繊細です。ファイルの一覧は、ファイルに中身が入るまではちゃんと動きます。共同編集のメモツールでは、サイドバーが getProjectTextFiles を呼んでいて、それは射影なしの select をしていました。つまり、プロジェクトの各ファイルの content カラムまで取ってきていたのです。名前の一覧を描くために。

解決策は退屈で、ちゃんと効きます。一覧が必要とするカラムを明示的に選ぶ二つめの関数です。

return await db
  .select({
    id: textFiles.id,
    projectId: textFiles.projectId,
    name: textFiles.name,
    isTemp: textFiles.isTemp,
    deletedAt: textFiles.deletedAt,
    createdAt: textFiles.createdAt,
    updatedAt: textFiles.updatedAt,
  })
  .from(textFiles)
  .where(and(eq(textFiles.projectId, projectId), isNull(textFiles.deletedAt)));

中身はファイルを選択したときに要求します。それより前ではありません。そしてここで、実際にやってみないと見えない細部が出てきます。ユーザーが二つのファイルを素早くクリックすると、一つめの遅い応答が二つめを上書きしかねません。store に書き込む前に、そのファイルがまだ選択中かどうかを確かめる必要があります。

if (fullFile && useAppStore.getState().selectedFileId === fileId) {
  useAppStore.getState().setSelectedFile(fullFile);
}

このチェックがないと、性能の問題を競合のバグに交換することになります。意図して再現できないぶん、そちらのほうが悪い。

管理画面にしか出ないバグ

管理画面に注意を払う価値がある理由のいちばん良い例は、unstable_cache でキャッシュした分析ダッシュボードがくれました。ローカルでも、本番の初回アクセスでも動きます。そのあと Invalid time value で落ちました。

unstable_cache はペイロードを JSON にシリアライズします。コールド実行で書かれた Date は、キャッシュされた読み取りすべてで string として返ってきます。そして Intl.DateTimeFormat.format は文字列を受け付けません。型が嘘をついていました。lastIngestedAt: Date | null は実際には Date | string | null でした。シリアライズの境界に型が付いていないので、TypeScript には見えません。

両方の形を正規化し、扱えないものを例外ではなく欠損として扱うことで直しました。

const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : formatter.format(date);

描画できない日付データは「データなし」と表示すべきで、ページ全体を倒すべきではありません。ダッシュボードでは、壊れた指標ひとつが残り十二個を道連れにしてはいけません。

いま社内向けの一覧すべてに適用していること

  • 上限はサーバー層に。 外から来る limit は入り口で、名前を付けた定数で抑えます。UI はバリデーションではありません。
  • デフォルトで count しない。 ページングは hasMore で。総数を求められたら推定値を渡します。求められたときだけです。
  • 明示的な射影。 一覧はカラムを選び、決して * にしません。大きなデータは詳細を開いたときに要求します。
  • 絞り込みに使うカラムにインデックスを。 勤怠アプリでは (user_id, date) の複合インデックスがありましたが、user_id だけで絞るクエリはうまく活かせませんでした。単独のインデックスを足す必要がありました。

管理画面は、本番で何かおかしいときにチームが覗く場所です。そこがまさに本番の量に耐えられない部分だというのは、高くつく皮肉です。