ALTER TABLEをfeature flagを使って安全にリリースできるかも?

2024/07/14に公開

やりたかったこと

テーブル定義などを変更するAlter Tableを行うとき、アプリケーションコードをそれに合わせて変更する必要がありますよね?
自動でマイグレーションを行うような構成を取っている場合、アプリケーションサーバーのリリース前にマイグレーションスクリプトを実行すると思います。
リクエスト数が多くないAPIなどの場合はそれで十分だと思いますが、例えばアクセス数が多いテーブルの場合、 マイグレーションスクリプトの実行 -> アプリケーションサーバーのデプロイ の間にリクエストなどが送られてくると、テーブルはALTER TABLE実行後の構造になっているのに、アプリケーションコードが参照しているテーブル構造は古いものを参照している みたいな状態になり、エラーが発生してしまいます。
これを避けるには、リリースした瞬間を狙って手動でマイグレーションスクリプトを実行するなどがありますが、そんな早押しクイズみたいなことはしたくない・・・

どうやったら安全にマイグレーションできるんだろうな・・・・うーん・・・・・・・・・・

Feature Flagでやってみたらいいんじゃないか?

イメージこんな感じです。こんな感じでできたら楽なんじゃないだろうか・・・!


enableFeature := db.Query("select enable from feature_flags where name="hoge" FOR UPDATE")
if enableFeature {
   result, err := db.Query("alter table後のテーブル構造にselect")
} else {
   result, err := db.Query("alter table前のテーブル構造にselect")
}

やってみよう!!!

サンプルコードを作ってみました。mysqlとpostgresで検証しています

https://github.com/u-yas/db-migration-feature-flags

usersというテーブルにあるnameというカラムをdisplay_nameというカラムにリネームしよう。という内容です。

以下のように、10ミリ秒に一回selectを実行するコードを無限ループさせておきます。

package main

import (
	"database/sql"
	"fmt"
	"log"
	"time"

	_ "github.com/lib/pq"
)

func main() {
	dbUser := "user"
	dbPass := "password"
	dbName := "db"

	db, err := sql.Open("postgres", fmt.Sprintf("user=%s dbname=%s password=%s sslmode=disable", dbUser, dbName, dbPass))
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	// Test the connection
	err = db.Ping()
	if err != nil {
		log.Fatal(err)
	}

	// 0.1秒間隔でgetUserを実行する
	for {
		tx, err := db.Begin()
		if err != nil {
			log.Fatal(err)
		}
		err = getUser(tx)
		tx.Commit()

		if err != nil {
			log.Fatal(err)

		}
		time.Sleep(10 * time.Millisecond)
	}

}

func getUser(tx *sql.Tx) error {

	var isEnabled bool
	err := tx.QueryRow("SELECT enabled FROM feature_flags WHERE name = 'users_name_to_display_name' FOR UPDATE").Scan(&isEnabled)
	if err != nil {
		tx.Rollback()
		return err
	}

	if isEnabled {
		result, err := tx.Query("SELECT id,display_name,age FROM users")
		if err != nil {
			return err
		}
		defer result.Close()

		for result.Next() {
			var id int
			var displayName string
			var age int
			err := result.Scan(&id, &displayName, &age)
			if err != nil {
				return err
			}

			fmt.Println("新しい")
		}
	} else {
		result, err := tx.Query("SELECT id,name,age FROM users")
		if err != nil {
			return err
		}
		defer result.Close()
		for result.Next() {

			var id int
			var name string
			var age int
			err := result.Scan(&id, &name, &age)
			if err != nil {
				return err
			}
			fmt.Println("古い")
		}
	}

	return nil
}

feature flagがonの場合、「新しい」、offの場合は「古い」がターミナルに永遠と出てきます。また、途中でエラーが発生した場合は処理が中断されます。

上記コードを実行中に以下のテストスクリプトを実行します

#!/bin/bash
set -e pipefail
db_service=$1

if [[ $db_service = "mysql" ]]; then

  # 100回 migration_up.shとmigration_down.shを交互に実行する
  for i in {1..100}; do
    sql=$(cat ./alter_table_up.sql)
    docker compose exec mysql mysql -u user -ppassword -D db -e "$sql"
    sql=$(cat ./alter_table_down.sql)
    docker compose exec mysql mysql -u user -ppassword -D db -e "$sql"
  done
  exit 0
elif [[ $db_service = "psql" ]]; then
  # 100回 migration_up.shとmigration_down.shを交互に実行する
  for i in {1..100}; do
    sql=$(cat ./alter_table_up.sql)
    docker compose exec postgres psql -U user -d db -c "$sql"
    sql=$(cat ./alter_table_down.sql)
    docker compose exec postgres psql -U user -d db -c "$sql"
  done
  exit 0

else
  echo "Invalid argument"
  exit 1
fi

100回程度upとdownのmigration sqlを実行するスクリプトを作ってみました。実行途中でアプリケーション側にエラーが発生しないか監視し、問題なければ実験成功です

こんな感じのテーブル定義とシードデータに

CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  age INT NOT NULL
);

CREATE TABLE feature_flags (
  id SERIAL PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  enabled BOOLEAN NOT NULL
);

insert INTO users (name, age) VALUES
('Alice', 20),
('Bob', 25),
('Charlie', 30),
('David', 35),
('Eve', 40),
('Frank', 45),
('Grace', 50),
('Heidi', 55),
('Ivan', 60),
('Judy', 65)
;

insert INTO feature_flags (name, enabled) VALUES
('users_name_to_display_name', false);

COMMIT;

こんな感じのalter tableを実行していきます

BEGIN;
update feature_flags set enabled = true where name = 'users_name_to_display_name';

ALTER TABLE users RENAME COLUMN name TO display_name;

COMMIT;

クエリ自体はごくごく単純です

実験結果は・・・!!!

githubのreadmeに動画を載せています。実行結果は以下のとおりです・・・!

postgres: 成功!

エラーが一度も発生することなくfeature flagの切り替えに成功しました。イメージ通りにいけたのでよかった・・・!!

mysql: 失敗・・・??

なんでmysqlだけ・・・?
理由は、mysqlの場合、alter table実行時に「暗黙的なコミット」が発生するからだそうです。

https://dev.mysql.com/doc/refman/8.0/ja/implicit-commit.html

なので、alter table実行前にfeature flagの更新がcommitされてしまい、不整合が発生してしまいエラーが出てしまっていました。

終わりに

暗黙的なコミットのことをあまり良く知っていなかったので今回の検証で勉強になってよかったなと思いました。

実際の業務で利用する場合、負荷試験などを行ってパフォーマンスに問題がないか、検証してみてから行ってくださいね!こんな単純にいくわけないので

では!

Discussion