既存DBの外部キーから集約境界を掘り出せないか(アイディア)
サービス分割で一番重いのは集約境界の決定。既存DBのFKとトランザクションのログを「設計の化石」として読み、境界の候補を機械で出せないかという思いつき。FKを1本残したまま分割すると他サービスのデプロイが止まることを実測したのが発端。
モノリスをサービスに割るとき、一番重いのは集約境界の決定だ。コードを動かす作業より、 「どこまでを1つのトランザクションで守るか」を決める作業のほうが長い。
これを人の議論だけでやると、たいてい「現行の画面」や「現在のチーム構成」に引っ張られる。 そこで思いついたのが、既存DBを設計の化石として読むというアプローチ。アイディアレベルの メモとして残しておく。
発端: FKを1本残すと隣のサービスのデプロイが止まる
先に実測した話から。MySQL 8.4 で、2つのサービスが同じインスタンスの別databaseに住んでいる 状態を作った。設計としては「サービスごとに専用database + 専用ユーザー」で分離してある。
この状態で、それぞれのマイグレーションを同時に走らせても互いに待たない。ここまでは期待通り。
ところが database を跨ぐ外部キーを1本張ると壊れる。
-- MySQL はこれを普通に許す
CREATE TABLE photo.items (
gear_item_id BIGINT UNSIGNED NOT NULL,
CONSTRAINT fk FOREIGN KEY (gear_item_id) REFERENCES gear.items(id)
);
親(gear.items)に重いDDLを流している最中に、子(photo.items)へDDLを打つとこうなる。
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
親テーブルのDDLは、FK定義の整合を保つために子テーブルのメタデータロックも取る。 ロックがサービス境界を越える。結果として「gear のマイグレーション中は photo をデプロイできない」 という、サービス分割の目的そのものを潰す状態ができる。
面白いのは、これが測るまで見えないこと。FKを張った時点ではエラーも警告も出ない。 気づくのは数ヶ月後、「なぜか他チームのデプロイが 1205 で落ちる」という形で。
ちなみにアプリの通常の書き込み(子へのINSERT)は同じ状況でも通った。壊れるのはDDL、つまり デプロイだけ。だから日常では気づけない。
3つの動機が同じ1本の線に収束する
この実測が面白かったのは、まったく別の出発点から同じ制約が出てくることだ。
| 出発点 | 言っていること |
|---|---|
| DDD(集約) | 他集約の中身は持たない。持つのはIDまで |
| 整合性の設計 | 別ドメインの状態変更は自分のtxで巻き戻せない。だから同一トランザクションに置けない |
| デプロイ独立性 | 跨ぐFKがあると親のDDLが子をロックし、他サービスのデプロイが落ちる |
集約境界 = トランザクション境界 = DDLロックの境界 = デプロイ独立の境界。
3つが一致するのは偶然ではなく、どれも同じ問いをしているからだと思う。 「巻き戻せる範囲はどこまでか」。
アイディア: FKグラフ + トランザクションの実測を突き合わせる
で、本題。分割の作業に入る前に、既存DBから境界の候補を機械で出せないか。
材料は2つある。
材料1: FKグラフ(宣言された関係)
information_schema から取れる。テーブルをノード、FKをエッジにしたグラフ。
SELECT table_schema, table_name, referenced_table_schema, referenced_table_name
FROM information_schema.key_column_usage
WHERE referenced_table_schema IS NOT NULL;
見るのは key_column_usage のほう。referential_constraints には
referenced_table_schema が無い(あるのは unique_constraint_schema)。
最初こちらで書いて ERROR 1054 を出し、stderr を捨てていたので「跨ぐFKは無い」と
読み違えた。空の結果と失敗を区別できない書き方をすると、検査が「常に通る」側に倒れる。
これは「設計者が過去に守りたかった不変条件」の化石だ。FKは宣言なので、書いた人の意図が 残っている。連結成分がそのまま集約候補になる、というのは単純すぎるが、出発点にはなる。
材料2: 同一トランザクションでの共起(実際の関係)
FKグラフだけでは足りない。FKが張られていない実質的な結合が必ずあるからだ。 「アプリが必ずセットで書いている2つのテーブル」は、FKが無くても1つの不変条件を共有している。
これは general log / performance_schema / APMのトレースから取れる。 1つのトランザクションの中で一緒に更新されたテーブルの組を数える。
tx1: [orders, order_items, inventory]
tx2: [orders, order_items]
tx3: [users, user_profiles]
...
FKが「宣言された関係」なら、これは「実際に効いている関係」。両方見ないと嘘になる。
- FKあり・共起なし → もう守っていない制約。切りやすい
- FKなし・共起あり → 暗黙の不変条件。ここを切ると壊れる。一番危ない
- 両方あり → 同じ集約の中身である可能性が高い
出したいアウトプット
グラフ分割の問題として解くと、たぶんこうなる。
- ノード = テーブル、エッジの重み = (FKの有無) + (共起の頻度)
- 「サービス間を跨ぐエッジの重みの総和」を最小化する分割を探す
- 切ることになったエッジを1本ずつ列挙する
3が本番だと思う。分割案そのものより、「この分割を選ぶと、この15本のFKを切る必要がある」 というリストのほうが意思決定に使える。そして切る1本ごとに、それが守っていたものの置き場を 決める必要がある。
| FKが担っていたこと | 切った後の置き場 |
|---|---|
| 存在保証(親がいる) | 同一集約なら Entity の不変条件。別集約なら「参照時点で存在した」に弱める |
| カスケード削除 | イベント + 受信側の冪等な削除 |
| 即時の整合 | pending 状態 + 同期コマンド + 冪等キー |
「弱める」と書いたところが本質で、分割は必ず整合性を弱める。弱めた分をどこで受けるかを 決めないまま切ると、制約がただ消えるだけになる。ツールが出せるのは「どこを弱めることになるか」 までで、「弱めていいか」は人が決める。
たぶん効かないところ
思いつきなので、弱点も見えている範囲で書いておく。
- 共起の観測期間。月次バッチしか触らないテーブルは、1週間のログでは孤立して見える
- FKが最初から無いDB。Rails/Laravelの文化圏だとFKを張らない運用も普通にある。その場合は材料1が丸ごと使えず、共起だけが頼りになる
- グラフ分割の答えは1つに決まらない。重みの付け方で結果が変わる。だから「候補を出して人が選ぶ」以上にはならないと思う
- 正規化の都合でできたテーブルを集約と取り違える。中間テーブルは両側に強く結合するので、どちらに寄せるかは機械には決められない
それでも作る価値がありそうな理由
既存DBの分割を議論するとき、いま起きているのは「みんなが知っている範囲のテーブルについて、 記憶で話す」ことだと思う。500テーブルあるDBの全体像を持っている人はいない。
FKグラフと共起の表は、全体を機械的に舐めた結果として出る。人の記憶より網羅的で、 かつ「これは知らなかった」が出てくる。実際、今回のFKの実測も「MySQLがdatabaseを跨ぐFKを 許す」という、言われれば当たり前だが誰も測っていなかった事実から始まった。
あと、切るFKのリストがあると移行の順序が決まる。FKを切る → 不変条件を移す → サービスを割る、の順でしか進めないので、リストがそのまま作業計画になる。
当面やること
ツールを作るより先に、跨ぐFKを機械で止める検査を入れるほうが安い。これは今日の実測で 使ったクエリそのままで済む。
SELECT table_schema, table_name, referenced_table_schema, referenced_table_name
FROM information_schema.key_column_usage
WHERE referenced_table_schema IS NOT NULL
AND table_schema <> referenced_table_schema;
CIに置いておけば、跨いだ瞬間に落ちる。置く位置はマイグレーションを適用した後。 ファイルを読む形にしないのは、適用結果(実際にロックを取る側)を見たいから。規約の文章として「他ドメインのテーブルを参照しない」と 書いてあっても、MySQLが許す限り1行で静かに入る。文章で禁じたものは、機械でも禁じる。
分割前のDBを読む機構は、それが動いてから考える。