1. はじめに
RDBMSを利用する際、「主キーなににする問題」があると思います。
完全ランダムな UUIDv4, 時刻順のUUIDv7 または AutoIncrement 。
「UUIDv7 にすると速くなるよ」
これよく聞きますよね。
ただ、「どれくらい速いのか。」「なぜ速いのか。」「効果が出るのがどういう条件なのか」を数字で示したものって中々見かけません。
そこで、MySQL(8.4.11)への1000 万行の書き込み処理を行い、速度・テーブルサイズ・1ページへの書き込み量の測定を行ってみました。
本記事はその実験レポートとさせていただきます。
2. 背景
実験レポートを読んでいただくに当たって、予備知識を揃えさせてください。
UUID の構造
MySQL で UUID を主キーにする場合、BINARY(16) としてバイト列の辞書順で並びます。
なので、先頭バイトに何が入っているかがそのまま並び順を決めます。
UUIDv4 (RFC 4122)
VersionとVariantを除く全てがランダムです。
UUIDv7(RFC 9562)
12 bitsの269に注目していただきたいんですが、
同一ミリ秒内に3件生成した際、先頭8バイトを16進で並べた場合違いがはっきりします。
ms内がランダム ms内が単調 (RFC 9562 method 2)
───────────────────── ─────────────────────
0198f2a4c1e0 7 f3a 0198f2a4c1e0 7 000
0198f2a4c1e0 7 091 0198f2a4c1e0 7 001
0198f2a4c1e0 7 c85 0198f2a4c1e0 7 002
│ └─ 乱数 │ └─ カウンタ
└─ version 7 └─ version 7
並び順はバラバラ 生成順 = バイト列の昇順
左は同一ミリ秒内で順序が保証されないですよね。
1 ミリ秒に何行入るかによりますが、後述する本実験の条件だとリーフページ 0.5〜9 枚分の範囲が毎回シャッフルされることになります。
InnoDB の物理配置について
MySQL の InnoDB はテーブルの実体が主キー順に並んだ B+tree そのものです。
そのため、主キーの値の並びが行の物理配置を直接決めます。
つまり、ランダムな鍵なら挿入先はTreeの全域に散り、時刻順の鍵であれば常にTreeの右端に着く、というのが理論値です。
UUIDv7 にすれば速いと言われてる主張と根拠は?
InnoDB は上記の挿入の違いに対して特別な処理を持つんですが。
btr_page_split_and_insert() は分割位置を無条件に中央には置きません。
ただ、btr_page_get_split_rec_to_right() が単調増加を検出した場合は分割点を挿入点そのものに置きます。
この時、「旧ページは満杯のまま残り、新ページには1件だけ載る」という挙動となります。
検出に失敗した場合、 50/50 の中央分割となるため、同じ行数を保持するのに必要なページ数が増えます。
「UUIDv7 を使えば速くなる」という主張は上記に依拠するんですよね。
図にするとこんな感じ。
■ = 使用中 □ = 空き 1 ページ = 約 110 行
【中央分割】単調増加を検出できなかった場合
満杯のページ 分割後
┌──────────────┐ ┌───────┬──────┐
│██████████████│ ─ INSERT ─→ │███████│□□□□□□│ 55 行
└──────────────┘ ├───────┼──────┤
110 rows │███████│□□□□□□│ 55 行
└───────┴──────┘
→ 同じ行数を保つのに 2 ページ必要になる。fill は 50%
【右端分割】単調増加を検出した場合
満杯のページ 分割後
┌──────────────┐ ┌──────────────┐
│██████████████│ ─ INSERT ─→ │██████████████│ 110 行(満杯のまま)
└──────────────┘ ├──────────────┤
110 rows │█□□□□□□□□□□□□□│ 1 行(以降ここに追記)
└──────────────┘
→ 旧ページはそのまま残る。定常状態の fill は 15/16 = 93.75%
上記主張に対する私の主張
ただ、この検出はヒューリスティクスです。
判定は「新しいレコードが、同じページの直前の挿入位置の真後ろに入るか」の 1 点のみ(PAGE_LAST_INSERT == insert_point)。ソースのコメントも "eager heuristics" と明言しています。
つまり、挿入順が 1 件でも乱れればその時点で成立しません。
連続回数を数えて猶予を持たせる仕組みではないんですよね。
そして、UUIDv7は同一ミリ秒内の順序を規定してません。
これはRFC9562でも許容されてるんですが、ms内を乱数で埋める実装と、カウンタで単調にする実装、どちらでも良いんですよね。
何が言いたいかといいますと、、。
「UUIDv7を使う」だけでは、右端分割が成立するかどうかって決まらないんですよね。
そのため、本実験ではUUIDv7のミリ秒内の無秩序がどこまで許容されるかを実測します。
3. 目的
実験の目的を定義しますね。ここがブレると渋いので。
順序性のみが異なる以下の 4 条件を用意します。
- UUIDv4
- UUIDv7(ミリ秒内がランダム)
- UUIDv7(ミリ秒内が単調)
- AutoIncrement
対象の 4 サンプルについて、以下の項目を測定します。
- ページ書き込み量
- クラスタ索引の物理サイズ
- 挿入スループット
- ページ分割回数と再編成回数
- 平均フィルファクタ
- 方向検出ヒューリスティクスが、どれだけの幅の無秩序まで耐えるか
加えて、同一ミリ秒に属する行数を制御変数として振り、検出が破綻する境界を探索します。
3.1. スコープ外
本稿はテーブル全体がバッファプールに収まる条件に限ります。
バッファプールを超えた場合の読み取り増幅は測定していません。
また、書き込みは単一ライタに固定しています。複数ライタでの最右ページ競合は測定していません。
3.2. 実験のサンプルについて
3.2.1. レコード情報
主キー以外の条件は揃えます。
-- UUIDv4
CREATE TABLE t_v4 (
id BINARY(16) NOT NULL PRIMARY KEY, -- 122bit random
payload CHAR(100) CHARACTER SET ascii NOT NULL,
created BIGINT NOT NULL
) ENGINE=InnoDB;
-- UUIDv7(ミリ秒内がランダム)
CREATE TABLE t_v7_naive (
id BINARY(16) NOT NULL PRIMARY KEY,
payload CHAR(100) CHARACTER SET ascii NOT NULL,
created BIGINT NOT NULL
) ENGINE=InnoDB;
-- UUIDv7(ミリ秒内が単調)
CREATE TABLE t_v7_mono (
id BINARY(16) NOT NULL PRIMARY KEY,
payload CHAR(100) CHARACTER SET ascii NOT NULL,
created BIGINT NOT NULL
) ENGINE=InnoDB;
-- AutoIncrement
CREATE TABLE t_seq (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
payload CHAR(100) CHARACTER SET ascii NOT NULL,
created BIGINT NOT NULL
) ENGINE=InnoDB;
payload は CHARACTER SET ascii で 1B/char に固定します。
※ utf8mb4 では予約幅が変わり、行サイズ ~144B の前提が崩れ data_length 以下の全数値が動くため
上記のとおりレコードを固定することで、行あたりの実効サイズが確定します。
行あたりの実効サイズは 16 (PK) + 100 (payload/ascii) + 8 (bigint) + ~20 (row header + trx_id + roll_ptr) ≈ 144 B。
16KB ページの有効領域を ~15.5KB とすると、満杯時で ~110 rows/page になります。
要するに、1 ページあたり 110 レコードが入るものと定義します。
3.2.2. キーの生成方法
ソースコード
// 乱数源は crypto/rand ではなく math/rand/v2 の ChaCha8 を固定 seed で使う。
// n≥3 の反復は「システムのばらつき」を測るためのものであり、反復ごとに鍵列が
// 変わると spread に鍵分布のばらつきが混入して分散の解釈が濁る。
// crypto/rand は seed を取れないため再現できない。
var keySeed [32]byte // 定数。結果 JSON に記録する
rng := rand.NewChaCha8(keySeed)
// 合成クロック: i 行目のタイムスタンプ。rowsPerMs は Phase 0 で実測値から決定する制御変数。
func synthMs(i uint64) uint64 { return startMs + i/rowsPerMs }
// UUIDv4 — 全 122bit がランダム
func NewV4() [16]byte {
var b [16]byte
rng.Read(b[:])
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant RFC 4122
return b
}
// UUIDv7(ミリ秒内がランダム) — 上位 48bit のみ unix_ms、残りは乱数
func NewV7Naive(i uint64) [16]byte {
var b [16]byte
putMillis(&b, synthMs(i)) // b[0:6]
rng.Read(b[6:]) // 残り 74bit がランダム
b[6] = (b[6] & 0x0f) | 0x70 // version 7
b[8] = (b[8] & 0x3f) | 0x80 // variant
return b
}
// UUIDv7(ミリ秒内が単調) — rand_a 12bit を同一 ms 内カウンタに割り当て (RFC 9562 method 2)
// 同一 ms 内で seq をインクリメントし、ms 境界で 0 にリセットする。
// これにより ms 内でも生成順 = バイト列辞書順が保証される。
func (g *V7Mono) Next(i uint64) [16]byte {
var b [16]byte
ms := synthMs(i)
if ms <= g.lastMs {
// 同一 ms、または既に繰り上げ済みの ms。決して巻き戻さない
ms = g.lastMs
g.seq++
if g.seq >= 4096 { // rand_a は 12bit。溢れたら次 ms へ繰り上げる
ms = g.lastMs + 1
g.seq = 0
}
} else {
g.seq = 0
}
g.lastMs = ms
putMillis(&b, ms) // b[0:6]
rng.Read(b[8:]) // rand_b 62bit のみ乱数
b[6] = 0x70 | byte(g.seq>>8) // version 7 + rand_a 上位 4bit
b[7] = byte(g.seq) // rand_a 下位 8bit
b[8] = (b[8] & 0x3f) | 0x80 // variant
return b
}
seq オーバーフローのガードは必須。
seq >= 4096を検査しない実装では、同一 ms 内に 4096 行を超えた瞬間にbyte(g.seq>>8)が 4bit に収まらず version nibble (0x70) を破壊する。しかもエラーを返さず、version が壊れた UUID が静かに挿入される。同時に ms 内単調性も失われ、UUIDv7(ミリ秒内が単調) の前提そのものが崩れる。合成クロックでは
rowsPerMsを 4096 未満に設定する限り原理的に到達しないが、スイープ時(rowsPerMs = 5000等)には確実に発生する。ガードは常に入れる。
4. 仮説
こんな感じになるかと
| Metric | UUIDv4 | v7(ms内ランダム) | v7(ms内単調) | AutoIncrement |
|---|---|---|---|---|
data_length |
~2.4 GB | ~1.7 GB | ~1.6 GB | ~1.55 GB |
| Leaf pages | ~150,000 | ~106,000 | ~100,000 | ~97,000 |
index_page_splits |
~150,000 | ~106,000 | ~100,000 | ~97,000 |
index_page_reorg_attempts |
高 | 中〜低 | ~0 | ~0 |
| 挿入スループット (相対) | 1.00x | ~1.15x | ~1.20x | ~1.25x |
| 平均フィルファクタ | 50-65% | 80-93% | ~93% | ~94% |
以下をベースに予測・推察しました。
行あたりの実効サイズは
16 (PK) + 100 (payload/ascii) + 8 (bigint) + ~20 (row header + trx_id + roll_ptr) ≈ 144 B。
16KBページの有効領域を~15.5KBとすると、満杯時で ~110 rows/page になります。
UUIDv4
挿入点が木全域に一様分散すると推測。
→ 方向カウンタが挿入のたびにリセットされ、それに伴い中央分割 (50/50) が支配的になる。
→ 分割1回にあたり、 ~8KBの memmove を伴う
→ 触るページが挿入のたびに入れ替わるため、作業セットがTree全体に広がる
→ バッファプールがあふれる場合、挿入 1件あたり ~1回のランダムリーフ読みが行われると推察。
UUIDv7(ミリ秒内がランダム)
挿入点は常に最右リーフとなると推測。
ただ、ms 内 ~50 行が無秩序。
→ 方向検出が ms 境界の近傍で断続的に失敗する
→ 中央分割が混入する。ただし分割後の右半ページは後続の挿入で再充填されるため、損失は限定的かつ回復性があると見る
→ 作業セットは最右パス + 直近数ページに収まる
UUIDv7(ミリ秒内が単調)
挿入点が常に最右レコードの直後
→ btr_page_get_split_rec_to_right() が定常的に成立
→ 旧ページ満杯のまま、新ページに 1 件のみ。移動があるとすると1レコードのみ。
→ 作業セットは実質 1 リーフ + 親ノードに収束する。
→ AutoIncrement とほぼ一致するはず
AutoIncrement
同上。
PK 幅 8B のぶんだけ UUIDv7(単調) より僅かに密になるのでは?
なお、上記の「作業セット」に関する予測が効くのは、テーブルがバッファプールを超えた条件(§3.1 のとおり本稿のスコープ外)です。
本稿では、触るページの散らばりが分割回数と再編成回数にどう出るかを見ます。
補足
v7(ms内ランダム) のフィル率は幅で予測する。 93.75% を上限とし、同一 ms 内バッチによる方向検出の失敗頻度に応じて低下しうる。この幅のどこに落ちるかこそが本実験の問いであり、事前に一点では確定できない。
分割回数と分割コストは別物。 分割 1 回 = ページ 1 枚生成であるため、回数比はページ数比に一致する。差の本質は 1 回あたりのコストにある。v4 の中央分割は ~8KB の memmove を伴うが、右端分割は 1 レコードのみを移動する。したがって回数比の差より、分割に費やす CPU 時間の比はより大きく開くと予測する。index_page_reorg_attempts がこのコスト差の代理指標となる。
B-tree height は 4 アームすべて 3 と予測する。 内部ノードのファンアウトを ~550 entries/page と見積もると、10M 行規模では全アームが height 3 に収まる。height が割れるとすれば ~25-33M 行の leaf 数境界。height はスループット差の説明変数として使わない — 観測で割れても割れなくても、他指標の解釈はこれに依存しない。
反証条件
-
data_lengthの UUIDv4 / v7(単調) 比が 1.1 倍未満 → フィルファクタ仮説を棄却 - v7(単調) の
index_page_reorg_attemptsが UUIDv4 と同水準 → 単調増加検出の成立仮説を棄却 - v7(ms内ランダム) が v7(単調) と統計的に区別できない → 「ms 内の無秩序は方向検出を壊す」仮説を棄却(=ヒューリスティクスは 0.5 ページ幅の無秩序に対して頑健、という発見になる)
5. 手法
実験の手順をまとめますね。
5.1. 実行環境
実験は専用の Colima プロファイル上で実施しました。
開発用のプロファイルは全部停止し、他コンテナによる I/O とホストページキャッシュの汚染を排除するのが目的です。
| 項目 | 値 |
|---|---|
| ホスト | macOS (Darwin 25.5.0) arm64 / Apple M5 / RAM 32 GB |
| VM | Colima + Lima、macOS Virtualization.framework (vz) |
| プロファイル |
perf(docker context は colima-perf) |
| VM 割当 | 6 vCPU / 16 GiB / 100 GiB |
| Guest | Ubuntu 24.04.4 LTS, kernel 6.8.0-117 |
| MySQL |
mysql:8.4.11(パッチレベルで固定) |
| Go | 1.26.5 darwin/arm64 |
イメージタグはパッチレベルで固定しています。
実際に、innodb_change_buffering の既定値がマイナーバージョンによって暗黙的に変わるリスクを排除しています。
5.2. InnoDBの設定を固定
InnoDBの設定について
innodb_flush_method = O_DIRECT # OS ページキャッシュをバイパス
innodb_flush_log_at_trx_commit = 2 # redo fsync を交絡から除外
skip_log_bin # binlog を除外
innodb_autoinc_lock_mode = 2
innodb_doublewrite = 0
innodb_change_buffering = none
innodb_page_size = 16384
innodb_adaptive_hash_index = ON
innodb_buffer_pool_size = 4G
三点、説明させてください。
5.2.1. innodb_autoinc_lock_mode = 2
既定のロックモードの場合、AutoIncrement がステートメントごとにテーブルロックを取り、主キーの順序性とは無関係な理由で遅くなります。
本実験の下限側の対照として使えなくなるため、インターリーブモードを指定しました。
5.2.2. innodb_doublewrite = 0
doublewriteの書き込み帯域はフラッシュしたページ数に比例します。
UUIDv4 は他のサンプルより遥かに多くのページをフラッシュすることが予測されます。
そのため v4 にだけ実験のテーマとは無関係な負荷が乗るリスクがあります。
このブレは仮説を支持する方向に働くので、除去しました。
doublewrite の書き込みは別カウンタ(Innodb_dblwr_pages_written)に計上されるのでInnodb_pages_writtenは汚れないです。
本設定が与える影響については、ON/OFFの感度ペアで数値化しております。
5.2.3. innodb_change_buffering = none
8.4 の既定値なんですが、既定値そのものがマイナーバージョンで往復している(8.0=all → 8.4=none → 9.5=all)ため、継承せず明示しました。
なお change buffer はセカンダリインデックス専用で、本実験のテーブルには無いため結果には影響しません。
5.3. 測定手順
1実行の流れは以下です。
- MySQLコンテナを再起動しバッファプールを cold にする
- テーブルを DROP / CREATE
- 主キーを全件、計測区間外で事前生成(
1000万行 x 16B = 160MB) - カウンタを取得(Before)
- 【計測区間】単一ライタで、1000 rows/tx のマルチ VALUES INSERT で 1000 万行を投入。
- 計測終了後、ダーティページが0になるまで待つ。
- カウンタを取得(After)、
ANALYZE TABLEの後にサイズを測定 - Sanity Check を実行し、fail があればその実行を破棄する(装置を直してから測り直す)
- 4 条件 × n=3 → 12 実行
- doublewrite ON の感度ペア n=1 → 2 実行
- 後述するミリ秒あたり行数のスイープ 5 水準 × n=3 → 15 実行
とりあえず、合計、1000万行を29回流し込みます!!
5.4. 測定項目
| 項目 | 取得元 |
|---|---|
| クラスタ索引の物理サイズ |
information_schema.TABLES.DATA_LENGTH(ANALYZE TABLE 後) |
| ページ分割・再編成回数 |
INNODB_METRICS の index_page_splits / index_page_reorg_attempts
|
| ページ書き込み量 | Innodb_pages_written |
| 物理読み | Innodb_buffer_pool_reads |
| 挿入スループット | 累積 rows/sec の時系列(1 万行ごと) |
B+Tree の高さは測定しません。
※ 外部ツールに依存する上、本実験では説明変数として成立しないため。
5.5. 装置が壊れてないことの確認について
僕化学専攻だったので、この辺もレポートに書けと言われて育ったので一応書きます。
装置が壊れたまま長時間回すのも防ぐために、1実行ごとに以下を検査し fail があればその実行自体を破棄とみなします。
- 固定した設定を全件読み戻して照合: my.cnf に書いたことは、反映されたことを保証しません
- UUID version nibble の全行検査: ms 内カウンタのオーバーフローは version を静かに破壊するため
- 投入行数が要求と一致するか: COUNT(*) で厳密に
- Innodb_buffer_pool_reads ≈ 0: テーブルが実際にバッファプールに収まっていたか
- Innodb_dblwr_pages_written = 0: doublewrite が実際に無効か
それでは測定していきましょう!!
6. 結果
測定してきました。長かったです。
すべて 1000 万行、n=3 の中央値です。
6.1. 主結果
| UUIDv4 | v7(ms内ランダム) | v7(ms内単調) | AutoIncrement | |
|---|---|---|---|---|
| 挿入スループット | 127,290 rows/s | 329,361 | 415,524 | 487,437 |
| v4 比 | 1.00x | 2.59x | 3.26x | 3.83x |
| テーブルサイズ | 2.50 GB | 2.62 GB | 1.55 GB | 1.45 GB |
| bytes/row | 249.9 B | 262.0 B | 154.9 B | 145.3 B |
| インデックスページ数 | 152,506 | 159,936 | 94,528 | 88,704 |
| ページ分割回数 | 133,401 | 139,950 | 94,488 | 88,591 |
| ページ再編成回数 | 15 | 36 | 1 | 1 |
| ページ書き込み量 | 2,407,840 | 150,109 | 102,099 | 95,256 |
| 1 ページあたり書き込み回数 | 15.8 | 0.94 | 1.08 | 1.07 |
| 物理読み | 0 | 0〜1 | 0 | 0〜1 |
全条件で Sanity Check は通過しました。投入行数は厳密に10,000,000行です。
おもろいっすね笑。v7(ms内ランダム)、速度は 2.6 倍出てるのにサイズは v4 より悪いんですよね笑。
6.2. 仮説との対比
| 予測 | 実測 | ||
|---|---|---|---|
| v7(単調) スループット | ~1.20x | 3.26x | 桁で過小予測 |
| AutoIncrement スループット | ~1.25x | 3.83x | 同上 |
| v7(単調) フィルファクタ | ~93% | 93.7% | 的中 |
| v7(単調) 分割回数 | ~100,000 | 94,488 | 的中 |
| v7(ms内ランダム) フィルファクタ | 80-93% | 55.4% | 下限を大きく割った |
| v7(ms内ランダム) サイズ | ~1.7GB | 2.62GB | v4を大きく上回った |
| v4 フィルファクタ | 50-65% | 58.1% | 的中 |
「v7(ms内ランダム) が v7(単調) と区別できない」は成立せず(サイズで 1.69 倍差)、ミリ秒内の無秩序は方向検出を壊す、という仮説側が支持されました。
一方で、スループット予測は 4 条件すべてで大きく外れています。
予測表に行を設けていなかった Innodb_pages_written が 15.8 対 0.94 という差を示しました。
6.3. ミリ秒あたり行数のスイープ
UUIDv7(ミリ秒内がランダム) のみ、n=3。
| rowsPerMs | 50 | 110 | 250 | 482 | 524 | 1000 | (参考) v4 |
|---|---|---|---|---|---|---|---|
| 無秩序窓のページ幅 | 0.5 | 1.0 | 2.3 | 4.4 | 4.8 | 9.1 | — |
| フィルファクタ | 50.4% | 52.4% | 52.6% | 55.4% | 55.3% | 56.7% | 58.1% |
| テーブルサイズ | 2.88 GB | 2.77 GB | 2.76 GB | 2.62 GB | 2.62 GB | 2.56 GB | 2.50 GB |
| ページ分割回数 | 153,750 | 147,895 | 147,353 | 139,950 | 140,057 | 136,585 | 133,401 |
| 1 ページあたり書き込み回数 | 0.94 | 0.94 | 0.94 | 0.94 | 0.94 | 0.94 | 15.8 |
測定した 0.5〜9.1 ページ幅の範囲に、閾値は現れませんでした。 フィルファクタは窓幅の増加に対して 50.4% → 56.7% と単調に上昇し、v4 の 58.1% に下から漸近して越えません。
1 ページあたり書き込み回数は全水準で 0.94 で不変です。
6.4. doublewrite の感度
innodb_doublewrite = 0 にしたことの影響を、ON/OFF ペア(n=1)で数値化しました。
| doublewrite OFF | ON | 変化 | |
|---|---|---|---|
| UUIDv4 | 127,290 rows/s | 96,667 | −24.1% |
| v7(ms内単調) | 415,524 rows/s | 354,908 | −14.6% |
ON にしても v4 / v7単調 の比は 3.26x → 3.67x で、順位も桁も変わりません。サイズ・分割回数・フィルファクタは ON/OFF で完全に一致しました。
6.5. 再現性
| 指標 | n=3 のばらつき |
|---|---|
| テーブルサイズ | 全条件で 1 バイトも差なし |
| ページ分割回数 | 全条件で完全一致 |
| フィルファクタ | 同上 |
| ページ書き込み量 | 条件内で 0.01〜0.1% |
| 挿入スループット | 条件内で 0.5〜11% |
構造的な指標が完全に決定的なのは、固定 seed の ChaCha8 と合成クロックにより鍵列が反復間で完全に同一だからです。
ばらつくのは時間的な指標のみで、これはシステム側の変動を表します。
7. 考察
7.1. スループット差の正体は fill ではなかった
予測では、フィルファクタの差からせいぜい 1.2 倍程度の差を見込んでいました。
実測は 3.26 倍だったので桁外しでした。
要因として、フィルファクタとスループットが対応していないからと推察します。
| fill | v4 比スループット | |
|---|---|---|
| v7(ms内ランダム) | 55.4%(最下位) | 2.59x |
| UUIDv4 | 58.1% | 1.00x |
fill が一番悪い条件が、v4 の 2.6 倍速いって事実が、fill では説明できません。
対応しているのが以下。
| 1 ページあたり書き込み回数 | v4 比スループット | |
|---|---|---|
| UUIDv4 | 15.8 | 1.00x |
| v7(ms内ランダム) | 0.94 | 2.59x |
| v7(ms内単調) | 1.08 | 3.26x |
| AutoIncrement | 1.07 | 3.83x |
v4 は 2.50 GB のテーブルを作るのに 39 GB 書いています。同じページを平均 15.8 回書き戻していますよね。
理由は挿入先の散らばりです。
v4 は挿入のたびに木の別の場所を触るので、ダーティになったページが再度触られる前に page cleaner にフラッシュされます。
次にそのページを触るとまた汚れ、また書かれる。といった感じ。
v7 系は挿入先が右端に集中するため、1 枚のページが満杯になるまでダーティのまま留まり、フラッシュは実質 1 回で済みます。
これは in-memory 条件でも効きます。
バッファプールに全部載っていても、ダーティページのフラッシュは止まらないためです。
予測表にこの行を設けていなかったのは、「メモリに載っていれば I/O は問題にならない」と暗に仮定していたからで、そこが間違いでした。
なお doublewrite の感度ペアがこの解釈を補強します。
doublewrite を ON にすると書き込み帯域が倍になりますが、劣化幅は v4 が −24.1%、v7単調が −14.6% と、書き込み量の多い側が大きく損をしています。書き込みコストを独立に動かしたときに予測どおりの向きに応答した、という点で、単なる相関以上の証拠になっています。
7.2. ミリ秒内がランダムだと、なぜ v4 より悪くなるのか
本稿でいちばん意外だった結果です。
| fill | サイズ | |
|---|---|---|
| v7(ms内ランダム, rpms=50) | 50.4% | 2.88 GB |
| UUIDv4 | 58.1% | 2.50 GB |
時刻順にしたのに、完全ランダムより悪いですよね。
いろいろ調べてみたんですが、分割された半端なページが後から埋め直されるかどうかの仕様に準じてると推察しました。
UUIDv4 は挿入先が木全域に散ります。
中央分割で 50% になったページも、その後の挿入がまた偶然そこに落ちてきて埋め直されます。
ランダム挿入の定常フィル率が理論上 ln2 ≈ 69% になるのはこの再充填があるからで、実測 58.1% はその水準かと。(仮説通り)
一方、UUIDv7(ミリ秒内ランダム) は違います。
挿入位置はミリ秒単位で前へ進み続け、二度と戻りません。
同一ミリ秒内の無秩序で中央分割は起きるのに、そのページはミリ秒が進んだ瞬間に窓の外へ出て、永久に埋め直されない。分割直後の 50% がそのまま凍結する感じかなと。
【UUIDv4】挿入先が木全域に散る
┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐
│50%│ │50%│ │50%│ │50%│ │50%│ ← 中央分割の直後
└───┘ └───┘ └───┘ └───┘ └───┘
↓ 後続の挿入がランダムに落ちてくる
┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐
│58%│ │58%│ │58%│ │58%│ │58%│ ← 埋め直される(理論 ln2≈69%、実測 58.1%)
└───┘ └───┘ └───┘ └───┘ └───┘
【UUIDv7(ms内ランダム)】挿入窓がミリ秒単位で前へ進み続ける
┌───┐ ┌───┐ ┌───┐ ┌───┐ ╔═══╗
│50%│ │50%│ │50%│ │50%│ ║挿入║ ← 挿入されるのはここだけ
└───┘ └───┘ └───┘ └───┘ ╚═══╝
↑ 一度通り過ぎたページには、二度と挿入されない
→ 分割直後の 50% のまま凍結する(実測 50.4%)
実測 50.4% がほぼちょうど 50% であることが、この説明と一致します。
つまり v7 は「分割を防ぐ」効果と「分割した状態をリセットする」効果を同時に失っている感じです。
ミリ秒内が単調なら前者が効くので問題になりませんが、単調でない場合は後者を失った分だけ v4 を下回ります。
7.3. 測定範囲に閾値は現れなかった
当初は「ミリ秒内の無秩序が 1 ページ幅を超えると方向検出が壊れる」という、境界のある挙動を想定していました。
ただ、実測は連続的で。
窓幅 0.5p → 1.0p → 2.3p → 4.4p → 4.8p → 9.1p
fill 50.4% 52.4% 52.6% 55.4% 55.3% 56.7% → v4 の 58.1% に漸近
右端分割の判定は挿入 1 件ごとに独立に下される(直前の挿入の真後ろか否か)ので、無秩序な列に対しては確率的にしか成立しません。
走長を数える仕組みではないため、そもそも「ここを超えたら壊れる」という境界を持ちません。
そして窓が広いほど、7.2 で述べた再充填の機会が増えます。
結果として v4 の水準へ下から近づき、追い越すことはない。v4 は「再充填はあるが分割も多い」上限側の対照であり、ミリ秒内ランダムな v7 はその劣化版という位置づけになります。
一方で 1 ページあたり書き込み回数は全水準で 0.94 のまま不変でした。書き込み量には窓幅が全く効いていません。
ここから、2 つの効果が独立な性質に応答していることが分かります。
| 効果 | 必要な性質 | 無秩序への耐性 |
|---|---|---|
| 書き込み量の削減 | 近接性(挿入先が近い) | 頑健。9 ページ幅の無秩序でも劣化なし |
| 空間の削減 | 厳密な単調性(常に最後尾) | 脆弱。0.5 ページ幅の無秩序で崩壊 |
7.4. 実務への含意
1. UUIDv4 から v7 への移行は、書き込み側については明確に正当化される
1 ページあたり書き込み回数が 15.8 → 1 前後。ディスク IOPS、SSD の消耗、クラウドの I/O 課金、レプリカへの転送量が、いずれも一桁変わります。
この比は環境に依存しません。ただし本稿は単一ライタでの計測であり、複数ライタ下での最右ページ競合は未検証です。
2. ただし「v7 にした」だけでは空間の恩恵はゼロ
ミリ秒内が単調でなければテーブルは v4 より大きくなります。2.88 GB 対 2.50 GB。使っているライブラリが RFC 9562 のどの方式を採っているかを確認する必要があります。
rand_a の 12 bit をミリ秒内カウンタに使う method 2 なら単調、乱数で埋めているなら単調ではありません。
3. 実装するなら、カウンタのオーバーフローガードは必須
rand_a は 12 bit なので、同一ミリ秒に 4096 行を超えると溢れます。ガードが無いとversion nibble を静かに破壊し、エラーも出ません。
7.5. 本稿の限界
- 単一ライタです。複数ライタでの最右ページ競合は測っていません。連番キーへの主要な反論はここなので、並行環境での結論は本稿からは導けません
- テーブルがバッファプールに収まる条件のみです。溢れた場合、7.2 のサイズ差がキャッシュ効率の差として速度に変換されると予測されますが、未測定です
- 読み取りを測っていません。 時刻順キーは範囲スキャンの局所性にも効くはずですが、本稿の対象外です
- セカンダリインデックスがありません。 InnoDB はセカンダリインデックスの各エントリに主キーを丸ごと持つため、UUID 16B と BIGINT 8B の差はインデックスの本数分だけ効きます
- 単一ホストでの測定です。スループットの絶対値には意味がありませんが、比と構造的指標は環境に依存しません
8. まとめ
MySQL 8.4 / InnoDB に 1000 万行を投入し、主キーの生成戦略だけを変えて比較しました。
1. 書き込み量が一桁違う
1 ページあたりの書き込み回数は UUIDv4 が 15.8 回、UUIDv7 系は 1 回前後。
v4 は 2.50 GB のテーブルを作るのに 39 GB 書いています。
挿入スループットの 2.6〜3.8 倍差を説明していたのは、事前の予測表に行を設けてすらいなかったこの指標でした。
2. 「UUIDv7 にする」だけでは半分しか取れない
ミリ秒内が単調でない UUIDv7 は、書き込み量の恩恵はフルに受けながら、テーブルサイズは UUIDv4 より大きくなりました(2.88 GB 対 2.50 GB)。
挿入位置が前へ進み続けるため、分割で半端になったページが二度と埋め直されないからだと考えられます。
3. 2 つの効果は独立している
| 効果 | 必要な性質 | 無秩序への耐性 |
|---|---|---|
| 書き込み量の削減 | 近接性(挿入先が近い) | 頑健。9 ページ幅の無秩序でも劣化しない |
| 空間の削減 | 厳密な単調性(常に最後尾) | 脆弱。0.5 ページ幅の無秩序で崩壊する |
ミリ秒あたりの行数を 50〜1000(リーフページ 0.5〜9.1 枚ぶん)まで振っても閾値は現れず、フィルファクタは連続的に変化して UUIDv4 の水準に下から漸近しました。
実務としての結論
UUIDv4 を使っているなら、UUIDv7 への移行は書き込み側について明確に正当化されます。
ただし使っているライブラリがミリ秒内を単調にしているか確認してください。
もし、していなければ、テーブルサイズは改善しないどころか悪化します。
自前で実装する場合、rand_a の 12 bit をミリ秒内カウンタに使う RFC 9562 method 2 を採ったうえで、4096 を超えたときのオーバーフローガードを必ず入れてください。
確かめていないこと
単一ライタ・バッファプールに収まる条件での結果です。
複数ライタでの最右ページ競合(連番キーへの主要な反論)と、バッファプールを超えた場合の読み取り増幅は測っていません。
9. 再現方法
コードと今回計測した測定結果は以下に置いてます。
- リポジトリ: https://github.com/umekikazuya/perf-uuid-v7
- 実験ノート: Issue #1(仮説の事前登録・考察)
- 実装と結果: PR #2
git clone https://github.com/umekikazuya/perf-uuid-v7
cd perf-uuid-v7/experiments/uuid-v7-innodb
単体テスト(DB 不要)
go test ./internal/uuidgen/...
1000 万行の本測定。全 29 実行でおよそ 1 時間
./scripts/phase1.sh
結果は results/ に 1 実行 1 ファイルで出力されます(.json が測定値、.csv がスループットの時系列)。
鍵生成は固定 seed の ChaCha8 を使っているため、鍵列は完全に再現されます。
10. 参考情報
仕様
InnoDB のソース
btr0btr.ccL1703btr_page_get_split_rec_to_right()btr0btr.ccL2305btr_page_split_and_insert()page0cur.ccL1364PAGE_DIRECTION/PAGE_N_DIRECTIONの更新
MySQL 8.4
- Clustered and Secondary Indexes: テーブルの実体がクラスタ索引そのものであること
-
INNODB_METRICS Table:
index_page_splits等の取得方法(innodb_monitor_enableが必要) - Doublewrite Buffer: §5.2.2 で無効化した設定
-
AUTO_INCREMENT Handling in InnoDB: §5.2.1 の
innodb_autoinc_lock_mode -
innodb_change_buffering: §5.2.3。既定値がバージョンで変わる

