- 物理機リージョン/AZ
- ハイパーバイザVM exit で捕まえる
- VMゲストOS
- コンテナnamespace / cgroup
- プロセスアプリ / DB今ここ
前回、木は挿入の順序しだいで偏ることを見ました。今回は別の軸、「1段でいくつに枝分かれさせるか」が段数をどう変えるかを PostgreSQL 17 の実測で確かめます。
まず結論
1回の読み取りでまとめて取れる量が大きいほど、1段あたりの分岐数は増えます。分岐が増えれば木は低くなり、根から葉まで辿るのに**読む段数が減ります**。
PostgreSQL 17 の実機で 1,000万行の索引は**根から葉まで 3 段**でした。同じ件数を分岐 2 の木に入れると 24 段です。ただしこれは索引を辿るとき触る段数の話で、**クエリの速さとは別の問題**です。
1,000万行のテーブルに1列の索引を作ったとき、根から葉まで何段になるでしょうか。
当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。
1回で取れる量が、分岐の数を決める
前回、木は挿入の順序しだいで偏ることを見ました。今回は別の軸です。1段で何本に枝分かれさせるか。
ディスクやSSDの読み取りに、1バイトだけ取るという選択肢はありません。決まった大きさの塊——ページ——をまとめて取ります。すると自然にこうなります。
1回の読み取りで運べるバイト数 ÷ 1項目のバイト数 = 1段で持てる分岐の数。
これが扇出しです。第7週『キャッシュと記憶階層 — なぜ何段も重ねるのか』の「1バイト欲しくても64バイトまとめて持ってくる」のディスク側です。
段数は分岐数を底とする対数で、底が2か数百かで桁違いに変わります。根から葉まで辿るとき1段につき1ページ読むので、段数が減れば読むページ数が減ります。
- ・項目ごとの行ポインタ(4バイト)、ヘッダ類、fillfactor の余白があるので、概算式は上限を与えるだけ
- ・触るページ数と「クエリが速いか」は別の話(後述)
公式表記は B-tree(PostgreSQL は btree)で、一般に B+木と呼ばれる特徴を持ちます。 この事情は DB編『「B+木」を公式は使わない — 通称と原典の表記を分ける』に譲ります。要るのは呼び名ではなく枝分かれの数です。
Because B-tree nodes can have many children, a B-tree is not the same as a binary tree, which is limited to 2 children per node.
この many children がいくつなのかを、実機で数えます。
PostgreSQL 17 で、実際に数えてみる
Docker の postgres:17-alpine(PostgreSQL 17.11, aarch64-unknown-linux-musl)で、bigint 1列のテーブルに索引を作りました。まずページサイズです。
$ docker exec g2pg psql -U postgres -tAc "show block_size"
8192
公式文書の “usually 8 kB” が実機で 8192 と確認できました。次に行数を変えて高さを取ります。
idx | i1 (1,000 rows) root | 3 level | 1idx | i2 (100,000 rows) root | 3 level | 1idx | i3 (10,000,000 rows) root | 290 level | 2relname | bytes | pages---------+-----------+-------i1 | 40960 | 5i2 | 2260992 | 276i3 | 224641024 | 27422
取り違えやすいのが level です。 1,000万行の索引は level = 2 ですが段数ではありません。ページを種別で数えると根・中間・葉の3種類が出るので、段数は 3 段です。nbtree.h は btpo_level を zero for leaf pages と書くので、根の level = 2 は 0・1・2 の3段です。
その集計が扇出しの実物です(索引 i3 の全 27,421 ページ)。
type | pages | min_items | avg_items | max_items------+-------+-----------+-----------+-----------i | 97 | 59 | 283 | 285l | 27323 | 148 | 367 | 367r | 1 | 97 | 97 | 97what | type | live_items | dead_items | avg_item_size | page_size | free_size--------------+------+------------+------------+---------------+-----------+-----------i3 root page | r | 97 | 0 | 15 | 8192 | 6216i3 leaf page | l | 367 | 0 | 16 | 8192 | 808
根が 97 項目、中間ページが平均 283 項目、葉が平均 367 項目。 これを掛けます。
97 × 283 × 367 = 10,074,517
投入した行数は 10,000,000。差は 0.75%。 扇出しを掛け合わせる素朴な算数が、実測を再現しています。
概算式も確かめます。葉の項目は平均16バイトなので 8192 ÷ 16 = 512。実測は 367、約 72%。この差の理由は公式文書にあります。
The first 24 bytes of each page consists of a page header (
PageHeaderData).
The final section is the "special section" which can contain anything the access method wishes to store. For example, b-tree indexes store links to the page's left and right siblings, as well as some other data relevant to the index structure.
ただし主因は別です。同じ文書は、項目ごとに行ポインタが要ると書いています。
Array of item identifiers pointing to the actual items. Each entry is an (offset,length) pair. 4 bytes per item.
さらに CREATE INDEX の文書は B-trees use a default fillfactor of 90 と書き、作成時の葉は約90%までです。b-tree の special section(16バイト)も入れると (8192 − 24 − 16 − 8192×0.10) ÷ (16 + 4) ≈ 366.6 で、実測とほぼ合います。512 は上限の見積もりで、分岐数そのものではありません。 中間の283も、作成時は約70%で打ち切った値です(nbtree.h の BTREE_NONLEAF_FILLFACTOR 70。(8152 − 8192×0.30) ÷ 20 ≈ 284.7)。
「1ページ 8192 バイト ÷ 1項目 16 バイト = 512」という計算と、実測の葉ページ 367 項目。この差はどう説明できますか。
同じ log でも、底が違えば別物
一般化します。n 件を扇出し b の木に入れた段数は、b を底とする n の対数に比例します。log で効くのは前回と同じ、底が違うのです。同じ 1,000万件で計算します。
分岐2 : log2(1e7) = 23.3 → 24段
分岐283 : log283(1e7) = 2.86 → 3段
24 対 3。 触るページ数が8倍違います。「どちらも O(log n) です」という説明は、この差を消します。記法は定数倍と底の違いを畳み込むので、同じ記法の中に 24 と 3 が同居できるのです。
留保を2つ添えます。
測ったのは bigint 1列の単純な索引。複合索引や可変長キーでは扇出しが落ちるので、367 や 283 を他のキー型に持ち込まないでください。 言えるのは「キーが小さいほど木は低くなりやすい」という向きだけです。
行数が100倍になっても、段数が動かない
底が大きいことの帰結が、実測にもうひとつ出ています。
行数 索引のページ数 level 段数1,000 5 1 2100,000 276 1 210,000,000 27,422 2 3
1,000行と10万行で段数が同じです。 さらに100倍で、ようやく1段増えました。段数は階段状にしか増えず、その幅が扇出しの分だけ広い。「データが10倍に増えたので索引が深くなったはずだ」という推論は、その10倍が階段をまたいでいなければ成り立ちません。
ページを大きくすればどうか。MySQL の用語集は、5.5 までの固定値だった16KBについてこう書いています。
large enough to hold the data for most rows, yet small enough to minimize the performance overhead of transferring unneeded data to memory.
1回で多く取れるが、要らないデータも運ぶ。 扇出しを上げることには対価があると公式が書いています。ただし本教材は MySQL では高さを実測していません。3段は PostgreSQL 17.11 の数字です。
実測では1,000行でも10万行でも段数が同じ(2段)で、1,000万行で3段になりました。ここから読み取れることはどれですか。
分岐が2 — 1段で半分に絞る
ノードが子を2つしか持たない木です。1段降りるごとに候補が半分になるので、1,000万件なら log2(1e7) = 23.3、切り上げて24段。1段につき1ページ読むなら、24回の読み取りにあたります。
分岐が283 — 1ページぶんまとめて絞る
PostgreSQL 17.11 で実測した中間ページの平均は283項目(作成直後の詰め具合)。1段で283分の1に絞れるので、1,000万件なら log283(1e7) = 2.86、切り上げて3段。実機の bt_metap も level=2、すなわち3段を返しました。
どちらも「根から葉まで辿る木」で、段数は log で効きます。それでも24段と3段に分かれました。この差を生んでいるものは何でしょうか。
1段でいくつに絞れるかは、何によって決まっていたかを思い出してください。
この章のまとめ
- 1回の読み取りで運べる量 ÷ 1項目のバイト数 = 1段の分岐数(扇出し)。 第7週の「まとめて持ってくる」のディスク側。
- 概算 8192 ÷ 16 = 512 に対し実測の葉は367項目(約72%)。行ポインタと fillfactor 90 の余白が主因で、概算は上限を与えるだけ。
- PostgreSQL 17.11・bigint 1列・1,000万行で根97・中間283・葉367。97 × 283 × 367 = 10,074,517 が投入行数と差0.75%で一致。
bt_metapのlevel = 2は段数ではなく 3 段。nbtree.h は葉の level を 0 と書く。- 同じ1,000万件で分岐2なら24段、分岐283なら3段。「O(log n)」は底を隠す。段数は階段状にしか増えず、1,000行も10万行も2段。
- 言えるのは「索引を辿るとき触るページ数」まで。バッファプールに載ればI/Oは起きず、索引が使われるとも限らない。構造から性能を無条件に導かない。
自分の現場で確かめる
読んだだけでは身につきません。手元の環境で実際に見てみてください。
理解度チェック
読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。
実測で得た扇出し(根97項目・中間平均283項目・葉平均367項目)を掛け合わせると、何が確かめられますか。
用語集(4語)
- 扇出し(ファンアウト)
- 1つのノードが持つ子の数。1ページに入る項目数として実測した。MySQL の用語集の “Because B-tree nodes can have many children, a B-tree is not the same as a binary tree, which is limited to 2 children per node.” の many children にあたる。
- ページ(ブロック)
- 読み書きの単位。PostgreSQL の公式文書は “Every table and index is stored as an array of pages of a fixed size (usually 8 kB, although a different page size can be selected when compiling the server)”。実機(17.11)の show block_size は 8192。
- ページヘッダと special section
- ページのうち項目に使えない領域。“The first 24 bytes of each page consists of a page header” に加え、索引には “the 'special section' ... For example, b-tree indexes store links to the page's left and right siblings” が入る。
- B-tree(公式表記)
- PostgreSQL・MySQL の公式文書が索引の構造に使う語(PostgreSQL は btree とも)。一般に「B+木」と呼ばれる特徴を持つ。表記の扱いは DB編『「B+木」を公式は使わない — 通称と原典の表記を分ける』が担当。なお MySQL 8.4 の `innodb_page_size` は Default Value が 16384。
出典(一次ソース)
- PostgreSQL 18 — Database Page Layout ↗
- PostgreSQL 18 — Building and Installation (--with-blocksize) ↗
- PostgreSQL 18 — CREATE INDEX(B-trees use a default fillfactor of 90) ↗
- PostgreSQL REL_17_STABLE src/include/access/nbtree.h(BTREE_NONLEAF_FILLFACTOR 70・btpo_level) ↗
- MySQL 8.4 — InnoDB Startup Options and System Variables ↗
- MySQL 8.4 — MySQL Glossary ↗