リクエスト/レスポンス プロトコルと RTT

Redis は、クライアント - サーバーモデル (リクエスト/レスポンス プロトコル とも呼ばれる ) を採用した TCP サーバーです。

つまり、リクエストは以下のステップにより遂行されます。

たとえば、4つのコマンドからなるシーケンスは以下のようになります:

クライアントとサーバーはネットワークリンクによって接続されています。そのようなリンクは非常に高速な場合(ループバックインタフェース)も、非常に低速な場合(2つのホスト間に多くの通信機器を挟む、インターネット上に確立された接続)もあります。ネットワークレイテンシがどうであっても、パケットがクライアントからサーバーに届くまで、またサーバーからクライアントに応答が返されるまでには、いくらかの時間がかかります。

この時間のことを RTT (Round Trip Time) といいます。クライアントが連続して多くのリクエストを実行する必要があるとき(たとえば、同じリストに多くの要素を追加する、またはデータベースに多くのキーを追加する、など)、これがパフォーマンスにどう影響するかはすぐに理解できることです。たとえば RTT が 250 ミリ秒の場合(インターネット上の非常に遅いリンクを想定します)、たとえサーバーが 1 秒間に 10 万リクエストを捌くことができたとしても、私たちは最大で 1 秒に 4 リクエストしか実行することができないでしょう。

もしインタフェースがループバックインタフェースなら、RTT は非常に短くなりますが(たとえば、私のマシンで 127.0.0.1 に ping を打つ時間は 0.044 ミリ秒です)、それでも連続して大量の書き込みを行うのは多くの時間を必要とします。

幸い、このようなケースを改善させる方法があります。

Redis Pipelining

リクエスト/レスポンス方式のサーバーは、クライアントが前の応答を読み出す前に、次のリクエストを処理するように実装することができます。この方法をとると、返答をまったく待つことなく 複数のコマンド をサーバーに送り、最後にまとめて 1 回で返答を読む、ということが可能になります。

これはパイプラインと呼ばれ、何十年も前から広く使われているテクニックです。たとえば多くの POP3 プロトコルの実装は、この機能をサポートすることでサーバーから新しいメールをダウンロードする処理スピードを劇的に向上させています。

Redis は非常に早い段階からパイプライニングをサポートしており、あなたがどんなバージョンを使っていたとしても、パイプライニングを利用することができます。以下は生の netcat ユーティリティを使った例です:

$ (echo -en "PING\r\nPING\r\nPING\r\n"; sleep 1) | nc localhost 6379
+PONG
+PONG
+PONG

こうすると、毎回の呼び出しごとに RTT コストを払うことなく、1 度のラウンドトリップで 3 つのコマンドを実行することができます。

より明確にするために、一番最初の例のオペレーションの順番は、パイプライニングを利用すると以下のようになります:

  • Client: INCR X
  • Client: INCR X
  • Client: INCR X
  • Client: INCR X
  • Server: 1
  • Server: 2
  • Server: 3
  • Server: 4

重要な注意 : クライアントがパイプライニングを使ってコマンドを送る場合、サーバーはメモリを消費して応答をキューに溜めておきます。もし非常に大量のコマンドをパイプライニングを使って送信する必要がある場合、妥当な上限を定めることが望ましいでしょう。たとえば 10000 コマンドを送信し、その応答を受けとったあとでさらに次の 10000 コマンドを送信する、というようなやり方です。スピードは一定に保たれますが、10000 コマンドのレスポンスをキューイングするための追加のメモリを必要とします。

ベンチマーク

以下のベンチマークでは、パイプライニングをサポートする Redis の Ruby クライアントを使い、パイプライニングによる性能向上についてテストしています。

require 'rubygems'
require 'redis'

def bench(descr)
    start = Time.now
    yield
    puts "#{descr} #{Time.now-start} seconds"
end

def without_pipelining
    r = Redis.new
    10000.times {
        r.ping
    }
end

def with_pipelining
    r = Redis.new
    r.pipelined {
        10000.times {
            r.ping
        }
    }
end

bench("without pipelining") {
    without_pipelining
}
bench("with pipelining") {
    with_pipelining
}

このシンプルなスクリプトを、もっとも性能向上の余地が小さい、 Mac OS X のループバックインタフェース上で動かした場合で以下の数値が得られました。

without pipelining 1.185238 seconds
with pipelining 0.250783 seconds

この結果が示すように、パイプライニングを使用することで、性能が 5 倍向上されています。

パイプライニング VS スクリプティング

Redis scripting (Redis 2.6 以上で利用可能) を使うと、サーバーサイドで多くの仕事が必要なユースケースにおいて、パイプライニングよりも効率良く処理ができる場合があります。スクリプティングの大きな利点は、データの read と write の両方を最小のレイテンシで得られるため、 読み取り, 計算, 書き込み という操作を非常に高速に実行できる点です(パイプライニングでは、クライアントは write コマンドの前に read コマンドの応答を待つ必要があるため、このようなケースに対応できません)。

しばしば、アプリケーションは、パイプライン中で EVAL または EVALSHA を実行したくなることがあるでしょう。これらはすべて可能で、 SCRIPT LOAD コマンドによりサポートされています(これは、 EVALSHA が失敗する危険なく呼び出されることを保証するものです)。