[Dd]enzow(ill)? with DB and Python

DBとか資格とかPythonとかの話をつらつらと

PythonでRedisを効率的に使う(redis-pyのパフォーマンスをあげるには)

前回書いたようにISHOCONというプライベートなISUCONに出てきました。MySQLの思いクエリをRedisに変えることでスコアは伸びたのですが、Redis自体もプロセスローカルのメモリに比べると遅いのでできるだけ効率的な使い方を模索しました。いつになく記事が長くなってしまいました。

hiredis

Pythonでredisを使うにはredis-pyを使います。これをいれるだけで使うことはできるのですが、パーサ部分をCで実装したHiredisを入れるとredis-py自体が高速化するので一緒に入れておくのがマストでしょう。(10倍くらい違うらしい)

$ pip install hiredis

hiredisをredis-pyの代わりに使うわけではなく、いれるとredis-pyが早くなるということだそうです。確かにhiredisが入ってるかどうかで動きが変わる部分がredis-pyにありそうです。

https://github.com/andymccurdy/redis-py/blob/a87ae0ddb5b591f15527312229a7c92284012a5b/redis/utils.py#L4-L8

パイプライン

例えば、ある関数内で複数回Redisとのやり取りをしている場合はpipelineを検討します。これは複数の処理を一括してRedisに投げることで、ラウンドトリップを減らすことができます。

例えば、以下のケースを考えます。

r = redis.StrictRedis(host='localhost', port=6379, db=0)
def no_chain():
    r.delete("A")
    r.delete("B")
    r.delete("C")
    r.set("A", 10)
    r.set("B", 10)
    r.set("C", 10)
    return r.get("C")

7回のやり取りをしています。これの処理時間を見てみます。

%timeit no_chain()
1.19 ms ± 47.1 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

1回あたり1.19msでした。

続いてこれを処理内容は変えずにpipelineに書き換えてみます。

r = redis.StrictRedis(host='localhost', port=6379, db=0)
def chain():
    pipe = r.pipeline()
    pipe.delete("A")
    pipe.delete("B")
    pipe.delete("C")
    pipe.set("A", 10)
    pipe.set("B", 10)
    pipe.set("C", 10)
    pipe.get("C")
    return pipe.execute()[-1]

pipelineは一旦pipeline()としてパイプを作成し、あとは処理したい内容をpipeに渡していきます。関数名等は通常時と変わりません。処理を指定し終えたらpipe.execute()を呼び出します。これで複数の処理が一括してRedisに届けられるわけです。

なお、それぞれの処理の結果が配列で戻るので今回は最後のpipe.get("C")だけがほしかったので[-1]として最後の結果だけを取得しています。

%timeit chain()
280 µs ± 15.7 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

280 µsまで短縮できました。一回に投げる処理数にもよりますが、かなりの高速化が期待できますね。

キーに対するあいまい検索

Redisではキー名に*を指定してkeys()を実行するとあいまい検索で該当するキーをすべて戻します。RDBMS脳的にはINDEXが前方一致でしか効かないイメージがあるため、ケースごとに確認しておきました。

まずはデータを準備します。

# 準備
conn = redis.StrictRedis(host='localhost', port=6379, db=0)
key1 = ["hoge", "foo", "bar"]
key2 = ["egg", "spam", "monty"]
key3 = ["1", "2", "3"]
conn.flushall()
pipe = conn.pipeline()
for x in key1:
    for y in key2:
        for z in key3:
            for i in range(500):
                pipe.set("{}_{}_{}_{}".format(x,y,z,i), "test_data_{}_{}_{}_{}".format(x,y,z,i))

_ = pipe.execute()

実行するとhoge_egg_1_0からbar_monty_3_4999までのK:Vが作成されます。それぞれの結果数を見ておきます。

print(len(conn.keys("hoge_*")))
4509
print(len(conn.keys("foo_*")))
4509
print(len(conn.keys("bar_*")))
4509
print(len(conn.keys("*_egg_*")))
4509
print(len(conn.keys("*_spam_*")))
4509
print(len(conn.keys("*_monty_*")))
4509
print(len(conn.keys("*1")))
1459
print(len(conn.keys("*2")))
1459
print(len(conn.keys("*3")))
1459

末尾キーはrange(500)で生成していますので結果数が少ない状態です。

ではパフォーマンスを見ます。

%timeit conn.keys("hoge_*")  # 前方一致
19.8 ms ± 650 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

%timeit conn.keys("*_egg_*") # 中間一致
20.8 ms ± 683 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

%timeit conn.keys("*1")      # 後方一致
8.18 ms ± 162 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

前方一致も中間一致もパフォーマンスの差はないようです。後方一致の場合だけ早いのは単に戻る結果数が1/3だからだと思います。

このようにRDBMSと違ってキーに対する検索はどの部分に対しても曖昧検索は同じパフォーマンスなので、キーを_で複数の属性繋いだ形にして、あいまい検索を使えばSQLのような比較的柔軟なクエリが可能です。 ※結合できないですが

ハッシュとの比較

さて、キーに複数の属性をもたせればある程度柔軟性を持たせられることがわかりましたが、同じことはハッシュでもできるはずです。

例えばtokyoというキーにcompany_1というハッシュキーを持たせて、その配下に値を設定することとtokyo_company_1というキーに値を設定することではどちらも近い情報が管理できます。

そのため、どちらがパフォーマンスに優れるかを確認します。

# 準備
conn = redis.StrictRedis(host='localhost', port=6379, db=0)
key1 = ["hoge", "foo", "bar"]
key2 = ["egg", "spam", "monty"]
key3 = ["1", "2", "3"]

# hoge_egg_1_0という単純なK:Vのデータ
pipe = conn.pipeline()
for x in key1:
    for y in key2:
        for z in key3:
            for i in range(500):
                pipe.set("{}_{}_{}_{}".format(x,y,z,i), "test_data_{}_{}_{}_{}".format(x,y,z,i))

_ = pipe.execute()


# hoge2_egg_1というキーに0-500をキーとするハッシュをもたせるデータ
pipe = conn.pipeline()
key1 = ["hoge2", "foo2", "bar2"]
for x in key1:
    for y in key2:
        for z in key3:
            for i in range(500):
                pipe.hset("{}_{}_{}".format(x,y,z),  i, "test_data_{}_{}_{}_{}".format(x,y,z,i))

_ = pipe.execute()

同じ結果を取り出してみます。

print(len(conn.keys("hoge_egg_1_*")))
500
print(len(conn.hgetall("hoge2_egg_1")))
500

どちらも500要素が戻ることが確認できたので時間を測定してみます。

%timeit conn.keys("hoge_egg_1_*")
2.92 ms ± 75.4 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

%timeit conn.hgetall("hoge2_egg_1")
4.4 ms ± 61.2 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

キーから直接取得する方が6割くらいの時間で終わっています。どうやらキーで直接属性をつけてしまったほうが早いようです。ハッシュ型については格納データによってメモリ圧縮がかかったりするらしいので状況によっては逆転するかもしれませんが・・・

Redisで柔軟なデータ型を使うには

Redisでは、Valueについては文字列(バイト列)を格納することになりますがそれでは不便です。Pythonであればpickleが使えますのでシリアライズできるほぼすべてのデータをRedisにしまうことが可能です。

# 値のセット
r.set("Key", pickle.dumps({"data1": "value1", "data2": "value2"}))
# 値の取得
pickle.loads(r.get("Key"))

近いことはjsonモジュールのdumps/loadsでもできますが、使えるデータ型が限られる上にパースのパフォーマンスも劣ります。そのため、ISUCONターゲットではPickle一択だと思いますが、PickleされたデータはPythonのバージョン間でも互換性が崩れることがありますので、他システムの連携等も踏まえるとJSONでシリアライズすべきケースのほうが多いと思います。

まとめ

Redisでパフォーマンスを考慮した使い方がある程度見えてきました。まだまだ機能はあるようですがまずはこのあたりをしっかり理解してISUCONを戦いたいと思います。