✍️
概念 #進化的アーキテクチャ #データ移行 #マイクロサービス #分散システム #データ整合性 📚 進化的アーキテクチャ

Dual Write:マイクロサービス分割時のデータ整合性パターンと落とし穴

ストア移行・マイクロサービス分割でデータを2箇所に書く Dual Write の仕組み・3つの落とし穴・採用判断基準を整理する

問題:マイクロサービス移行中のデータ整合性

モノリスからマイクロサービスへの移行中、または旧データストアから新ストアへの移行中には「2つのストアが一時的に共存する」期間が発生する。このとき、どのようにデータを同期するかが課題になる。

Dual Write は、アプリケーションが同じデータを 2 つのストアに書き込むことで、移行期の整合性を保とうとするアプローチだ。

基本フロー

         ┌─────────────────┐
 クライアント →  Application   │
         └────────┬────────┘
                  │ 同じデータを2箇所へ書く
          ┌───────┴────────┐
          ↓                ↓
   ┌────────────┐   ┌────────────┐
   │  Store A   │   │  Store B   │
   │(旧 / 現行)│   │(新 / 移行先)│
   └────────────┘   └────────────┘

TypeScript 実装例

class UserRepository {
  constructor(
    private readonly oldStore: OldDatabase,   // 旧ストア(移行元)
    private readonly newStore: NewDatabase,   // 新ストア(移行先)
    private readonly flags: FeatureFlags,
  ) {}

  async save(user: User): Promise<void> {
    // 旧ストアへの書き込み(常に実行)
    await this.oldStore.upsert('users', user);

    // Dual Write フラグが有効な場合のみ新ストアへも書く
    if (this.flags.isEnabled('dual-write-users')) {
      await this.newStore.upsert('users', user);
    }
  }

  async findById(id: string): Promise<User | null> {
    // 移行完了まで読みは旧ストアから
    return this.oldStore.findOne('users', { id });
  }
}

3つの落とし穴

落とし穴1:部分失敗(Partial Failure)

旧ストアへの書き込みが成功し、新ストアへの書き込みが失敗した場合、2つのストアに不整合が生じる。

旧ストア書き込み → 成功 ✓
新ストア書き込み → 失敗 ✗(ネットワークタイムアウト、ストア障害など)

2ストア間でデータが異なる状態(サイレントに不整合が蓄積する)

対策: リトライの仕組みを入れるか、Outbox Pattern(後述)で原子性を確保する。

落とし穴2:順序不整合(Ordering Inconsistency)

分散環境では、書き込みの順序が保証されない。

Thread 1: User.email = "a@example.com" → 旧: OK、新: 遅延
Thread 2: User.email = "b@example.com" → 旧: OK、新: 先に到着

結果:
  旧ストア: "b@example.com" (最新)
  新ストア: "a@example.com" (Thread 1 が後から到着してしまった)

対策: 書き込みに楽観的ロック(バージョン番号)を使い、古い値での上書きを防ぐ。

落とし穴3:レイテンシ増加

Dual Write では書き込みが直列になる(旧→新の順)か、並列でも全体の完了を待つ必要がある。

// 直列の場合:旧 + 新 の合計レイテンシ
await this.oldStore.upsert('users', user); // 10ms
await this.newStore.upsert('users', user); // 15ms
// 合計: ~25ms(元の 2.5 倍)

// 並列の場合:遅い方のレイテンシ
await Promise.all([
  this.oldStore.upsert('users', user),  // 10ms
  this.newStore.upsert('users', user),  // 15ms
]); // 合計: ~15ms(遅い方に引きずられる)
// ただし、片方の失敗でもエラーになる

落とし穴のまとめ

落とし穴影響対策
部分失敗サイレントなデータ不整合Outbox Pattern / リトライ + 冪等性
順序不整合古い値が新ストアに残る楽観的ロック(バージョン番号)
レイテンシ増加SLA 悪化移行期間を短く設定 / 非同期化を検討

いつ Dual Write を選ぶか

状況推奨アプローチ
移行期間が短く、書き込み量が少ないDual Write(シンプル)
整合性が厳しく要求されるOutbox Pattern
アプリを汚したくない、大量データCDC(Change Data Capture)
読み取りシャドウ(新ストアの動作検証)Dual Write + 読みは旧ストアのまま

Dual Write はシンプルな実装で済む反面、部分失敗への耐性が低い。整合性要件が高い場合は Outbox Pattern や CDC を検討する。

移行完了後のクリーンアップ

// 移行完了後:旧ストアへの書き込みを削除し、コードをシンプルにする
async save(user: User): Promise<void> {
  // Dual Write フラグを削除、新ストアのみへ書く
  await this.newStore.upsert('users', user);
}

async findById(id: string): Promise<User | null> {
  // 読みも新ストアへ切り替え
  return this.newStore.findOne('users', { id });
}

関連概念

出典・参考文献

  • Martin Kleppmann, Designing Data-Intensive Applications (2017) Chapter 11 “Stream Processing”
  • Gunnar Morling, “Dual Writes — The Unknown Cause of Data Inconsistencies” — morling.dev

出典: 進化的アーキテクチャ(O'Reilly)/ Designing Data-Intensive Applications(Martin Kleppmann)