22.

MySQL 8.0未満で階層データを扱うSQL|隣接リスト・経路列挙・入れ子集合

編集

MySQL 8.0未満には再帰CTE(WITH RECURSIVE)が存在しないため、親子関係で表される階層構造(ツリー)をSQLだけで扱うには「隣接リスト+自己結合やアプリ側ループ」「経路列挙(パス文字列)」「入れ子集合モデル(Nested Set)」のいずれかで設計する必要があります。本記事では、それぞれの仕組みとSQL例、長所短所、そして8.0以降への移行指針までを整理します。

この記事の要点
  • MySQL 8.0未満は再帰CTEが使えないため、階層の「展開」を再帰SQLで一発取得できない。
  • 代表的な3手法は「隣接リスト(親IDを持つ)」「経路列挙(パスを文字列で持つ)」「入れ子集合モデル(lft/rgtの範囲を持つ)」。
  • 隣接リストは更新が簡単だが、階層を辿るには自己結合の段数固定かアプリ側ループ、またはユーザー変数を使った技法が必要。
  • 経路列挙は子孫の一括取得が LIKE で簡単だが、パス長やデータ整合性に注意。
  • 入れ子集合は子孫取得が高速な反面、ノード追加・移動時の更新コストが大きい。
  • MySQL 8.0以降なら WITH RECURSIVE が使えるため、可能なら隣接リスト+再帰CTEへ移行するのが素直。

背景:なぜ8.0未満は「特別な手法」が必要なのか

カテゴリツリー、組織図、コメントのスレッド、部品表(BOM)など、現実のデータには「親をたどると先祖が連なり、子をたどると子孫が広がる」階層構造が頻繁に登場します。こうした構造を扱う最も標準的な方法は、ノードに自分の親を指す列(例:parent_id)を持たせる隣接リスト(adjacency list)です。直感的で更新も簡単ですが、「あるノードの子孫を全部取りたい」「ルートからの深さを知りたい」といった階層をまたぐ問い合わせを1クエリで書くには再帰処理が要ります。

再帰的な問い合わせを標準SQLで表現する仕組みが再帰共通テーブル式(再帰CTE、WITH RECURSIVEです。MySQLで再帰CTEが利用できるようになったのはMySQL 8.0.1からで、それ以前の系列(5.5/5.6/5.7など、いわゆる8.0未満)では使えません。そのため8.0未満の環境では、再帰CTEに頼らずに階層を扱う「別の設計」または「別の問い合わせ技法」が必要になります。本記事の主題はまさにこの点です。

手法①:隣接リスト+自己結合 / アプリ側ループ

各行が自分の親IDだけを持つ、最も基本的なモデルです。テーブル定義は次のようになります。

CREATE TABLE category (
  id        INT PRIMARY KEY,
  name      VARCHAR(100) NOT NULL,
  parent_id INT NULL  -- ルートは NULL
);

「直接の親子」を結ぶだけなら自己結合で十分です。たとえば各カテゴリと、その親カテゴリ名を並べるには次のように書きます。

SELECT c.id, c.name, p.name AS parent_name
FROM   category c
LEFT JOIN category p ON p.id = c.parent_id;

問題は「任意の深さの子孫すべて」を取得したい場合です。再帰CTEが無い8.0未満では、主に次の3通りで対応します。

  • 段数を固定した自己結合:階層の最大深さがあらかじめ分かっている(例:最大4階層)場合、その回数だけ LEFT JOIN を重ねる。深さが増えるとSQLが破綻するため、固定階層向け。
  • アプリ側ループ:まずルートを取得し、得られたIDを WHERE parent_id IN (...) に渡して1段ずつSQLを発行し、結果が空になるまでアプリケーション側で繰り返す。実装が分かりやすく、深さ無制限に対応できる。
  • ユーザー変数を使った擬似再帰:セッション変数(例:@val)に取得済みIDを連結しながら、FIND_IN_SET で子を辿る技法。1クエリで子孫を集められるが、変数の評価順序に依存するため可読性・移植性は低い。

段数固定の自己結合の例(最大3階層分の子孫を平坦に取得)は次のとおりです。

SELECT l0.id AS root_id,
       l1.id AS child_id,
       l2.id AS grandchild_id
FROM   category l0
LEFT JOIN category l1 ON l1.parent_id = l0.id
LEFT JOIN category l2 ON l2.parent_id = l1.id
WHERE  l0.parent_id IS NULL;

手法②:経路列挙(パスを文字列で持つ)

経路列挙(path enumeration、materialized path)では、各ノードがルートからそのノードまでの経路を文字列として保持します。たとえば /1/4/9/ のように、祖先のIDを区切り文字で連結した path 列を追加します。

CREATE TABLE category_pe (
  id   INT PRIMARY KEY,
  name VARCHAR(100) NOT NULL,
  path VARCHAR(255) NOT NULL  -- 例: '/1/4/9/'
);

この設計の利点は、子孫の一括取得が LIKE 一発で書ける点です。あるノードのパスが /1/4/ なら、その配下すべては「パスが /1/4/ で始まる行」になります。

-- ノード id=4 の子孫をすべて取得
SELECT id, name, path
FROM  category_pe
WHERE path LIKE '/1/4/%'
ORDER BY path;

祖先の取得も、パスを区切り文字で分解すれば求められます。深さは path 内の区切り文字の数から計算でき、ツリー順の表示も ORDER BY path で自然に得られます。前方一致 LIKE 'prefix%' はインデックスを活用できるため、読み取りは比較的高速です。

手法③:入れ子集合モデル(Nested Set, lft/rgt)

入れ子集合モデル(nested set model)は、ツリーを「左から右へ深さ優先で巡回したときの訪問順」として数値化する手法です。各ノードに左端を表す lft と右端を表す rgt の2つの値を持たせ、子孫はすべて親の lftrgt の間に入れ子状に収まるという性質を使います。

CREATE TABLE category_ns (
  id   INT PRIMARY KEY,
  name VARCHAR(100) NOT NULL,
  lft  INT NOT NULL,
  rgt  INT NOT NULL
);

子孫の取得は範囲条件だけで書け、再帰もアプリ側ループも不要です。

-- 親ノード(parent.id = 1)の子孫をすべて取得
SELECT child.*
FROM  category_ns AS parent
JOIN  category_ns AS child
  ON child.lft > parent.lft
 AND child.lft < parent.rgt
WHERE parent.id = 1
ORDER BY child.lft;

逆に「あるノードの祖先」も、自分の lft を挟み込む(lft < 自分の lft かつ rgt > 自分の rgt)ノードを探すだけで求められます。読み取り性能は3手法の中でも優秀です。一方で、ノードを1つ挿入・削除・移動すると、その影響範囲にある多数の行の lftrgt を一斉に振り直す必要があり、更新コストが大きいのが弱点です。

各手法の比較

手法 保持する情報 子孫取得 更新コスト 実装の複雑さ
隣接リスト parent_id 再帰CTEが無いと弱い(自己結合は段数固定/アプリ側ループ) 小(1行更新) 低い
経路列挙 path文字列 容易(前方一致LIKE) 中(部分木移動でパス書き換え)
入れ子集合 lft / rgt 容易かつ高速(範囲条件) 大(挿入・移動で多数行を再採番) 高い

おおまかな指針として、更新が頻繁で読み取りが単純なら隣接リスト子孫の一括読み取りが多く更新がそこそこなら経路列挙読み取りが圧倒的に多く更新がまれなら入れ子集合が向く傾向があります。要件次第のため、実データの規模とアクセスパターンで判断してください。

MySQL 8.0以降なら再帰CTEが使える

環境を8.0以降へ更新できるなら、隣接リストのまま再帰CTEで階層を辿るのが最も素直です。データモデルを変えずに済み、可読性も高くなります。

-- MySQL 8.0以降: 隣接リストを再帰CTEで展開
WITH RECURSIVE tree AS (
  SELECT id, name, parent_id, 1 AS depth
  FROM  category
  WHERE id = 1  -- 起点ノード
  UNION ALL
  SELECT c.id, c.name, c.parent_id, t.depth + 1
  FROM  category c
  JOIN  tree t ON c.parent_id = t.id
)
SELECT * FROM tree ORDER BY depth, id;

移行の指針としては、新規設計なら8.0以降を前提に隣接リスト+再帰CTEを基本とし、8.0未満を使い続ける必要がある間だけ、本記事の3手法から要件に合うものを選ぶ、という考え方が分かりやすいでしょう。なお、入れ子集合や経路列挙は8.0以降でも有効な設計であり、再帰CTEの登場によって即座に不要になるわけではありません。

落とし穴

つまずきやすいポイント
  • 自己結合は階層数が固定JOIN を重ねる方式は、結合段数を超える深さのノードを取得できない。階層が深くなる可能性があるデータには不向き。
  • アプリ側ループのクエリ回数:1段ずつ問い合わせると、階層が深いほどラウンドトリップが増える。IN でまとめて取得するなどの工夫が必要。
  • ユーザー変数技法の移植性:セッション変数を使った擬似再帰は評価順序に依存し、将来のバージョンや他DBで同じ挙動を保証しにくい。
  • 経路列挙のパス長path 列の桁数が階層の深さを制限する。あふれないよう余裕を持った型を選ぶ。
  • 経路列挙の整合性:親を移動した際に子孫のパスをまとめて書き換えないと不整合になる。トランザクションでの一括更新が前提。
  • 入れ子集合の更新コスト:ノードの挿入・削除・移動で多くの行の lftrgt を振り直すため、更新が多いデータでは性能が出にくい。
  • LIKEのワイルドカード位置LIKE '/1/4/%' の前方一致はインデックスが効くが、'%/4/%' のように先頭にワイルドカードを置くと効かない点に注意。

よくある質問(FAQ)

Q. MySQLで再帰CTE(WITH RECURSIVE)はいつから使えますか?
A. MySQL 8.0.1で導入されました。したがって8.0未満(5.7以前)では利用できず、本記事のいずれかの手法が必要になります。

Q. 結局どの手法を選べばよいですか?
A. 一概には決まりません。更新が多く読み取りが単純なら隣接リスト、子孫の一括取得が多いなら経路列挙、読み取り中心で更新がまれなら入れ子集合が候補です。実データの規模・更新頻度・問い合わせパターンを見て選んでください。

Q. 8.0未満で隣接リストの子孫を1クエリで取りたい場合は?
A. 階層数が固定なら段数分の自己結合で対応できます。深さが不定なら、アプリ側で1段ずつ繰り返し問い合わせるか、セッション変数と FIND_IN_SET を使う擬似再帰の技法を用います。後者は可読性・移植性が低い点に留意してください。可能であれば8.0以降へ更新し、再帰CTEを使うのが最も簡潔です。

編集
Post Share
子ページ

子ページはありません

同階層のページ
  1. ダウンロード&インストール方法(Windows)
  2. インストール方法(Linux)
  3. コマンド一覧
  4. SQL
  5. データ型
  6. 関数
  7. 管理ツール
  8. 設定
  9. パフォーマンスチューニング関連
  10. エクスポートおよびインポート
  11. エラー&トラブル
  12. 文字コードの確認
  13. 実行中の SQL の状態確認およびプロセスキルの方法
  14. パスワードの無効化設定
  15. root ユーザーの初期パスワード確認方法
  16. rootユーザーのパスワード変更方法
  17. LIMIT, OFFSET の始まりと挙動
  18. mysqlのバージョン確認方法
  19. MySQLで実行計画を表示する方法
  20. レプリケーションのステータス確認方法
  21. 中央値の導き方(バージョン8未満)
  22. 階層SQL(バージョン8未満)
  23. パーセンタイルの導き方
  24. 特定スキーマの全テーブルの全カラム情報を取得する方法

最近更新/作成されたページ