やわらかテック

興味のあること。業務を通して得られた発見。個人的に試してみたことをアウトプットしています🍵

トランザクションがあれば安心ではないのでRailsでもロックをかけたい

Railsでトランザクションを扱いたい時はActiveRecordに実装されたtransactionメソッドを呼び出します。

ActiveRecord::Base::transaction do
  :
end

今までRailsでトランザクションのブロックを宣言した時は、他のトランザクションからデータの取得、更新は出来ないと思っていましたが、先日、勘違いであることに気づきました。更新はトランザクションの待機によってすぐには実行されませんが、データの取得は可能です。取得もさせたくないのであれば排他制御を行い明示的にロックの宣言をする必要があります。

トランザクションのブロックを宣言しただけでは、他のトランザクションからデータの取得は可能なため、データの整合性が合わなくなる可能性があります。

発行されるSQL

トランザクションのみ

ただトランザクションが実行されるのみで、ロックはされないため他のトランザクションからデータの取得は可能だが、更新はできない。

ActiveRecord::Base::transaction do
  product = Product.find(product_id)
end

# -- SQL --
# BEGIN;
# SELECT * FROM products WHERE product_id = product_id;
# COMMIT;

トランザクションとロック

.lockを使うことでレコードに対してロックがされます。FOR UPDATESELECT句に付与されていることが分かります。

ActiveRecord::Base::transaction do
  product = Product.lock.find(1)
end

# -- SQL --
# BEGIN;
# SELECT * FROM products WHERE product_id = product_id FOR UPDATE; 
# COMMIT;

ActiveRecord::Locking::Pessimistic

もしくはwith_lockを使うことも可能。ブロック内は1つのトランザクションとして実行されるので、複数のデータにロックをかけたい時には便利そう。

product = Product.find(1)
product.with_lock do
  user = User.lock.find(1)
end

# -- SQL --
# BEGIN;
# SELECT * FROM products WHERE product_id = product_id FOR UPDATE;
# SELECT * FROM users WHERE user_id = user_id FOR UPDATE;
# COMMIT;

ロックをしている場合の挙動

ターミナルAから特定のレコードにロックをかけた状態で、ターミナルBから同じレコードに対して更新処理を行ってみました。結果は以下の通りで、ROLLBACKされていることが分かります。

更新処理ではなく、読み取りであればエラーにならずに実行することができました。

購入処理に再チャレンジ

先ほど、図で示した購入処理をロックを使用して実行してみます。トランザクションAをターミナルA。トランザクションBをターミナルBとして実行しました。ターミナルBでは一度、エラーとなってしまいましたが、再度実行することで最終的には整合性のとれた在庫数になってくれました。

ロック時のタイムアウト値はpostgresql.confにて設定が可能なのですが、0秒になっていたので3秒に変更して再度、実行してみました。先ほどとは違い実行時にタイムアウトエラーが発生していないことが分かります。