✍️
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 });
}
関連概念
- → Outbox Pattern(部分失敗を防ぐ代替手法)
- → CDC(Change Data Capture)(アプリ非侵襲の代替手法)
- → Expand-Contract パターン(スキーマ変更の段階的移行)
出典・参考文献
- Martin Kleppmann, Designing Data-Intensive Applications (2017) Chapter 11 “Stream Processing”
- Gunnar Morling, “Dual Writes — The Unknown Cause of Data Inconsistencies” — morling.dev
- 1. 🧬進化的アーキテクチャとは:定義・誕生背景・3原則
- 2. 📐適応度関数(Fitness Functions):アーキテクチャ特性を自動検証する
- 3. 🔗適切な結合(Appropriate Coupling):Connascence と分散モノリスの罠
- 4. 🚀段階的変更(Incremental Change):Feature Toggle・Blue-Green・Canary・Dark Launch
- 5. 🔄Expand-Contract パターン:ゼロダウンタイムでスキーマ変更する
- 6. ✍️Dual Write:マイクロサービス分割時のデータ整合性パターンと落とし穴
- 7. 📤Outbox Pattern:トランザクション境界を越えた安全なイベント発行
- 8. 📡CDC(Change Data Capture):アプリを汚さずに変更を伝播する
- 9. 🐚Unix 哲学を現代アーキテクチャへ翻訳する
- 10. 🧅クリーンアーキテクチャが進化耐性をもたらす理由
- 11. 🏢Conway's Law:組織構造がアーキテクチャを決める
出典: 進化的アーキテクチャ(O'Reilly)/ Designing Data-Intensive Applications(Martin Kleppmann)