コピペコードで快適生活

明日使えるソースを自分のために

ActiveRecord::Base.transactionで並列で同じレコードを扱ったときの動きメモ

排他制御まわりがちゃんとわかっていなかったので整理。

Article.find(1).value # => 1

def inc
  row = Article.find(1)
  row.value = row.value + 1

  row.save
end

# 同時並行処理する
Thread.new { inc }
Thread.new { inc }

Article.find(1).value # => 2になるか,3になるかは神のみぞ知る

だけど、

Article.find(1).value # => 1

def inc
  ActiveRecord::Base.transaction do
    row = Article.find(1)
    row.value = row.value + 1

    row.save
  end
end

# 同時並行処理する
Thread.new { inc }
Thread.new { inc }

Article.find(1).value # => これなら3になるよね!たぶん

こうするとうまくいくはず。

検証

Article.find(1).value # => 1

def inc(wait1 = 0, wait2 = 0)
  sleep wait1 if wait1 > 0
  ActiveRecord::Base.transaction do
    row = Article.find(1)
    row.value = row.value + 1

    sleep wait2 if wait2 > 0

    row.save!
  end
end

# 同時並行処理する
Thread.new { inc(0, 10) } # ... t1
Thread.new { inc(5, 0) }  # ... t2

# t1.transaction -> t1.find -> t2.transaction(待ちになる) ->
# t1.save -> t1.commit -> t2.find -> t2.save -> t2.commit
# になる想定。

Article.find(1).value # => 3にならず、2になってしまった...

だめじゃん。

検証2

悲観的ロックをかける
https://qiita.com/upinetree/items/b3329501561268f7678a

Article.find(1).value # => 1

def inc(wait1 = 0, wait2 = 0)
  sleep wait1 if wait1 > 0
  ActiveRecord::Base.transaction do
    # SELECT * FROM "articles" FOR UPDATE
    row = Article.all.lock.find(1)
    row.value = row.value + 1

    sleep wait2 if wait2 > 0

    row.save!
  end
end

# 同時並行処理する
Thread.new { inc(0, 10) } # ... t1
Thread.new { inc(5, 0) }  # ... t2

# t1.transaction -> t1.find -> t2.transaction(待ちになる) ->
# t1.save -> t1.commit -> t2.find -> t2.save -> t2.commit
# になる想定。

Article.find(1).value # => 3になった!

これだとうまくいった。

最後に

トランザクションについて

トランザクションは、完全に排他してくれるわけではなく、あくまで、複数テーブルのUPDATEで「すべて更新される」か「すべて更新されないか」を保証するだけのもの。
トランザクション内でロックがかかった場合は、COMMITかROLLBACKされるまでロックは維持される。

DBレベルのロックについて(PostgreSQLの場合)

【テーブルレベルのロック】
ROW EXCLUSIVE
UPDATE、DELETE、およびINSERTコマンドは、(参照される他の全てのテーブルに対するACCESS SHAREロックに加えて)対象となるテーブル上にこのモードのロックを獲得します。 通常、このロックモードは、テーブルのデータを変更する問い合わせにより獲得されます。
→ つまり何も指定せずに、INSERT、UPDATE、DELETEすると、ROW EXCLUSIVEが自動でかかる。

【行レベルのロック】
FOR UPDATE
FOR UPDATEによりSELECT文により取り出された行が更新用であるかのようにロックされます。
→ 基本的にSELECTではロックはかからないけど、かけたければFOR UPDATE使う。

https://www.postgresql.jp/document/9.4/html/explicit-locking.html より

ロックについて

ロックには種類がある。

【楽観的ロック】
アプリケーションレベルのロック。
更新する直前に、対象レコードが取得時と変わっていないかを確認する。

【悲観的ロック】
DBレベルのロック。
レコード取得した時点で、対象レコードにロックをかける。
SELECT … FOR UPDATE を利用したもの。
→ 今回話しているのはこっちのロックの方。

補足

RedisObject使っているとクラスメソッドのlockが上書きされちゃうようなので、Article.lock... ではなく Article.all.lock... としたほうが無難っぽい。

https://github.com/nateware/redis-objects/blob/master/lib/redis/lock.rb#L36