hitomedia Tech Blog

株式会社ヒトメディアのテクノロジーに関するブログです

RSpecで悲観的ロックのテストを書く

こんにちは。花粉症で鼻水が止まらない日々が続いている 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を用いることで同時実行処理をテストできるようになりました。

もっとシンプルに書く方法や、こうするとより良い等ありましたらアドバイスいただけますと嬉しいです。

参考


  1. データのクリーンアップに関してはdatabase_rewinderを利用しています。