人間とウェブの未来

「ウェブの歴史は人類の歴史の繰り返し」という観点から色々勉強しています。

mrubyのRedisクライアントのPipelining対応とDisqueクライアント

この記事はmruby adevent calendar 24日目の記事です。

mrubyでRedisを操作するmruby-redisは以前から開発していたのですが、最近Pipelining対応のPRを頂きPipelining対応したのでそれの性能を確認してみました。また、その結果簡単に作れるようになった分散型インメモリジョブキューであるDisqueのmrubyクライアントであるmruby-disqueの紹介をします。

github.com

github.com

RedisとhiredisのPipelining

mruby-redisはhiredisというCライブラリを利用しており、hiredisでは効率的にRedisとIOできるようにPipelining機能をサポートしています。RedisとhiredisにおけるPipeliningを簡単に説明すると、

  • 通常のRedisアクセスはクライアントからメソッドを発行するとRedisプロトコルを通じてReplyを待つ
  • Pipeliningの場合は、クライアントからメソッド発行するとRedisへのinputバッファにRedisのAPIをキューイングする
  • Reply用のAPIを叩くと、inputバッファにキューイングしたメソッドをまとめてRedisに投げることでソケットwrite()をまとめる
  • write()後はread()しながらreplyを待ち結果をバッファに貯めこむ事によりソケットのread()をまとめられる
  • 上記のような動作によりRedisとのソケットI/Oのためのシステムコール数を少なくし効率化できる機能がPipelining

という動きになります。これによって、ソケットI/Oが効率化されて高速化される事が予想されます。

Pipeliningのベンチマーク

ということで、早速ngx_mrubyを経由してベンチマークをとってみましょう。

まずは以下のようにngx_mrubyを設定します。

        location /redis1 {
          mruby_content_handler_code '
            redis = Userdata.new.redis
            redis.queue(:set, "mruby-redis-test:foo", "0")
            redis.queue(:set, "mruby-redis-test:foo", "BAR1")
            redis.queue(:get, "mruby-redis-test:foo")
            Nginx.rputs redis.bulk_reply[2]
          ';
        }
        location /redis2 {
          mruby_content_handler_code '
            redis = Userdata.new.redis
            redis["mruby-redis-test:foo"] = "0"
            redis["mruby-redis-test:foo"] = "BAR2"
            Nginx.rputs redis["mruby-redis-test:foo"]
          ';
        }

環境は以下です。

  • MacBook Pro (Retina, 13-inch, Late 2013)
  • CPU 2.8 GHz Intel Core i7
  • Memory 16 GB 1600 MHz DDR3

nginxのworkerは一つになるように以下のようなnginx.confを設定し、Redisはbrewでいれたものをデフォルトで起動しました

ベンチマーク結果は以下となります。

$ ab -k -c 100 -n 100000 http://127.0.0.1:58080/redis1
Requests per second:    13182.81 [#/sec] (mean)
$ ab -k -c 100 -n 100000 http://127.0.0.1:58080/redis2
Requests per second:    7297.13 [#/sec] (mean)

pipeliningを使った方が倍ぐらい速いことがわかりました。また、replyメソッドは結果が来るまでまつような処理になっており、queueはひたすらタスクを内部的にキューイングしています。

この例で行くと、/redis2へのアクセスはsocketに対するread()write()がメソッド単位で実行されているのに対し、/redis1ではPipeliningにより、メソッド自体は4つ呼ばれていますが、内部的にRedisと通信しているソケットにおいてはread()write()がそれぞれ1回しか実行されていないと考えられます(システムコールの数は確認していないので間違えているかも...)

Disqueのクライアントも作ってみた

この流れでRedisと同じ作者が作られているDisqueという、分散型in-memoryジョブキューというのがあり、これはRedisプロトコルによってDisqueとお喋りができるので大変便利なミドルウェアです。つまりクライアント的には、Redisのプロトコルを喋る汎用的なクライアントを実装しておけば、それがそのままDisqueと通信することができるという事になります。

今回のPipelining処理実装は、各Redis API毎にメソッドを定義しておらず、APIと引数のstringをそのままhiredisのライブラリを経由してRedisと通信するような汎用的な実装になっているため、Disqueへの通信も簡単にできることになります。

ということで、mruby-redisを使えばRubyだけでmruby-disqueを使ってみました。

使い方は大体以下の通りで、

assert("Disque#addjob") do
  d = Disque.new
  job_id = d.addjob "q1", "box", "0"
  assert_not_equal(nil, job_id)
  d.deljob job_id
end

assert("Disque#getjob") do
  d = Disque.new
  d.addjob "q1", "box", "0"
  d.addjob "q1", "cat", "0"
  job1 = d.getjob "q1"
  job2 = d.getjob "q1"
  assert_equal("box", job1.job_name)
  assert_equal("cat", job2.job_name)
  d.deljob job1.job_id, job2.job_id
end

assert("Disque#run_command") do
  d = Disque.new
  job_id = d.addjob "q1", "box", "0"
  qlen = d.run_command :qlen, "q1"
  assert_equal(1, qlen)
  d.deljob job_id
end

Disqueを起動後に、Disque#addjobでジョブを追加し、Disque#getjobでジョブを取得する、というのが基本的な使い方です。また、Disque#run_commandを使うと、全てのDisque APIを汎用的に使う事が可能で、例えば上記の例のようにd.run_command :qlen, "q1"などとすると、キューq1にあるキューの数を取得するQLENAPIを実行することができます。

まとめ

ということで、今回はmrubyのRedisクライアントmruby-redisのPipelining対応と、その結果簡単に作る事ができたmruby-disqueの紹介でした。今後は、Redisクライアントの非同期(コールバック)対応等によってより多くの面で使えるように改善していきたいと思っております。