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