背景
ユーザーのログイン時に大量データを更新する処理が実装されていた。この更新処理は数時間に1回程度実行されれば最新が保たれるものだが、ログイン時以外にも同時に複数箇所から更新処理へアクセスすることがあり、その場合に、同時並列実行を防ぐために排他制御が実装されていた。しかし、その更新処理のエラーログなどから排他制御がうまく機能してないのでは?という疑惑がうまれ調査改善することとなった。
環境
- Ruby 3.1
- Rails 7.0.2
従来の排他制御
これまではRails.cacheを使って排他制御を行っていた。Rails.cacheにはRedisを使用するように設定されている。
# 更新処理処理クラス
# 更新処理を実行する際に排他制御する
# valueの値がtrueならロック中、falseなら解除中とする
# ロックする
key = 'locking_id:1'
Rails.cache.write(key, true)
# 〜〜更新処理〜〜
# ロックを解除する
Rails.cache.write(key, false)
# 更新処理を呼び出す前にロックが掛かってないか確認する
# ロックがかかっていればtrue, 解除中ならfalse, 取得できないときはnilなのでfalseを返すようにする
key = 'locking_id:1'
is_lock = Rails.cache.read(key) || false
unless is_lock
# 〜〜更新処理クラスを呼び出す〜〜
end
一見、問題なさそうに見えるが、排他状態を確認してからロックするまでにコード上、数十行だが間があり、ほぼ同時に実行される2つのアクセスなどから、実際にこの間を通り抜けて2重以上に更新処理が実行されているのが現状だった。
改善した排他制御
確認と排他状態の更新の両方を同時に行うために、RedisのSETNXメソッドを使うことにした。
SETNXメソッド
- Set not exists の略で、keyが存在してなければその値をセットするメソッド。
- セットすることができたら1、セットできなかったら0を返してくれる
- 現在はSetメソッドにあるnxオプションを使っても同じことができる
- 参考:https://redis.io/commands/setnx/
実装した結果
# 更新処理を実行する際に排他制御する
# Redisと接続する
redis = Redis.new(...)
# ロックする
key = 'locking_id:1'
is_set = redis.set(key, true, nx:true)
# セットできなかったらロックがかかっているので更新処理せずに処理を抜ける
return unless is_set
# 〜〜更新処理〜〜
# ロックを解除する
# 解除する際はデータを削除しておく
redis.del(key)
このように実装することで、確認とロックすることが同時に行われるようになったので、ほぼ同時にアクセスされた際にも排他制御が機能するようになった。
Rails.cacheでは使えない?
最初に従来と同じようにRails.cacheのメソッドで実現できないか考えて、Rails.cache.writeにoptionを渡すことが可能のようだったので、下記のように書いてnxオプションが機能するか試してみたが、機能しなかった。
# 常にセットされておりnxオプションが機能してない
is_lock = Rails.cache.write(key, value, nx: true)
おわり
排他制御をRedisで行う際にはnxオプションを使おう!