「ソニックガーデン 若手プログラマ - Qiita Advent Calendar 2024」13日目の記事です。
はじめに
「複数の処理がほぼ同じタイミングで走ってしまった」というケースに備えておくことはとても重要です。同タイミングで走った処理たちであっても、一つずつ直列的に処理したい(排他制御にしたい)ケースがあるからです。
たとえばECサイトを開発していた場合、排他制御を適切に実装することで、以下のような事態を防ぐことができます。
- 在庫が残り1つしかない商品に対して、複数の注文を処理してしまう
- 初回注文時のみポイント付与したいのに、二重に付与してしまう
排他制御を実現する手段の1つとして、データベースに「行ロック」という仕組みがあります。これは、任意のレコードに対して、自処理だけが更新できるようにロックを取得することで、他処理からはそのレコードを更新できなくするものです。また、他処理側の書き方によっては、レコードの取得すらも待機させることができます。
この記事では、Rails で行ロックを実装する方法について説明します。データベースは PostgreSQL の前提ですが、他のデータベースでも同様の機能を持つものが多いと思いますので、参考になると思います。
準備
今回は、先ほどの「初回注文時のみポイント付与したい」という要件を例にとります。
まずは準備として Rails アプリケーションを用意していきます。
# 簡素化のため、行ロックに本質的に関係しないテーブルやカラムについては用意していません。
mkdir sample_app && cd $_
rbenv local 3.3.6
rails _8.0.0_ new . --database=postgresql
rails db:create
rails g model user
rails g model order user:references
rails g model point_transaction user:references point:integer
rails db:migrate
最後に、モデル間の関連づけも記述しておきます(コード例は省略します)。
これで準備は完了です。ちなみに筆者は以下の環境を用意しました。
- Ruby 3.3.6
- Rails 8.0.0
- PostgreSQL 16.6
処理を用意する
注文処理 User#order!
を用意しました。行ロックの有無でそれぞれ実験したいので、行ロックについてはまだ何も実装していません。
実験では、2つのコンソールを用意し、同タイミングにそれぞれでこのメソッドを実行します。
ただ、全くの同タイミングを用意するのは難しいため、実験用としてトランザクション内に sleep
を使用します。これによって、片方の処理のトランザクションが終わる前にもう片方の処理も走ることになるので、同タイミングを擬似的に用意できます。
class User < ApplicationRecord
has_many :orders, dependent: :destroy
has_many :point_transactions, dependent: :destroy
FIRST_ORDER_POINT = 100
def order!
transaction do
orders.create!
if orders.count == 1
point_transactions.create!(point: FIRST_ORDER_POINT)
end
sleep 10
end
end
end
実験
実験してみます。2つのコンソール( rails c
)を用意し、それぞれであまり時間をあけずにすみやかに(といっても sleep 10
なので少し猶予はありますが)、このメソッドを実行します。
※ アプリ名が SampleApp
ではなく Rails800
となっていますが、気にしないでください。
User.create!
rails800(dev)> user = User.first
User Load (1.8ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 1 /*application='Rails800'*/
=>
#<User:0x0000000122c52d08
...
rails800(dev)> user = User.first
User Load (0.9ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 1 /*application='Rails800'*/
=>
#<User:0x000000011dc70b00
...
rails800(dev)> user.order!
TRANSACTION (0.6ms) BEGIN /*application='Rails800'*/
Order Create (9.3ms) INSERT INTO "orders" ("user_id", "created_at", "updated_at") VALUES (1, '2024-12-06 04:41:50.905745', '2024-12-06 04:41:50.905745') RETURNING "id" /*application='Rails800'*/
Order Count (0.8ms) SELECT COUNT(*) FROM "orders" WHERE "orders"."user_id" = 1 /*application='Rails800'*/
PointTransaction Create (4.0ms) INSERT INTO "point_transactions" ("user_id", "point", "created_at", "updated_at") VALUES (1, 100, '2024-12-06 04:41:50.926030', '2024-12-06 04:41:50.926030') RETURNING "id" /*application='Rails800'*/
TRANSACTION (2.2ms) COMMIT /*application='Rails800'*/
=> 10
rails800(dev)> user.order!
TRANSACTION (1.4ms) BEGIN /*application='Rails800'*/
Order Create (1.5ms) INSERT INTO "orders" ("user_id", "created_at", "updated_at") VALUES (1, '2024-12-06 04:41:51.440637', '2024-12-06 04:41:51.440637') RETURNING "id" /*application='Rails800'*/
Order Count (1.1ms) SELECT COUNT(*) FROM "orders" WHERE "orders"."user_id" = 1 /*application='Rails800'*/
PointTransaction Create (0.6ms) INSERT INTO "point_transactions" ("user_id", "point", "created_at", "updated_at") VALUES (1, 100, '2024-12-06 04:41:51.452265', '2024-12-06 04:41:51.452265') RETURNING "id" /*application='Rails800'*/
TRANSACTION (2.2ms) COMMIT /*application='Rails800'*/
=> 10
出力内容でもわかってしまいますが、改めて結果を確認してみます。まず注文データのほうですが、こちらは同じユーザーに2つの注文データが作成されていました。これは問題ありません。
しかし、ポイントデータも見てみると、こちらも2つ作成されてしまいました。これでは「初回注文時のみポイント付与したい」という要件を満たせていません。2回目のポイント付与の分だけ損失が出てしまっている、とも言えます。
rails800(dev)> user.orders.count
Order Count (16.8ms) SELECT COUNT(*) FROM "orders" WHERE "orders"."user_id" = 1 /*application='Rails800'*/
=> 2
rails800(dev)> user.point_transactions.count
PointTransaction Count (6.1ms) SELECT COUNT(*) FROM "point_transactions" WHERE "point_transactions"."user_id" = 1 /*application='Rails800'*/
=> 2
行ロックを使用していないため、同タイミングでの複数処理を直列的に処理できず、先に走ったほうのトランザクションが終わる前に、後に走ったほうのトランザクションが始まり、注文データの作成を済ませ、orders.count
を取得するコードまで進んでしまいました。
その結果、orders.count
がどちらも 1
となってしまい、どちらも初回注文扱いとなってポイントデータを作成してしまった、ということです。
SELECT FOR UPDATE
を理解する
早速コードを修正していきたいところですが、まずは行ロックについて理解する必要があります。
行ロックは Rails ではなくデータベースの機能です。トランザクションの中で、FOR UPDATE
をつけた SELECT
文を発行することで、行ロックが取得できます。
行ロックが取得されている間に、他処理がその行の更新・削除を試みた場合、行ロックの解放を待機する状態となります。また、通常の SELECT
であれば、他処理であっても即座にその行を取得することができますが、FOR UPDATE
をつけた SELECT
では取得できず、同じく待機状態となります。
行ロックを取得していた処理がトランザクションを抜けると、行ロックは自動的に解放され、待機状態となっていた処理は再開されます。
この待機状態の仕組みで、直列的な制御が実現されます。
lock!
メソッドを理解する
Rails で行ロックを実装するために、今回は lock!
メソッドを使用します。改修後のコードはこのようになります。
class User < ApplicationRecord
has_many :orders, dependent: :destroy
has_many :point_transactions, dependent: :destroy
FIRST_ORDER_POINT = 100
def order!
transaction do
orders.create!
self.lock! # NOTE: この行だけを追加しました
if orders.count == 1
point_transactions.create!(point: FIRST_ORDER_POINT)
end
sleep 10
end
end
end
lock!
メソッドは、内部的には reload
メソッドを用いて、当該のレコードを取得し直そうとします。そのとき、reload
メソッドには lock: true
というオプションが渡されます。reload
メソッドは、lock: true
というオプションがある場合、行ロックを取得しつつリロードしようとします。
つまり、lock!
メソッドは、「このレコードで、行ロックを取得しつつ、リロードする」という動きとなります。また、先述の通り、FOR UPDATE
をつけた SELECT
文はトランザクションの中でのみ有効に機能するため、lock!
メソッドは transaction do ... end
の中で使用する必要があります。
行ロックを実装する
ここで lock!
と行ロックについてまとめておきます。
-
lock!
は、行ロックを取得しつつ、リロードする -
lock!
は、transaction
ブロックの中で使用する(行ロックのSQLはトランザクション内でのみ機能する) - 行ロックを取得している間に、他処理がその行の更新・削除を試みた場合、待機状態となる
- 行ロックを取得している間に、他処理がその行の行ロック取得を試みた場合も、待機状態となる
- 行ロックは、トランザクションを抜けると、自動的に解放される
また、先ほどの実験の結果からすると、改修後は、orders.count
を実行した時に、どちらか片方は 1
に、もう片方は 2
にならなければなりません。つまり、コンソール2の orders.count
が実行される前に、コンソール1で注文データを作成し、そのトランザクションが終了している必要があります。
orders.count
が実行されるより前にロックを取得するようなコードにすることで、コンソール1のほうはそのまま処理を進めていき、コンソール2のほうはそこで処理を待機状態にすることができそうです。
これらを踏まえて、再度改修後のコードを見てみます。
class User < ApplicationRecord
has_many :orders, dependent: :destroy
has_many :point_transactions, dependent: :destroy
FIRST_ORDER_POINT = 100
def order!
transaction do
orders.create!
self.lock! # NOTE: この行だけを追加しました
if orders.count == 1
point_transactions.create!(point: FIRST_ORDER_POINT)
end
sleep 10
end
end
end
この self.lock!
が唯一の改修コードです。
先ほどと同じ実験を再度行った場合、処理の流れは以下のようになります。
- コンソール1が
self
つまり対象ユーザーのレコードの行ロックを取得し、self
をリロードする - コンソール1のトランザクションが終わる前に、コンソール2のトランザクションが始まる
- コンソール2が
self
の行ロックを取得しようとするが、コンソール1のトランザクションが終わってないので、待機状態となる - コンソール1のトランザクションが終わり、注文データの作成がコミットされ、行ロックが解放される
- コンソール2が 待機状態を終了し、
self
の行ロックを取得し、self
をリロードする - 注文データの作成がコミットされているため、コンソール2の
orders.count
が2
を返す。そのためポイントデータは二重に作成されることなく、処理終了となる
再度実験
では改修後のコードで再度実験してみましょう。
rails db:migrate:reset
User.create!
rails800(dev)> user = User.first
User Load (1.8ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 1 /*application='Rails800'*/
=>
#<User:0x000000012c7b99e0
...
rails800(dev)> user = User.first
User Load (1.9ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 1 /*application='Rails800'*/
=>
#<User:0x0000000125ef0530
...
rails800(dev)> user.order!
TRANSACTION (0.4ms) BEGIN /*application='Rails800'*/
Order Create (5.9ms) INSERT INTO "orders" ("user_id", "created_at", "updated_at") VALUES (1, '2024-12-06 05:01:24.480624', '2024-12-06 05:01:24.480624') RETURNING "id" /*application='Rails800'*/
User Load (0.5ms) SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1 FOR UPDATE /*application='Rails800'*/
Order Count (0.5ms) SELECT COUNT(*) FROM "orders" WHERE "orders"."user_id" = 1 /*application='Rails800'*/
PointTransaction Create (2.2ms) INSERT INTO "point_transactions" ("user_id", "point", "created_at", "updated_at") VALUES (1, 100, '2024-12-06 05:01:24.498517', '2024-12-06 05:01:24.498517') RETURNING "id" /*application='Rails800'*/
TRANSACTION (3.6ms) COMMIT /*application='Rails800'*/
=> 10
rails800(dev)> user.order!
TRANSACTION (0.2ms) BEGIN /*application='Rails800'*/
Order Create (9526.2ms) INSERT INTO "orders" ("user_id", "created_at", "updated_at") VALUES (1, '2024-12-06 05:01:24.991449', '2024-12-06 05:01:24.991449') RETURNING "id" /*application='Rails800'*/
User Load (1.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1 FOR UPDATE /*application='Rails800'*/
Order Count (1.1ms) SELECT COUNT(*) FROM "orders" WHERE "orders"."user_id" = 1 /*application='Rails800'*/
TRANSACTION (1.3ms) COMMIT /*application='Rails800'*/
=> 10
結果を確認すると、ポイントデータは1つしか作成されていませんでした。行ロックによって無事に「初回注文時のみポイント付与したい」という要件を満たすようになりました。
rails800(dev)> user.orders.count
Order Count (0.4ms) SELECT COUNT(*) FROM "orders" WHERE "orders"."user_id" = 1 /*application='Rails800'*/
=> 2
rails800(dev)> user.point_transactions.count
PointTransaction Count (1.1ms) SELECT COUNT(*) FROM "point_transactions" WHERE "point_transactions"."user_id" = 1 /*application='Rails800'*/
=> 1
おわりに
以上、Rails で行ロックを実装する方法について説明しました。
排他制御は、在庫数が関係する処理、金銭やポイントが関係する処理で特に重要になってきます。アプリケーションのレベルでは防ぎきれない部分となりますので、行ロックの仕組みを用いて適切に処理しましょう。
明日は「ソニックガーデン 若手プログラマ - Qiita Advent Calendar 2024」14日目、@nabesan1114 の記事です。お楽しみに!
参考
これらのページにより詳しく記載されていますが、Rails では行ロックを取得するためのメソッドがいくつか用意されています。使い方や付随する機能がそれぞれ異なりますが、行ロックを取得するという部分は変わりませんので、自分のコードに取り入れやすいものを採用するとよいと思います。
-
ActiveRecord::QueryMethods
のlock
-
ActiveRecord::Locking::Pessimistic
のwith_lock
-
ActiveRecord::Locking::Pessimistic
のlock!
本記事では、3つ目の lock!
メソッドを用いて行ロックを実装しました。