リレーション境界分析ツールhabakiriを作ってみた
FKグラフから切断点を出すCLI(habakiri)を作り、Magento 2で実際に橋を切ってHTTP分離まで実験。その後、Rails静的解析でMastodonをDB接続ゼロで解析し、E-R図の機械生成、切断レベル、hub契約、Girvan–Newmanによる「大物数個への分割案」まで積んだ開発記録。
前回、「既存DBのFKを設計の化石として読み、境界の候補を機械で出せないか」 というアイディアを書いた。結びは「分割前のDBを読む機構は、それが動いてから考える」だった。
作った。habakiri(v0.3.0)。 この記事は、作る → Magento 2 で実際に切る → Mastodon を ActiveRecord から静的解析する → 「大物数個への分割案」を出せるようになるまでの記録。
habakiri がやること
information_schema(Postgres は pg_catalog)だけを読んで、3 段のパイプラインを通す。
- hub 除去 — 次数の高いテーブル(
store、accountsなど)は shared kernel。 分割の議論から外し、共有 or 複製と割り切る - CASCADE 縮約 —
ON DELETE CASCADEはライフサイクル共有の宣言。親子を 1 ノードに潰す - 橋検出(Tarjan) — 残ったグラフで、1 本切るだけで塊が分離するエッジを列挙する
FK には重みを付ける。CASCADE(3) > NOT NULL(2) > NULLABLE(1)。この順序が後々まで効いてくる。
順序には一度失敗した。最初は縮約を hub 除去より先にやっていて、
store への「掃除目的の CASCADE」が推移閉包で繋がり、190 テーブルが 1 つの
ライフサイクルに融合した。hub を先に外せば縮約は局所的な親子に限定される。
順序を入れ替えた途端、境界が現れた。
Magento 2 で実際に切る
最初の実地検証は Magento 2。295 テーブル / 382 FK から橋が 2 本出た。
1 本目は downloadable_link_purchased ×— sales(購入権と注文)。どちらも
SET NULL / NULL可 = 「切っても存在保証が要らない」候補。しかもこの橋、
2015 年のダンプと 2026 年の 2.4.9 で同じ場所に同じ 2 本あった。10 年不変の境界。
2.4.9 を実インストールして注文を流しながら切ったら、「測るまで見えない」が連発した。
- DB で DROP しても
setup:upgradeが FK を復活させる。現行 Magento は declarative schema(db_schema.xml)が正。正しい切断は宣言側から外すこと — 外した途端、 Magento が自分で FK を DROP した。Rails の schema.rb も Prisma も同じ構造を持つ。 DB がスキーマの正とは限らない - 復活時、既存データは検査されない。親なし
order_idを抱えたままforeign_key_checks=0で制約が張られ、「FK があるのに不整合」が黙って成立する - アプリは最初から「注文が消えても生きる」設計だった。マイダウンロード一覧は sales を JOIN せず、非正規化コピーで表示する。dangling でもページは 200
FK 切断とアプリ層の分離はセット
仕上げに、切り出した 2 テーブルを Go 製ミニサービス(別 DB)へ移し、Magento 本体からは テーブルを DROP。境界を渡っていたコードは 5 箇所(observer 3 つの書き込み、一覧の読み、 ダウンロード消費)で、diff 総量 +71 / −80 行。注文 → 一覧 → ダウンロード → 他人の権利の 拒否まで全旅程が HTTP ごしで完走した。
FK を切っただけの段階では、アプリの可視挙動は何も変わらない。切断は分離の前提を 整えるだけで、分離の本体はアプリ層にある。そして分離した初回のログには、モノリスの 同一トランザクションが隠していた順序依存(状態遷移が行の作成より先に届く)がもう現れていた。
FK の重みは「切断後に必要な API 契約の強さ」の予測として裏返る:
| 重み | DB 上の意味 | 切断後の HTTP 契約 |
|---|---|---|
| NULLABLE (1) | 消えても良い参照 | 結果整合・非同期で足りる |
| NOT NULL (2) | 存在依存 | 存在保証 API = 同期呼び出し or マスタ複製 |
| CASCADE (3) | ライフサイクル共有 | 切ると分散トランザクション。切らない |
ActiveRecord から静的解析する — Mastodon で検証
前回の記事の弱点「FK を張らない文化圏では材料が無い」を潰すため、
--rails(と --yii1)を足した。DB 接続ゼロで app/models の
belongs_to / has_many を読み、同じ FK 構造に写す。重みの対応が綺麗に決まる —
dependent: :destroy → CASCADE(3)、必須 belongs_to → NOT NULL(2)、optional: → 1。
Mastodon の実物 146 モデルに当てたら、フィクスチャで全部通っていたテストを
すり抜けた6 つのバグが立て続けに見つかった。abstract 基底の STI 貫通(全テーブルが
application_records に融合)、複数行宣言の class_name 取りこぼし、with_options
ブロック(オプションが行ではなくブロックに付く)、名前空間の demodulize 漏れ、
singularize("status") = "statu" の誤変換、PORO 混入。実物こそが最強のテストスイート。
取りこぼしの怖さは「読めない」ではなく「偽の切断点を出してしまう」ことだった。 誤検出されたテーブルは橋として現れる。修正のたびに橋の数が 39 → 31 → 22 と減り、残ったものだけが本物。
修正後の Mastodon: 111 テーブル / 178 FK 相当。hub は accounts(次数 60)と statuses(17) — 全域がこの 2 つに参照を張るハブ・アンド・スポーク構造で、 モジュール単位に疎な Magento とは対照的だった。 さらに静的ソースならではの情報として、callback やメソッドから宣言外の他モデルに触れている 結合を[強]/[弱] 2 段の注記で出す(偽エッジは作らない)。
図の機械生成 — E-R 図で「切る前」と「切った後」
結果はテキストより図で見たい。最初は自前で放射レイアウトを書いたが、ラベル衝突と
交差最少化で実績エンジンに敵わず捨てて、D2 をライブラリとして
埋め込んだ(ELK レイアウト)。外部コマンド不要・1 バイナリ・バイト決定的のまま、
sql_table shape の E-R 図が出る。
視覚エンコードは 線種 = つながりの種類(実線 = FK、✂ = 橋、破線紫 = 宣言外の疑い)、 色 = 重み(グレー NULL可 / 青 NOT NULL / 朱 CASCADE 級)。
橋には切断レベルが付く。L1 = NULL可のみ(結果整合で切れる)、L2 = NOT NULL あり (存在保証 API かマスタ複製)、L3 = 宣言外の疑い[強]が同じ対に張っている(FK が示すより高い)。 「切った後」の図は既定でレベル 1 だけを切る — サービス切り出しはレベル 1 で足りることが多いし、 全部切った図は上限の可視化であって実務の姿ではない。
レベル 1 で安い橋 9 本だけが消え、残った ✂L2/✂L3 は「追加で必要になる契約レベル」の 表示として図に残る。
大物数個に割る — hub 契約と Girvan–Newman
橋のリストから分かるのは、周辺の小さなテーブルを個別に切り離せることまで。 本命の大きな塊は橋では割れない。Mastodon の交流(follows / favourites / reports…)と認証(users / access_tokens…)は橋ゼロで、 繋いでいるのは hub(accounts)だけだからだ。
そこで 2 つ足した。
hub 契約: hub 除去で捨てていた FK を拾い、ユニット × hub の「本数・方向・NOT NULL」を 集計する。交流 → accounts は 19 本(NOT NULL 13)、認証 → accounts は 1 本。 accounts の所有権は明白に交流側で、認証の分離は安い — 橋の無い大物同士の 分離コストは、橋ではなく hub 契約に現れる。
分割案: ノード = ユニット(ブロック / 島)+ hub、辺 = 橋 + 疑い[強] +
hub 契約(FK 本数 / hub 次数で正規化した二部グラフ射影 — store のような万能 hub が
全体を糊付けするのを次数で抑える)。この重み付きグラフに
Girvan–Newman(辺媒介中心性)+ モジュラリティ最大化をかける。橋は betweenness
極大辺の特殊形なので、これは橋検出の正統な一般化。乱数ゼロの完全決定的で、
決定性テストは「map 順の浮動小数加算で Q がラストビット揺れして分割が変わる」という
渋いバグまで初回から捕まえた。
hub がどのコミュニティに落ちたかは所有権の提案になり、他グループはその hub への 契約を API 化する、と読む。
粒度の階段 — 小さく割らない選択肢
モジュラリティ最大が常に正解ではない。Girvan–Newman はデンドログラム(階層)を
生むので、各段に小コミュニティ吸着をかけた「粒度の階段」を全部持ち、
--services N で選べるようにした。Mastodon の実測:
2 分割 Q=0.00: accounts 圏(92) | fasp(4)
3 分割 Q=0.22: accounts 圏(87) | moderation(5) | fasp(4)
4 分割 Q=0.50: accounts 圏(66) | 認証(21) | moderation(5) | fasp(4)
7 分割 Q=0.61: accounts 圏(26) | 認証(23) | 交流(23) | statuses 圏(12) | …
11 分割 Q=0.63: (Q 最大)
階段を上から読むと「粗くしたとき、どのグループ同士が併合されるか」= 拡張の順路が分かる。 Q の伸びは 4 分割 → 7 分割で大きく、7 → 11 でほぼ平坦。分割数を増やす効果は 7 分割あたりで頭打ち、という読み方ができる。
そして大事な整理: habakiri が測っているのは切断コストで、切る価値
(チームの所有権、変更頻度、スケール特性)は図の外にある。user_roles 1 テーブルを
マイクロサービスにしたら、分散のコストを全部払って得るものはゼロだ。L1 の橋は
「どこで束ねてもいい自由度」、分割案は「束ね方の候補」であって、
最後の 1 個を選ぶのは人間の仕事のまま。
まとめ
- FK 切断とアプリ層の分離はセット。前者だけでは何も分離されていない
- DB がスキーマの正とは限らない。切断はマイグレーション / 宣言の層で行う
- 実物のコードは想定の 3 倍の書き方をしてくる。フィクスチャは実物に勝てない
- 橋検出 → 辺媒介中心性、と一般化すると「周辺テーブルの個別切り離し」が「少数の大きなまとまりへの分割案」になる
- 分離の支払い先は橋だけではない。橋ゼロの大物同士は hub 契約に払う
habakiri v0.3.0 は MySQL / Postgres / Rails / Yii1 対応、読み取り専用。残る宿題はクエリログ由来の 共起(「FKなし・共起あり」の実測)で、これが入ると前回の記事の材料 2 が閉じる。