Redisの大量レコードを(ほぼ)全てexportする

こんにちは。auスマートパス開発部の子安です。
最近すっかり寒くなりましたね。冬といえばコタツです。そしてコタツといえば双六。双六 -> サイコロ -> Redis。 ・・・はい、やっとたどり着きました。今回はRedisの話です。

全てのレコードを吐き出したい

今やKVSの代名詞と言えるほど使われているRedisですが、一つ困ったことがあります。 というのも、レコードを全てダンプするようなコマンドがないのです! みなさんどうしていますか?

素直なやり方

最初に思いつくのは、KEYSしてMGETかもしれません。

# export_by_keys.py
r = redis.StrictRedis(REDIS_HOST)
res_keys = r.keys()                     # KEYS
if res_keys:
    res_mget = r.mget(res_keys)         # MGET
    for key, val in zip(res_keys, res_mget):
        print(utf8(key), utf8(val))

>> 完全なコード

これは手軽なのですがKEYS *は全てのキーを返しますので、レコードが多くなっていくとサーバ、クライアントともに問題が起きてきます。公式ドキュメントにも「普段使いは意図してないよ」とありました。

http://redis.io/commands/KEYS

Don’t use KEYS in your regular application code.

このドキュメントの続きにもある通り、こういった場合にはカーソルによって少しずつキーを取得していくSCANを使うのが作法です。 つまり次の手として考えられるのは、SCANとMGETですね。

# export_by_scan.py
r = redis.StrictRedis(REDIS_HOST)
next_cur = INITIAL_CUR
while True:
    res_scan = r.scan(next_cur)             # SCAN
    next_cur = res_scan[0]
    if res_scan[1]:
        res_mget = r.mget(res_scan[1])      # MGET
        for key, val in zip(res_scan[1], res_mget):
            print(utf8(key), utf8(val))
    if next_cur == INITIAL_CUR:
        break

>> 完全なコード

このコードは問題なく動作します。が、SCANとMGETで2回ネットワーク越しにリクエストしているところが気になります。取得したキーをそのまま投げ直しているわけですから、ここは一括して処理できないものでしょうか。

Luaを使う

そうかLuaを使えるじゃないか、と思って書いてみたのがこちら。

# export_by_lua.py
r = redis.StrictRedis(REDIS_HOST)
with open('scan_with_value.lua', 'r') as f:
    lua_script = f.read()
    scan_with_value = r.register_script(lua_script)     # SCRIPT LOAD
    next_cur = INITIAL_CUR
    while True:
        res = scan_with_value([next_cur])               # EVALSHA
        next_cur = res[0]
        if res[1]:
            for key, val in res[1]:
                print(utf8(key), utf8(val))
        if next_cur == INITIAL_CUR:
            break

>> 完全なコード

この中で読み込んでいるLuaスクリプトはこちら。

-- scan_with_value.lua
local t_scan = redis.call('SCAN', KEYS[1])
if next(t_scan[2]) then
    local values = redis.call('MGET', unpack(t_scan[2]))
    return {t_scan[1], zip(t_scan[2], values)}
else
    return {t_scan[1], {}}
end

>> 完全なコード

やってることは同じくSCANしてMGETなのですが、Luaスクリプトはサーバサイドで動作しますので、クライアントからは一度のリクエストでキーと値のリストを取得できることになります。

比較してみる

1,000,000レコードを保持したRedisに対して実行してみます。

$ time python3 export_by_scan.py >/dev/null
real    0m42.267s
user    0m34.564s
sys     0m2.384s

$ time python3 export_by_lua.py >/dev/null
real    0m46.726s
user    0m35.100s
sys     0m1.844s

これは実はローカルに立てたRedisへ接続しているのでした。ローカルだと流石にあんまり変わらないですね。むしろ少し遅いぐらいです。 そこでリモートに同じく1,000,000レコードを保持したRedisを立てて、接続してみると・・・

$ time python3 export_by_scan.py redis-remote-host >/dev/null
real    1m38.121s
user    0m35.844s
sys     0m2.780s

$ time python3 export_by_lua.py redis-remote-host >/dev/null
real    1m14.163s
user    0m38.488s
sys     0m1.864s

3/4程度の時間で処理を完了しました。ネットワーク次第とはいえ、充分有意な差が出たと思っています。

(おまけ)大量レコードの登録

吐き出すのではなく逆に投入する — 大量のレコードを登録する手順については、公式ドキュメントにガイドがあります。

http://redis.io/topics/mass-insert

the preferred way to mass import data into Redis is to generate a text file containing the Redis protocol, in raw format, in order to call the commands needed to insert the required data.

要はプロトコルを流し込む仕組みがあるということですね。公式はRubyで書いてあったのですが、ついでなのでPythonで書きなおしてみました。

# to_redis_protocol.py
def to_redis_protocol(*cmd):
    protocol = ''
    protocol += '*{0}\r\n'.format(len(cmd))
    for arg in cmd:
        protocol += '${0}\r\n'.format(len(bytes(arg, 'utf-8')))
        protocol += arg + '\r\n'
    return protocol

>> 完全なコード

まとめ

正直な話、全レコードをダンプする要件があるとしたら(そしてレコードが大量になるとしたら)、それは要件を考え直した方が良いかもしれません。なぜならその処理は必ずシリアルになり、スケールしないからです。SETイベントを通知して何らかの処理を入れるとか、Lua拡張で更新処理とともに何らかの処理を入れるなどの代案を検討すると良いでしょう。

それとLua拡張は面白いですよね。工夫次第でいろんなことができるので遊んでみてください。Luaの言語仕様もそれほど大きなものではありませんし、気軽に書けると思います。

今回使ったもの

  • Redis 3.0.5
  • Lua 5.1.4
  • Python 3.4.3 / redis-py 2.10.5