2つのアプリバージョンが1つのデータベースを共有する:デュアルライトとデュアルリードの移行

想像してみてほしい。あなたのチームは本番データベースのテーブルに新しいカラムを追加した。スキーマ変更はスムーズに完了した。次に、そのカラムに書き込む新しいアプリケーションバージョンをデプロイする必要がある。しかし、古いアプリケーションバージョンはまだ稼働しており、リクエストを処理している。そして、その新しいカラムについては何も知らない。

新しいアプリが新しいカラムだけに書き込み始めると、古いアプリはそのデータを参照できない。古いアプリにリクエストが割り当てられたユーザーは、不完全または不整合な結果を受け取ることになる。古いアプリを即座に停止することはできない。すべてを一度に新しいバージョンに切り替えることもできない。両方のバージョンが共存し、同じデータベースを共有する移行期間が必要になる。

ここで、デュアルライトとデュアルリードのパターンが不可欠になる。エレガントではない。単純でもない。しかし、ダウンタイムなしで稼働中のシステムのデータ構造を移行するための実用的な方法なのだ。

核心的な問題:2つのバージョン、1つのデータベース

新しいカラムやテーブルを追加してスキーマを拡張する場合、データベースは新旧両方の構造を保持できる。しかし、そのデータを読み書きするアプリケーションはそれほど柔軟ではない。古いアプリケーションバージョンは古い構造しか理解しない。新しいアプリケーションバージョンは両方を理解するが、すべてのインスタンスがアップグレードされるまで、古いバージョンが動作し続けるようにしなければならない。

単純なアプローチは、新しいアプリが新しいカラムだけに書き込むことだ。しかし、それでは古いアプリが即座に壊れる。古いアプリは古いカラムから読み取り、データが見つからず、失敗する。正しいアプローチは、古いアプリがなくなるまで、新しいアプリが両方の場所に書き込むことだ。

次のシーケンス図は、移行期間中の書き込みと読み取りの流れを示している。

sequenceDiagram participant OldApp as 古いアプリ participant NewApp as 新しいアプリ participant DB as データベース Note over OldApp,DB: デュアルライトフェーズ NewApp->>DB: 古いカラムに書き込み NewApp->>DB: 新しいカラムに書き込み OldApp->>DB: 古いカラムから読み取り DB-->>OldApp: 古いカラムのデータ NewApp->>DB: 古いカラムから読み取り(古い方を優先) DB-->>NewApp: 古いカラムのデータ Note over OldApp,DB: カットオーバーポイント NewApp->>DB: 新しいカラムから読み取り(切り替え) DB-->>NewApp: 新しいカラムのデータ Note over OldApp,DB: カットオーバー後 NewApp->>DB: 新しいカラムのみに書き込み NewApp->>DB: 新しいカラムから読み取り

デュアルライト:2つの場所に同時に書き込む

デュアルライトとは、新しいアプリケーションバージョンがすべての書き込み操作で、古い構造と新しい構造の両方にデータを書き込むことを意味する。ユーザーがレコードを作成すると、新しいアプリは従来通り古いカラムにデータを入力し、その後、同じデータを新しいカラムにも書き込む。

これは単純に聞こえるが、2つの詳細が非常に重要だ。

以下は、ユーザープロフィール更新のためのデュアルライトとデュアルリードを実装したJavaScriptの例である。

async function updateUserProfile(userId, name, email) {
  // デュアルライト:最初に古いカラムに書き込み、次に新しいカラムに書き込む
  await db.query(
    'UPDATE users SET name = ?, email = ? WHERE id = ?',
    [name, email, userId]
  );
  await db.query(
    'UPDATE users SET profile_data = ? WHERE id = ?',
    [JSON.stringify({ name, email }), userId]
  );
}

async function getUserProfile(userId) {
  // デュアルリード:新しいカラムを優先し、古いカラムにフォールバック
  const row = await db.query(
    'SELECT profile_data, name, email FROM users WHERE id = ?',
    [userId]
  );
  if (row.profile_data) {
    return JSON.parse(row.profile_data);
  }
  return { name: row.name, email: row.email };
}

第一に、書き込み順序は一貫していなければならない。 最初に古いカラムに書き込み、次に新しいカラムに書き込む。古いカラムへの書き込み後、新しいカラムへの書き込み前にプロセスが失敗した場合でも、古いアプリはデータを読み取ることができる。新しいカラムにはそのレコードが欠落するが、後でバックフィルで修正できる。もし最初に新しいカラムに書き込んでプロセスが失敗した場合、古いアプリは即座に不完全なデータを参照することになる。それは本番インシデントの原因となる。

第二に、値は同一でなければならない。 古いカラムと新しいカラムに書き込まれるデータは、同じ情報を表現していなければならない。2つの書き込みの間に変換やロジックの違いがあると、後でデバッグが非常に困難なデータ不整合が発生する。書き込みロジックは同一に保つ。新しいカラムが異なる形式や構造でデータを保存する場合でも、意味は同じでなければならない。

デュアルリード:2つの場所から読み取り、古い方を優先する

デュアルライトが稼働すれば、新しいアプリは両方のバージョンが読み取れるデータを書き込める。しかし、読み取りはどうか?新しいアプリはすぐに新しいカラムから読み取りを開始できるが、それには問題がある。古いアプリはまだ古いカラムだけに書き込んでいる。新しいアプリが新しいカラムだけから読み取ると、古いアプリが書き込んだデータを見逃してしまう。

解決策はデュアルリードだ。新しいアプリは両方の場所から読み取るが、移行中は古いカラムを優先する。これにより、古いアプリが書き込んだデータが常に新しいアプリから見えるようになる。時間の経過とともに、新しいカラムにデータが正しく流れていることを確認しながら、徐々に読み取りを新しいカラムに移行できる。

この段階的な移行こそ、フィーチャーフラグが役立つ場面だ。新しいアプリがリクエストのごく一部だけ新しいカラムから読み取るように設定できる。問題がなければ割合を増やす。エラーが発生したらフラグを戻せば、すべての読み取りは古いカラムに戻る。再デプロイは不要だ。

古いアプリが書き込んだデータはどうするのか?

この移行期間中、古いアプリはまだ稼働しており、古いカラムだけに書き込んでいる。新しいアプリはこれを処理しなければならない。新しいアプリが古いアプリによって書き込まれたレコードを読み取ると、古いカラムにしかデータがない。新しいアプリは古いカラムからそのデータを読み取り、使用し、オプションでバックグラウンド処理の一部として新しいカラムにコピーできる。

これはデュアルライトとは異なる。これはバックフィルプロセスであり、新しいアプリが両方の場所に書き込みを開始する前に書き込まれたデータを新しいカラムに埋めるために、別途実行される。バックフィルは、デュアルライトとデュアルリードのパターンが安定した後に実行されるバッチ操作である。

本当の複雑さ:移行の調整

このフェーズで最も難しいのはコードではない。調整だ。どのインスタンスがどのバージョンを実行しているかを把握する必要がある。最後の古いインスタンスがいつ廃止されたかを知る必要がある。古いカラムと新しいカラムの間のデータ不整合を監視する必要がある。

デュアルライト中は、すべての書き込み操作が2回の書き込みになる。つまり、データベース負荷の増加、トランザクション時間の増加、そして障害の可能性が増える。このフェーズではデータベースのメトリクスを監視する。書き込みレイテンシが増加した場合は、書き込みをバッチ化するか、非同期レプリケーションを使用する必要があるかもしれない。

デュアルリード中は、すべての読み取り操作が2つの場所をチェックする必要があるかもしれない。これによりクエリロジックが複雑になり、読み取りパスが遅くなる可能性がある。キャッシュは慎重に使用する。新しいカラムが信頼できる情報源になるべき場合、古いカラムからのデータをキャッシュしてはならない。

移行のための実践的チェックリスト

  • スキーマ拡張(新しいカラムまたはテーブル)がデプロイされ、検証されていることを確認する。
  • デュアルライトロジックを備えた新しいアプリバージョンをデプロイする:最初に古いカラムに書き込み、次に新しいカラムに書き込む。
  • 新しいアプリが書き込んだデータが古いアプリから見えることを確認する。
  • 新しいアプリでデュアルリードを有効にする:デフォルトでは古いカラムから読み取るが、切り替えに備える。
  • フィーチャーフラグを使用して、読み取りを古いカラムから新しいカラムに段階的に移行する。
  • 古いカラムと新しいカラムの間のデータ不整合を監視する。
  • バックフィルプロセスを開始して、古いデータを新しいカラムにコピーする。
  • バックフィルが完了し、すべての読み取りが新しいカラムを使用するようになったら、デュアルライトロジックを削除する。
  • どのアプリケーションも古いカラムから読み取っていないことを確認した後、古いカラムまたはテーブルを廃止する。

まとめ

デュアルライトとデュアルリードは恒久的なパターンではない。すべてのユーザーに対してシステムを稼働させ続けながらデータ構造を移行するための一時的な橋渡しだ。目標は、新しい構造だけが重要で、古い構造を削除できる状態に到達することである。それまでは、すべての書き込みは2つの場所に行き、すべての読み取りは2つのソースをチェックし、チームは不整合に警戒を怠らない。このフェーズは居心地の悪いものだが、世界を止めずに稼働中のデータベーススキーマを変更する唯一の方法なのだ。