概要
たまたま使う機会があって調べたのでRedisのトランザクションをRubyのredis
Gemから扱う方法をまとめます。通常RubyからRedisを使うケースはSidekiqのようなGemやRailsのRedisキャッシュストア, ActionCable経由で使うことがほとんで、直接Redisを意識して使うケースは希かも知れません。
TL;DR
- Redisコマンドの
MULTI
,EXEC
で複数のコマンドをアトミックに実行できます。 - アトミックとはいえロールバックされません。コーディングミスによる誤った操作で部分的にコマンドが実行されることはあり得ます。
- さらにRedisコマンドの
WATCH
を使うと楽観ロックによる排他制御が行えます。
Redisのトランザクションについての詳細についてはRedisのドキュメントを参照されることをおすすめします。
前提ソフトウェア
ソフトウェア | バージョン | 備考 |
---|---|---|
Redis | 5.0.8 | |
ruby | 2.6.3p62 | |
redis | 4.1.3 | https://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
MULTI
はEXEC
せずにDISCARD
すると中止することができます。中止するとそれまでキューイングされたコマンドは実行されません。
MULTI
SET mykey, 'abc'
TTL mykey, 60
DISCARD
これでSET
, TTL
のような一連の書き込み処理をまとめて安全に実行できます。なおコマンド実行はキューイングされますのでGET
のような読み込みコマンドはMULTI
で実行できません。読み込みを伴う排他制御は後述のWATCH
による楽観ロックを使います。
上記のコマンドを実行するスクリプトをRubyのredis
Gemを使って実装してみます。Redis#multi
は呼び出し時のブロックの有無により動作が異なります。
Redis#multi
をブロックなしで呼び出すとMULTI
が単独で実行されます。EXEC
やDISCARD
はRedis#exec
やRedis#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#multi
はredis
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回ほどGET
とSET
でスクリプトからカウントアップすることを考えます。(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
で指定したキーで、その後に実際にキーを読むかは関係ありません。
WATCH
はRedis#watch
で実行できます。Redis#watch
もRedis#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"