既存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が「宣言された関係」なら、これは「実際に効いている関係」。両方見ないと嘘になる。

出したいアウトプット

グラフ分割の問題として解くと、たぶんこうなる。

  1. ノード = テーブル、エッジの重み = (FKの有無) + (共起の頻度)
  2. 「サービス間を跨ぐエッジの重みの総和」を最小化する分割を探す
  3. 切ることになったエッジを1本ずつ列挙する

3が本番だと思う。分割案そのものより、「この分割を選ぶと、この15本のFKを切る必要がある」 というリストのほうが意思決定に使える。そして切る1本ごとに、それが守っていたものの置き場を 決める必要がある。

FKが担っていたこと切った後の置き場
存在保証(親がいる)同一集約なら Entity の不変条件。別集約なら「参照時点で存在した」に弱める
カスケード削除イベント + 受信側の冪等な削除
即時の整合pending 状態 + 同期コマンド + 冪等キー

「弱める」と書いたところが本質で、分割は必ず整合性を弱める。弱めた分をどこで受けるかを 決めないまま切ると、制約がただ消えるだけになる。ツールが出せるのは「どこを弱めることになるか」 までで、「弱めていいか」は人が決める。

たぶん効かないところ

思いつきなので、弱点も見えている範囲で書いておく。

それでも作る価値がありそうな理由

既存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を読む機構は、それが動いてから考える。

← Back to all posts