こんにちは。花粉症で鼻水が止まらない日々が続いている hilotter です。
同一レコードに対し複数ユーザ(もしくはバッチ処理等)から同時に更新される可能性がある場合、排他制御を行う必要があります。
今回はシンプルなポイント付与機能を例に、悲観的ロックを用いた排他制御のテストを書いてみたいと思います。
シンプルなポイント付与機能仕様
- ユーザは他のユーザにポイントを送ることができる
今回のサンプルは以下の環境で確認しました。
- Rails 5.1.4
- MySQL 5.7.20
- MySQLのトランザクション分離レベル REPEATABLE-READ(デフォルト設定)
add_pointメソッドの実装
- Userモデルにポイントを付与できる
add_point
メソッドを追加します
transaction内でlock!
を用いて悲観的ロックをかけています。
# app/models/user.rb class User < ApplicationRecord def add_point(point) ActiveRecord::Base.transaction do lock! self.point += point save! end end end
RSpec
続いてテストを書いていきます。
RSpecのデフォルト設定では、テスト用のデータ登録を行った際に素早くデータのクリーンアップができるようにトランザクション内でテストが実行され、テストが終わった際にトランザクションがロールバックされるようになっています。
今回はトランザクションのBEGIN ~ COMMITが正しく行われることを確認したいため、この設定を無効化します。 1
# spec/rails_helper.rb config.use_transactional_fixtures = false
続いて本題のUserのspecを書きます。
Threadを用いてポイント付与のメソッド(add_point
)を同時に呼び出すようにします。
# spec/models/user_spec.rb require 'rails_helper' RSpec.describe User, type: :model do describe "#add_point" do # 100ポイントを持っているAさん let!(:user) { create(:user, point: 100) } context "2人のユーザから同時更新された場合" do before do threads = [] threads << Thread.new do ActiveRecord::Base.connection_pool.with_connection do # Bさんから10ポイントを付与 u = User.find(user.id) u.add_point(10) end end threads << Thread.new do ActiveRecord::Base.connection_pool.with_connection do # 同じタイミングでCさんから50ポイントを付与 u = User.find(user.id) u.add_point(50) end end threads.each(&:join) end it { expect(user.reload.point).to eq(100 + 10 + 50) } end end
specの実行とtest logの確認
bundle exec rspec
を実行するとテストが通ります 🎉
test実行時のクエリログも確認してみましょう。
# log/test.log (0.6ms) BEGIN (1.6ms) BEGIN User Load (2.0ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 178 LIMIT 1 FOR UPDATE User Exists (0.9ms) SELECT 1 AS one FROM `users` WHERE `users`.`email` = 'user1@example.com' AND (`users`.`id` != 178) LIMIT 1 SQL (0.9ms) UPDATE `users` SET `point` = 110 WHERE `users`.`id` = 178 User Load (13.3ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 178 LIMIT 1 FOR UPDATE (4.5ms) COMMIT User Exists (4.1ms) SELECT 1 AS one FROM `users` WHERE `users`.`email` = 'user1@example.com' AND (`users`.`id` != 178) LIMIT 1 SQL (0.9ms) UPDATE `users` SET `point` = 160 WHERE `users`.`id` = 178 (6.2ms) COMMIT
悲観的ロックにより、1つ目のupdateの完了を待ってから、2つ目のupdate文が実行されています。
悲観的ロックをかけなかった場合
悲観的ロックをかけなかった場合の実行結果も確認しておきましょう。
# app/models/user.rb class User < ApplicationRecord def add_point(point) ActiveRecord::Base.transaction do # lock! self.point += point save! end end end
この状態でrspecを実行すると、150が結果として返ってきてしまいました。
(実行の度に結果が変わるため150もしくは110が返ってきます)
F Failures: 1) User#add_point 2人のユーザから同時更新された場合 should eq 160 Failure/Error: it { expect(user.reload.point).to eq(100 + 10 + 50) } expected: 160 got: 150
クエリログを確認してみると、ロックをかけていないため(100 + 10)のupdate文と(100 + 50)のupdate文が実行されてしまい、2つ目のupdate文によって1つ目のupdate文の結果が失われてしまっています。(ロストアップデート)
# log/test.log (0.5ms) BEGIN User Load (0.8ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 179 LIMIT 1 (0.8ms) BEGIN User Exists (1.1ms) SELECT 1 AS one FROM `users` WHERE `users`.`email` = 'user1@example.com' AND (`users`.`id` != 179) LIMIT 1 User Exists (1.3ms) SELECT 1 AS one FROM `users` WHERE `users`.`email` = 'user1@example.com' AND (`users`.`id` != 179) LIMIT 1 SQL (4.9ms) UPDATE `users` SET `point` = 150 WHERE `users`.`id` = 179 SQL (9.5ms) UPDATE `users` SET `point` = 110 WHERE `users`.`id` = 179 (5.7ms) COMMIT (2.0ms) COMMIT
まとめ
同時実行処理の手動確認は難しいですが、Threadを用いることで同時実行処理をテストできるようになりました。
もっとシンプルに書く方法や、こうするとより良い等ありましたらアドバイスいただけますと嬉しいです。
参考
-
データのクリーンアップに関してはdatabase_rewinderを利用しています。↩