こんにちは、Tony Duongです。株式会社SpacelyでRailsバックエンドエンジニアとして働いており、Spacelyプラットフォームの開発・保守を担当しています。
本記事では、当社の spacely_web Railsアプリケーションで遭遇した ロストアップデート(lost update) について、何が起きたか・どう直したか・RSpecでどう検証したか(失敗時と成功時の出力)を整理します。
題材は 1行の json / jsonb 列(WorkflowRun の progress)です。2つのジョブが同じ JSON オブジェクトに 別々のキー を追加するときに起きる競合を扱います。Spacely の spacely_web ではデータベースに MySQL を使っているため、ここでの分離レベルの議論は MySQL InnoDB を前提にしています。
ロストアップデートとは?
ロストアップデートは、2つのトランザクションが同じ行を 読み、それぞれ更新内容を 計算 して 書き戻す ときに、後から書いた更新が先の更新を上書きしてしまう現象です。
MySQL InnoDB のデフォルト分離レベルは REPEATABLE READ(ドキュメント)、PostgreSQL は READ COMMITTED です。どちらでも、アプリ側で non-locking な read-modify-write をすると、最後の書き込みが勝って片方が消える ことがあります。
なぜ REPEATABLE READ でも起きるか
REPEATABLE READ は、non-locking read に対してトランザクション内のスナップショット整合性を保証しますが、アプリケーションの read-modify-write を自動で直列化してくれるわけではありません。FOR UPDATE やロック、リトライ戦略が必要です。
いつ起きるか
典型的には、並列ジョブが同じ行に対して 読み → Ruby で Hash マージ → update! をロックなしで行うときです。
どんな影響か
- JSON の 片方のキーが消える
- タイミング依存で再現が難しい
- 例外にならないことが多く、検知が遅れる
どう発生したか:2ジョブが同じ JSON 列に別キーを書く
WorkflowRun の progress(json / jsonb)に、ジョブAは step_a、ジョブBは step_b を記録するとします。
class WorkflowRun < ApplicationRecord # progress: json / jsonb (RubyではHashとして扱う) end # ジョブから呼ばれる想定 def record_step_done!(workflow_run_id, key, value) run = WorkflowRun.find(workflow_run_id) data = run.progress.presence || {} run.update!(progress: data.merge(key => value)) end
初期値が progress == {} のとき、A は {"step_a" => "done"}、B は {"step_b" => "done"} を追加したいので、本来は 両方のキー が残るべきです。
しかし並行実行だと:
- A が
{}を読む - B も
{}を読む - A が
step_aのみを書き込む - B が古いスナップショットから
step_bのみを書き込み、step_aを落とす

どう直すか
| アプローチ | Railsでの考え方 | 向いているケース |
|---|---|---|
| 悲観的ロック | find(id).with_lock { 再読込; マージ; save } |
Ruby側マージを維持したい・更新が短い |
| 楽観的ロック | lock_version + StaleObjectError を rescue して再試行 |
排他ロック時間を抑えたい・ジョブ再試行可能 |
| DBネイティブ JSON マージ | 例: PostgreSQL の jsonb || で1文更新 |
1 SQL で安全に表現できる・DB依存を許容 |
Rails の楽観的ロック仕様: ActiveRecord::Locking::Optimistic
今回、実運用で採用した修正は 悲観的ロック(with_lock)です。
アプリ側での最小修正は、行ロックでマージを直列化することです。
def record_step_done!(workflow_run_id, key, value) WorkflowRun.find(workflow_run_id).with_lock do |run| data = run.progress.presence || {} run.update!(progress: data.merge(key => value)) end end
with_lock は SELECT ... FOR UPDATE を使うため、同じ行への同時マージが直列化されます(Rails ドキュメント: ActiveRecord::Locking::Pessimistic)。
RSpecでの検証
狙いは次の2つです。
- ロックなし read-merge-save だと、最終 JSON から片方のキーが欠ける(失敗)
with_lockでマージを囲むと、同じ期待が通る(成功)
Queue 2本でタイミングを揃える
- 各スレッドは
find後にreadyへ通知しgo.popで待機 2.times { go << true }で同時解放して競合させるthreads.each(&:value)で待機しつつ例外を表面化
例(説明用)
context "when two workers add different keys to the same JSON column concurrently" do it "keeps both keys" do workflow_run = create(:workflow_run, progress: {}) id = workflow_run.id ready = Queue.new go = Queue.new threads = [ Thread.new do run = WorkflowRun.find(id) ready << true go.pop data = run.progress.presence || {} run.update!(progress: data.merge("step_a" => "done")) end, Thread.new do run = WorkflowRun.find(id) ready << true go.pop data = run.progress.presence || {} run.update!(progress: data.merge("step_b" => "done")) end ] 2.times { ready.pop } 2.times { go << true } threads.each(&:value) final = WorkflowRun.find(id).progress expect(final).to include("step_a" => "done", "step_b" => "done") end end
ロックなし実装では、上の include が片方欠けで失敗しやすく、with_lock 適用後は同じ期待が通るようになります。
マルチスレッドの spec でコミット可視性が不安定な場合は、トランケーションなどフィクスチャ戦略の調整が必要です。
テスト失敗時の出力(修正前)
Failures:
1) WorkflowRun when two workers add different keys ... keeps both keys
Failure/Error: expect(final).to include("step_a" => "done", "step_b" => "done")
expected {"step_b" => "done"} to include {"step_a" => "done", "step_b" => "done"}
(欠けるキーは実行順により step_a / step_b のどちらか。)
テスト成功時の出力(修正後)
WorkflowRun
when two workers add different keys to the same JSON column concurrently
keeps both keys
Finished in 0.42 seconds (files took 2.1 seconds to load)
1 example, 0 failures
まとめ
- JSON 列への 読み→マージ→書き は、別キー追加でもロストアップデートを起こし得る
- 対策は
with_lock、楽観ロック+再試行、または DB側の1文マージ - RSpec では
Queueバリアで競合を作ると再現しやすい
Spacely では、Rails / Sidekiq を中心としたバックエンド開発を行っており、今回のような並行処理やデータ整合性の課題をチームで議論しながら日々改善しています。 今回の記事のような、並行処理やデータ整合性の課題を一つずつ改善していく仕事に興味がある方と、一緒により良いプロダクトを作っていけたら嬉しいです。