MicroAd Developers Blog

マイクロアドのエンジニアブログです。インフラ、開発、分析について発信していきます。

Redis Pipelineによる通信を解析してみる

はじめに

こんにちは。マイクロアドでソフトウェアエンジニアをしている飛田と申します。

最近、業務でストリーム処理アプリケーションのスループットを改善する必要がありました。特に、一つのリクエストに対して複数回Redisへの参照や書き込みを行う必要があるアプリケーションだったため、Redis周りの処理時間がボトルネックとなっていました。そこで、Redis Pipelineを導入したところ、パフォーマンスの向上を実感しました。

リリース後、Pipelineがどのように効率化を実現しているかをさらに理解したくなり、通信の仕組みを解析することにしました。

本記事では、Redis Pipelineの基本的な動作および実際の通信内容について解説していきます。

Redis Pipelineの基本的な動作

Redis Pipelineは、Redisサーバーとの通信を効率化するための機能です。 通常、Redisにコマンドを送信するときは、クライアントからサーバーにコマンドを送り、その結果を受け取るまで待機します。この通信の往復により、特に大量のコマンドを送信する場合には大きなオーバーヘッドが発生します。

従来の通信とパイプラインの違い

通常のRedis通信では、クライアントが1つのコマンドを送信し、その結果を受信する動作を繰り返します。 この方式では各コマンド送信ごとにネットワーク遅延が発生します。

例えば、100個のコマンドを送る場合、100回の送受信が行われ、それぞれに通信のラウンドトリップタイム(RTT)が加わります。

一方、Redis Pipelineは、複数のコマンドをまとめてサーバーに送信しその結果をまとめて受け取ることで、通信回数とネットワーク遅延を削減します。 クライアントはサーバーに連続してコマンドを送り、それらが全て処理された後にまとめて結果を受け取ります。これにより、送信と受信の回数が1回ずつで済むため、大量のコマンドを効率的に処理できます。

実験

通常のRedis通信とRedis Pipelineによる通信の違いを調査するため、それぞれのパケットをキャプチャして解析します。両者がどのように通信しているのか、具体的な違いを明らかにしていきます。

この実験では、SETコマンドを3回送った後、GETコマンドを3回送信します。

まずは準備として、以下のdocker-compose.ymlを使用してRedisサーバーを起動します。

version: '3.8'

services:
  redis:
    image: "redis:latest"
    ports:
      - "6379:6379"

以降、実験結果について示しますが、 ローカルPCのDocker上でRedisサーバーを起動し、ローカルPCのPythonのコードからRedisと通信しています。

通常のRedis通信を解析

まず、通常のRedis通信のパケットを解析してみます。 以下は、実験に使用するRedisと通信するPythonスクリプトです。

import redis # redis-pyを使用: https://github.com/redis/redis-py

client = redis.Redis(host='localhost', port=6379)

print(client.set('key1', 'value1'))
print(client.set('key2', 'value2'))
print(client.set('key3', 'value3'))

print(client.get('key1'))  # b'value1'
print(client.get('key2'))  # b'value2'
print(client.get('key3'))  # b'value3'

以下の表はRedisへのSETコマンドの送信をパケットキャプチャした結果です。

各SETコマンドが個別に送信され、それぞれ応答を受け取るため、RESP(Redis Serialization Protocol)によるリクエスト/レスポンス間にACKパケットが存在しています。

No. Time Source Destination Protocol Length Info
15 0.087359 ::1 ::1 RESP 111 Request: SET key1 value1
16 0.087374 ::1 ::1 TCP 76 6379 > 52440 [ACK] Seq=11 Ack=146 Win=407616 Len=0 TSval=4233421079 TSecr=3046015615
17 0.087696 ::1 ::1 RESP 81 Response: OK
18 0.087706 ::1 ::1 TCP 76 52440 > 6379 [ACK] Seq=146 Ack=16 Win=407744 Len=0 TSval=3046015615 TSecr=4233421079
19 0.087819 ::1 ::1 RESP 111 Request: SET key2 value2
20 0.087827 ::1 ::1 TCP 76 6379 > 52440 [ACK] Seq=16 Ack=181 Win=407616 Len=0 TSval=4233421079 TSecr=3046015615
21 0.088063 ::1 ::1 RESP 81 Response: OK
22 0.088076 ::1 ::1 TCP 76 52440 > 6379 [ACK] Seq=181 Ack=21 Win=407744 Len=0 TSval=3046015617 TSecr=4233421081
23 0.088279 ::1 ::1 RESP 111 Request: SET key3 value3
24 0.088289 ::1 ::1 TCP 76 6379 > 52440 [ACK] Seq=21 Ack=216 Win=407552 Len=0 TSval=4233421081 TSecr=3046015617
25 0.088558 ::1 ::1 RESP 81 Response: OK
26 0.088569 ::1 ::1 TCP 76 52440 > 6379 [ACK] Seq=216 Ack=26 Win=407744 Len=0 TSval=3046015617 TSecr=4233421081

Redis Pipelineによる通信を解析(1)

次にRedis Pipelineの通信のパケットを解析してみます。 以下は、実験に使用するRedisと通信するPythonスクリプトです。

import redis # redis-pyを使用: https://github.com/redis/redis-py

client = redis.Redis(host='localhost', port=6379)

pipeline = client.pipeline()

pipeline.set('key1', 'value1')
pipeline.set('key2', 'value2')
pipeline.set('key3', 'value3')

results = pipeline.execute()

for result in results:
    print(result)

print(client.get('key1'))  # b'value1'
print(client.get('key2'))  # b'value2'
print(client.get('key3'))  # b'value3'

以下の表はRedisへのSETコマンドの送信をパケットキャプチャした結果です。 MULTIコマンドを使って複数のSETコマンドを一度に送信し、まとめて実行されています。

複数のSETコマンドを一度のリクエストで送信し、それに対する応答も一度に返されるため、通常の単発リクエスト/応答の通信に比べて、ネットワーク上の往復回数が減少します。

No. Time Source Destination Protocol Length Info
13 0.002088 ::1 ::1 RESP 210 Request: MULTI SET key1 value1 SET key2 value2 SET key3 value3 EXEC
14 0.002100 ::1 ::1 TCP 76 6379 > 54520 [ACK] Seq=11 Ack=245 Win=407552 Len=0 TSval=2625827618 TSecr=1789556526
15 0.002449 ::1 ::1 RESP 127 Response: OK QUEUED QUEUED QUEUED Array(3)
16 0.002460 ::1 ::1 TCP 76 54520 > 6379 [ACK] Seq=245 Ack=62 Win=407680 Len=0 TSval=1789556527 TSecr=2625827619

Redis Pipelineによる通信を解析(2)

「Redis Pipelineによる通信を解析(1)」では、 SETコマンドを3回送信し、3つのコマンドが1つのパケットとして送信されることが確認できました。

実際のユースケースではもっと大きなデータを投入することが想定されますので、 次に1000件のSETコマンドを送信する実験をしてみました。

実験に使用するPythonのスクリプト:

import redis

client = redis.Redis(host='localhost', port=6379)

pipeline = client.pipeline()

for i in range(0, 1000):
    pipeline.set(f'key{i}', f'value{i}')

results = pipeline.execute()

以下は、パケットキャプチャ結果です。

すべてのSETコマンドが、MULTI-EXECで1つのトランザクションになっていることが確認できます。

また、「Redis Pipelineによる通信を解析(1)」で行った実験とは異なり、SETコマンド群が複数パケットに分かれていることも確認できます。

No. Time Source Destination Protocol Length Info
15 2.052071 ::1 ::1 RESP 6111 Request: MULTI SET key0 value0 SET key1 value1 ... SET key159 value159
16 2.052077 ::1 ::1 RESP 6082 Request: SET key160 value160 SET key161 value161 ... SET key313 value313
17 2.052080 ::1 ::1 TCP 76 6379 > 55103 [ACK] Seq=11 Ack=6146 Win=401600 Len=0 TSval=2425214130 TSecr=1791217777
18 2.052081 ::1 ::1 RESP 6082 Request: SET key314 value314 SET key315 value315 ... SET key467 value467
19 2.052084 ::1 ::1 TCP 76 6379 > 55103 [ACK] Seq=11 Ack=12152 Win=395648 Len=0 TSval=2425214130 TSecr=1791217777
20 2.052088 ::1 ::1 TCP 76 6379 > 55103 [ACK] Seq=11 Ack=18158 Win=389632 Len=0 TSval=2425214130 TSecr=1791217777
21 2.052089 ::1 ::1 RESP 6082 Request: SET key468 value468 SET key469 value469 ... SET key621 value621
22 2.052091 ::1 ::1 TCP 76 6379 > 55103 [ACK] Seq=11 Ack=24164 Win=383616 Len=0 TSval=2425214130 TSecr=1791217777
23 2.052093 ::1 ::1 RESP 6082 Request: SET key622 value622 SET key623 value623 ... SET key775 value775
24 2.052100 ::1 ::1 RESP 6082 Request: SET key776 value776 SET key777 value777 ... SET key929 value929
25 2.052101 ::1 ::1 TCP 76 6379 > 55103 [ACK] Seq=11 Ack=30170 Win=377600 Len=0 TSval=2425214130 TSecr=1791217777
26 2.052103 ::1 ::1 RESP 2820 Request: SET key930 value930 SET key931 value931 ... SET key999 value999 EXEC
27 2.052104 ::1 ::1 TCP 76 6379 > 55103 [ACK] Seq=11 Ack=36176 Win=371584 Len=0 TSval=2425214130 TSecr=1791217777
28 2.052108 ::1 ::1 TCP 76 6379 > 55103 [ACK] Seq=11 Ack=38920 Win=398912 Len=0 TSval=2425214130 TSecr=1791217777
29 2.053092 ::1 ::1 RESP 4131 Response: OK QUEUED QUEUED ... QUEUED
30 2.053108 ::1 ::1 TCP 76 55103 > 6379 [ACK] Seq=38920 Ack=4066 Win=403712 Len=0 TSval=1791217778 TSecr=2425214131
31 2.053460 ::1 ::1 RESP 4801 Response: QUEUED QUEUED ... QUEUED
32 2.053473 ::1 ::1 TCP 76 55103 > 6379 [ACK] Seq=38920 Ack=8791 Win=398976 Len=0 TSval=1791217778 TSecr=2425214131
33 2.054312 ::1 ::1 RESP 5308 Response: QUEUED QUEUED QUEUED QUEUED QUEUED QUEUED QUEUED QUEUED QUEUED QUEUED QUEUED QUEUED QUEUED QUEUED QUEUED QUEUED QUEUED QUEUED QUEUED QUEUED QUEUED QUEUED QUEUED QUEUED QUEUED Array(1000)

※ 大量のSETコマンドが投げられているパケットやそのレスポンスのパケットにつきましては、Request: MULTI SET key0 value0 SET key1 value1 ... SET key159 value159のように...で略記しています

(参考) 通常のRedis通信とRedis Pipelineで実行時間を比較する

上記で行った複数の実験により、Redis Pipelineは送受信するパケット数が少ないということが判明しました。 そのため、ラウンドトリップ時間が節約できて、バルクインサートを高速に完了できることが期待されます。

ここでは、荒い実験として1万件のSETコマンドにかかる実行時間を、通常のRedis通信Redis Pipelineによる通信で比較してみました。

以下は比較のためのPythonスクリプトです。

import redis
import time

NUM_ITER = 10000
client = redis.Redis(host='localhost', port=6379)

# Redis PipelineのSETの実行時間を計測
start_time = time.time()
pipeline = client.pipeline()
for i in range(0, NUM_ITER):
    pipeline.set(f'key{i}', f'value{i}')
results = pipeline.execute()
end_time = time.time()
execution_time = end_time - start_time
print(f"Redis Pipeline 実行時間: {execution_time} 秒")

# 通常のRedisのSETの実行時間を計測
start_time = time.time()
for i in range(0, NUM_ITER):
    client.set(f'key{i}', f'value{i}')
end_time = time.time()
execution_time = end_time - start_time
print(f"通常 Redis 実行時間: {execution_time} 秒")

実行結果:

Redis Pipeline 実行時間: 0.0724639892578125 秒
通常 Redis 実行時間: 3.4237308502197266 秒

荒い実験の結果、Redis Pipelineによる通信にかかった時間は、通常のRedis通信と比較して、2%程度になりました。 このことから、バルクインサートについてはRedis Pipelineの方が速度面で優れているということが分かります。

おわりに

今回の実験を通じて、Redis Pipelineを用いることで、複数のコマンドをまとめて処理し、通信の往復回数を減らすことができるため、パフォーマンスが大幅に向上することが確認できました。

特に、大量のデータを書き込む場合においては、通常の個別リクエストよりもPipelineを使用することで、ネットワーク遅延やオーバーヘッドを最小限に抑えられそうですね。