Redis Expire Performance

Scala書けますで入社した筈なのにnewrelicみながらDBのチューニングばかりしてる、ぽんこつです。

今回はRedisをどうやって叩くと早くなるのかを、主にexpireまわりで適当にベンチしたりした。

私のDBの経験値がMySQLに偏っているのであまり手出ししてこなかった弊社のRedis環境だが、パフォーマンス絡みの問題を聞いたり見かけるようになってきたので、Redisのパフォーマンスを引き出す方法について調査をした。

弊社のサーバアプリケーションは主にScalaで書かれているので、ScalaからRedisに繋いだときのベンチマークが中心になる。

JMH

JMHはマイクロベンチマークツールの一種。JVMJITによって、何度も同じ箇所を実行すると、徐々にコードを最適化して早く動くようになる、等々の要素を排除し、できるだけ正しいベンチマークをすることができるツール。

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