0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

データ変更スクリプトでtransactionを使うべきでなかった話

0
Posted at

背景

  • Rails アプリで、問い合わせフォームの「流入チャネル」選択肢の文言を変更・追加するタスクがあった
  • 既存 DB に保存されている旧値を新しい値へ統一するため、本番コンソールで手動実行するデータ移行スクリプトを書いた
  • レビューで「このスクリプトは微妙。transaction は不要」とフィードバックをもらった
  • なぜ transaction で囲むのが不適切で、なぜ「段階検証型」のスクリプトの方が良いのかを整理する

前提のデータイメージ

Inquiry モデルの channel カラムに以下の旧値が保存されている状況を想定する

旧値 新値
"sns" "Social Media (SNS)"
"ad" "Web Advertising"
"referral" "Customer Referral"

元の書き方(レビュー前)

最初に書いたスクリプトは以下の通り

ActiveRecord::Base.transaction do
  Inquiry.where(channel: 'sns').update_all(channel: 'Social Media (SNS)')
  Inquiry.where(channel: 'ad').update_all(channel: 'Web Advertising')
  Inquiry.where(channel: 'referral').update_all(channel: 'Customer Referral')
end

事前確認は Inquiry.where(channel: ['sns', 'ad', 'referral']).count を一度見ただけ

レビューで指摘された書き方

スクリプトが微妙。こんな感じに確認しながらやりたい。transaction は不要

sns_scope = Inquiry.where(channel: 'sns')
p sns_scope.count
sns_scope.update_all(channel: 'Social Media (SNS)')
p sns_scope.last.channel
p Inquiry.where(channel: 'sns').count    # 0 になっているはず

各チャネルに対して上記を 1 セットずつ、1 つずつ手で進めるという運用

なぜレビュー後の書き方が良いのか

1. transaction が不要な理由

  • 3 つの UPDATE は互いに独立しているsns の更新と ad の更新に依存関係はない。ad の更新がコケたからといって sns の更新を巻き戻さないといけない合理的理由がない
  • update_all は SQL 1 本の UPDATE 文に展開されるため、それ自体がすでにアトミック(PostgreSQL / MySQL は個々の SQL 文を暗黙のトランザクションで包んでいるので、1 本の UPDATE は「対象行すべてに適用されるか 0 行か」というステートメントレベルの原子性が保証される)
  • 本番コンソールでの手動実行では、各ステップの結果を目視で確認しながら進めたいtransaction で囲むと中間状態が見えづらくなるし、最後でコケたら前半の成功分もロールバックされて「どこまで進んだか」が逆にわかりづらくなる
  • ActiveRecord::Base.transaction は本来「複数の更新を 1 つの原子単位として扱いたい時」に使うもの。独立した複数ステップを個別検証したい場面とはユースケースが逆

2. 1 件ずつ確認しながら進める理由

ステップ 意味
事前の count 想定外に多い / 少ない件数だったら止まれる(本番データは想定外が混ざりがち)
更新後の値確認 UPDATE が意図通り反映されたかを一件サンプルで見る
count 旧値が 0 件になったことを確認(実行中に新規追加されてないかの最終チェック)

コケたり想定外の件数だった時に即座に気づけて、次のステップに進むかどうかを人間が判断できる。これが手動データ移行で一番大事なポイント。transaction で一括実行すると、この「人間の判断ポイント」が丸ごと消えてしまう

なぜ最初の書き方が間違っていたか

  • 「安全のため」の思考停止で transaction を使った:複数の UPDATE があれば transaction で囲む、という浅い判断をしてしまっていた。transaction の本来の意味(原子性が必要な更新を束ねる)を考慮できていなかった
  • 手動実行の文脈を軽視した:本番コンソールでの手動データ移行は、むしろ「段階的に確認しながら進める」ことが安全につながる。transaction は人間の判断ポイントを奪う方向に働く
  • 事前件数確認だけで満足した:更新前の件数確認は書いたが、更新後の検証ステップがなかった。「UPDATE が意図通り反映されたか」「旧値が残っていないか」の確認が欠落していた

補足:ActiveRecord::Relation の遅延評価の落とし穴

レビュー指摘の書き方をそのまま写経すると、ActiveRecord::Relation の遅延評価特性によって落とし穴にハマる可能性がある

sns_scope = Inquiry.where(channel: 'sns')  # Relation(遅延評価、この時点ではSQLは発行されない)
sns_scope.update_all(channel: 'Social Media (SNS)')
p sns_scope.last.channel
# → sns_scope.last で WHERE channel = 'sns' のクエリが再実行される
# → 更新後は該当レコードが存在しないので nil が返り、.channel で NoMethodError

より堅牢にするなら pluck(:id) で ID を先に握ってから検証するのが良い

target_ids = Inquiry.where(channel: 'sns').pluck(:id)
p target_ids.size                                          # 事前件数

Inquiry.where(id: target_ids).update_all(channel: 'Social Media (SNS)')

p Inquiry.where(id: target_ids).pluck(:channel).uniq       # => ["Social Media (SNS)"]
p Inquiry.where(channel: 'sns').count                      # => 0

ポイントは 3 つ

  • pluck(:id) で対象集合を ID として固定しておくことで、更新前後で同じレコード群を追跡できる
  • .uniq全件が期待値になったことを確認する(last 1 件だけを見ても更新漏れに気づけない)
  • 最後に旧値の残数が 0 件であることを確認する

なぜ pluck(:id) で固定するのか

ActiveRecord::Relation は条件式を保持したオブジェクトなので、呼び出すたびに SQL が再実行される(Active Record Query Interface - Lazy Loading 参照)。更新前の Relation は「channel = 'sns' のレコード」を指すので、更新後は中身が空になる。一方 pluck(:id) で取得した配列は普通の Ruby オブジェクトなので、更新後もそのまま「更新対象だったレコード群」を指し続ける

結論

  • データ移行スクリプトは段階検証型で書く(事前 countupdate → 事後検証 → 再 count
  • transaction は「原子性が必要か?」を判断基準にして使う。独立した複数更新を束ねる用途で使うのは目的違い
  • 手動実行の場面では、人間の判断ポイントを奪わない書き方を選ぶ
  • ActiveRecord::Relation の遅延評価を前提にするなら、pluck(:id) で対象を固定するのが安全

感想

  • 「複数更新だから transaction」って脳内マクロが動いていた。ちゃんと原子性の必要性から考えないとダメだな

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?