- Authors

- Name
- Youngju Kim
- @fjvbn20031
- はじめに
- なぜデータマイグレーションは難しいのか
- パターン 1: Dual-Write
- パターン 2: Backfill
- パターン 3: CDC (Change Data Capture)
- 三つのツールを組み合わせた全体像
- 検証(Verification)
- Shadow Read(シャドーリード)
- カットオーバー(Cutover)
- ロールバック(Rollback)
- 冪等性と再処理(Idempotency & Reprocessing)
- ケーススタディ
- よくある落とし穴(Pitfalls)
- マイグレーションチェックリスト
- おわりに
- 参考資料
はじめに
データマイグレーションは「あるテーブルから別のテーブルへ INSERT SELECT を一度回すだけ」のように見えますが、稼働中のサービスではまったく異なる難易度の作業になります。データが止まっているなら、ただコピーすれば済みます。しかし実際のサービスは、マイグレーションが進む間も書き込みを受け続けます。ユーザーは注文を入れ、決済が発生し、プロフィールが更新されます。私たちがデータを移すまさにその瞬間にも、ソースは変わり続けています。
ですから無停止マイグレーションの本質は「静止したデータを移すこと」ではなく、「流れる川を別の川床へ移すこと」に近いのです。川を堰き止めずに水路を変えなければなりません。この記事では、次の三つの中核ツールを組み合わせてこの問題を解く方法を扱います。
- Dual-Write: アプリケーションが旧・新ストアへ同時に書き込む
- Backfill: 過去データをバッチで移す
- CDC(Change Data Capture): 変更ログを読み、新ストアへリアルタイムに同期する
そして、これらのツールをどう段階的に組み合わせて「リードカットオーバー → ライトカットオーバー」へ安全に切り替えるか、検証はどう行うか、問題が起きたらどうロールバックするかを整理します。
ここで扱うマイグレーションの範囲は広いです。PostgreSQL から PostgreSQL へのテーブル分割、MySQL から新しいシャーディングクラスタへの移行、モノリシック DB からマイクロサービス専用 DB への分離、さらには RDBMS から別種のストアへの移行まで、同じ原理が適用されます。
なぜデータマイグレーションは難しいのか
一貫性(Consistency)の問題
最初にぶつかる壁は一貫性です。ソース(source)とターゲット(target)がどの瞬間にも同じ状態を保つようにするのは、思ったより難しいものです。Backfill で過去データを移している間も、ソースは変わり続けます。たった今コピーした行が 1 秒後に更新されると、ターゲットには過去のバージョンが残ります。
時刻 t0: source の row(id=42) = {amount: 1000}
時刻 t1: backfill が row(id=42) を target へコピー → target = {amount: 1000}
時刻 t2: source の row(id=42) が {amount: 2000} に UPDATE
結果: source = 2000, target = 1000 (不一致!)
この不一致を防ぐには、backfill 以降に発生した変更を何とかして target へ再反映しなければなりません。まさにこの地点で CDC や Dual-Write が必要になります。
順序(Ordering)の問題
二つ目の壁は順序です。同じ行に対して複数の変更が発生したとき、それらが target に適用される順序が source の順序と違うと、最終状態がずれます。
source の順序: UPDATE amount=2000 → UPDATE amount=3000
target の適用: UPDATE amount=3000 → UPDATE amount=2000 (逆転!)
結果: source = 3000, target = 2000
CDC パイプラインでパーティションが複数あったり、Dual-Write で二つのストアへ書く順序が保証されなかったりすると、このような逆転が生じます。ですから、同じキーへの変更は同じパーティション・同じ順序で処理されるよう設計しなければなりません。
その他の難しさ
| 問題 | 説明 |
|---|---|
| スキーマの差異 | source と target でカラム型・制約・インデックスが異なりうる |
| データ量 | 数億行を移す間、source に負荷をかけてはいけない |
| トランザクション境界 | 複数行を一つのトランザクションにまとめる意味が target で壊れうる |
| 削除の処理 | source で DELETE された行を target でも消す必要がある |
| 重複の処理 | 再処理時に同じ変更が二度適用されてはいけない(冪等性) |
これらすべてを一度に解く魔法はありません。代わりに Dual-Write、Backfill、CDC を組み合わせ、段階的にリスクを減らしていきます。
パターン 1: Dual-Write
概念
Dual-Write は、アプリケーションが書き込み要求を受けたとき、source と target の両方に書き込む方式です。新しいデータは自動的に両側へ入るので、マイグレーション時点以降のデータは target にも存在することになります。
+-----------------+
write ---> | Application |
+--------+--------+
|
+----------+----------+
| |
v v
+-----------+ +-----------+
| source | | target |
| (old DB) | | (new DB) |
+-----------+ +-----------+
シンプルな実装
func CreateOrder(ctx context.Context, o Order) error {
// 1) まず source へ書く(真実の源)
if err := sourceDB.Insert(ctx, o); err != nil {
return fmt.Errorf("source write failed: %w", err)
}
// 2) target へも書く
if err := targetDB.Insert(ctx, o); err != nil {
// target の失敗をどう扱うか?(以下で議論)
log.Warn("target write failed", "order_id", o.ID, "err", err)
metrics.Inc("dual_write.target_failure")
}
return nil
}
Dual-Write のリスク
Dual-Write は直感的ですが、罠が多いです。核心は「二つのストアへ書く操作が原子的でない」という点にあります。
ケース A: source 成功、target 失敗 → target にデータ欠落
ケース B: source 成功、target 成功、しかしその間にプロセスが死ぬ → 部分反映
ケース C: 並行性 — 二つの要求が異なる順序で二つの DB に到達 → 順序逆転
分散トランザクション(2PC)でまとめれば原子性は得られますが、レイテンシと可用性を失います。一方の DB が遅くなると全書き込みが遅くなり、コーディネータが死ぬとトランザクションが止まります。ですから実務では通常 2PC を避け、次のいずれかを選びます。
| 戦略 | 説明 | 欠点 |
|---|---|---|
| ベストエフォート dual-write | target の失敗をログするだけで無視 | target に穴があく |
| Source 優先 + CDC 補正 | source のみ真実、target は CDC で埋める | dual-write が不要になる |
| Outbox パターン | source トランザクションに outbox 行を一緒に記録 | 別途リレーが必要 |
実際に Dual-Write を単独で使うと、ケース A による欠落が蓄積します。だからこそ多くのチームは Dual-Write の代わりに、あるいは併用して CDC を使います。Dual-Write の最も安全な変形は Outbox パターンです。
Outbox パターン
Outbox パターンは、ビジネスデータと「イベント」を同じトランザクションで記録して原子性を確保します。
BEGIN;
INSERT INTO orders (id, amount, status)
VALUES (42, 2000, 'CREATED');
INSERT INTO outbox (aggregate_id, event_type, payload)
VALUES (42, 'OrderCreated', '{"id":42,"amount":2000}');
COMMIT;
同じトランザクションなので、両方コミットされるか両方ロールバックされます。その後、別のリレー(あるいは CDC)が outbox テーブルを読み、target へ伝播します。こうすると「source には書いたがイベントは出なかった」状況がなくなります。
パターン 2: Backfill
概念
Backfill は、マイグレーション開始時点にすでに存在していた過去データをバッチで移す作業です。Dual-Write や CDC は「これ以降の変更」だけを捉えるので、過去データは別途移す必要があります。
バッチ戦略
数億行を一度に SELECT * すると source DB が止まります。必ずキー範囲で区切ってバッチ処理します。
func Backfill(ctx context.Context, batchSize int) error {
var lastID int64 = 0
for {
rows, err := sourceDB.Query(ctx, `
SELECT id, amount, status, updated_at
FROM orders
WHERE id > $1
ORDER BY id
LIMIT $2`, lastID, batchSize)
if err != nil {
return err
}
batch := scanRows(rows)
if len(batch) == 0 {
break // 終了
}
// target へ UPSERT(冪等)
if err := targetDB.UpsertBatch(ctx, batch); err != nil {
return err
}
lastID = batch[len(batch)-1].ID
time.Sleep(throttle) // source 負荷の調整
}
return nil
}
Backfill の設計原則
- キー範囲(keyset)ページネーション: OFFSET の代わりに
id > lastID方式で徐々に遅くならないように - UPSERT を使う: 同じバッチを二度回しても安全になるように(冪等性)
- スロットリング: source の CPU、レプリケーション遅延(replication lag)を見ながら速度を調整
- 再起動可能: 進行位置(checkpoint)を保存し、中断後に続けて実行
- リードレプリカを使う: 可能なら source の read replica から読んで負荷を分散
Backfill とリアルタイム変更の競合
Backfill が row(id=42) を t1 にコピーしたのに、t2 に CDC が同じ行の UPDATE を届けたとします。もし CDC イベントが backfill より先に処理されると、backfill の古い値が後から上書きしてしまう可能性があります。これを防ぐ一般的な方法は次のとおりです。
1) バージョン/タイムスタンプ比較: target.updated_at >= incoming.updated_at なら無視
2) Backfill は INSERT ... ON CONFLICT DO NOTHING を使う(既にあれば CDC 値を保持)
3) CDC は常に UPSERT(最新値で上書き)
つまり backfill は「空いた場所だけを埋め」、リアルタイム変更は CDC が責任を持つよう役割を分けると、競合が単純になります。
パターン 3: CDC (Change Data Capture)
概念
CDC はデータベースの変更ログ(WAL、binlog など)を読み、変更イベントのストリームへ変える技術です。アプリケーションコードに触れずに、source のすべての INSERT/UPDATE/DELETE を捉えられます。代表的なツールが Debezium です。
+-----------+ WAL/binlog +-----------+ events +-----------+
| source | ---------------> | Debezium | -----------> | Kafka |
| (DB) | | connector | | topic |
+-----------+ +-----------+ +-----+-----+
|
v
+-----------+
| consumer |
| -> target |
+-----------+
PostgreSQL 論理レプリケーションベースの CDC
PostgreSQL は論理レプリケーション(logical replication)とレプリケーションスロット(replication slot)を提供します。Debezium はこの上で動作します。
-- 論理レプリケーションのための設定 (postgresql.conf)
-- wal_level = logical
-- パブリケーション(publication)の作成
CREATE PUBLICATION orders_pub FOR TABLE orders;
-- レプリケーションスロットの確認
SELECT slot_name, plugin, active
FROM pg_replication_slots;
レプリケーションスロットは強力ですが危険です。コンシューマが止まると WAL が整理されずに溜まり、ディスクが満杯になることがあります。必ずスロットの lag を監視しなければなりません。
-- レプリケーションスロットが保持している WAL サイズの確認
SELECT slot_name,
pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
) AS retained_wal
FROM pg_replication_slots;
Debezium コネクタ設定の例
{
"name": "orders-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "source-db",
"database.port": "5432",
"database.user": "debezium",
"database.dbname": "shop",
"topic.prefix": "cdc",
"plugin.name": "pgoutput",
"slot.name": "orders_slot",
"publication.name": "orders_pub",
"table.include.list": "public.orders",
"snapshot.mode": "initial"
}
}
ここで snapshot.mode が重要です。initial なら、Debezium は起動時にまず既存データのスナップショットを撮り、その後で変更ストリームへ移ります。このスナップショットが事実上 backfill の役割を代わりに果たすこともあります。ただし大容量テーブルでは incremental snapshot を検討すべきです。
CDC イベントの形
Debezium のイベントは変更前後の状態を両方とも含みます。
{
"op": "u",
"before": { "id": 42, "amount": 1000, "status": "CREATED" },
"after": { "id": 42, "amount": 2000, "status": "PAID" },
"source": { "lsn": 123456789, "ts_ms": 1718500000000 },
"ts_ms": 1718500000123
}
op: c(create)、u(update)、d(delete)、r(read=snapshot)before/after: 変更前後の行source.lsn: ログ位置、順序判定に使用
コンシューマでの冪等な適用
CDC コンシューマは同じイベントを二度受け取ることがあります(at-least-once)。ですから適用は必ず冪等でなければなりません。
func ApplyEvent(ctx context.Context, e CDCEvent) error {
switch e.Op {
case "c", "u", "r":
// LSN 比較で古いイベントは無視
_, err := targetDB.Exec(ctx, `
INSERT INTO orders (id, amount, status, _lsn)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE
SET amount = EXCLUDED.amount,
status = EXCLUDED.status,
_lsn = EXCLUDED._lsn
WHERE orders._lsn < EXCLUDED._lsn`,
e.After.ID, e.After.Amount, e.After.Status, e.LSN)
return err
case "d":
_, err := targetDB.Exec(ctx,
`DELETE FROM orders WHERE id = $1 AND _lsn < $2`,
e.Before.ID, e.LSN)
return err
}
return nil
}
_lsn カラムで「すでにより新しい変更が適用されていれば無視する」ガードを置くと、順序逆転と再処理を同時に防げます。
三つのツールを組み合わせた全体像
実務では通常 Backfill + CDC の組み合わせを使います。Dual-Write は CDC の導入が難しい環境で補助として使うか、Outbox 形式に変形して使います。典型的な流れは次のとおりです。
[1] CDC スロット作成(この時点からの変更を捉え始める)
|
v
[2] Backfill 開始(過去データをバッチで target に埋める)
| 同時に CDC は新規変更を target に適用
v
[3] Backfill 完了 + CDC lag ~ 0 (source == target が収束)
|
v
[4] 検証(row count、checksum、サンプル比較)
|
v
[5] Shadow read(target を読むが結果は捨て、比較のみ)
|
v
[6] Read cutover(読み取りを target へ切り替え)
|
v
[7] Write cutover(書き込みを target へ切り替え、source を停止)
|
v
[8] 安定化後、source/CDC を整理
順序が重要です。CDC スロットを backfill より先に作らなければ、backfill 中の変更を取りこぼします。そして冪等な適用のおかげで、backfill と CDC が同じ行に触れても最終状態は収束します。
検証(Verification)
カットオーバー前に、source と target が本当に同じかを確認しなければなりません。「たぶん同じだろう」で進めると、データ消失をプロダクションで発見することになります。
ステップ 1: 行数比較(row count)
最も安く速い検査です。ただし行数が同じでも内容が違うことがあるので、一次ゲートとしてのみ使います。
-- source
SELECT count(*) FROM orders;
-- target
SELECT count(*) FROM orders;
ステップ 2: チェックサム(checksum)比較
行単位または範囲単位でチェックサムを計算して比較します。全体を一度に比較すると高コストなので、キー範囲で分けて行います。
-- 範囲ごとのチェックサム (PostgreSQL)
SELECT
(id / 100000) AS bucket,
count(*) AS cnt,
md5(string_agg(
id || ':' || amount || ':' || status,
',' ORDER BY id
)) AS checksum
FROM orders
GROUP BY (id / 100000)
ORDER BY bucket;
source と target で同じクエリを回し、bucket ごとの checksum が異なる区間だけを選んで精密比較すると効率的です。
ステップ 3: サンプル比較(sample comparison)
全数比較が難しければ、ランダムサンプルを抽出して行全体を比較します。特に、最近変更された行、境界値(最大の id、NULL が多い行)を優先的に見ます。
検証の優先順位:
1) 最も最近修正された行(CDC lag による欠落の検出)
2) 最も古い行(backfill 初期バグの検出)
3) 境界値 / 特異値(NULL、負数、空文字列)
4) 無作為 N 件
検証結果の整理
| 検査 | コスト | 捉えるもの | 限界 |
|---|---|---|---|
| Row count | 非常に低い | 大量の欠落/重複 | 内容の差を捉えられない |
| Checksum(範囲) | 中 | 内容不一致の区間 | ソート/型の差に敏感 |
| Sample 比較 | 低 | 具体的な行の差 | 全数保証にならない |
| Shadow read | 中 | 実際のクエリ結果の差 | 運用負荷が増える |
Shadow Read(シャドーリード)
検証をもう一段階引き上げる技法が shadow read です。実際の運用トラフィックで target を読みますが、結果はユーザーに渡さず、source の結果と比較だけします。
func GetOrder(ctx context.Context, id int64) (Order, error) {
// 真実は依然として source
o, err := sourceDB.GetOrder(ctx, id)
if err != nil {
return Order{}, err
}
// shadow: target も読んで比較(非同期、失敗は無視)
go func() {
shadow, serr := targetDB.GetOrder(context.Background(), id)
if serr != nil {
metrics.Inc("shadow.read_error")
return
}
if !shadow.Equal(o) {
metrics.Inc("shadow.mismatch")
log.Warn("shadow mismatch", "id", id,
"source", o, "target", shadow)
}
}()
return o, nil
}
Shadow read は実際のクエリパターン(結合、フィルタ、ソート)における差を捉えます。Row count や checksum が通っても、特定のクエリでインデックスやスキーマの差により異なる結果が出る場合を、ここで発見できます。ただし運用負荷が約 2 倍になるので、サンプリング比率を調整します。
カットオーバー(Cutover)
Read cutover を先に
読み取りを先に切り替える理由は、リスクが低いからです。読み取りはデータを変えないので、target から誤って読んでも source のデータは無事です。問題が見えたら直ちに source へ戻せばよいのです(読み取りのロールバックは無損失)。
read_from_target = feature_flag("orders.read_target") // 0% -> 1% -> 10% -> 50% -> 100%
パーセントを段階的に上げながら、shadow mismatch とエラー率を見ます。
Write cutover は慎重に
書き込みの切り替えは元に戻しにくいです。write cutover 以降に target だけに入ったデータは source にないので、単純なロールバックではそのデータを失います。ですから write cutover の前に、通常は次を準備します。
1) 逆方向 CDC の準備: target -> source 同期をあらかじめ構成
(ロールバック時に target の新規データを source へ戻すため)
2) 短い書き込み停止ウィンドウ(任意): 一瞬だけ書き込みを止め、
CDC lag を 0 にしてから切り替えると最も安全
3) 冪等キーの保証: 切り替え直後の再試行が重複を作らないように
カットオーバーのシーケンス例
T-0 write を source で受けつつ CDC が target に反映中(lag ~0 を維持)
T-1 read を 100% target に切り替え、24 時間観察
T-2 逆方向 CDC(target->source)を有効化
T-3 (任意)5 秒間書き込みを一時停止、双方向 lag 0 を確認
T-4 write を target に切り替え
T-5 source への書き込みを遮断、target が唯一の真実
T-6 1 週間の安定化後、順方向 CDC、レプリケーションスロット、source を整理
ロールバック(Rollback)
マイグレーション計画で最も重要な部分は「戻る道」です。ロールバック経路のないカットオーバーは賭けです。
| 段階 | ロールバック方法 | データ損失 |
|---|---|---|
| Backfill 中 | そのまま中断、target を破棄 | なし |
| Read cutover | フラグを source へ戻す | なし |
| Write cutover 直後 | 逆方向 CDC で target の変更を source へ反映後に切り替え | 逆方向同期があれば無損失 |
| Write cutover 後しばらく経過 | 逆方向 CDC の維持有無に依存 | なければ損失リスク |
核心の教訓: write cutover の時点で逆方向 CDC を先に有効にしておけば、切り替え直後に問題が起きても、target に溜まった新規データを source へ戻せるので、無損失ロールバックが可能です。この逆方向経路を数日維持し、安全が確認されたら整理します。
冪等性と再処理(Idempotency & Reprocessing)
CDC と Backfill のどちらも「同じ変更を複数回適用しても結果が同じであるべき」という冪等性を要求します。コンシューマは再起動されうるし、イベントは重複配信されうるし、backfill は再実行されうるからです。
冪等性を保証する方法
1) UPSERT(ON CONFLICT): 存在すれば更新、なければ挿入 → 重複 INSERT を防止
2) バージョンガード: _lsn / updated_at / version カラムで古い変更を無視
3) 自然キーまたは決定的キー: 同じ入力は常に同じ PK へマッピング
4) 冪等トークン: メッセージ ID を処理記録テーブルに保存して重複を遮断
処理記録(dedupe)テーブルの例
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 適用トランザクションの中で
BEGIN;
INSERT INTO processed_events (event_id)
VALUES ('evt-abc-123')
ON CONFLICT (event_id) DO NOTHING;
-- 上の INSERT が 0 件なら既に処理したイベント → 本作業をスキップ
-- ... 実際のデータ適用 ...
COMMIT;
再処理(reprocessing)は恐ろしいことではなく、正常な運用の一部です。冪等性が保証されれば「最初からやり直しても同じ結果」なので、CDC コンシューマのオフセットを過去へ巻き戻して疑わしい区間を再適用しても安全です。
ケーススタディ
ケース 1: 大きなテーブルを二つに分割
orders テーブルが大きくなりすぎて、orders と order_items に分割する必要があるとします。
[既存] orders(id, user_id, item_json, amount, ...)
[目標] orders(id, user_id, amount, ...)
order_items(id, order_id, sku, qty, price)
手順:
- 新しい二つのテーブルを作成、CDC スロットを既存の
ordersに接続 - Backfill: 既存の
ordersを読み、新しい二つのテーブルへ変換挿入(item_jsonをパースしてorder_items行を生成) - CDC コンシューマも同じ変換ロジックを適用(新規/修正注文を二つのテーブルへ振り分け)
- 検証: 注文件数、金額合計、アイテム個数合計を source/target で比較
- Read cutover → Write cutover
このとき変換ロジック(transform)が backfill と CDC の両側で同一でなければなりません。変換が一方にだけ適用されると、データが分岐します。変換関数を共通モジュールへ切り出し、両者が同じコードを使うようにするのが核心です。
ケース 2: モノリシック DB からのサービス分離
決済機能を別のマイクロサービスとして切り出し、専用 DB へ移す場合です。
[既存] monolith_db.payments (モノリスが直接アクセス)
[目標] payment_service_db.payments (決済サービスのみがアクセス)
この場合アプリケーションの境界が変わるので、Dual-Write(または Outbox)が有用です。
- 決済サービスが新 DB へ書き始め、同時にモノリスは既存 DB を読む
- Outbox + CDC で新規決済を両側に同期
- Backfill で過去の決済を移行
- 検証後、モノリスの決済読み取りを新サービスの API 呼び出しへ切り替え(read cutover)
- モノリスの決済書き込みを新サービスへ委譲(write cutover)
- モノリスの直接 DB アクセスを除去
サービス分離では、データだけでなく「アクセス経路」も一緒に移す必要がある点が、テーブル分割と異なります。
よくある落とし穴(Pitfalls)
1. CDC スロットを backfill より遅く作る
スロットを遅く作ると、その間の変更を取りこぼします。常にスロット/CDC を先に開始し、backfill を後にします。
2. レプリケーションスロットの lag を放置する
コンシューマが止まると PostgreSQL の WAL が整理されずに溜まり、source のディスクが満杯になります。最悪の場合 source DB がダウンします。スロット lag のアラームは必須です。
3. 順序保証を忘れる
同じキーの変更が異なる順序で適用されると、最終状態がずれます。同じキーは同じパーティションへ、適用時に LSN/バージョンガードを置きます。
4. DELETE を漏らす
INSERT/UPDATE だけを気にして DELETE を移さないと、target にゴースト行が残ります。CDC の op=d を必ず処理します。
5. スキーマ変更とマイグレーションを同時に
マイグレーション途中で source にカラムを追加すると、CDC と変換ロジックが壊れることがあります。マイグレーションウィンドウの間は source のスキーマを凍結するのが安全です。
6. 検証をカットオーバー直前だけ
検証は一度きりではなく継続的に回すべきです。カットオーバー直前だけ見ると、その間に新しく生じた不一致を取りこぼします。
7. ロールバックのリハーサルをしない
ロールバック手順を文書にだけ置いて実際にやってみないと、いざ緊急時に動きません。ステージングでロールバックを実際に実行してみるべきです。
8. タイムゾーン/型のミスマッチ
source と target で timestamp 型、タイムゾーン、数値精度が違うと、checksum が毎回ずれます。比較前に型を正規化します。
マイグレーションチェックリスト
[ 準備 ]
[ ] target スキーマの定義とインデックス設計を完了
[ ] 変換(transform)ロジックを backfill/CDC が共有する共通モジュールとして作成
[ ] CDC スロット/コネクタの構成、snapshot.mode を決定
[ ] 冪等な適用の保証(UPSERT + バージョンガード)
[ ] dedupe テーブルまたは冪等トークンを準備
[ 実行 ]
[ ] CDC スロットを backfill より先に作成
[ ] keyset ページネーション + スロットリングで backfill
[ ] backfill の checkpoint を保存(再起動に備える)
[ ] CDC lag、レプリケーションスロット lag、source 負荷を監視
[ 検証 ]
[ ] row count 比較(一次ゲート)
[ ] 範囲ごとの checksum 比較
[ ] 最近/最古/境界値のサンプル比較
[ ] shadow read で実際のクエリ結果を比較
[ カットオーバー ]
[ ] read cutover をパーセントで段階的に切り替え
[ ] 逆方向 CDC(target->source)を準備
[ ] (任意)短い書き込み停止ウィンドウで lag 0 を確保
[ ] write cutover 後に source の書き込みを遮断
[ ロールバック/整理 ]
[ ] ロールバック手順の文書化 + ステージングでのリハーサル
[ ] 安定化期間の間は逆方向 CDC を維持
[ ] 安全確認後にスロット/コネクタ/source を整理
おわりに
無停止データマイグレーションの核心は、派手なツールではなく段階的なリスク低減です。Backfill で過去を移し、CDC で現在に追いつき、冪等性で衝突を吸収し、検証で確信を得て、read を先に write を後に切り替え、常に戻れる道を開けておきます。
特に覚えておくべき三つは次のとおりです。第一に、CDC スロットを backfill より先に作り、変更を取りこぼさないこと。第二に、すべての適用を冪等にして、再処理を恐れないこと。第三に、write cutover の前に逆方向 CDC を有効にし、無損失ロールバック経路を確保すること。この三つを守るだけで、ほとんどのマイグレーションは事故なく終わります。
データは流れる川です。川を堰き止めず、ゆっくり新しい川床を掘り、水路が安定したら古い川床を埋めましょう。