nano-cms ドキュメントは株式会社ケイズ・ソフトウェアが運営しています。
nano-cms技術ドキュメント
← ドキュメント一覧へ

予約を 100 件積んでも数百ミリ秒で「公開当日」を見せる ― 予約プレビューのキャッシュ設計

予約公開プレビューは、「いまの下書き単体」ではなく、その記事の公開予定日時に、ほかの予約も織り込んだサイトの実際の姿を見せます。お知らせやバナーを何件も前もって仕込んでも、重なり合った「公開当日の完成形」を出してみる前に確認できる ―― これが予約プレビューの狙いです。

ここではその裏側、パフォーマンスの話です。予約は 10 件、ときに 100 件と積み上がります。プレビューを開くたびに毎回その「未来の状態」を一から組み立てると、素朴なやり方では数秒かかります。プレビューに数秒待たされては使いものになりません。@nano-cms/core はこれを数百ミリ秒に収めています。どうやって速くしたか、そしてなぜ「予約を 100 件積んでも競合で止まらない」のかを、設計判断として書きます。

なぜ「全部を git でマージ」だと間に合わないのか

予約された変更の集まりから「公開当日の状態」を作る、といちばん素朴に考えると、各予約を順番にマージしていく形になります。予約はそれぞれ専用の作業ブランチに溜まっているので、公開予定の早い順に、main にそのブランチのツリー(サイト全体のファイル木)を 1 件ずつマージして仮想的な「未来の main」を組み上げる ―― という発想です。

これは動きます。ただし遅い。実際に bare リポジトリを作って測ると、100 件のチェーンをツリーごと全部マージするのに約 3.6 秒かかりました(1 件あたり約 36 ミリ秒 × 100)。プレビューは開くたびにこの計算が要ります。3.6 秒の読み込みを毎回挟むプレビューは、確認ツールとして成立しません。

速くする前に、そもそも全部を git でマージする必要があるのかを分けて考えます。プレビューが合成しているものは、実は性質の違う 3 種類が混ざっていました。

記事と共通パーツは、git を使わない

プレビューが「公開当日の姿」を出すために重ねているのは、次の 3 系統です。これらは編集対象も保存場所も別々で、互いに直接は触りません。

  • 記事(ニュースやお知らせの本文)― データ。リレーショナル DB に版として入る。
  • 共通パーツ(ヘッダー・フッターなど全ページで使い回す断片)― データ。同じく DB に版として入る。
  • テンプレート・CSS・JS(サイトの骨格ファイル)― ファイル。git のブランチに入る。

このうち記事と共通パーツは、git をまったく使いません。未来の状態は DB のクエリ 1 本で出せます。「承認済みで、かつ公開予定日時がプレビュー基準時刻以前」の版を絞り込み、公開予定の順に重ねるだけです。git のブランチをマージする必要はありません。

3 系統を「公開当日の姿」としてそろえる鍵は、たった 1 つの時刻です。プレビューしている予約の公開予定日時を previewAt として入口で確定し、それを 3 系統すべてに同じ値で配ります。記事は「previewAt 時点で公開済みの版」を、共通パーツも同じ条件で、ファイルは「previewAt までに公開される予約を織り込んだ中身」を返す。基準時刻が 1 つなので、3 系統がばらばらの瞬間を指してしまうことがありません。

図1: 1つの時刻 previewAt で記事・共通パーツ・ファイルの3系統をそろえる。記事と共通パーツはDBクエリ、gitを使うのはファイルだけ。
図1: 1 つの時刻(previewAt)で 3 系統をそろえる。記事と共通パーツは DB クエリ、git を使うのはファイルだけ。

ここで previewAt は必ず値が決まっている、という前提が効いています。「公開予定日時が未定」という状態を作れないようにしてあるので、「時刻が決まらないから、とりあえず現在時刻で代用する」という曖昧な逃げ道がありません。代用が無いということは、プレビューが「だいたいこんな感じ」ではなく、公開当日の実際の見え方と一致する、ということです。

こうして、重い計算が要るのは 3 系統のうちファイルだけに絞られました。

ファイルだけが git を要る ― だから「ファイル単位」で接ぐ

テンプレートや CSS は git のブランチに入っているので、未来の中身を出すにはマージが要ります。ただ、サイトのツリー全体をマージする必要はありません。プレビューで実際に表示するのは 1 ファイルずつです。であれば、そのファイルだけをマージすればいい。

具体的には、あるファイルについて、公開予定の早い順に「先行する予約のうち、そのファイルを編集したものの中身」を 1 件ずつ重ねます。ファイルを触っていない予約は、そのファイルのマージには登場しません。ここが効きます。

同じ 100 件のチェーンでも、あるファイルを実際に編集した予約が 5 件だけなら、マージも 5 回で済みます。ツリー全体を 100 件マージすると約 3.6 秒かかったのに対し、そのファイルを触った 5 件だけをファイル単位でマージすると約 130 ミリ秒。およそ 27 倍の差です。

図2: 全ツリーを毎回マージする素朴なやり方(約3,607ms)と、触ったファイルだけをマージするやり方(約130ms・27倍速)の比較。
図2: 全ツリーを毎回マージする素朴なやり方と、触ったファイルだけをマージするやり方。コストは予約の総数ではなく「そのファイルを触った予約の数」に比例する。

大事なのは、計算量が「予約の総数」ではなく「そのファイルを触った予約の数」にしか比例しないことです。予約が 100 件あろうと 1,000 件あろうと、いま見ているファイルを編集した予約が数件なら、その数件ぶんしか計算しません。現実のサイトでは、1 ページが 50 ファイルでも、特定の予約が触るのはそのうち数ファイルです。だからチェーンが長くても、プレビューは数百ミリ秒に収まります。

「後勝ち」を git の標準オプションに翻訳する

ここで競合の話です。ユーザーの問いは「予約を 100 世代積んでも、競合とかなしに確認できるよね?」でした。答えは、できます。理由は、競合を自前のアルゴリズムで解こうとせず、git の標準オプションに任せたからです。

2 つの予約が同じ行を別々に書き換えていたら、それは競合です。@nano-cms/core はこれを「後勝ち」― 公開予定が後の予約の内容を採る ― で機械的に解決します。例外を作りません。実装は git の merge-file--theirs を渡すだけです。

# 「後勝ち」= あとの予約(theirs)を採って競合を自動解決する
# -p は結果を標準出力へ返すだけで、ファイルは書き換えない
git merge-file -p --theirs <これまでの累積> <分岐元> <その予約の版>

merge-file は、競合があっても異常終了しません。終了コードは「残った競合の数」を表すので、競合が在っても正の整数が返るだけで、マージ結果のテキストはそのまま受け取れます。さらに --theirs を付けてあるので競合は後勝ちで解消され、競合マーカーは残りません。「競合したら止まる」のではなく「競合したら後勝ちで解決して進む」動きになります。

では後勝ちは性能を犠牲にするのか。しません。全 100 件が同じファイルの同じ箇所を奪い合う「全件競合」のチェーンを測っても、競合のないチェーンとほぼ同じ時間でした(全件競合で約 3.6 秒、競合なしで約 3.6 秒)。後勝ちオプションのオーバーヘッドは事実上ゼロです。だから「予約を 100 件積んでも、競合で止まらないし、競合があっても遅くならない」と言えます。

後勝ちで自動解決できないケースだけは、安全のために止めます。たとえばファイルの改名と削除がぶつかった場合のように、「あとの版を採る」と機械的に決めると壊れかねない構造的な衝突です。こうした例外は止めて人手に委ね、それ以外の日常的な競合(同じ行の取り合い)はすべて後勝ちで進めます。後勝ちを徹底したことで、「競合状態」という特別な状態そのものを設計から無くせました。

キャッシュの鍵を「中身そのもの」から作る ― 無効化を持たない

ファイル単位で 27 倍速くなりましたが、それでも 1 回 130 ミリ秒の計算は残ります。同じプレビューを何度も開くなら、結果をキャッシュしたい。ここで普通に悩むのが「いつキャッシュを捨てるか(無効化)」です。予約が編集されたら、その予約に依存する古いキャッシュを消さないといけない ―― この「消す処理」はたいていバグの温床になります。

@nano-cms/core は、無効化処理を持たないことにしました。キャッシュの鍵を、計算結果を決める材料そのものから作るのです。具体的には、ファイルのパス・main の現在のコミット・チェーン上の各予約ブランチの先頭コミット・マージ戦略、これらを SHA-256 で 1 本のハッシュに畳んで鍵にします。

図3: キャッシュの鍵を計算結果の材料から作り、予約が変われば鍵も変わって自動でキャッシュが外れる仕組み。
図3: 鍵を「計算結果を決める材料(パス・各コミット・戦略)」から作る。予約が変われば鍵も変わり、古い結果は自然に参照されなくなる。

この鍵には、各予約ブランチの先頭コミットのハッシュが混ざっています。だからどれか 1 つの予約が編集されてコミットが変われば、鍵も変わります。鍵が変われば、それは別のファイルです。古い結果は誰からも参照されなくなり、新しい鍵で計算し直して保存するだけ。「古いキャッシュを探して消す」処理は要りません。チェーンの内容が変わると自動的に外れる ―― いわゆる content-addressable な持ち方です。

保存先は、その bare リポジトリの中の専用ディレクトリ(git の管理対象外)です。選択肢は他にもありました。git のオブジェクトとして持てば GC で自然に消えますが、参照階層を増やす運用コストを嫌って見送りました。Redis やメモリに置けば速いものの、揮発すると作り直しのコストがかかるので、ファイルに永続化する既存の方針にそろえました。では、使われなくなった古い鍵のファイルは永遠に溜まるのか ―― 溜まりません。月に 1 度のデータ最適化(git GC)の直後に、「最後に書き出してから 30 日たったもの」と「合計が 100 MiB を超えたぶんを古いものから」まとめて削除します。基準が「最後に書き出してから」なのは、読み出しだけでは更新時刻が変わらないからで、厳密に「よく読まれている順」ではありません。ですが、まだ使う結果をうっかり消しても困りません。次の表示でキャッシュ外れとして再計算し(130 ミリ秒)、新しい鍵で保存し直すだけ ―― キャッシュはいつでも作り直せる、という前提が後始末を軽くしています。

キャッシュに当たれば、計算は要らずほぼ即時に返ります(設計上の見積もりで 1〜2 ミリ秒。実測で確かめたのは、当たらなかったときの再計算が 130 ミリ秒、ということまでです)。

机上で決めない ― 100 件で測ってから仕様にする

ここまでの「全ツリーは遅い」「ファイル単位は 27 倍速い」「後勝ちのオーバーヘッドはゼロ」は、すべて見込みではなく実測です。設計を決める前に、使い捨ての bare リポジトリに 100 件の予約ブランチを作り、git の生コマンドで時間を測ってから仕様を確定しました。測った中身は次の通りです。

測った構成(100 件チェーン) 全件 1 件追加
全ツリーをマージ(競合なし) 約 3,636 ms 約 40 ms
全ツリーをマージ(全件が同じ行を競合・後勝ち) 約 3,613 ms 約 44 ms
ファイル単位(触ったのは 5 件だけ) 約 130 ms

全件競合(2 行目)が競合なし(1 行目)とほぼ同じ時間なのが、後勝ちオプションのオーバーヘッドがゼロだという根拠です。そしてファイル単位(3 行目)が全ツリー(1 行目)の約 27 分の 1。これが「触ったぶんにしか比例しない」効果です。

正直に書いておくと、この計測は使い捨ての合成リポジトリでのものです。実運用のリポジトリと実データでのベンチは実装フェーズに回しました。また、複数ファイルを同じリクエストで計算するときは、チェーンの情報を 1 度だけ求めて全ファイルで使い回し、git のオブジェクト取得をまとめて並行化することで、ある計測では 1.36 秒から 0.81 秒に縮めています ―― このあたりは実装後の最適化です。

設計のポイント

  • 速さは「git を全部には使わない」判断から始まる。記事と共通パーツは DB クエリで畳み、git が要るファイルだけに重い計算を絞る。
  • そのファイルを触った予約の数にしか比例しないので、予約が何件積もうとプレビューは数百ミリ秒に収まる。
  • 競合は自前で解かず、git の後勝ちオプションに任せる。オーバーヘッドはゼロで、競合状態という特別扱いそのものを無くせる。
  • キャッシュの鍵を計算結果の材料そのものから作るので、「古いキャッシュを消す処理」が要らない。

予約プレビューの速さは、派手なアルゴリズムから来ているわけではありません。「どこに git が本当に要るか」を切り分け、要るところだけを最小単位で計算し、結果を内容そのもので鍵付けして持つ ―― この 3 つの判断の積み重ねです。なお、このドキュメントサイト自体も @nano-cms/core で動いており、各ページも予約公開で「当日の見え方」を確認してから出しています。