Active Recordのロック機能(Pessimistic/Optimistic Locking)を理解して競合を防ぐ
はじめに
複数のユーザーやプロセスが同時に同じデータを更新しようとするWebアプリケーションでは、「競合状態(Race Condition)」が発生するリスクが常に存在します。例えば、銀行口座の残高、在庫数、座席の予約など、データの正確性が非常に重要な場面で競合が発生すると、深刻な問題を引き起こします。
あるユーザーがデータを読み込んでから、更新を保存するまでの間に、別のユーザーが同じデータを更新してしまうかもしれません。この結果、最初のユーザーの更新は、意図せず古いデータに基づいて行われ、2番目のユーザーの更新を上書きしてしまいます(「ロストアップデート問題」)。
この問題を解決するために、データベースには「ロック」という仕組みが備わっています。Active Recordは、このデータベースのロック機能を簡単に利用するための2つの主要な戦略、「ペシミスティックロック(悲観的ロック)」と「オプティミスティックロック(楽観的ロック)」を提供しています。
1. ペシミスティックロック(悲観的ロック)
ペシミスティックロックは、その名の通り「競合は頻繁に起こるだろう」と悲観的に考え、データを読み込む時点でデータベースの行をロックして、他のトランザクションからの更新やロック取得をブロックする手法です。
処理が終わるまで誰も手出しできないようにするため、データの整合性を非常に強く保証できます。これは、データベースが提供するSELECT ... FOR UPDATE
のような行レベルロック機能を利用して実現されます。
使い方
Active Recordでは、lock
メソッドを使います。lock
メソッドは必ずトランザクションブロック(transaction do ... end
)の中で使う必要があります。
# 例: 銀行口座からの引き落とし
Account.transaction do
# 口座ID:1 の行をロックし、他のトランザクションからの更新をブロック
account = Account.lock.find(1)
# 残高計算
if account.balance >= 1000
account.balance -= 1000
account.save!
end
end # トランザクションが終了するとロックが解放される
このAccount.transaction
ブロックが実行されている間、もし別のプロセスが同じ口座(id: 1
)をロックしようとすると、最初のトランザクションが完了するまで待たされます。これにより、残高の計算中に他のプロセスが残高を書き換えてしまう、といった事態を防ぐことができます。
メリット
- 強力な一貫性: データの整合性を確実に保証できます。
- 実装が比較的容易: ロックの管理をデータベースに任せることができます。
デメリット
- パフォーマンスの低下: ロックを待つプロセスが発生するため、システムの並列性が低下します。ロックの期間が長くなると、多くのリクエストが待たされ、パフォーマンスのボトルネックになる可能性があります。
- デッドロックのリスク: 複数のトランザクションが互いに相手のロック解放を待ち合ってしまう「デッドロック」が発生する可能性があります。
いつ使うべきか?
- 競合が頻繁に発生することが予想される場合。
- データの不整合が絶対に許されない、金融取引などのクリティカルな処理。
- ロックの対象となる処理が短時間で終わる場合。
2. オプティミスティックロック(楽観的ロック)
オプティミスティックロックは、「競合はめったに起こらないだろう」と楽観的に考え、データベースレベルでのロックは行いません。その代わり、レコードを更新する際に、「自分が読み込んだ時点から誰もデータを更新していないか」をチェックし、もし誰かが更新していた場合は、更新を失敗させるという戦略をとります。
使い方
まず、対象となるモデルのテーブルにlock_version
という名前のinteger
型のカラムを追加する必要があります。
rails g migration AddLockVersionToProducts lock_version:integer
# db/migrate/xxxxxxxx_add_lock_version_to_products.rb
class AddLockVersionToProducts < ActiveRecord::Migration[7.0]
def change
add_column :products, :lock_version, :integer, default: 0, null: false
end
end
rails db:migrate
を実行します。
このlock_version
カラムが存在すると、Active Recordは自動的にオプティミスティックロックを有効にします。レコードが更新されるたびに、Railsはlock_version
の値をインクリメントします。
そして、更新時にはWHERE
句に読み込み時のlock_version
を含めることで、競合を検知します。
UPDATE products SET price = 200, lock_version = 2 WHERE id = 1 AND lock_version = 1;
もし他のプロセスが先に更新してlock_version
が2
になっていた場合、このUPDATE
文は0件のレコードにしかマッチせず、更新は失敗します。このとき、Active RecordはActiveRecord::StaleObjectError
という例外を発生させます。
アプリケーション側では、この例外を捕捉(rescue
)して、ユーザーに再試行を促すなどの適切なエラーハンドリングを行う必要があります。
# 在庫管理の例
@product = Product.find(params[:id])
begin
# ユーザーが編集フォームで行った変更を適用
if @product.update(product_params)
redirect_to @product, notice: 'Product was successfully updated.'
else
render :edit
end
rescue ActiveRecord::StaleObjectError
# 競合が発生した場合の処理
flash.now[:alert] = "This product has been updated by someone else. Please review the changes and try again."
# データベースの最新の値をフォームに反映させる
@product.reload
render :edit, status: :conflict
end
メリット
- 高いパフォーマンス: データベースをロックしないため、システムの並列性を損ないません。
- デッドロックが発生しない。
デメリット
- 実装の複雑さ:
ActiveRecord::StaleObjectError
の例外処理を自前で実装する必要があります。ユーザーに競合の解決を委ねるUI/UXを考慮する必要があるかもしれません。 - 競合が多いと不向き: 競合が頻発すると、ユーザーは何度も更新の失敗と再試行を繰り返すことになり、UXが悪化します。
いつ使うべきか?
- 競合が稀にしか発生しないと予想される場合。
- ユーザーが長時間フォームを編集するなど、データ読み込みから更新までの時間が長い操作。
- 高いスループットが求められるシステム。
まとめ
特徴 | ペシミスティックロック(悲観的ロック) | オプティミスティックロック(楽観的ロック) |
---|---|---|
基本戦略 | 先にロックして、競合を未然に防ぐ | まず実行し、更新時に競合を検知する |
Active Record | lock メソッド (transaction 内) | lock_version カラムを追加 |
データベース | SELECT ... FOR UPDATE でDBロック | DBロックはしない (UPDATE のWHERE 句で対応) |
競合発生時 | 他のプロセスが待たされる | ActiveRecord::StaleObjectError 例外が発生 |
パフォーマンス | 並列性が低下する可能性がある | 高い並列性を維持できる |
適した場面 | 競合が多く、処理が短いクリティカルな処理 | 競合が少なく、処理が長い操作 |
ペシミスティックロックとオプティミスティックロックは、どちらが優れているというものではなく、アプリケーションの要件や特性に応じて使い分けるべきトレードオフの関係にあります。データの一貫性とパフォーマンスのバランスを考え、適切なロック戦略を選択することが、堅牢でスケーラブルなRailsアプリケーションを構築する鍵となります。