Scala書けますで入社した筈なのにnewrelicみながらDBのチューニングばかりしてる、ぽんこつです。
今回はRedisをどうやって叩くと早くなるのかを、主にexpireまわりで適当にベンチしたりした。
私のDBの経験値がMySQLに偏っているのであまり手出ししてこなかった弊社のRedis環境だが、パフォーマンス絡みの問題を聞いたり見かけるようになってきたので、Redisのパフォーマンスを引き出す方法について調査をした。
弊社のサーバアプリケーションは主にScalaで書かれているので、ScalaからRedisに繋いだときのベンチマークが中心になる。
JMH
JMHはマイクロベンチマークツールの一種。JVMはJITによって、何度も同じ箇所を実行すると、徐々にコードを最適化して早く動くようになる、等々の要素を排除し、できるだけ正しいベンチマークをすることができるツール。
ScalaでJMHを使う場合はsbt-jmhを使うと良い。sbtのpluginとして使うことができる。今回はsbt-jmhを使っている。(ただしRedisの場合I/Oがからむので、JMH本来の正確さは期待できないと思われる)
BulkInsert
とりあえず30万件データを突っ込むのに掛かる時間を調べる。jedisのオーバヘッドが大きい可能性を考慮してまずは最速パターン(と思われる)を計測してからjedisの計測に入る。
shell
for i in `seq 1 10000` do redis-cli set ${i}key ${i}value done
適当に作ったが、あまりにも時間掛かり過ぎて計測不能だった。1コマンド毎にredis-cliを立ち上げるのは無謀だった模様。
shell (redis protocol)
一旦コマンドリストを生成して流し込んでもいいけど、今回はredis protocolを使ってみる。
http://redis-documentasion-japanese.readthedocs.org/ja/latest/topics/mass-insert.html
redis protocolをpipeで流せばいいらしい。該当ページのRubyスクリプトを参考に組んでみる。
def gen_redis_proto(*cmd) proto = "" proto << "*"+cmd.length.to_s+"\r\n" cmd.each{|arg| proto << "$"+arg.to_s.bytesize.to_s+"\r\n" proto << arg.to_s+"\r\n" } proto end count = ARGV.size > 0 ? ARGV[0].to_i : 300000 (1..count).each { |i| puts gen_redis_proto("SET","key%d" % i,"value%d" % i) } puts gen_redis_proto("FLUSHDB")
これで生成したredis protocolをredis-cliに渡す
ruby gen_proto.rb | time redis-cli --pipe
結果は1.303s。はやい。
jedis
Javaに対応したバインディングは幾つもあるが、今回は一番メジャーっぽいjedisを使う。社内で使ってるからという話もある。
@Benchmark @BenchmarkMode(Array(Mode.AverageTime)) def jedis(): Unit = { val client = new Jedis() (1 to 300000).foreach { i => client.set(s"key${i}", s"value${i}") } client.flushDB() }
Warm upを3回、Iterationを5回やって平均を出す。
jmh:run -i 5 -wi 3 -f1 -t1 org.mvrck.BulkInsertTest
結果は4.274s。ちょっと遅過ぎる気がする
jedis multi
コマンドを毎回発行してるから遅いと思われるので、Transactionを使ってみる
https://github.com/xetorthio/jedis/wiki/AdvancedUsage
今回からannotationは省略して書く
def setMulti(): Unit = { val client = new Jedis() multi(client) { t => (1 to 300000).foreach { i => t.set(s"key${i}", s"value${i}") } } client.flushDB() } def multi[T](jedis: Jedis)(f: Transaction => T): T = { val multi = jedis.multi() val res = f(multi) multi.exec() res }
0.541s。アイエェ!?redis protocolより早いナンデ!?timeの時間計測に問題あるのかな…?まぁ早いならいいかな…(適当)
expire遅くないか
と本番環境のnewrelic見て思ったので調査する。
setWithExpire
まずはset時にexpireを設定する
def withExpire(): Unit = { val client = new Jedis() multi(client) { t => (1 to 300000).foreach { i => t.set(s"key${i}", s"value${i}", "NX", "EX", 100) } } client.flushDB() }
0.792s。割と遅くなる。
別でExpireする
挿入する物によっては1コマンドでexpire設定できないこともあるので、それを模してみる
def splitExpire(): Unit = { val client = new Jedis() multi(client) { t => (1 to 300000).foreach { i => t.set(s"key${i}", s"value${i}") t.expire(s"key${i}", 100) } } client.flushDB() }
1.031s。更に遅くなる。ので、setするときにexpireが設定できないリスト型などで速度が気になりそう。
expire time
expireを設定したkeyが実際に削除されるタイミングで負荷が上がるのでは、という仮説があったので調査する。1msで揮発するようにすればそれっぽいテストができそう。
def withLittleExpire(): Unit = { val client = new Jedis() multi(client) { t => (1 to 300000).foreach { i => t.set(s"key${i}", s"value${i}", "NX", "PX", 1) } } }
1.002s。単純にexpireを設定した場合が0.792sなので遅くなってる。瞬間的に揮発のタイミングが重なるとちょっとあぶないかもしれない。
read時にexpire
Redisの使用用途は主にcacheなので、使用頻度が低い奴は消えて欲しいけど高いやつは残って欲しい。良くある。そこでreadするときにexpireすればいいのでは!本当か?
read only
比較対象としてreadの速度を計測する。
def read(): Unit = { val client = new Jedis() multi(client) { t => (0 until 100).foreach { i => t.set(s"key${i}", s"value${i}") } } multi(client) { t => (1 to 300000).foreach { i => t.get(s"key${i%100}") } } client.flushDB() }
0.286s。流石危険な程早いと言われるRedis。
read with expire
expireを付けるとどうなるか。
def readWithExpire(): Unit = { val client = new Jedis() multi(client) { t => (0 until 100).foreach { i => t.set(s"key${i}", s"value${i}", "NX", "EX", 100)} } multi(client) { t => (1 to 300000).foreach { i => val key = s"key${i % 100}" t.get(key) t.expire(key, 100) } } client.flushDB() }
0.618s。倍ぐらい掛かるようになった。
倍ならまぁ、という気もするが、可能ならばRedisサーバ側の設定を変更し、maxmemory-policyのallkeys-lruなどで設定した方が良いと思われる。
まとめ
Redisのexpireすごく遅くないか、という疑惑があり色々比較してみたけど、大体倍ぐらいだったので、maxmemory-policyの検討をしつつ必要なら使えば良いのでは、という結論になった。あとtransactionは強力なのでできるだけ使うようにしたい。
弊社での実測の話をすると、getの度にexpireした結果として、expireの処理時間が結構長いことがnewrelicで判明した。このため、getと同時にttlコマンドで残り期間を確認して、伸ばす必要があるときだけ伸ばすようにした結果、expireが激減して全体として高速化した。ただしその場合transactionが使えなくなるので今回は計測しなかった。
今回テストに使ったコードは以下にある。
https://github.com/mvrck-inc/sandbox/tree/master/redis-performance
計測方法にあまり自信が無いので、問題がありそうなら教えて欲しい。
今回の環境
OS: Arch Linux Redis: 3.0.7 JDK: OpenJDK 1.8.0_74 CPU: Core i5 4690T Memory: 16GB