背景
- 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で全件が期待値になったことを確認する(last1 件だけを見ても更新漏れに気づけない) - 最後に旧値の残数が 0 件であることを確認する
なぜ pluck(:id) で固定するのか
ActiveRecord::Relation は条件式を保持したオブジェクトなので、呼び出すたびに SQL が再実行される(Active Record Query Interface - Lazy Loading 参照)。更新前の Relation は「channel = 'sns' のレコード」を指すので、更新後は中身が空になる。一方 pluck(:id) で取得した配列は普通の Ruby オブジェクトなので、更新後もそのまま「更新対象だったレコード群」を指し続ける
結論
- データ移行スクリプトは段階検証型で書く(事前
count→update→ 事後検証 → 再count) -
transactionは「原子性が必要か?」を判断基準にして使う。独立した複数更新を束ねる用途で使うのは目的違い - 手動実行の場面では、人間の判断ポイントを奪わない書き方を選ぶ
-
ActiveRecord::Relationの遅延評価を前提にするなら、pluck(:id)で対象を固定するのが安全
感想
- 「複数更新だから
transaction」って脳内マクロが動いていた。ちゃんと原子性の必要性から考えないとダメだな
参考
-
ActiveRecord::Transactions::ClassMethods - Rails API —
transactionの公式リファレンス -
Active Record Query Interface - Rails Guides —
where/pluck/ Relation の遅延評価 -
ActiveRecord::Relation#update_all - Rails API —
update_allは単一 SQL に展開される仕様 - PostgreSQL: Transactions — トランザクションの原子性(ACID の A)