フラクショナルカスケーディングと分数インデックス
要素の間に新しい順序キーを無限に挿入でき、リストの並べ替えや協調編集で全行を振り直さずに済む。順序維持文字列キーの数理と実装の落とし穴を原理から理解できます。
- 1.分数インデックスは、隣り合う2つの順序キーの「中間」を必ず生成できる稠密順序を文字列で実現し、要素間に無限挿入を可能にする。
- 2.鍵は基数bのなかで桁を伸ばしながら厳密に a と b の間を取る生成法と、辞書順比較がそのまま数値順序になる桁揃えの設計にある。
- 3.協調編集では同時挿入でキーが衝突しうるため、末尾に一意なサイトIDを付けて全順序にし、CRDTやリスト順序の安定したソートキーとして使う。
何を解こうとしているのか
リストの並びをデータベースに保存する素朴な方法は、各行に整数の position(0,1,2,…)を持たせることです。しかしこの方式は、ある要素を 1 番と 2 番の あいだ に入れたいだけで破綻します。整数 1 と 2 の間には整数がないため、後続の要素すべての番号を振り直す(リナンバリング)か、行をロックして再計算するしかありません。1 件の挿入が O(n) 件の更新を生み、協調編集のように複数クライアントが同時に並べ替える環境では、その一括更新どうしが衝突します。
分数インデックス(fractional indexing) は、順序キーを整数ではなく 稠密な順序集合 の値として持つことでこれを解きます。稠密とは「異なる任意の2要素 a と b(a が b 未満)の間に、必ず別の要素が存在する」性質です。有理数や実数はこの性質を持つので、1 と 2 の間には 1.5、その間には 1.25…と無限に値が取れます。要素を間に挿入したいなら、両隣のキーの中間を1つ計算して新しいキーにするだけでよく、他の行には一切触れません。挿入は O(1) の書き込みになります。
「分数(fractional)」は、キーを 0 と 1 の間の分数として表す発想に由来します。一方、計算幾何学の フラクショナルカスケーディング は、複数のソート済みリストを横断する二分探索を高速化する別技法で、サンプル要素を隣のリストへ「カスケード」させて橋渡しします。どちらも「整列済み列の間を効率よく行き来する」着想を共有しますが、本稿が扱うのは前者の順序キー生成です。両者は名前が近いだけで適用場面が異なる点に注意してください。
なぜ整数ではなく文字列なのか
理屈の上では有理数をそのまま使えますが、実装では問題が起きます。中間を取り続けると分母が指数的に増え、浮動小数点では精度が枯渇し、可変長整数では比較コストが膨らみます。そこで実用実装は 基数 b の桁列(文字列) でキーを表し、辞書順(lexicographic)比較がそのまま順序比較になる ように設計します。これなら任意精度を桁の追加だけで確保でき、データベースの文字列インデックス(B+Tree など)にそのまま載せて高速に範囲検索・整列できます。
要点は2つです。第一に、キーを「0.(桁列)」という基数 b の小数とみなすこと。第二に、辞書順と数値順を一致させる桁揃え を守ることです。たとえば基数 10 で 5 と 25 を辞書順で比べると、先頭の 2 が 5 より小さいので 25 が前になり、これは小数 0.5 と 0.25 の数値順(0.25 が前)と一致します。つまり「小数点以下の桁列」として左詰めで比較する限り、辞書順 = 数値順が成り立ちます。実装では基数を大きく取り(base64 など)、文字集合自体を昇順に並べておけば、文字どうしのバイト比較が桁の大小比較に一致します。
中間キーの生成アルゴリズム
中心となるのは「a と b の厳密な中間にある最短の桁列を返す」関数 midpoint(a, b) です。a が b 未満であることを前提に、両端を基数 b の小数として桁ごとに比較していきます。
midpoint(a, b): # a, b は小数点以下の桁列、a < b(辞書順)
i = 0
# 共通する先頭桁はそのままコピーする
while a[i] == b[i]: # b 側が尽きたら b は無限に 0 が続くとみなす
append a[i] to result
i++
# 食い違う最初の桁を見る
da = digit(a, i) # a が尽きたら 0
db = digit(b, i)
if db - da >= 2:
# 桁の間に隙間があるので、その中央の1桁で確定
append floor((da + db) / 2) to result
return result
else:
# 隣接桁(差が1)。a 側の桁を採用し、a の続きと最大桁の中間へ降りる
append da to result
return result + midpoint(rest_of(a, i+1), "") # b 側は最大桁の上限とみなす
ポイントは食い違った桁の差です。差が 2 以上なら、その2桁の 中央の1桁 を置くだけで a より大きく b より小さい値が確定し、桁を伸ばさずに済みます。差が 1 しかない(隣接している)ときは、その桁では間に入れないので a 側の桁を採り、1桁深く降りて さらに中間を探します。再帰の各段で必ず探索範囲が a 寄りに狭まり、いずれ差 2 以上の隙間か桁の尽きに到達して停止します。生成されるキーは平均してごく短く、最悪でも入力キー長 + 数桁に収まります。
リストの 先頭 に挿入するときは左隣が存在しないので、下限 a を「空(= 最小)」とみなして midpoint("", b) を呼びます。末尾 なら右隣がないので上限 b を「最大」とみなします。実装では基数の最小文字・最大文字を番兵として扱うか、b が空のとき桁を1つ足して中央に置く分岐を用意します。空リストへの最初の挿入は、基数のちょうど真ん中の1桁(base64 なら中央付近の文字)を返すのが定石で、以後の前後挿入に余白を残せます。
協調編集での全順序化
単独クライアントなら midpoint だけで足りますが、同時挿入 が入ると話が変わります。2 つのクライアントが同じ隙間(同じ a と b の間)にそれぞれ要素を入れると、両者が まったく同じ中間キー を計算してしまい、キーが衝突します。順序キーは要素を一意に整列させる責務を負うので、衝突は順序の不定(どちらが先か決まらない)を招きます。
解決は、分数キーの 末尾に一意なサイトID(クライアントIDやアクター識別子)を連結 して全順序(total order)にすることです。比較は「分数部を先に比べ、等しければサイトIDで決定的に破る」。これにより同一隙間への並行挿入でも、全レプリカが同じ規則で同じ順序に収束します。これは CRDT のリスト型(RGA や LSEQ 系)が内部で用いるのと同じ発想で、分数インデックスは「位置を表す不変な識別子」をキー1本で表現する軽量な実装手段になります。
| 観点 | 整数 position | 分数インデックス(文字列) |
|---|---|---|
| 間への挿入 | 後続を全リナンバリング(O(n)) | 中間キーを1つ生成(O(1) 書き込み) |
| 同時挿入 | 一括更新どうしが衝突しやすい | サイトID付与で全順序に収束 |
| キー長 | 固定(小さい) | 挿入を繰り返すと漸増しうる |
| 並べ替え | 範囲の番号を振り直す | 移動先の両隣で中間キーを再生成 |
| 索引適性 | 数値索引に最適 | 辞書順索引にそのまま載る |
実装上の落とし穴
分数インデックスは挿入が局所的で速い反面、キー長が単調に伸びうる 弱点があります。最悪のパターンは「常に同じ要素の直後(または直前)に挿入し続ける」ケースで、毎回 1 桁ずつ深く降りるため、キー長が挿入回数に比例して伸びます。長くなったキーは索引の肥大とソートコスト増を招くため、実務では一定長を超えたら オフピークにバックグラウンドで全キーをリバランス(振り直し) する運用を併用します。
辞書順 = 数値順の前提が崩れるとソートが静かに狂います。第一に、文字集合の順序とバイト順序の不一致:使う基数の文字をコード順に昇順整列しておかないと比較が逆転します。第二に、末尾のゼロ相当文字:基数の最小文字でキーを終えると「a より小さい a の延長」を作れず中間生成が詰まるため、最小文字を末尾に置かない規約にします。第三に、可変長を数値の整数部として比較する誤り:あくまで「0.(桁列)」の小数として左詰め比較する設計を守ること。これらは単体テストで隣接キーの順序不変条件(生成キーが両隣の厳密な間にある)を検査して防ぎます。
「分数インデックスが解く問題は」には 間への挿入で後続を全リナンバリングせず O(1) 書き込みにする と答えます。「なぜ文字列で持つか」は 任意精度を桁追加で確保し、辞書順=数値順により文字列索引にそのまま載るから。「midpoint の停止理由は」は 食い違う桁の差が 2 以上なら中央1桁で確定、差 1 なら1桁降りて範囲が必ず狭まるから。「協調編集での衝突対策は」は 末尾にサイトIDを連結して全順序化 と整理しておくと安全です。
まとめ
分数インデックスは、順序キーを整数ではなく 稠密な順序集合(実装上は基数 b の桁列=文字列)として持つことで、隣り合う2キーの 厳密な中間 を midpoint で生成し、要素間への挿入を他行に触れず O(1) 書き込みで実現します。鍵は 辞書順比較がそのまま数値順序になる桁揃え と、食い違う桁の差に応じて中央1桁で確定するか1桁降りる生成法です。協調編集では同時挿入でキーが衝突しうるため、末尾に サイトID を連結して全順序化し、CRDT のリスト順序や安定したソートキーとして使います。挿入が局所的で速い反面、片側挿入の連続でキー長が伸びうるため、長くなったキーの 定期リバランス を運用に組み込み、クラスタ化/非クラスタ化インデックスなど辞書順索引の上で範囲検索・整列に活かすのが定石です。
データベース Article
フラクショナルカスケーディングと分数インデックスを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
分数インデックス
比較で見る軸
難易度: advanced / カテゴリ: データベース / タグ数: 5
導入後に効く点
鍵は基数bのなかで桁を伸ばしながら厳密に a と b の間を取る生成法と、辞書順比較がそのまま数値順序になる桁揃えの設計にある。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- データベース
- タグ数
- 5
判断チェックリスト
- 自社の用途が「分数インデックス / 順序維持」に近いか確認する。
- 強みである「分数インデックスは、隣り合う2つの順序キーの「中間」を必ず生成できる稠密順序を文字列で実現し、要素間に無限挿入を可能にする。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。