管理画面はいつも同じ始まり方をします。テーブルがひとつ、テスト用の行が四つ、そして 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だけで絞るクエリはうまく活かせませんでした。単独のインデックスを足す必要がありました。
管理画面は、本番で何かおかしいときにチームが覗く場所です。そこがまさに本番の量に耐えられない部分だというのは、高くつく皮肉です。
