【Rails】lock_versionを使って排他制御(楽観的ロック)を行う方法
楽観的ロックとは?
複数のユーザーが同じレコードを編集する際に、後のリクエストに対してエラーを投げるロックのこと。
図解してみるとこのような流れです。

- ユーザー2人が同じタイミングで商品の在庫(4つ)を取得する
- 右のユーザーが2つ購入して商品の在庫は2つに更新される
- 左の人が1つ購入し商品の在庫を1つ減らして3つに更新しようとするが、このタイミングでエラーになる
右の人が購入し終わってから左の人が商品の在庫数を取得すればこんなことにはならないのですが、同じタイミングで在庫数を取得しているためこのように矛盾が生じてしまいます。
それを防ぐのが排他処理で、今回利用するのはその一種の「楽観的ロック」。データの参照時には何もせず、更新時に競合しているかチェックしてくれるものです。
Railsで排他制御(楽観的ロック)を行う方法
1. Railsアプリを作成
$ rails new opti_test$ cd opti_test$ rails generate scaffold foodScaffolding機能を使い、サクッと簡易的なアプリを作ります。
アプリ名は適当にopti_test。テーブル名はfoods、料理を取り扱うモデルです。
参考:rails generate scaffold | Railsドキュメント
2. lock_versionカラムを定義
class CreateFoods < ActiveRecord::Migration[5.2] def change create_table :foods do |t| t.string :name # 料理名 t.integer :price # 値段 t.integer :lock_version, default: 0 t.timestamps end endendマイグレーションファイルにカラムを定義していきます。
今回楽観的ロックを行うのに必要なのがlock_version。これは行のバージョンを管理するためのレコードで、更新時に値が異なっていた(他のユーザーによる更新と競合した)場合にエラーを投げます。
3. マイグレーション実行
$ rails db:migrateマイグレーションを実行。
$ rails cirb(main):001:0> Food.column_names=> ["id", "name", "price", "lock_version", "created_at", "updated_at"]コンソールモードでカラムを確認してあげて、ちゃんと登録されてればOK。
4. レコード作成
irb(main):001:0> Food.create(name: "竜田揚げ", price: 700)サンプルのレコードを1件作成しておきます。(lock_versionにはデフォルト値が入るので指定は不要)
5. ビューを編集
<%= form_with(model: food, local: true) do |form| %> (省略) <%= form.text_field :name%> <%= form.number_field :price%> <%= form.hidden_field :lock_version%> <%= form.submit("値段を修正する") %><%end%>_form.html.erbに上のように追記します。
nameとpriceに対しては通常通りフィールドを設置し、lock_versionはhidden_fieldにして値をコントローラーに受け渡せるようにします。
6. コントローラー実装
def food_params params.require(:food).permit(:name, :price, :lock_version)endfoods_controller.rbのfood_paramsメソッドを上のようにすることで、パラメーターとして受け渡しができるようになります。
def update begin (省略) rescue ActiveRecord::StaleObjectError render plain: "競合しています。" endendupdateメソッド内に例外処理を追加。
ActiveRecord::StaleObjectErrorはまさしく競合が起きた時のエラーで、rescue文でその際にエラー文を表示します。
動作確認

実際に試してみるためにまずlocalhost:3000/foodsにアクセスし、「Edit」をクリックします。

すると編集画面が表示されるので、これを2つのウィンドウで開きます。
そして片方を適当に編集してあげて、もう片方でも編集してあげようとすると⋯

競合の旨を伝えるエラーメッセージが表示されました 🎉