サービスの稼働を止めずに安心してデータベースのカラム移行を行ったはなし

こんにちは。バックエンドの開発をメインにしつつ、フロントエンド開発やチーム横断レビュー、EMなどもやっています miukoba です。

サービスの稼働を止めずに安心してデータベースのカラム移行を行った時の手順や観点を記載します。

この記事はリンクアンドモチベーション Advent Calendar 2021の15日目の記事です。

https://qiita.com/advent-calendar/2021/mcs

昨日の記事はあつきの [良いエンジニアとは？PMが今年エンジニアに言われて嬉しかった言葉Best5 - Qiita](https://qiita.com/atsuki_911/items/1f53051b8c6566f41ada) でした。直接の関わりは薄いチームのプロダクトマネージャーなのですが、なかなかいいメンバーがいるなーと感じました。

本題です。

# 変更の概要とシステム状況

カラム移行の前提は以下の通りです。

- 機能の追加に伴い、同じテーブル内で別のカラムを使用するようにデータベースを変更する
- 選択肢を増やしたく、カラム名の変更だけでなく、真偽値のカラムを選択肢を登録できるよう変更する
- 現状では、同じデータベースを参照するアプリケーションが他にも存在する事が分かっている[^1]

[^1]: いわゆる共有データベースなのでアンチパターンです。別途データベースの分離を進めています。共有データベースに関しては、書籍 [O'Reilly Japan - マイクロサービスアーキテクチャ](https://www.oreilly.co.jp/books/9784873117607/) や [O'Reilly Japan - モノリスからマイクロサービスへ](https://www.oreilly.co.jp/books/9784873119311/) 等に記載があります。


## 新旧カラムの概要

機能追加に伴い、下記のような設計にしました。

なおRuby on Railsを使用している関係上、旧カラムは真偽値ですが `tinyint(1)` 、新カラムはlimit:1 を指定したintegerで `tinyint(4)` (ActiveRecord::Enumを使用) になっています。


|  | 移行前の設計 | 移行後の設計 |
|:-:|:-:|:-:|
| データ | 真偽値 | 複数種類が登録可能(enumで定義） |
| データ型 | tinyint(1) | tinyint(4) |
| 登録内容 | 「実施する」<br>「実施しない」<br>のどちらかを登録する | 「実施しない」<br>「Aで実施」<br>「Bで実施」<br>...<br>と複数の意味をもたせる [^2] |
| not null制約 | あり | あり |
| default指定 | なし | なし |

以後、それぞれのカラムを「旧カラム」「新カラム」と呼びます

[^2]: 設計として 「実施する」「実施しない」　と　「実施方法A」「実施方法B」 を別のカラムにし、2つのカラムを使用する設計等も考えられますが、今回ここの決定意図については割愛します


# 指針

今回の変更は、書籍 [データベース・リファクタリング](https://www.amazon.co.jp/dp/4894715007) でいうところの、「カラムの置き換え」と「カラムの削除」に該当します。

本書の記載から大まかな流れを引用します

カラムの置き換え

> 1. 新しいカラムを導入する。
> 2. 元のカラムを廃止予定にする。
> 3. 同期用トリガーを導入する。
> 4. 他のテーブルを更新する

カラムの削除

> 1. 削除方針の選択
> 2. カラムの削除
> 3. 外部キーの再作成

「他のテーブルを更新する」が少し分かりにくいのですが、外部キーの一部として使われているような場合や、関連するインデックスの更新を意味しています。

今回のカラムは外部キーやユニークインデックス等には使用されていなかったため、ここはスキップしました。旧カラムが含まれていた場合はもうちょっと追加手順が必要になります。（なかなか大変です）

今回はこれを参考に組み合わせつつ、いくつか手順を追加しています。


# カラムの移行手順の詳細

## データベース設計

単純に新しいカラムを追加するときと同様、下記のような項目を決めます

- カラム名
- カラムコメント
- データ型
- 各種制約
- index

ただし、「not null」かつ「default値なし」については、既存レコードがありカラム追加時に設定することができないため、データ移行や登録処理の反映が終わったあとで定義を変更する必要があります。

加えて、カラムの移行のため、データ移行のマッピングについて決定します。
今回は、既存データの「実施する」はすべて「Aで実施」として取り扱うこととし、旧カラムから新カラムへのデータ移行で「実施する」をAとBに振り分ける必要は無いこととしました。

なお記載としては分かれていますが、設計時点で大まかに変更規模を把握するための影響調査は設計時点で行っておきます。

- 影響範囲の大きさ
- 登録・更新・削除しているプログラムと、修正規模
- 参照しているプログラムと、修正規模


## より広い影響調査

概ね設計が固まったら、もう少し広い範囲で影響調査をします。

主に、同じデータベースを参照している事が分かってい他チームへの伝達などです。

- 共通のデータベースの一部テーブルを使用している別システムや、データ分析などで参照している機能がないかの確認
    - 直接コードリポジトリを参照しての確認および担当チームへの協力依頼
- データの監視やモニタリングに使用しているRedash[^3]クエリでの使用が無いかの確認
    - あった場合は、関係者への連絡と、クエリの更新の必要があるか（クエリの変更が必要かどうか）の確認などをする

[^3]: データベースに接続し、SQLを用いでデータの可視化やアラートの設定ができるツール

### 補足：Redashの影響調査方法

Redashに関しては、Redashに登録しているクエリ文字列がRedash自体のデータベースに保存されるので、そこから調べることが可能です。

設定をすると、Redash上でRedashのデータに接続することも可能です。

以下は、カラム名でクエリ全体から部分一致で検索するクエリ例です（カラム名やカラムの使用具合によっては結果が多すぎることもあるので、工夫が必要です）

```
SELECT 
    queries.id,
    queries.name,
    queries.query,
    queries.created_at,
    queries.updated_at,
    users.name AS user_name
FROM
    queries
        LEFT OUTER JOIN
    users ON users.id = queries.user_id
WHERE
    queries.query LIKE '%old_column_name%'
ORDER BY queries.id ASC;
```


## 新カラムを追加する

データベースに新カラムを追加します。

既存テーブルで既にデータが入っている状態なので、まずはNULL許可の状態でカラムを追加しました。
default指定はせず未指定の場合にNULLが入るようにしたのは、後述する不整合や考慮漏れを発見できるようにです。

良く条件指定されるカラムなので、同時にindexを張りました。
まだ新カラムは使用されませんが、今回は手順を分割しすぎて忘れないように、カラム追加時点でindexを張っています。
パフォーマンスがシビアな場合等、この時点ではindexを張らずに、参照系の切り替えをする前に貼ったほうが良い場合もあると思います。


## 旧カラム登録時に新カラムへも同時にデータを登録するようにシステムを変更する

登録・更新処理で、新旧両方のカラムを更新するようにします

「データベース・リファクタリング」では、データ同期の方法として「トリガーで同期する」「ビューを使用して旧テーブルの代わりとなるビューを導入する」「バッチ更新」の3つが紹介され、なかでも「トリガー」がおすすめされています。

今回は以下の理由により、3つのいずれでもなく、登録更新プログラムで新旧カラムを両方更新するように変更してリリースしました。

- トリガーのメリットと同じく、リアルタイムに新カラムにも値を登録できること
- 参照は他にもありそうだが、登録・更新処理はおそらく1箇所だろうと思われたこと
- チームとしてトリガーを使い慣れておらず、トリガーのテストや設定情報の管理が、プログラ修正に比べてスムーズにできない可能性が高かったこと
- あとあと新カラムへデータを登録するようプログラムを変更する必要があるので、この時点で実装しても開発工数のムダがあまり発生しなかったこと
- 新旧カラムの登録ロジックは複雑ではないため、一時的に2つのカラムに登録するように変更しても、パフォーマンス上の大きな懸念がなかったこと


なお、実は登録処理が他に存在していたことがあとあと発覚しました。
移行ステップを刻んでいたため、データ不整合を起因とした障害・バグなどは出さずに済みました。


## 旧カラムから新カラムへデータを移行する

プログラムのリリースで、新規登録・更新分は新カラムに値が登録されるようになりましたが、更新のない既存レコードは新カラムがnullの状態です。
なので、旧カラムを参照して新カラムに登録してくバッチ処理を開発・実行し、新カラムに値をすべて登録しました。

既存データの「実施する」はすべて「Aで実施」として取り扱うこととし、旧カラムから新カラムへのデータ移行で「実施する」をAとBに振り分ける必要は無いこととしました。
そのためデータ移行処理は割と単純で、データを移し替えるだけでした。

こちらも管理上の理由で、UPDATE文等ではなく、Railsのrake taskとして記述しデプロイ・実行を行いました。


## 不整合や考慮漏れを発見できるよう、データ同期の監視を設定する

旧カラム登録時に新カラムへも同時にデータを登録するようにシステムを変更したものの、一部ケースでうまく移行されないなどの実装漏れやバグがあったり、把握していなかったシステムからの登録更新が存在していた場合に、データ不整合が発生することでそれらの処理の発見が可能になります。

今回はRedashを使用しているので、Redashのアラート機能を使用して1日1回確認する監視クエリを設定しました。

具体的には下記のようなクエリで、
結果が1件以上見つかったときはRedashと連携しているSlackにアラートが来て見つかるように設定しました。

```
SELECT 
    COUNT(*)
FROM
    対象テーブル
WHERE
    (旧カラム = 1 AND 新カラム = 0)
    OR (旧カラム = 0 AND 新カラム = 1)
    OR (新カラム NOT IN (0 , 1)
    OR 新カラム IS NULL);
-- should be 0
```


## しばらく間を開ける

ここまできたら、しばらく時間を起きます。時間をあける理由は下記です。

- この間に影響のあったチームに新カラムを参照するように移行対応をしてもらう
- データ不整合のアラートが出たら、調査対応をする

カラムの特性によりますが、月次の処理で使われるようなテーブル・カラムだった場合は、1ヶ月以上置いたほうが安心かもしれません。

今回は日々更新されるカラムでしたが、1ヶ月程度たったところでアラートが出て、データの不整合（登録更新の考慮漏れ）が発覚しました。
具体的にはイレギュラー手順として、初期データの投入を直接INSERT文で実行するプログラムがあり、旧カラムにしかデータを登録しない状態になっていました。

別件にはなりますが、この初期データ投入スクリプトはコード管理上管轄チームが離れすぎていたので、メインでこのデータベースをいじっているチームの管轄として、スクリプトを再作成しました。


## 新カラムをメインで参照・更新しつつ、旧カラムも更新するようにシステムを変更する

ついに新カラムを参照・更新するようなプログラムを変更してリリースします。
このタイミングで合わせてnot null制約をつけました。やっと新カラムが理想的な定義になりました。

一斉にすべて新カラムに切り替えができるときれいなのですが、機能リリースも段階を踏んで分けることにしたため、一部旧カラムを参照しているコードが残った状態で機能のリリースを行いました。そのため、現在はまだ旧カラムの更新も続けています。
マッピング上はデータ移行のときとズレがありますが、「Aで実施」「Bで実施」のどちらの場合も、旧カラムは「実施する」で登録するようにしています。


体験談で記載していますが、今回の開発に関してはここまで進んでいません。
現在、まだ旧カラムを参照している機能を新カラムを使用するように変更する開発を行っています。


# 旧カラムカラムの削除手順の詳細

## 参照がないか確認する

ログなどから、旧カラムを使用していないか確認します。
`*` で全カラムをSELECTしている場合に完全に見つけられないこともありますが、一応確認しておくと良いです。

## カラム削除予定とマークする

稼働中RailsアプリのDBに対してカラム削除をする際は、事前に ignored_columns の設定をします。（ActiveRecord がカラム情報をキャッシュしているため）
ここは説明記事が他にもたくさんあるので割愛します。


## 削除直前での再度の影響確認

カラム移行の際に実施した影響調査から時間がたっている場合、再度Redashのクエリ調査や、他チームへの確認ができると良さそうです。

## カラムを削除する

実際にカラムを削除して完了です。
おつかれさまでした。


# まとめ

あらためて記載して見ると結構な量になりました。
省略していますが、各ステップでレビューやテストも実施しましょう。

今回はリファクタリングだけが目的ではなく、機能追加のために必要で実施しましたが、コードと同じようにデータベースのリファクタリングも積極的に実施していきたいです。

データベースリファクタリングは、地味だけど大切だと思うので、ノウハウがもっと共有されると良いなと思っています。