Redis のトランザクションは EVAL で解決する

みなさん、 Redis 使っていますか? Redis はキャッシュから永続データまで様々な用途に使うことのできるインメモリの KVS データベースです。(https://redis.io)

今回は、開発において発生したトラブルを元に、トランザクションの実現にも活用できる EVAL コマンドの紹介をしたいと思います。

EXPIRE を活用したかったが…

この Redis には様々な機能が搭載されており、プログラムでデータの管理をしなくても Redis に任せることができます。その一例として、 EXPIRE という機能があります。

これは Redis データベースにある既存のキーに有効期限を与える機能です。例えば、ログインセッションの情報を 60分間だけ Redis に保存するなら、以下のような具合です。

redis> SET Session::<UserId> "0123456789abcdefgh"
"OK"
redis> EXPIRE Session::<UserId> 3600
(integer) 1
redis> TTL Session::<UserId>
(integer) 3599

このようにすれば、1時間後にはキーが勝手に削除されるためプログラムで有効期限を考える必要はありません。

しかし、私のプロダクト Activity-Relay にてワーカーがデータを送信した中で最近1分にエラーの発生したサーバーの記録に EXPIRE を使っていたところ、 EXPIRE のつかないキーが大量発生してしまうという事態が起こりました。

当時、 Activity-Relay はワーカーの動作が不安定で動作が止まってしまうことが多く、強制再起動を CronJob で数十分に1度しているような状況でした。(この問題は現在解消済み)当時使っていたコードは以下のようなものです。

err := sendActivity(inboxURL, Actor.ID, []byte(body), hostPrivatekey)
	if err != nil {
		domain, _ := url.Parse(inboxURL)
		mod, _ := redisClient.HSetNX("relay:statistics:"+domain.Host, "last_error", err.Error()).Result()
		if mod {
			redisClient.Expire("relay:statistics:"+domain.Host, time.Duration(time.Minute))
		}
	}

HSETNX でキーを作成し、新規作成された場合は EXPIRE を実行する。既存のキーがあれば、そのままというものです。

EXPIRE がないキーが大量発生してしまった理由は、HSETNX の後かつ EXPIRE の前にワーカーが停止した → EXPIRE がないキーが存在するために次回以降の HSETNX で新規作成が起きない → EXPIRE も二度とそのキーに実行されないということだったのだと思われます。

Redis にある2つのトランザクションの方法

では、 HSETNX の後には必ず EXPIRE をする(または、HSETNXEXPIRE も両方やらない)ようにすればいいと考えるのが自然なことです。一般にデータベースではトランザクションとよばれるものですね。

Redis には2種類のトランザクションを行うためのコマンドが用意されています。

MULTI, EXEC, DISCARD コマンド (Transactions – Redis)

MULTI は、まさにトランザクションを実現するために用意されたコマンドです。MULTI を送信すると、そのセッションにおいて以降に渡されるコマンドがすべて Redis サーバーにキューされ、 EXEC コマンドを受け取るときに一気に実行されます。DISCARD コマンドを送れば、キューされたコマンドがすべて削除されます。

この方法では、EXEC を送信されると一連のコマンドが他のコマンドに割り込まれることなく実行されるため、他のクライアントによる影響を受けたりすることはありません。なお、途中のコマンドに誤りがあってエラーが生じても一連のコマンドはすべて実行されてロールバックされることはないことについて注意する必要があります。RDB によくあるトランザクションとはその点が異なります。この方法には短所がいくつかあります。

  • 各コマンドをキューに投入するためにそれぞれリクエストを送信しなくてはならない点
  • クライアントが強制再起動などで EXEC を行えない事態になった場合に、それまでにキューに入れたコマンドは無駄になる点

EVAL コマンド (EVAL – Redis)

EVAL は、 Redis サーバー上に Lua のスクリプトを送信し、操作を行わせるコマンドです。Lua スクリプトでは、コマンドを実行して値を読み書きしたり、条件分岐・計算をしてその結果を返すことができます。そして、一連のスクリプトをクライアントから送信すれば、一連のコマンドなどがアトミックに実行されるためトランザクションの実現として利用できます。

EVAL コマンドの使い方

例えば、 Activity-Relay において使われていた例を Lua スクリプトに書き起こすと以下のようになります。

元のコード:

err := sendActivity(inboxURL, Actor.ID, []byte(body), hostPrivatekey)
	if err != nil {
		domain, _ := url.Parse(inboxURL)
		mod, _ := redisClient.HSetNX("relay:statistics:"+domain.Host, "last_error", err.Error()).Result()
		if mod {
			redisClient.Expire("relay:statistics:"+domain.Host, time.Duration(time.Minute))
		}
	}

Lua スクリプトにした場合:

err := sendActivity(inboxURL, Actor.ID, []byte(body), hostPrivatekey)
	if err != nil {
		domain, _ := url.Parse(inboxURL)
		evalScript := "local change = redis.call('HSETNX',KEYS[1], 'last_error', ARGV[1]); if change == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end;"
		redisClient.Eval(evalScript, []string{"relay:statistics:" + domain.Host}, err.Error(), 60).Result()
	}

HSETNX のもつ変化有無について表している変数 mod に相当する情報が Go のコードから確認できなくなっている点に留意する必要がある。

Lua スクリプトに注目すると、

local change = redis.call('HSETNX',KEYS[1], 'last_error', ARGV[1]);
if change == 1 then
  redis.call('EXPIRE', KEYS[1], ARGV[2])
end;

EVAL コマンドは、第一引数に Lua スクリプト、第二引数にキーの数、第三(~キーの数)引数が KEYS[1] から順番に充てられ、それ以降の引数が ARGV[1] から順番に充てられます。(Go クライアントの go-redis では、第二引数は与えられるキーの文字列配列の長さで自動的に決められています。)

redis.call() によって、Redis のコマンドを実行することができ、返り値に実行結果が得られます。これをローカル変数に格納することもできます。また、 return を使えばクライアントに対して実行結果として値を返すことができます。よって、 Redis 側で何らかの集計を行って、その結果のみをクライアントに返すという使い方も可能です。そして、これはすべてアトミックに行われるためスクリプトの一部のみが実行されて止まってしまうということはありません。

EVAL で避けるべきこと

このように便利な EVAL ですが、いくつか注意するべきこともあります。

  1. 時間のかかるスクリプトを実行すると、他のクライアントによるコマンドの実行が遅延する。
  2. サイズが大きなスクリプトを何度も実行すると、スクリプトを送信するために無駄な通信帯域を消費する。

1. の時間のかかるスクリプトの問題については、 EVAL の仕様によるものであるためあまり重い作業を一発でやろうとしないなど工夫を行うしかないでしょう。 2. のスクリプトのサイズが通信帯域を消費するという問題には EVALSHA というコマンドが解決します。

EVALSHA コマンド (EVALSHA – Redis)

事前に SCRIPT LOAD コマンドによって Lua スクリプトを Redis へ送信すると SHAダイジェストが返ってきます。この SHAダイジェストをスクリプト本体の代わりに用いてコマンドを送信することができるのがこの EVALSHA コマンドです。

ただし、 SCRIPT LOAD コマンドが事前に行われていなければ EVALSHA コマンドは失敗するということについて注意が必要となるため、その点をケアする工夫が求められます。

EVAL のおかげで EXPIRE のつけ忘れから解放された

EVAL コマンドを活用したことで Activity-Relay では EXPIRE キーをつけ損ねてしまうことがなくなり、最近1分にエラーの発生したサーバーの情報は1分後に消えるようになりました。また、 Redis へのコマンドの発行数が減り、無駄な応答をクライアントで受け取る必要もなくなった点で通信帯域を節約できるようになり大変満足しています。

みなさんも、Redis でトランザクションをしたい通信帯域を節約したい簡単な計算や集計を任せたいという時に EVAL コマンドを活用してみてはいかがでしょうか。

タイトルとURLをコピーしました