PostgreSQL で無停止リファクタリング 〜テーブルスキーマの変更〜

  • URLをコピーしました!

こんにちは、バックエンドエンジニアの江間です。

プレスキットと呼ばれる機能の MVP(実用最小限の製品: Minimum Viable Product)のリリースから半年以上経ちました。さらなる機能追加のために、提供する機能の動作は変更せず、テーブルスキーマの変更のリファクタリングを行う事になりました。

この変更で API から呼び出している SQL も書き換える必要が出てきました。すると DB 側のテーブルスキーマの変更とアプリケーション側の変更を行う必要があります。

両方の変更を反映させるために、メンテナンスを入れて一時的に機能を停止することも選択肢としてあります。

しかし、今回はメンテナンスを入れずに無停止で行う方針を選びました。

この記事では、実際にやってみた事例について解説していこうと思います。

目次

なぜ無停止?

一番の理由として「PR TIMES」というサービスは情報インフラを目指しています。可能な限りサービスが止まる事が無いように運用されています。

データベースに変更を入れる時であっても、無停止で出来る方法があれば、その方法を選ぶ事を検討します。

2つ目の理由として、メンテナンスを入れて切り替える場合、メンテナンス後に正しく動くか分からないというデメリットがあります。無停止で段階的に移す方法では、切り戻しが随時できる(=どこにも引き返せないタイミングがない)というメリットもあります。

ということで、今回は無停止でテーブルスキーマの変更を行う方針になりました。

前提

今回修正する対象となるプレスキットという機能は、ロゴ・画像・資料などの種類があり、総称してプレスキットと呼んでいます。

これまでは、企業テーブルがロゴ・画像・資料などの各プレスキットテーブルに直接リレーションがありました。しかし、今後この各プレスキットテーブルに対して横断的に操作したい場合、少し不便さを感じました。そのため、上位概念のテーブルを用いた設計に変更しました。

企業とプレスキットの関係図

実際のテーブルスキーマの変更では以下の事を行いました。

  • 各プレスキットのテーブルから company_id を drop する
  • company_id とuuid をもつ中間テーブルを作成する
  • 各プレスキットテーブルは uuid を中間テーブルへの外部キーとする
改修前後のER図

ここで、テーブルスキーマの変更の対象となるデータベースは1つだけになります。

そのため、アプリケーション側から2つのテーブルに対して更新を行う場合は、トランザクションを利用して整合性を担保しています。

また、この記事では、テーブルスキーマの変更前のテーブルを「改修前のテーブル」、テーブルスキーマの変更後のテーブルを「改修後のテーブル」と呼んで解説します。

無停止リファクタリング計画

今回のテーブルスキーマの変更では、提供する機能の動作は変更が無いようにするリファクタリングになります。
現在リファクタリング対象となるプレスキット機能は、常に複数のユーザーが利用しています。

運用中のテーブルのスキーマ変更では以下のような問題が予想されます。

  • スキーマ変更でロックが掛かってしまう
  • スキーマ変更とアプリケーション側の変更のデプロイのタイミングを完全に揃える事が出来ない

こういった問題を避けながら無停止で DB リファクタリングする場合、改修前のテーブルと改修後のテーブルの両方を用意して、段階的にテーブルの切り替えを行います。

そして、今回は改修前後でテーブル名が変わらないようにしたいです。

切り替え中は改修後のテーブル名を一時的な名前とし、切り替えが完了した後に、改修前のテーブルをドロップし、改修後のテーブルはもとの名前に変更します。

無停止リファクタリングの手順

また、アプリケーション側の変更は、データベースへ直接アクセスが行われるリポジトリ層内だけに閉じ込めます。この他のレイヤーや、リポジトリ層を呼び出し部分は書き換えを行わないです。

これによって、サービス層以上の階層で既存のテストを使うことができます。

コードの設計と、今回の影響範囲

1. 改修後テーブルの作成

まずは改修後のテーブルを追加します。改修前のテーブルと同じ名前には出来ないのでnew_などのプレフィックスをテーブル名に付けます(これは後でリネームします)

テーブルの作成前後のER図

2. アプリケーション側の改修(1)

1回目のアプリケーション側の改修では、追加した改修後のテーブルにもデータの取得やデータの更新を行うようにします。(=ダブルライト方式)

この変更では、動作が変わらない事が期待されています。そのため、既存のE2Eテストがあれば、それを用いて実装後もそのテストが通る事で動作を保証します。(もし無ければ、事前に書きましょう。)

データの取得

データ取得時は「まず改修後のテーブルからデータを取得」し、データが無ければ「改修前のテーブルからデータを取得」します。

アプリケーション側の修正(1)前後のデータ取得のフローチャート図

コードではこの様に書いています

    /**
     * DBからプレスキット画像を取得する
     * memo: 改修後のテーブル → 改修前のテーブル という順でデータを取得します
     */
    public static function getById(string $uuid, ?PDO $pdo = null): ?PressKitImage
    {
        ....

        $sql = '~~'; // 改修後のテーブルからデータを取得するSQL
        $stmt = $pdo->prepare($sql);
        $stmt->bindValue('uuid', $uuid, PDO::PARAM_STR);
        $stmt->execute();

        // ToDo この中は”アプリケーション側の改修(2)”で削除する ----------------------------------------
        if (($row = $stmt->fetch(PDO::FETCH_ASSOC)) !== false) {
            // 改修後のテーブルからデータが取得できればこの時点で返す。そうでなければ改修前のテーブルを確認する。
            return new PressKitImage($row);
        }

        $sql = '~~'; // 改修後のテーブルからデータを取得するSQL
        $stmt = $pdo->prepare($sql);
        $stmt->bindValue('uuid', $uuid, PDO::PARAM_STR);
        $stmt->execute();
        // ToDo end  ----------------------------------------

        if (($row = $stmt->fetch(PDO::FETCH_ASSOC)) === false) {
            return null;
        }

        return new PressKitImage($row);
    }

愚直に2回 SQL を実行します。似たような内容のコードが出来ますが、明確に消すタイミングと消すコードが決まっているので、あえてこの様に書いています。

また、わかりやすく ToDo コメントを入れておくと、コードを消すときに楽になります。

データの更新

データ更新時は「改修前のテーブルデータの更新」「改修後のテーブルデータの更新」の両方を行います。

今回はトランザクションを利用しているので、更新処理の順番は特に問題にはならないです。しかし、一般的にはエラーが生じた時に戻す事をしても影響が少ない方を最初に持ってきます。

アプリケーション側の修正(1)前後のデータ更新のフローチャート図

こちらも同様に2回 SQL を実行しています。

    /**
     * DBにプレスキット画像をINSERTする。この関数は呼び出し元でトランザクションを開始する必要がある。
     * memo: 改修後のテーブルと、改修前のテーブルの両方に追加する。
     */
    public static function create(PressKitImage $pk_image, PDO $pdo)
    {
        if (!$pdo->inTransaction()) throw new LogicException('トランザクションが開始していません');

        ...

        $sql = '~~'; // 改修後のテーブルからデータをINSERTするSQL
        $stmt = $pdo->prepare($sql);
        $stmt->bindValue(...);
        ....
        $stmt->execute();

        // ToDo この中は”アプリケーション側の改修(2)”で削除する ----------------------------------------

        $sql = '~~'; // 改修前のテーブルからデータをINSERTするSQL
        $stmt = $pdo->prepare($sql);
        $stmt->bindValue(...);
        ....
        $stmt->execute();

        // ToDo end ----------------------------------------
    }

// その他、updateやdeleteも同様。

また、弊社では NewRelic を導入しているので、エンドポイントごとのテーブルアクセスが確認出来ます。

このグラフから、無事に改修後のテーブルへアクセスが行われている事が確認できます。

デプロイ前後のNewRelicの画像
あわせて読みたい
New Relic ダッシュボード共有会 in PR TIMES こんにちは江間です。 先日、New Relic さん主催で New Relic ダッシュボードの共有会が開催されました。 この共有会では社内エンジニアが作成した・実際に利用している...

3. マイグレーション

アプリケーション側の改修(1)のリリース後に保存されたデータは、改修前と改修後の両方のテーブルにありますが、リリース前に保存されたデータは改修前のテーブルにしかありません。

すべてのデータが両テーブルに存在するようにバッチ処理でマイグレーションを行います。

        // 両方のテーブルにデータを存在させるマイグレーション
        /** @var array{company_id:int} $row */
        foreach ($rows as $row) {
            $company_id = $row['company_id'];

            $this->pdo->beginTransaction(); // 1企業ごと実行する
            try {
                // 「改修前のテーブルの方にだけデータが有る」場合を想定している。
                // もし対象企業一覧取得からこのトランザクション開始までの間に、企業が更新処理を行ってしまった場合はLogicExceptionになる。
                if (sizeof(PressKitImageRepo::getsByCompanyIdFromNewTable($company_id, $this->pdo)) !== 0) { // 改修後のテーブルだけからデータを取って来てチェックする
                    throw new \LogicException("改修後のテーブルにデータが見つかりました");
                }

                // トランザクション内では改修前のテーブルからデータを取ってくる
                $images = PressKitImageRepo::getsByCompanyId($company_id, $this->pdo);
                // 改修後のテーブルへのデータ挿入を行う。改修後のテーブルにはデータは無い想定なので、DELETEは不要。
                PressKitImageRepo::createToNewTable($images, $this->pdo); // 更新後のテーブルだけINSERTを行う

                $this->pdo->commit();
                $this->logger->info("success company_id:{$company_id} count:" . sizeof($images));
            } catch (Exception $exception) {
                $this->pdo->rollBack();
                throw $exception;
            }

            usleep($this->usleep_time); // DBに負荷をかけないためにsleepを入れる。Dev環境等で動作確認時はここを0にして素早く実行する
        }

ここでもトランザクションを使っているので、ユーザーがアプリケーション上で更新を行ったタイミングと、このバッチ処理の実行タイミングがちょうどよく重なってしまうとデッドロックが起こります。

今回の場合は、ユーザーが更新を行うAPIへのアクセス数からデッドロックが起こる可能性は低いとして、そのままバッチ処理を実行しました。

4. アプリケーション側の改修(2)

2回目のアプリケーション側の改修では、データの取得やデータの更新を追加した改修後のテーブルだけ行うようにします。

アプリケーション側の修正(2)前後のデータ取得のフローチャート図
アプリケーション側の修正(2)前後のデータ更新のフローチャート図

実際のコード上の修正は、アプリケーション側の改修(1)で印を付けておいた ToDo コメントの中を削除するだけです。

再び NewRelic のグラフを確認してみます。すると、改修前のテーブルへのアクセスが無くなり、改修後のテーブルへのアクセスだけになっている事がわかります。

デプロイ前後のNewRelicの画像

5. 改修前のテーブルをドロップ

改修前のテーブルはアクセスされる事がなくなったので、ドロップします。

しかし、もしもの事態を想定して、切り戻しが出来るようにしたいです。

そのためドロップする前に、一度リネームを行います。

-- このテーブルは削除予定であること(絶対に使われていないこと)が明確にわかる名前がいいです。
ALTER TABLE an_old_table RENAME TO an_old_table_was_deleted_by_the_issue_number;

リネーム後、数日開けて問題が無ければテーブルをドロップします。

DROP TABLE IF EXISTS an_old_table_was_deleted_by_the_issue_number;
テーブルドロップ前後のER図

6. テーブルのリネーム

一つのデータベース内で同じ名前を使うことが出来ないので、改修前と改修後のテーブルが両方存在する無停止リファクタリングでは、どうしても改修後のテーブル名は改修前と異なるものになります。

なので、改修後のテーブル名が改修前のテーブル名になるようにリネームします。

テーブルリネーム前後のER図

しかし、改修後のテーブルは現在もコード内で使われています。単純に ALTER TABLE ~ RENAME だけを行うと、コード内でテーブルが見つからないという事態が起こります。

この問題を回避しつつリネームを行う方法として、今回は更新可能ビュー(Updatable view)を使いました。更新可能ビューでは UPDATE や INSERT が実行可能になり、その変更先はビューの参照元のテーブルになります。(ビューが更新可能ビューであるためには様々な条件がありますが、詳しくは公式ドキュメントをご参照ください。)

今回、この機能をテーブルのエイリアス的な利用をして、リネームを行います。実装方針としては以下の図の通りです。

変更可能ビューを用いてテーブルのリネームを行う手順

更新可能ビューは、SELECT * FROM tableのようなシンプルなクエリからビューを作成すると、自動的に更新可能ビューとなります。

-- これはシンプルなビューなので、自動的に更新可能ビューとなる。
CREATE VIEW view_press_kit_images AS SELECT * FROM press_kit_logos;

更新可能ビューが出来たら、アプリケーション側で参照しているテーブル名を全て作成したビューに変更します。

アクセス先をテーブルからビューに切り替えた時のNewRelicの画像

アプリケーション側がビューを参照するようになったら、1つのトランザクションで更新後のテーブルのリネームと、ビューの更新を行います。

(PostgreSQL ではトランザクション内で ALTER TABLE ができます)

BEGIN;

-- 後にDROP VIEWするためのリネーム
ALTER VIEW view_press_kit_images RENAME TO view_press_kit_images_prev_was_deleted_by_issue_number;

-- テーブルのリネーム
ALTER TABLE replaced_press_kit_images RENAME TO press_kit_images;

-- 同じ名前でVIEWを作成
CREATE VIEW view_press_kit_images AS SELECT * FROM press_kit_images;

COMMIT;

※注意
今回は、まだデータ量とデータアクセスが共に少なく、リネームで ALTER TABLE ~ RENAME を実行しても問題無かったです。しかし、データの量や、データへのアクセス量が増加するとロックがかかる時間を検討する必要があります。

参考: 運用中のPostgreSQLのスキーマを無停止で安全に変更する

リネームが完了したら、アプリケーション側で参照しているビューを全てテーブル名に切り替えます。

アクセス先をビューからテーブルに切り替えた時のNewRelicの画像

これで、リネーム作業が完了し、すべてのデータベースリファクタリング作業が完了しました。

終わりに

今回は PostgreSQL の機能も活用して、無停止でテーブルスキーマの変更を行いました。

無停止で行うと、段階を踏んで変更を加えていくので、結構な時間がかかりました。(約1ヶ月ぐらい)

ただ、メンテナンスを入れて機能を止める事をせず、テーブルスキーマの変更が可能であることが経験として得られ、大きな成長に繋がったと思います。

以上、よろしくお願いします。

  • URLをコピーしました!

この記事を書いた人

株式会社PR TIMES 開発本部 バックエンドエンジニア

目次