リレーション境界分析ツール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 段のパイプラインを通す。

  1. hub 除去 — 次数の高いテーブル(store、accounts など)は shared kernel。 分割の議論から外し、共有 or 複製と割り切る
  2. CASCADE 縮約 — ON DELETE CASCADE はライフサイクル共有の宣言。親子を 1 ノードに潰す
  3. 橋検出(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 を実インストールして注文を流しながら切ったら、「測るまで見えない」が連発した。

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 級)。

Mastodon 切る前の E-R 図

橋には切断レベルが付く。L1 = NULL可のみ(結果整合で切れる)、L2 = NOT NULL あり (存在保証 API かマスタ複製)、L3 = 宣言外の疑い[強]が同じ対に張っている(FK が示すより高い)。 「切った後」の図は既定でレベル 1 だけを切る — サービス切り出しはレベル 1 で足りることが多いし、 全部切った図は上限の可視化であって実務の姿ではない。

Mastodon レベル 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 化する、と読む。

Mastodon 分割案

粒度の階段 — 小さく割らない選択肢

モジュラリティ最大が常に正解ではない。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 個を選ぶのは人間の仕事のまま。

まとめ

habakiri v0.3.0 は MySQL / Postgres / Rails / Yii1 対応、読み取り専用。残る宿題はクエリログ由来の 共起(「FKなし・共起あり」の実測)で、これが入ると前回の記事の材料 2 が閉じる。

← Back to all posts