Redisのトランザクションと楽観ロックについて調べたのでRubyから使ってみた
タグ: rubyredis / 初版公開: 2020-04-12

概要

たまたま使う機会があって調べたのでRedisのトランザクションをRubyのredisGemから扱う方法をまとめます。通常RubyからRedisを使うケースはSidekiqのようなGemやRailsのRedisキャッシュストア, ActionCable経由で使うことがほとんで、直接Redisを意識して使うケースは希かも知れません。

TL;DR

  • RedisコマンドのMULTI, EXECで複数のコマンドをアトミックに実行できます。
  • アトミックとはいえロールバックされません。コーディングミスによる誤った操作で部分的にコマンドが実行されることはあり得ます。
  • さらにRedisコマンドのWATCHを使うと楽観ロックによる排他制御が行えます。

Redisのトランザクションについての詳細についてはRedisのドキュメントを参照されることをおすすめします。

前提ソフトウェア

ソフトウェアバージョン備考
Redis5.0.8 
ruby2.6.3p62 
redis4.1.3https://rubygems.org/gems/redis/versions/4.1.3

複数のコマンドをアトミックに実行する

例としてキーに値を設定(SET)してTTLを設定(EXPIRE)する操作を考えます。このようなケースではSETした直後にスクリプトが異常終了してEXPIREが実行できない場合SETだけ実行されるとTTLが設定されないキーができてしまい不整合が生じます。

複数のコマンドをアトミックに実行したい場合はRedisのMULTI, EXECコマンドを使います。RedisにおいてMULTI以降のコマンドはキューイングされEXECした時にアトミックに実行されます。Redisは直列で処理を行うので他のクライアントの操作に対して割り込まれることもありません。

MULTI
SET mykey, 'abc'
TTL mykey, 60
EXEC

MULTIEXECせずにDISCARDすると中止することができます。中止するとそれまでキューイングされたコマンドは実行されません。

MULTI
SET mykey, 'abc'
TTL mykey, 60
DISCARD

これでSET, TTLのような一連の書き込み処理をまとめて安全に実行できます。なおコマンド実行はキューイングされますのでGETのような読み込みコマンドはMULTIで実行できません。読み込みを伴う排他制御は後述のWATCHによる楽観ロックを使います。

上記のコマンドを実行するスクリプトをRubyのredisGemを使って実装してみます。Redis#multiは呼び出し時のブロックの有無により動作が異なります。

Redis#multiをブロックなしで呼び出すとMULTIが単独で実行されます。EXECDISCARDRedis#execRedis#discardを使って実行してやる必要があります。

require 'redis'
redis = Redis.new

p redis.multi #=> "OK"
p redis.set('mykey', 'abc') #=> "QUEUED"
p redis.expire('mykey', 60) #=> "QUEUED"
p redis.exec #=> ["OK", 1]

Redis#multiをブロック付きで呼び出すとブロックの内部はEXEC後に実行されブロックを抜ける際に自動的にEXECされます。Redis#multiredis gemのパイプライン機能で一括に実行されます。Redis#multiブロック中の操作はRedis::Futureが返りRedisへの操作はブロックを抜けるまで保留されます。もしブロック内の処理でRubyの例外が発生した場合はそもそもMULTIさえ実行されずRedisの操作は一切行われません。

require 'redis'

redis = Redis.new

res = redis.multi do
  p redis.set('mykey', 'abc') #=> <Redis::Future [:set, "mykey", "abc"]>
  p redis.expire('mykey', 60) #=> <Redis::Future [:expire, "mykey", 60]>
end
p res #=> ["OK", true]

注意点

RedisのEXECはアトミックですが万一EXECに失敗してもロールバックはされません。このためプログラムミスで不適切なコマンドを実行しようとして失敗した場合、MULTI以降にキューイングしたコマンドが部分的に実行され得ることに注意が必要です。

以下はString型の値を持つキーに対してList型にしか実行できないLPOPを実行する例です。文法的には正しいですがEXECは失敗して例外Redis::CommandErrorが発生します。LPOPの前後に実行しているSETは実行済みの状態のままになります。

require 'redis'

redis = Redis.new

begin
  redis.multi do
    redis.set('a', 'partial execution')
    redis.lpop('a')
    redis.set('b', 'partial execution')
  end
rescue
  p $! #=> #<Redis::CommandError: WRONGTYPE Operation against a key holding the wrong kind of value>
end

p redis.get('a') #=> "partial execution"
p redis.get('b') #=> "partial execution"

もう1つのケースとしてRedisサーバがEXECの最中にクラッシュしたらどうなるのでしょうか。これは私も経験したことがないのですが、もしRedisサーバがクラッシュした場合でappend-only fileを使っている場合(appendonly yes)はエラーを検出して回復できるそうです。

詳細はRedisのドキュメントを参照して下さい。なぜRedisにはロールバックがないのかを含めて解説されています。

楽観ロックによる排他制御を行う

Redisのドキュメントに倣って、特定のキーの値を2,000回ほどGETSETでスクリプトからカウントアップすることを考えます。(RedisにはINCRコマンドがありますので本当にキーの値をカウントアップする時はそちらを使った方が良いです)

楽観ロックを使わず素朴に書いた以下のスクリプトを実行してみます。

require 'redis'

Redis.new.set('counter', 0)

threads = 2.times.map do
  Thread.start do
    redis = Redis.new

    1000.times do
      val = redis.get('counter').to_i + 1
      redis.set('counter', val)
    end
  end
end

threads.each(&:join)

p Redis.new.get('counter') #=> "1306"

当然ながら排他制御されていませんので2,000より少ない数字が出力されます。値を読んだ後に別スレッドで値が書き込まれ、結果的に同じ値を書いてしまうことがあるからです。

このような場合はRedisでMULTI, EXEC に加えて WATCH コマンドを使うといわゆる楽観ロックによる排他制御が行えます。WATCHを使うとWATCHしたキーがEXECまでに間に他のクライアントによって変更されるとEXECは失敗します。楽観ロックの対象はWATCHで指定したキーで、その後に実際にキーを読むかは関係ありません。

WATCHRedis#watchで実行できます。Redis#watchRedis#multiと同じようにブロック有無によって挙動が異なります。Redis#watchをブロック付きで呼んだ場合は、Redis#watchはブロックの評価結果を戻り値として返します。またRedis::ConnectionErrorを除くStandardErrorのサブクラスの例外がブロック内で発生すると自動的にUNWATCHを実行してWATCHを解除してくれます。

元のRubyスクリプトを改良しWATCH, MULTI, EXECする形に書き直してみます。以下のスクリプトではRedis#multiで実行するEXECが失敗した場合にRedis#watchの戻り値としてnilが得られます。これをチェックしてredoで無限リトライするようにします。実際にはリトライ回数を制限するのが良いでしょう。

require 'redis'

Redis.new.set('counter', 0)

threads = 2.times.map do
  Thread.start do
    redis = Redis.new

    1000.times do
      res = redis.watch('counter') do
        val = redis.get('counter').to_i + 1

        redis.multi do
          redis.set('counter', val)
        end
      end

      redo unless res
    end
  end
end

threads.each(&:join)

p Redis.new.get('counter') #=> "2000"

期待どおり2,000までカウントアップすることができました。

Tips: RubyクライアントからRedisに実行されているコマンドを確認する

RubyのクライアントからRedisに対してどのようなコマンドが実行されているのか確認したくなることがあります。特にRedisインスタンスのブロック付きメソッドを使用しているとパイプライン機能で実行されるコマンドをRubyスクリプト中から確認することは困難です。このようなときはRedisのMONITORコマンドを使用するとRedisサーバで実行したコマンドを確認してデバッグすることができます。

以下はredis-cliからMONITORコマンドを実行して適当な操作を行った際に得られる出力の例です。

# redis-cli
127.0.0.1:6379> MONITOR
OK
1586676376.691487 [0 172.28.0.1:38078] "multi"
1586676376.692826 [0 172.28.0.1:38078] "set" "foo" "bar"
1586676376.692937 [0 172.28.0.1:38078] "incr" "baz"
1586676376.692956 [0 172.28.0.1:38078] "exec"