モノリスのデータ切り出しは3段階で考える

サービス切り出しの難所はコードでなくデータ。境目は「1つのトランザクションが新旧両方に届くか」で、同一DB同一スキーマ→同一インスタンス別database→別インスタンスの3段階に分けると、各段階で必要な機構(FK棚卸し・expand/contract・outbox)と、段階2で止まる合理性がはっきりする。ER図とインフラ図で各段階の変化を追う。

はじめに

モノリスからサービスを切り出す話は、コードの分け方(モジュール境界、 ストラングラー・フィグ)として語られることが多い。しかし実際に手が止まるのは データです。テーブルは JOIN と外部キーで絡み合っていて、しかも書き込みは 移行の間も止まらない。

この記事は、データの切り出しを3段階に分ける整理です。段階を分ける基準は 1つだけ:

1つのトランザクションが、新旧両方の置き場所に届くか。

これが届く間は普通の道具で進められる。届かなくなった瞬間に、必要な機構が 一段重くなる。だから「どこまで届く状態を保ったまま、何を済ませておくか」が 移行設計のほぼすべてになります。

全体戦略

先に地図を出します。各段階でやること・使う機構・次へ進む条件はこうなります。

跨ぎFKの棚卸しが済み

増加を CI で止められる

跨ぎFK・JOIN・トランザクションが

ゼロ(CI が守っている)

機構

機構

機構

段階1: 同一DB・同一スキーマ

コードの所有権だけ分ける

段階2: 同一インスタンス・別database

結合を切り、データを移送する

段階3: 別インスタンス

物理的に分離する

FK のペア別集計 +

ベースライン差分(逆行防止)

オンラインマイグレーション標準機構

(二重書き→バックフィル→読み切替→削除)

イベント連携機構

(outbox / 重複排除 / 再送)

+ binlog レプリケーションで移行

重要なのは矢印の条件です。段階2→3 の条件「跨ぎゼロ」が CI で守られている限り、 段階3はいつでも実行できる移行であって「いつか来るビッグバン移行」ではない。 以降、この地図を1段ずつ歩きます。

前提: 走る例

通販のモノリスを例にします。注文まわり(orders / order_items)と 商品・在庫まわり(products / stocks)が1つの database に同居していて、 在庫側を「在庫サービス」として切り出したい、という設定です。

FK order_id

FK product_id

FK product_id

orders

bigint

id

PK

bigint

user_id

varchar

status

order_items

bigint

id

PK

bigint

order_id

FK

bigint

product_id

FK

int

quantity

products

bigint

id

PK

varchar

name

int

price

stocks

bigint

product_id

PK

FK でもある

int

available

注文確定のコードは今、こういう1トランザクションを張っています:

BEGIN;
INSERT INTO orders ...;
INSERT INTO order_items ...;
UPDATE stocks SET available = available - 3 WHERE product_id = 42;  -- 境界を跨ぐ更新
COMMIT;

この「注文と在庫引当が atomic」こそが、切り出しを難しくしている当のものです。

段階1: 同じ DB・同じスキーマのまま、コードだけ分ける

いわゆるモジュラーモノリス。コードの所有権(stocks に触ってよいのは 在庫モジュールだけ)を決めて守らせる段階で、データは何も動かしません。

在庫モジュール

注文モジュール

FK order_id

FK product_id ← 境界を跨ぐ①

FK product_id

orders

order_items

products

stocks

線を引いてみて初めて、境界を跨ぐ結合が数えられるようになります。この例では 跨ぎ FK は order_items.product_id → products.id の1本(+ 注文確定トランザクション 内の stocks 更新という「コードの跨ぎ」)。この段階の仕事は現状を測ることです:

インフラは何も変わりません。アプリ1つ、RDS 1台、database 1つのままです。

段階2: 同じ DB インスタンスの中で、database を分ける

products / stocks を inventory database へ動かします。物理サーバは1台のまま。

RDS インスタンス(1台のまま)

アプリ(まだ1プロセスでもよい)

在庫引当は API 呼び出しに

shop ユーザーで接続

(inventory への GRANT なし)

inventory ユーザーで接続

database: inventory

products

stocks

database: shop

orders

order_items

注文モジュール

在庫モジュール

ここが一番おいしい段階です。切り出しの難所を全部、「まだトランザクションが 張れる安全な場所」で済ませます。

結合の切り方(ER の変化)。跨ぎ FK は張れなくなる(別 database への FK は 移送後に物理的に消える)ので、order_items.product_id はただの値になります。 商品名の表示のような読みは、在庫モジュールの API か、レプリカテーブルで解決します:

FK(同一 database 内は残す)

イベントで同期

値参照。存在確認は API

order_items

bigint

id

PK

database: shop

bigint

order_id

FK

bigint

product_id

FK なし・値として保持

int

quantity

varchar

product_name

注文時点のスナップショット

products_replica

bigint

product_id

PK

database: shop(表示用)

varchar

name

products

bigint

id

PK

database: inventory

varchar

name

int

price

stocks

bigint

product_id

PK

database: inventory

int

available

注文確定トランザクションの分解。冒頭の「注文と在庫引当が atomic」は ここで捨てます。注文を pending で確定 → 在庫モジュールへ引当コマンド (冪等キー付き)→ 結果で confirmed / rejected へ、というワークフローに 組み替える。これが切り出しの中で一番設計が要る箇所で、DB でなくドメインの 仕事です。段階2でやるのは、失敗してもまだ全部同じインスタンスにあって 検算も修復も安いから。

テーブルの移送。products / stocks を shop から inventory database へ動かす 作業は、オンラインマイグレーションの標準機構 (二重書き → バックフィル → 読み切替 → 旧側を削除)がそのまま使えます。 カラム改名と同じ型で、対象がテーブルに変わるだけ。二重書きがまだ同一 トランザクションで書ける(同一インスタンスなので atomic)ことが効いています。

注意が1つ。この段階では跨ぎのトランザクションが技術的にはまだ張れて しまう。MySQL は database を跨ぐ FK も JOIN もエラーにしないので、 「張れるけど張らない」を文章でなく機械で守る必要があります。接続ユーザーを database ごとに分けて GRANT で縛る + 跨ぎ FK の CI 検査、の二段です。 ここを規律で守れない場合、段階3に進んだ瞬間に「張れなくなって初めて発覚する 結合」が一斉に火を吹きます。

段階3: 別インスタンスに分ける

AWS で言えば RDS インスタンス(Aurora ならクラスタ)をサービスごとに 別にする段階です。紛らわしいのは Aurora のリーダー/ライター構成で、 同一クラスタ内のレプリカは書き込み1系統の同じトランザクションドメイン なので段階3ではありません。「クラスタごと別」が段階3です。

AWS 上の形トランザクション障害・運用
段階21つの RDS インスタンスに複数 databaseまだ張れる(規律で禁じる)フェイルオーバー・メンテ・スケールが運命共同体
段階3サービスごとに別インスタンス/クラスタ物理的に張れない別々に落ち、別々にメンテでき、別々にスケールする

機構が一段重くなる理由は、二重書きが atomic でなくなることです。段階2までは 「旧テーブルと新テーブルへの書き込み」を同一トランザクションに入れられた。 インスタンスが分かれると「旧側は成功、新側は失敗」が構造的に起こるので、 二重書きそのものが信用できない。代わりに outbox パターンを使います — 業務の書き込みと同一トランザクションで outbox テーブルに変更を積み、 relay が相手側へ運ぶ(at-least-once + 受信側 inbox の重複排除。 実バスで検証した形と同じ機構)。

RDS: inventory 用インスタンス

在庫サービス

SNS/SQS FIFO

RDS: shop 用インスタンス

注文サービス

業務データと outbox を

同一トランザクションで書く

relay が publish

配送

inbox が重複排除して適用

注文アプリ

orders / order_items

outbox

イベントバス

在庫アプリ

products / stocks

inbox

ただし、段階2を経由していれば段階3の移行自体は「データの意味を変えない 移送」に縮んでいます。inventory database だけを binlog レプリケーションで 新インスタンスへレプリケーションで同期し、追いついたら DSN を切り替えるだけ:

切替後

RDS 旧インスタンス

shop のみ(inventory は削除)

RDS 新インスタンス

inventory(本番)

切替

在庫アプリの DSN を差し替え

(レプリカ昇格)

切替前

binlog レプリケーション

replicate-do-db=inventory

RDS 旧インスタンス

shop + inventory

RDS 新インスタンス

inventory(レプリカ)

DMS でも同じことができます。切替のリハーサルには Aurora fast clone (データ量非依存で5分半)がそのまま使えて、 本番同等データで昇格と DSN 切替の予行ができます。

段階2で止まるのは撤退ではない

段階3に進むと、インスタンス台数分の固定費(db.t4g.medium でも1台 $80/月前後)と、 跨ぎトランザクション不能に伴うイベント連携機構の常設が付いてきます。 これに見合う要求は「別々に落ちてほしい」「別々にスケールしたい」 「チームごとに運用を分けたい」のどれかが現実になったときで、 それまでは段階2 + 跨ぎ FK の CI 検査で止まるのが合理的です。

大事なのは、段階2で止まっていても段階3へのオプションを保ち続けていること。 跨ぎ FK ゼロ・跨ぎ JOIN ゼロ・跨ぎトランザクションゼロが CI で守られている限り、 段階3は「いつでも実行できる移行」であって「いつか来るビッグバン移行」ではない。 逆に規律が破れていると、オプションは静かに失効します — 破れたことは 段階3を試みるまで誰にも見えないので。

まとめ

← Back to all posts