愛と勇気と缶ビール

ふしぎとぼくらはなにをしたらよいか

More Transactional Redis (2) - Lua Scripting in Action

前回の記事: More Transactional Redis (1) - Redis is not a transactional storage - 愛と勇気と缶ビール

Lua Scriptingでatomicな処理を実現できるぜい!ってとこで前回の記事は終わっていました。今回は、Lua Scriptingを試す際に踏むとよいであろうstep、実用する際のTIPSなどを書いていきます。

おそらく、多くの方はLua以外の言語で書かれたWebアプリからRedisのLua Scriptingを利用することになるでしょう。実際にホスト言語に文字列として埋め込むか別ファイルにするかは別にして、Redis上でLuaを動かすことは即ち

ホスト言語(Perl, Ruby, Python, etc, ...) -> (Redis) -> ゲスト言語(この場合はLua)

という異言語間のコミュニケーションを行うことに他なりません。この場合はRedis経由ですが。RedisでLuaを実行して、最終的にレスポンスを得るまでのデータの流れに着目すると、

ホスト言語におけるデータ表現 -> Redisにおけるデータ表現 -> Luaにおけるデータ表現 -> 
                                                                                    Lua内での処理
ホスト言語におけるデータ表現 <- Redisにおけるデータ表現 <- Luaにおけるデータ表現 <- 

という感じになります。こんな変な絵を書いて何が言いたいのかというと、「わりと複雑なことをやろうとしているのだから慎重に歩を進めようか」ということです。

まずLuaから始めよ

初めからRedis上でのLua Scriptingに挑んで「なんかわからんけどエラー出てる!」という状態になってしまうと、どこがどう間違っているか分からずに彷徨うことになってしまうので、まずはLuaの処理系を入れて少し素振りしましょう。OS Xなら

brew install lua

でインストールできます。後で使うので、brew install luarocksもしておきましょう。

Lua言語自体はどこで学んでもいいのですが、言語自体はここにおける本題ではないので、できるだけ時間をかけずにサクッと要点を抑えましょう。

http://lua-users.org/wiki/TutorialDirectory

↑僕はここをいくつか眺めました。条件分岐やループ、tableの扱いなどが分かればとりあえず十分だと思われます。言語の全てを知る必要は全くありません。

後で出てくるので、cjsonの扱い方もなんとなくこの時点で知っておくとよいかもしれません。

luarocks install lua-cjson

cjsonはLuaでJSONを扱うためのライブラリで、RedisでLuaを使う時はrequire等することなくそのままcjsonという名前で使えます。luarocksなしでも入れられますが、pathとかの設定がなんかややこしくてよくわかんない感じになるので、luarocksで入れてしまうのがオススメです。

redis-cliでEVALを試す

Luaの素振りが終わったら、EVAL – Redisを読みます。それこそ舐めるように読みます。Redisのドキュメントにはさりげなく有用な情報が書かれていることが多いので、この場合に限らず舐めるように読むべきでしょう。

一通り読み終わったら、redis-cliでlocalのRedisに繋いでEVALを試しましょう。Redisは当然ながら、brew install redisとかで入れた上でredis-serverで起動しておきましょう。

シンプルな何もしないscriptから試していきます。EVALのドキュメントに書いてありますが、"return 1"の次の0は、その後に指定するkeyの数です。

EVAL 'return 1' 0
-> (integer) 1

少し背伸びして、redisのコマンドを発行してみましょう。

EVAL 'redis.call("set", "key", "value")' 0
-> (nil)

今度は、keyをEVALの引数で指定した上でGETを発行してみましょう。

EVAL 'return redis.call("get", KEYS[1])' 1 "key"
-> "value"

KEYSはLuaの配列なので、indexが1-basedです。KEYSとARGSの仕様については、EVALのドキュメントを参照。

最後にcjsonを使ってみます。

EVAL 'return cjson.encode({ ["result"] = redis.call("get", KEYS[1]) })' 1 "key"
-> "{\"result\":\"value\"}"

EVALの素振りはこの位で十分です。必要であれば色々試してみましょう。

そして実践へ

ここまで来れば、あなたの好きな言語からEVALを発行しても「どこがおかしいのか」がなんとなく分かる状態になっています。この記事の残りの部分は、必ずしもこうしなければいけない、という類のものではなく「こうするといいかも」というTIPSです。

引数、返り値のencode/decodeにJSONを使う

前の方で少し触れましたが、Redis内で実行されるLuaではcjsonというライブラリがロードすることなく使えます。これを利用して、Lua Scriptに渡す引数、およびLuaからの返り値をJSONでやりとりすることにより、ホスト言語 - Lua間のインタフェースをより柔軟に設計できます。

例えば、Luaで以下のようなfunctionと実際の処理を定義しておき、

local handle_json = function(f)
  local args = cjson.decode(ARGV[1])
  local result = f(args)
  return cjson.encode(result)
end

handle_json(function(args)
  -- ここに実際の処理を書く
  return args
end)

ホスト言語(例えばRuby)で以下のようなラッパーを用意しておけば、

def redis_eval(lua_code, args)
  result = redis_client.eval(lua_code, 0, args.to_json)
  JSON.parse(result)
end

このように、LuaRubyの双方で引数の数や返り値の数を気にすることなく呼び出しが可能になります。

result = redis_eval(lua_code, { :key1 => "value1", :key2 => "value2" })
p result # {:key1=>"value1",:key2=>"value2"}

ちなみに「EVAL内でLuaのfunctionを定義するのはいくない」と公式ドキュメントに書いてあるのですが、僕がベンチマーク取った感じだとlocal変数にfunctionを代入しているからといってused_memoryやused_memory_luaが次第に膨らんでいく、ということはありませんでした。そもそも変数に入れない普通のfunction定義はEVALに弾かれるし、仮にlocalで定義された値が一時的にメモリ上に確保されるとしても割とすぐ回収されそうな雰囲気が出ているので、localを使って共通関数を作ること自体は問題なさげです。

RedisのLuaではcmsgpackも使えるので、JSONの代わりにMessagePackを使うことも可能です。引数が巨大になりそうな場合はMessagePackでencode/decodeした方がネットワーク帯域に優しいかもしれません。

EVALSHAを使ってRedisへのinbound trafficを絞る

当然ながら、Lua Script全体を毎回送りつけるEVALを使うより、Lua Scriptのsha1hexだけを送るEVALSHAを使ったほうが効率が良いです。主にRedisに対するinboundのnetwork traffic的な意味で。

といっても、「予めRedis上でSCRIPT LOADしておいて、scriptがある状態を作っておいてからEVALSHAする」といういわゆるストアドプロシージャのような運用方法を取ると、

  • 新しくRedisのノードを追加した場合などに、そこにちゃんとscriptがあるという保証が必要
    • 例えばscriptがあるかどうか確かめるための監視を入れるとか
  • scriptの内容を更新するのが面倒
    • アプリのロジックは最新版に更新されてるけどRedis上のLua Scriptの更新を忘れていて死亡、とかもあり得る

…のように色々面倒なことが発生します。EVALのリファレンスにも書いてありますが、おすすめは以下のような方法です。

  1. アプリケーション側でLua Script文字列のsha1hex値を計算する
  2. とりあえず EVALSHA {sha1hex} ... しちゃう
  3. Redisから"NOSCRIPT"的なエラーが返ってきた場合のみ EVAL {Lua Script} ... する (EVALした時点でRedisが内部的にsha1hexを計算し、以降はEVALSHAが通るようになる)

これが使えるのは、Redisが "SCRIPT LOADを手動で打たなくても、EVALした時点でsha1hexが計算され、以降はそのsha1hexを使ったEVALSHAが可能になる" という挙動をするためです。Lua Scriptに変更を入れた場合はsha1hexの値も当然変わるため、更新漏れも起こりません。

Lua on Redisでの例外処理

ホスト言語Aからゲスト言語Bをコールする際に、「言語Bで起こったエラーをどのようにハンドリングし、どのように言語Aに伝播させるか」というのは熟慮すべき部分の一つです。「言語Bのどこかでエラー起きてるけど、エラーログやスタックトレースみてもさっぱり原因が分からん!」みたいなのが最悪のパターンです。

今回の場合、ホスト言語側で可能な限りのバリデーションを行いおかしな値がLua Scriptに渡らないようにするのがベターですが、それでも予期しないエラーがLua Script内で起こった場合にどうするか?というのは、考えておいた方がいいです。
(ここについては、「ホスト言語側で鉄のバリデーションを行うからLua Script内での例外については考えない」という割り切りもアリだと思います。)

よほど複雑なLua Scriptを書く場合は話が異なりますが、Redis上で動かすLua Scriptの中でエラーが起こりやすそうなのはredis.callを使ってRedisのコマンドを発行する部分だと思われます。redis.callに着目すると、例外処理には2通りの方法があります。

  • redis.pcallを使う
  • pcallを使ってfunctionを囲む (Luaにおける、いわゆる通常の例外処理)

redis.callの代わりにredis.pcallを使ってRedisのコマンドを呼び出すと、コマンドが失敗した場合にその場でエラーになるのではなく { err: "[ERROR MESSAGE" } のようなLuaのtableが返り値として取れるようになります。この機能を使った場合、全てのRedisコマンドを呼び出した後に返り値を見てエラーかどうかを判定しなければならず、コードが煩雑になるのでここではお勧めしません。Luaのpcallを使って、次のように全体を括ってしまうのがお勧めです。

local flag, ret = pcall(function()
  local args = cjson.decode(ARGV[1])
  -- ここに実際の処理を書く
end)

if ( flag ) then
    return cjson.encode(ret)
else
    return cjson.encode( { ["error"] = string.format("error msg: %s, args: %s", ret, ARGV[1]) } )
end

これだと、redis.call以外でエラーが起こった場合でもキャッチできて安心ですね!
エラーが起こったLuaの行番号までは分かりませんが、「どういう引数を渡していて、どういうエラーが起こったか」までホスト言語に伝播できれば短いscriptについては充分だと考えます。ホスト言語側ではエラーログを出しておくなりなんなり、好きにしましょう。

備考1: Lua Scriptingのパフォーマンス

パフォーマンスというと曖昧ですが、要は「RedisのCPUが跳ねたりネットワークのトラフィックが跳ねたりしないか?」というのも実際にサービスに投入する場合は気になる所だと思われます。詳しい内容は書きませんが、会社でベンチマークを取った結果から得られた知見を抜粋します:

  • おそらく、多くの環境ではRedisを動かしているhostのCPUが一番先に限界になる。ネットワークトラフィック的には余裕。
    • というのは、Redisは1コアしか使えないので。そのコアを使い切ったら終わりで、それ以上のリクエストは捌けない。これはLua Scriptingに限った話ではないが...
  • だいたい36行900byteくらいのLua Scriptだと、EVALSHAを使うことでRedisへのinbound trafficが50%くらい削減できる。これはもっと長いLua Scriptだと顕著になると思われる。
  • EVAL/EVALSHAを使わず同じコマンドを発行するベンチマークが、最もスコアが高かった。Redis上でLuaを実行するのは (主にCPU的な意味で) 安くはないということ。
    • Luaを使わないベンチは、同環境だとEVALに対して1.6〜1.7倍くらいのスコア

総じて言うと、「普通に使えるレベル」です。

備考2: RedisにおけるEVAL, EVALSHAのレプリケーションについて

これはほぼ余談ですが、2.6系のRedisではEVAL, EVALSHAのそれぞれが以下のように扱われます。

要は、EVALはそのままEVALとしてレプリケーションされますが、EVALSHAは内部的にEVALに変換してのちレプリケーションされる、ということです。
この挙動は「いつレプリケーションに参加したか分からないslaveにそのscriptがロードされてるかどうかなんてmasterでは分かんないよねー」という理由でそうなっているのだと思われますが、これが原因でRedis2.6系ではEVALSHAを使うことでmasterへのinbound trafficは削減できてもmaster -> slaveへのoutbound, slaveでのinboundがEVALの時と同じまま、という結果になってしまいます。

この挙動は2.8系以降では改善されており、masterにEVALSHAを打った場合には適宜EVALSHAとしてレプリケーションが行われるようになっています。
具体的なアルゴリズムについては以下を参照。

https://github.com/antirez/redis/blob/2.8/src/replication.c#L1443

まとめ

駆け足で紹介しましたが、通常のRedisコマンドではatomicに実行できないような処理をLuaを使って実装することで、夢が広がりんぐになるかと思います。enjoy!