カラムストアの遅延更新:delta-mainとマージ
更新が苦手なはずの列ストアが、なぜ高速に書き込めるのかが分かります。書き込み用 delta と読み取り用 main を分け、トゥームストーンと周期マージで畳む SAP HANA 流の仕組みを原理から押さえられます。
- 1.列指向は同一カラム値を連続配置し辞書圧縮するため、1行の更新だけで圧縮ブロックを再構成する羽目になり点更新が苦手。そこで更新を圧縮済み main へ直接当てず、書き込み最適化の小さな delta にいったん追記し、読み取り時に両者を重ねる遅延更新を採る。
- 2.更新・削除は main の値を書き換えず、行版にトゥームストーン(validity/delete ビット)を立てて論理削除し、新版を delta へ追記する追記専用モデルで表す。main は不変に保たれ、スキャンはトゥームストーンで旧版を弾く。SAP HANA は未圧縮の L1 delta と辞書付きの L2 delta を二段で持つ。
- 3.delta が肥大化すると重ね合わせとスキャンが重くなるため、背景プロセスが周期的に delta を main へマージする。辞書の再構築と再符号化を伴い、新 main を別領域に構築してアトミックに切り替える。マージ契機の調律と参照中スナップショットの保持が運用の要点。
なぜ列ストアは「直接更新」できないのか
列指向ストレージ は同一カラムの値を連続配置し、辞書符号化や RLE で強く圧縮することで、集計スキャンを桁で速くします。ところがこの構造は更新と相性が最悪です。理由は二つあります。
第一に、1行を挿入・更新するだけで全カラムの圧縮ブロックに触れる必要があります。値はカラム別に連続して詰められているため、論理的な「1行」は物理的にバラバラの位置に散っているからです。第二に、辞書符号化では各値が辞書 ID(整数)に置き換えられ、ID はソートされた辞書上の位置に対応します。新しい値を1つ足すだけで辞書のソート順がずれ、既存の全 ID を振り直す羽目になることもあります。圧縮済みのカラムを途中で書き換えるのは、ZIP ファイルの中身を1バイトだけ差し替えるのに似て、ほぼ全体の再構成を招きます。
そこで列ストアは、更新を圧縮済みの本体へ直接当てるのを諦め、書き込み最適化の小さな層にいったん吸わせ、後でまとめて畳むという遅延更新(lazy update)を採ります。これが delta-main アーキテクチャです。
delta と main の役割分担
| 層 | 役割 | 物理形式 | 更新方針 |
|---|---|---|---|
| delta(差分ストア) | 直近の挿入・更新・削除を吸う書き込み最適化層 | 未圧縮 / 軽量な行・列バッファ | 随時追記(高速) |
| main(基底ストア) | 大半のデータを保持する読み出し最適化層 | 列指向・辞書圧縮・高密度 | 不変。マージでのみ更新 |
要点は、main を不変(immutable)に保つことです。書き込みはすべて delta が受け、main は周期マージのときだけ作り替えられます。これにより main の圧縮効率とスキャン局所性が壊れません。delta は小さく未圧縮なので点更新を低コストで追記でき、読み取り時には main と delta を重ね合わせて一貫した像を見せます。
SAP HANA は delta をさらに二段に分けます。L1 delta は完全に未圧縮の行指向(書き込みに最適、OLTP の点更新を即吸う)、L2 delta は辞書を持つがソートしない列指向(L1 から流れ込み、ある程度まとまった差分を保持)です。データは L1 → L2 → main の順に 二段階のマージで畳まれ、各段で書き込み最適から読み出し最適へ少しずつ寄せていきます。「書きやすさ」と「読みやすさ」は連続したスペクトルで、その途中段を物理的に用意した設計と捉えると分かりやすいです。
トゥームストーンによる削除と更新
main が不変なら、削除と更新をどう表すのかが問題になります。答えは main の値を物理的に消さず、論理的に無効化することです。各行版に有効/無効を示すビットを持たせ、削除はそのビットを倒す(あるいは delete bitmap の該当ビットを立てる)ことで表します。この無効化マーカーが**トゥームストーン(tombstone、墓石)**です。
更新は「削除+挿入」に分解されます。旧版をトゥームストーンで無効化し、新版を delta へ追記する、という追記専用(append-only)モデルです。
DELETE row r:
main 上の r の validity ビットを 0 にする(物理削除しない)
UPDATE row r → r':
main 上の r を validity=0 でトゥームストーン化
r' を delta に追記(valid な新版)
scan:
for v in (main ∪ delta):
if v.validity == 1 and visible_at(snapshot_ts):
yield v # トゥームストーンと不可視版を弾く
main ∪ delta のスキャンで、トゥームストーンが立った旧版と、スナップショット時刻に不可視な版を弾くのがポイントです。可視性の判定は MVCC のスナップショットに従い、各行版のコミット時刻とクエリのスナップショット時刻を比べます。これにより、マージや GC が走っても実行中のクエリは一貫した像を見続けられます。
削除・更新を繰り返すと main にトゥームストーン化された無効行が蓄積します。これらは物理的にはまだ存在するため、スキャンは無効行も読み飛ばしながら走ることになり、有効行が疎になるほどスキャン効率が落ちます。delete bitmap でまとめて弾けても、ブロックの密度が下がる点は避けられません。トゥームストーンを実際に回収して main を詰め直すのは、次に述べるマージの仕事です。
周期マージ:delta を main へ畳む
delta は放置すれば肥大化し、(1) 読み取りの重ね合わせコスト増、(2) 未圧縮 delta のスキャンが列スキャンより非効率、(3) トゥームストーンの蓄積、を同時に悪化させます。そこで背景プロセスが周期的に delta を main へマージし、delta を空に戻します。これは LSM-Tree のコンパクションと同根の発想で、ランダムな点更新をシーケンシャルなまとめ書きに均す操作です。
ただし列ストアのマージには、LSM にはない固有の重さがあります。辞書の再構築と再符号化です。
merge(main, delta) → main':
1. main の有効行(validity=1)と delta の有効行を集める
2. 統合した値集合から新しいソート済み辞書を作る
3. 全行を新辞書の ID で再符号化し、列ごとに再圧縮する
4. main' を別領域に構築し、アトミックに切り替える
5. 旧 main を参照するスナップショットが消えたら GC で回収
main と delta では辞書が別物なので、単純に連結はできません。両者の値域を合わせた新しい辞書を作り直し、全行を新 ID で符号化し直します。同時にトゥームストーン化された無効行はここで落とされ、main が物理的に詰め直されます。マージは重い一括処理ですが、書き込みパスから切り離された背景処理なので、OLTP のレイテンシには直接乗りません。
| 判断点 | 選択肢 | トレードオフ |
|---|---|---|
| 契機 | delta サイズ閾値 / 時間間隔 / 負荷の谷 | 頻繁=delta は小さく読みは軽いがマージ負荷とCPU増。稀=逆 |
| 粒度 | テーブル全体 / パーティション単位 / 更新の多いブロックのみ | 細粒度ほど I/O 局所化、管理は複雑 |
| 切り替え | 新 main を別領域に構築しアトミック差し替え | マージ中も読み書きを止めないが一時的に二重の領域が要る |
| 旧版の扱い | 参照中スナップショットの間は旧 main を残す | 実行中クエリを壊さず GC で後追い回収 |
マージ中も読み書きは止まりません。新しい main を別領域に構築してからアトミックに切り替えるコピーオンライト方式が定石で、切り替え前の古い main は参照中のスナップショットに見せ続けます。どのスナップショットからも参照されなくなった旧 main は MVCC のガベージコレクションと同じ理屈で回収されます。
列ストアの分析クエリは数分〜数十分走ることがあります。実行中のクエリが古いスナップショットを握り続けると、その間は旧 main を回収できず、新旧の main が二重にメモリ・ディスクを占有します。これは MVCC の版回収で長時間トランザクションが回収可能ラインを過去に固定するのと同じ病理で、列ストアでは main 全体が丸ごと残るため影響が大きくなります。重い分析と頻繁なマージが重なる環境では、メモリ膨張に注意が要ります。
マージ契機の調律がすべてを決める
delta-main の性能は、究極的には delta をどれだけ小さく保てるかに集約されます。書き込みレートに対してマージのスループットが上回っていれば delta は小さく保たれ、読み取りは main 主体で高速に走ります。逆にマージが追いつかなければ delta が膨れ、未圧縮 delta のスキャンと重ね合わせが分析を遅くします。
かといってマージを攻めすぎると、辞書再構築と再符号化の CPU・I/O コストが書き込みと競合します。SAP HANA の二段構成(L1 → L2 → main)は、まさにこの調律を段階化したもので、軽い L1→L2 マージを頻繁に、重い L2→main マージを稀に走らせることで、書き込み即応性とマージ負荷のバランスを取っています。契機・粒度・段数の設計が、書き込みと読み取りのどちらにどれだけ余力を回すかを決めるわけです。
まとめ
列指向は辞書圧縮で集計を速くする代わりに点更新が苦手なため、更新を圧縮済み main へ直接当てず、書き込み最適化の小さな delta にいったん追記する遅延更新を採ります。main は不変に保ち、削除・更新は値を書き換えずトゥームストーンで論理無効化して新版を delta へ追記する追記専用モデルで表し、スキャンは MVCC スナップショットで両者を重ねつつ無効版を弾きます。delta は背景の周期マージで main へ畳まれ、ここで辞書の再構築・再符号化とトゥームストーンの物理回収が行われます。新 main を別領域に構築しアトミックに切り替え、旧 main は参照が消えてから GC で回収します。SAP HANA の L1/L2 二段 delta が示すように、マージ契機の調律で delta を小さく保てるかが、列ストアで更新と分析を両立させる鍵です。
データベース Article
カラムストアの遅延更新:delta-mainとマージを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
列指向
比較で見る軸
難易度: advanced / カテゴリ: データベース / タグ数: 6
導入後に効く点
更新・削除は main の値を書き換えず、行版にトゥームストーン(validity/delete ビット)を立てて論理削除し、新版を delta へ追記する追記専用モデルで表す。main は不変に保たれ、スキャンはトゥームストーンで旧版を弾く。SAP HANA は未圧縮の L1 delta と辞書付きの L2 delta を二段で持つ。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- データベース
- タグ数
- 6
判断チェックリスト
- 自社の用途が「列指向 / delta-main」に近いか確認する。
- 強みである「列指向は同一カラム値を連続配置し辞書圧縮するため、1行の更新だけで圧縮ブロックを再構成する羽目になり点更新が苦手。そこで更新を圧縮済み main へ直接当てず、書き込み最適化の小さな delta にいったん追記し、読み取り時に両者を重ねる遅延更新を採る。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。