本番稼働中の backfill migration で学んだ、ロック時間から考える設計判断

こんにちは。ポケットサインでエンジニアとして働いている畑佐です。普段はミニアプリの開発に携わっています。

ある機能開発で、既存データを埋め直す backfill migration を本番稼働中に実行したところ、同じテーブルを更新する通常リクエストにタイムアウトやエラーが発生しました。

migration 自体は更新を進めていましたが、実際には長時間の transaction が対象テーブルの更新ロックを保持し続け、通常の更新処理と競合していました。

この記事では、どんな実装が問題を起こしたのか、なぜ通常処理に影響したのか、どの設計案を比較したのか、そして最終的に何を優先して判断したのかを整理します。

何をする migration だったのか

今回の migration は、target_table に追加した新しいカラムに対して、既存データを backfill するものでした。

処理内容は、既存の source_value から derived_value を生成して埋める、というものです。この変換は SQL だけでは完結しにくく、Go 側で source_value を読み出して加工し、その結果を DB に反映する構成にしていました。

ここで重要だったのは、対象が backfill 専用のテーブルではなく、サービス稼働中にアプリケーションからも更新されるテーブルだったことです。つまり、単に「正しく埋まるか」ではなく、通常処理とどう共存させるかまで含めて設計する必要がありました。

実際に起きたこと

この migration を本番で実行したところ、同じ target_table を更新する通常処理でタイムアウトやエラーが発生しました。migration の対象レコードは 10 万件以上あり、その更新を 1 transaction の中で 1 行ずつ実行していました。

サービス全体が停止したわけではありません。問題だったのは、migration が更新した行にぶつかった通常処理だけがロック待ちになり、migration が完了するまで返答を返せなかったことです。

どんな実装が問題だったのか

初期実装は、未更新レコードを取得し、Go 側で加工しながら 1 件ずつ UPDATE していく構成でした。イメージとしては次のようなものです。

func upBackfillDerivedValue(ctx context.Context, tx *sql.Tx) error {
    for {
        rows, _ := tx.QueryContext(ctx, `
          SELECT id, source_value
          FROM target_table
          WHERE derived_value = ''
          ORDER BY id
          LIMIT $1
      `, batchSize)

        for rows.Next() {
            var id, sourceValue string
            rows.Scan(&id, &sourceValue)

            derived := buildDerivedValue(sourceValue)

            _, _ = tx.ExecContext(ctx, `
              UPDATE target_table
              SET derived_value = $2
              WHERE id = $1
          `, id, derived)
        }

        if noMoreRows {
            break
        }
    }
    return nil
}

この実装では、10 万件以上の対象レコードを 1 transaction の中で、1 行ずつ UPDATE していました。

重要なのは transaction を使ったことそのものではない点です。問題は大量の更新を 1 transaction の中で実行し、先に更新した行のロックを transaction の終了まで保持し続けたことでした。

UPDATE すると対象行のロックが取られ、そのロックは transaction が終わるまで保持されます。今回の実装では、10 万件以上を 1 行ずつ更新していたため、transaction 全体の時間が長くなりました。その間、先に更新した行のロックも解放されません。

構図としてはこうです。

  • migration が target_table の多数行を順番に更新する
  • 各行の更新ロックは 1 transaction の中で保持され続ける
  • 通常処理は通常の SELECT であれば読み取り自体はできる
  • 一方で、通常処理が同じ行を UPDATE しようとすると待たされる
  • アプリケーションとしてはタイムアウトやエラーに見える

つまり今回問題になったのは、テーブル全体が読めなくなったことではありません。通常処理が同じ行を更新しようとしたときに、migration 側が保持している更新ロックの解放を待たされたことでした。

今回見直すべきだったのは、derived_value を埋める処理の妥当性だけでなく、通常処理と同じ行を更新しうる前提で、更新ロックをどれだけ短く保持できる構成になっているか、という点でした。

どう改善したか

見直しの中心に置いたのは、通常処理と競合したときのロック保持時間を最小化することでした。

対応として変えたのは主に 2 点です。

  • UPDATE を 1 行ずつ打つのをやめ、bulk update にする
  • migration 全体を 1 transaction に載せるのをやめる

改善後のイメージは次のような構成です。

func init() {
    goose.AddMigrationNoTxContext(upBackfillDerivedValue, downBackfillDerivedValue)
}

func upBackfillDerivedValue(ctx context.Context, db *sql.DB) error {
    for {
        rows := loadUnfilledRows(ctx, db, batchSize)

        var ids []string
        var derivedValues []string
        for _, row := range rows {
            ids = append(ids, row.ID)
            derivedValues = append(derivedValues, buildDerivedValue(row.SourceValue))
        }

        if err := bulkUpdate(ctx, db, ids, derivedValues); err != nil {
            return err
        }
    }
    return nil
}

func bulkUpdate(ctx context.Context, db *sql.DB, ids, derivedValues []string) error {
    _, err := db.ExecContext(ctx, `
      UPDATE target_table AS t
      SET derived_value = data.derived_value
      FROM (
          SELECT
              unnest($1::uuid[]) AS id,
              unnest($2::text[]) AS derived_value
      ) AS data
      WHERE t.id = data.id
  `, pq.Array(ids), pq.Array(derivedValues))
    return err
}

なぜ今回は NoTx を選んだのか

今回優先したのは、通常処理と競合したときのロック保持時間を短くすることでした。そのため、migration 全体を 1 transaction に載せるのではなく、batch ごとに bulk update し、1 回の更新を短く閉じる構成にしました。

この構成には、全体を一括で確定する形にはならないという欠点があります。途中で止まった場合、そこまでの更新は反映済みになります。そのため、全件一括の強い整合性が必要な migration には向きません。

ただ、今回の backfill ではその欠点を許容することができました。 未設定の derived_value に値を埋める処理だったため、途中で止まっても未更新のレコードを見ることで残りを拾い直せる構成にできたためです。

一方で、加工結果を一度確認してから反映したい場合や、全件をまとめて確定したい場合には、temp table や作業テーブルに加工結果を集約してから本テーブルへ反映する構成も考えられます。

今回は通常処理への影響を小さくすることと、実装をシンプルに保つことを優先し、migration 全体を 1 transaction にまとめず、batch ごとに bulk update する構成を採用しました。

稼働中テーブルの backfill で確認すること

今回の migration を通じて、backfill を設計するときは次の 3 点を先に確認するようになりました。

1. 通常処理と同じ行を更新しうるか

backfill 対象のテーブルが、通常リクエストからも更新されるかを確認します。

今回問題になったのは、対象テーブルが使われていたこと自体ではなく、migration と通常処理の両方が同じ行を UPDATE しうることでした。通常の SELECT で読むだけであれば、少なくとも今回と同じ「UPDATE 同士が同じ行ロックを取り合う」形の問題にはなりにくかったはずです。

2. 更新ロックをどれくらい長く保持しうるか

通常処理と migration が同じ行を更新しうるなら、競合したときの待ち時間を見積もる必要があります。

今回の対象は 10 万件以上あり、それらを 1 transaction の中で順番に更新していました。そのため、先に更新した行のロックは、後続の大量更新が終わって transaction が commit されるまで解放されません。

見るべきだったのは、transaction を使うかどうかだけではなく、1 transaction の中で何件更新し、その間どれだけ長く行ロックを保持しうるかでした。

3. 中断しても安全に再実行できるか

本番で流す backfill は、途中で止める可能性があります。

そのため、未更新条件で残りを拾い直せるか、同じ処理を再実行しても壊れないかを確認しておく必要があります。再実行できる構成にしておくと、全体を長い transaction で包まず、短い単位で更新しやすくなります。

おわりに

backfill は、一見すると既存データを埋めるだけの migration に見えます。しかし、稼働中システムで通常処理からも更新されるテーブルを対象にするなら、それは単なるデータ修復ではなく、本番トラフィックと共存しながら流す書き込み処理です。

今回の反省は、更新結果の正しさは見ていた一方で、通常処理をどれだけ待たせうるかを設計要件として十分に置けていなかったことです。

そのうえで今回は、長い transaction を避け、更新を一定件数ごとにまとめて反映することで改善しました。唯一の正解ではありませんが、少なくとも backfill を設計するときに「正しく埋まるか」だけでなく、「通常処理とどう共存させるか」を最初に問うべきだと学びました。