ページングの終了条件が1つしか無いと、永久に回って 504 になる
- 1 時間前
- 読了時間: 4分
集計が 504 になったとき、コードを読み直して最初に目についたのは、終了条件が1つしか無いページングだった。
for (;;) {
const page = await load(skip);
found.push(...page.items);
if (page.items.length < PAGE_SIZE) break; // 終了条件はこれだけ
skip += PAGE_SIZE;
}「返った件数がページサイズ未満なら最後のページ」という判定は、読み取りが期待どおりに動く限り正しい。裏を返せば、期待どおりでないときに止まる仕組みが1つも無い。
たとえば、読み取りの実装が skip を無視して毎回満杯のページを返したとする。件数は常にページサイズと同じなので、条件は永久に成立しない。ループは回り続け、やがて 504 になる。そして画面に出るのは 504 だけである。無限ループを疑う材料は、どこにも残らない。
ページ数の上限を足す
3か所とも、件数の上限をページ数として置いた。
読み取り | 上限 |
注文管理コレクション | 30ページ(約3000件) |
キャンセル記録コレクション | 30ページ |
実行ログコレクション | 20ページ |
上限に達したときは、集計を返しつつ、打ち切られたことを画面に出す。黙って通すと、部分的な結果がそのまま集計値になり、あとになって足りないと分かる。上限は暴走を止めるためのもので、正しい件数を保証するものではない。両方を同時に成立させることはできないので、少ないかもしれないと言うほうを選んだ。
この天井は、把握しておかないと後で刺さる。注文の読み取りはサイト全体を対象にしてコード側で絞っているので、30ページの上限は会員向けのキャンセル画面にも効く。参照期間の120日で3000注文を超えると、会員の注文一覧が静かに欠けはじめる。ログには警告が出るが、画面には出ない。
依存していない読み取りを直列で待たない
集計の入口は、注文、キャンセル記録、実行ログ、マスタの4つを順番に await していた。この4つは互いに依存していない。
const [orders, cancellations, opsLog, facilities] = await Promise.all([
loadOrdersInWindow(budget),
loadCancellationsByPickupDate(ymd, budget),
loadOpsLog(ymd, budget),
loadFacilities(budget),
]);往復1回が仮に2秒なら、これだけで8秒が2秒になる。依存しているのは会員のプロフィールだけで、これは注文から会員 ID が出てからでないと引けない。並列にできるものと、順番が要るものを分けるだけの話である。
同じデータを1リクエストで二度読まない
完了処理は、更新を書いたあとに画面を引き直すため、集計の関数をそのまま呼んでいた。そこでもう一度、全注文を舐めていた。
対象日の内容は、完了処理では変わらない。だから手元の注文配列を使い回せばよい。集計の関数に、収集済みのデータを受け取る第2引数を足した。
一方、実行ログはたった今書いたので読み直す。読み直すものと使い回すものを、変更の有無で分ける。
版差の吸収に、毎ページ払わない
注文の検索 API は、引数の形が版によって2通りある。両方試す実装にしていたので、ページを辿るたびに、通らないほうの形を1回ずつ払っていた。20ページなら、失敗を20回ぶん余分に待つ。
1回目に通った形を覚えて、次からそちらを先に試すようにした。互換のために両方残すのは妥当でも、その代金をページ数ぶん払う理由は無い。
根っこはデータ構造にある
ここまでは全部、全件走査を前提にした改善である。走査そのものをやめられないのは、対象日が商品オプションの文字列として保存されていて、クエリで絞れないからだった。参照期間の全注文を読んで、コード側で日付を解釈している。
本当の解は、注文の時点で対象日を明細ごと自前のコレクションへ書き出すことだと思う。そうすればインデックス付きの1クエリになる。ただしデータ構造の変更なので、注文フックの追加と既存注文の移行が要る。
だから今回は、測る仕組みだけを入れた。本当に注文の読み取りが遅いのかを確かめてから決める順番にしたかったからである。参照期間を縮める案も見送った。上限を足すと、対象日が読めない明細が新しい注文から出てこなくなり、「どの日の集計にも入らないまま消える」を防ぐ設計が崩れる。
コメント