ダウンタイムゼロでデータベースを進化させる - スキーマ変更の達成
Takashi Yamamoto
Infrastructure Engineer · Leapcell

はじめに
ペースの速いソフトウェア開発の世界では、継続的インテグレーションおよび継続的デリバリー(CI/CD)パイプラインが標準となっています。アプリケーションは常に進化しており、それに伴い、基盤となるデータモデルも進化します。新しい列の追加、データ型の変更、テーブル名の変更など、データベーススキーマへの変更を加えることは、従来はアプリケーションをオフラインにする必要があり、それが避けたいダウンタイムにつながっていました。24時間365日稼働するビジネスにとって、このような中断は、大きな金銭的損失や評判の低下につながる可能性があります。「ゼロダウンタイム」のデータベーススキーマ変更の追求は、もはや贅沢ではなく、高可用性を維持し、スムーズなユーザーエクスペリエンスを確保するための基本的な要件となっています。この記事では、この重要な目標を達成するための中心的な概念と実践的な戦略を探り、アプリケーションが途切れることなく進化できるようにします。
中断のないスキーマ進化のためのコアコンセプト
メカニズムを詳しく掘り下げる前に、ゼロダウンタイムのスキーマ変更を支えるいくつかのコアコンセプトを理解することが不可欠です。
後方互換性(Backward Compatibility): これは核となる要素です。データベーススキーマに加えられた変更は、古いスキーマバージョンでまだ実行されている既存のアプリケーションを破損させてはなりません。これは通常、古いスキーマを期待しているアプリケーションが、スキーマが部分的に進化していても、データを正しく読み書きできることを意味します。
前方互換性(Forward Compatibility): このコンセプトは、新しいスキーマバージョンで実行されているアプリケーションが、古いスキーマバージョンにまだ準拠しているアプリケーションによって書き込まれた可能性のあるデータと(読み書きで)やり取りできることを保証します。これは、古いバージョンと新しいバージョンのアプリケーションの両方がアクティブになる可能性のある移行期間に重要です。
アトミック操作(Atomic Operations): 複雑なスキーマ変更に対して常に実現可能とは限りませんが、この原則は、大規模な変更を、小さく、独立的で、元に戻せるアトミックな操作に分解することを奨励します。これにより、各ステップのリスクプロファイルが最小限に抑えられます。
マイグレーションツール: Flyway、Liquibase、あるいはカスタムスクリプトのような専門的なツールは、スキーマ変更を管理し、制御されたバージョン追跡された方法で適用するために不可欠です。これらは、変更が環境全体で一貫して適用されることを保証します。
デュアルライトおよびリードレプリケーション(Dual-Write and Read-Replication): これらのパターンは、複雑なデータ移行や構造変更の際に非常に重要です。デュアルライトは、古い場所にも新しい場所にも同時にデータを書き込むことを含みます。リードレプリケーションは、データの存在やアプリケーションバージョンに基づいて、古い場所と新しい場所の両方から読み取ることを含む場合があります。
ゼロダウンタイムスキーマ変更のための戦略と手順
ゼロダウンタイムのスキーマ変更を達成するには、通常、移行全体を通して後方互換性と前方互換性の両方を維持するように慎重に調整された多段階プロセスが含まれます。実践的な例とともに一般的な戦略を探りましょう。
1. 新しい列の追加(デフォルト値を持つNULL非許容)
これは比較的簡単な変更ですが、原則を際立たせます。
戦略:
- まず、新しい列をNULL許容として追加します。
- 新しい列に書き込むアプリケーションをデプロイします。
- 必要に応じて既存のデータを移行します。
- 列をNULL非許容に更新します。
例: ordersテーブルにshipping_address列を追加する。
-- ステップ 1: 新しい列をNULL許容として追加 ALTER TABLE orders ADD COLUMN shipping_address VARCHAR(255) NULL;
- 説明: この時点では、既存のアプリケーションは正常に機能し続けます。アプリケーションの新しいインスタンス(デプロイ時)は
shipping_addressへの書き込みを開始できます。重要なのは、shipping_addressを知らない古いアプリケーションはそれを単に無視し、後方互換性を維持することです。
// アプリケーションコード例(新しいバージョン) public void createOrder(Order order) { // ... 他のフィールド preparedStatement.setString(4, order.getShippingAddress()); // 新しい列に書き込む // ... } // アプリケーションコード例(古いバージョン) public void createOrder(Order order) { // ... 他のフィールド // 変更なし、古いアプリはshipping_addressとやり取りしない // ... }
-- ステップ 2: shipping_addressに書き込める新しいアプリケーションバージョンをデプロイ。 -- (これはSQLではなく、CI/CDパイプラインによって処理されます)
-- ステップ 3(オプションですが一般的):既存のデータをバックフィル。 -- これは、既存の注文に対するバッチジョブまたは一度限りのスクリプトを必要とする場合があります UPDATE orders SET shipping_address = (SELECT address FROM users WHERE users.id = orders.user_id) WHERE shipping_address IS NULL;
- 説明: このステップにより、古い注文にも
shipping_addressが設定されます。これは、データベースに過負荷をかけないように、注意深く、潜在的にはバッチで実行する必要があります。
-- ステップ 4: 列をNULL非許容にする。 ALTER TABLE orders ALTER COLUMN shipping_address VARCHAR(255) NOT NULL;
- 説明: すべての古いアプリケーションがアップグレードされ、既存のデータがバックフィルされた(または新しいデータが常に書き込まれている)ことを確認したら、NULL制約を強制できます。この列にNULL値を挿入しようとすると失敗し、将来の書き込みのデータ整合性が保証されます。
2. 列またはテーブルの名前変更
名前の変更は、アプリケーションがデータにどのように言及するかに直接影響するため、より複雑です。
戦略(ブルー/グリーンデプロイメントアプローチ):
- 希望する名前の新しい列/テーブルを作成します。
- アプリケーションでデュアルライトを実装します:古い場所と新しい場所の両方に書き込みます。
- 古い場所から新しい場所へデータをバックフィルします。
- アプリケーションを更新して、新しい列/テーブルから読み取ります。
- デュアルライトを停止します。
- 古い列/テーブルを削除します。
例: productsテーブルでproduct_codeをskuに名前変更する。
-- ステップ 1: 新しい列'sku'をNULL許容として追加。 ALTER TABLE products ADD COLUMN sku VARCHAR(50) NULL;
// ステップ 2: デュアルライトロジックを持つアプリケーションをデプロイ(新しいバージョン) // 古いアプリは引き続き'product_code'に書き込む public void updateProduct(Product product) { // ... 他のフィールド // 古い列(product_code)に書き込む preparedStatement.setString(2, product.getProductCode()); // 新しい列(sku)に書き込む preparedStatement.setString(3, product.getSku()); // sku()が初期値としてproduct_codeを返すことを前提とする // ... }
- 説明: アプリケーションは、同じデータを両方の列に書き込むようになります。これにより、古いスキーマバージョンと新しいスキーマバージョンの両方に最新のデータが確実に格納されます。
-- ステップ 3: 'product_code'から'sku'へデータをバックフィル。 UPDATE products SET sku = product_code WHERE sku IS NULL;
- 説明: これは、既存のすべてのデータを移行します。これはかなりの操作になる可能性があるため、バッチ処理を検討してください。
// ステップ 4: skuから読み取るようにアプリケーションをデプロイ(新しいバージョン) // 読み取り優先順位:'sku'を試行し、移行中の安全のために'product_code'にフォールバックする public Product getProductById(long id) { // ... クエリ String sku = resultSet.getString("sku"); if (sku == null) { sku = resultSet.getString("product_code"); // 移行中の古いデータ用のフォールバック } // ... product.setSku(sku); // アプリケーションは sku を期待するようになる // ... return product; }
- 説明: アプリケーションは、
skuからの読み取りを優先するようになります。フォールバックにより、移行中に古いアプリケーションが直接product_codeに書き込んだ場合(まだ更新されていないバッチジョブなど)でも、新しいアプリケーションはそれを読み取ることができます。
-- ステップ 5: デュアルライトを停止('sku'のみに書き込むアプリケーションをデプロイ)。 -- (アプリケーションコードから'product_code'への書き込みを削除する)
-- ステップ 6: 古い列'product_code'を削除。 ALTER TABLE products DROP COLUMN product_code;
- 説明: すべてのアプリケーションが
skuを使用しており、データが一貫していることを確信したら、古い列を削除できます。
3. テーブルの分割または正規化の解除
テーブルの分割や正規化の解除のような複雑な操作も、より煩雑なデータ移行を伴いますが、同様の原則に従います。
戦略:
- 新しいテーブルを作成します。
- アプリケーションでデュアルライトを実装して、新しいテーブルをポピュレートします。
- 既存のデータを新しいテーブルにバックフィルします。
- アプリケーションの読み取りを更新して、新しいテーブルを使用するようにします。
- デュアルライトを停止し、古いテーブルからの読み取りを削除します。
- 古いテーブルを削除するか、正規化を解除した場合は古い列を削除します。
この戦略は、アプリケーションレイヤーがダウンタイムなしで変更に円滑に適応できるように、段階的な移行を強調しています。各ステップは、後方互換性または前方互換性を保証し、リスクを最小限に抑えます。
結論
ゼロダウンタイムのデータベーススキーマ変更の達成は、洗練された、しかし不可欠な、最新の高可用性システムのための実践です。後方互換性と前方互換性の原則を受け入れ、変更をアトミックなステップに分解し、デュアルライトのようなパターンと慎重なデプロイ戦略を活用することで、組織はデータモデルをシームレスに進化させることができます。スキーマ進化に対するこの反復的なアプローチは、継続的なサービス提供を保証し、潜在的なダウンタイムを、目には見えないが、極めて重要なエンジニアリングの偉業に変えます。

