Rails アプリで遭遇したロストアップデート:原因・修正・RSpecでの検証

こんにちは、Tony Duongです。株式会社SpacelyでRailsバックエンドエンジニアとして働いており、Spacelyプラットフォームの開発・保守を担当しています。

本記事では、当社の spacely_web Railsアプリケーションで遭遇した ロストアップデート(lost update) について、何が起きたか・どう直したか・RSpecでどう検証したか(失敗時と成功時の出力)を整理します。

題材は 1行の json / jsonbWorkflowRunprogress)です。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 列に別キーを書く

WorkflowRunprogressjson / 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"} を追加したいので、本来は 両方のキー が残るべきです。

しかし並行実行だと:

  1. A が {} を読む
  2. B も {} を読む
  3. A が step_a のみを書き込む
  4. 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_lockSELECT ... FOR UPDATE を使うため、同じ行への同時マージが直列化されます(Rails ドキュメント: ActiveRecord::Locking::Pessimistic)。


RSpecでの検証

狙いは次の2つです。

  1. ロックなし read-merge-save だと、最終 JSON から片方のキーが欠ける(失敗)
  2. 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 を中心としたバックエンド開発を行っており、今回のような並行処理やデータ整合性の課題をチームで議論しながら日々改善しています。 今回の記事のような、並行処理やデータ整合性の課題を一つずつ改善していく仕事に興味がある方と、一緒により良いプロダクトを作っていけたら嬉しいです。

採用ページ