Zenn
🚒

[goose]DBマイグレーション失敗して巻き戻せなくなった話

2025/03/17に公開

gooseとは

gooseはGo言語で書かれたデータベースマイグレーションツールです。SQLファイルやGo言語のコードを使ってデータベーススキーマの変更を管理できます。Upコマンドでマイグレーションを適用し、Downコマンドで巻き戻すことができるシンプルな設計になっています。

A database migration tool. Manage your database schema by creating incremental SQL changes or Go functions.

(出典: pressly/goose - GitHub

マイグレーションファイルも、適用したい変更をgoose Up、それを打ち消すクエリをgoose Downに書くという比較的シンプルなものです。

-- +goose Up
CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) NOT NULL UNIQUE,
    email VARCHAR(100) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- +goose Down
DROP TABLE IF EXISTS users;

困ったこと

マイグレーションファイルには複数のクエリを記述することができます。

例:ユーザーテーブルを作成し、別のクエリで外部キー制約をつける

しかし、複数のクエリが含まれるマイグレーションが途中で失敗した場合、一部のクエリだけが適用された状態になってしまいます。

例:外部キー制約をつけるのに失敗したが、ユーザテーブルは残ったままになる。

新しくテーブルを作成するなど単純なクエリの場合は良いのですが、適用されたクエリを打ち消すのが大変な場合は、中途半端な状態で残ってしまうと非常に厄介です。

途中で失敗するマイグレーションの例

以下のようなマイグレーションファイルを作成しました。複数のクエリが含まれており、2つ目のクエリで意図的にエラーを発生させています。

-- +goose Up

-- まず成功する処理:ユーザーテーブルを作成
CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- わざと失敗させる処理:存在しないカラムを参照
INSERT INTO users (name, email, invalid_column)
VALUES ('テストユーザー', 'test@example.com', 'この列は存在しません');

-- +goose Down
DROP TABLE IF EXISTS users;

この状態でマイグレーションを実行すると、以下のような問題が発生します:

  1. 最初のクエリ(CREATE TABLE users)は正常に実行される
  2. 2つ目のクエリ(INSERT INTO users)で存在しないカラム(invalid_column)を指定しているためエラーになる
  3. マイグレーション全体が失敗した扱いになるが、1つ目のクエリは適用されたままになる(理想は、1つ目のクエリがロールバックされてほしい)
  4. gooseのバージョン管理テーブルには記録されないため、このマイグレーションは「未適用」の状態

つまり、データベースの実際の状態と、gooseで定義したマイグレーションで構築される理想的なDBの状態に不整合が生じてしまいます。

試したこと

1. StatementBegin/StatementEndで全体を囲う

gooseの公式ドキュメントを見ると、複数のクエリを-- +goose StatementBegin-- +goose StatementEndで囲むと、一つのステートメントとして扱うことができるようです。

By default, SQL statements are delimited by semicolons - in fact, query statements must end with a semicolon to be properly recognized by goose.

More complex statements (PL/pgSQL) that have semicolons within them must be annotated with -- +goose StatementBegin and -- +goose StatementEnd to be properly recognized.

(出典: pressly/goose - SQL Migrations

-- +goose Up

-- +goose StatementBegin
CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO users (name, email, invalid_column)
VALUES ('テストユーザー', 'test@example.com', 'この列は存在しません');
-- +goose StatementEnd

-- +goose Down
DROP TABLE IF EXISTS users;

しかし、これでも問題は解決せず、最初のクエリだけが適用されてしまいました。

2. トランザクションを明示的に記述する

MySQLでは、START TRANSACTIONCOMMITで囲うことで、COMMITを実行するまで、DBに変更が反映されません。

START TRANSACTION を使用すると、そのトランザクションを COMMIT または ROLLBACK で終了するまで、自動コミットは無効のままになります。 そのあと、自動コミットモードはその以前の状態に戻ります。

(出典: MySQL 8.0 リファレンスマニュアル - START TRANSACTION, COMMIT, ROLLBACK 構文

-- +goose Up

START TRANSACTION;

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO users (name, email, invalid_column)
VALUES ('テストユーザー', 'test@example.com', 'この列は存在しません');

COMMIT;

-- +goose Down
DROP TABLE IF EXISTS users;

しかし、これも機能せず、最初のクエリだけが適用されました。

3. NO TRANSACTIONディレクティブを使用する

マイグレーションを行うときは、gooseがトランザクションとして実行してくれるらしいのですが、これを無効化し、自分で定義したトランザクションが実行できればうまくいくのではないかと考えました。

gooseのIssueを見ると、-- +goose NO TRANSACTIONというディレクティブがあるようです。これを使って自前でトランザクション管理を試みました。

I'm currently using a NO TRANSACTION directive before migrations that need to use non-SQL commands.

(出典: "NO TRANSACTION" migrations should mark database as dirty if not successful #728

-- +goose Up
-- +goose NO TRANSACTION

START TRANSACTION;

CREATE TABLE users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO users (name, email, invalid_column)
VALUES ('テストユーザー', 'test@example.com', 'この列は存在しません');

COMMIT;

-- +goose Down
DROP TABLE IF EXISTS users;

残念ながら、これも解決策にはなりませんでした。

4. Goのマイグレーションコードを作成する

gooseはSQL以外にもGo言語でのマイグレーションをサポートしています。もう、ここまで来たらSQLではなくGoのマイグレーションファイルを使えばうまくいくかと考えました。しかし、それにはカスタムバイナリを作成する必要があるようで、それは実装コストが高すぎる!

Import github.com/pressly/goose
Register your migration functions
Run goose command, ie. goose.Up(db *sql.DB, dir string)

(出典: pressly/goose - Go Migrations

結論

いろいろ試してみた結果、以下の対策を取ることにしました:

  1. マイグレーションファイルを最小単位に分割する
    複数のクエリを1つのファイルにまとめるのではなく、論理的な最小単位でファイルを分割します。このようにすることで、マイグレーションの粒度を細かくし、失敗した場合の影響範囲を最小限に抑えられます。

  2. Downで確実に巻き戻せるようにする
    どうしても複数のクエリが必要な場合は、Downセクションで確実に各クエリの効果を打ち消せるようにします。

Down時にもクエリが失敗する可能性があるため、マイグレーションの単位を小さくするのはかなり重要です。

さいごに

少し、消化不良な記事となってしまいました。

試行錯誤してみたのですが、マイグレーションツールの利用経験が浅いため、もしかしたら私の知識不足で、実は解決策があるのではないかと感じています。

もし、「こうしたらうまくいくのでは?」と案が思い浮かんだ方がいらしたら、コメントでご意見やアドバイスをいただけるとありがたいです!

Discussion

ログインするとコメントできます