Redis 管道
如何通过批处理 Redis 命令来优化往返时间
Redis 管道是一种通过一次发出多个命令来提高性能的技术,而无需等待每个命令的响应。管道得到大多数 Redis 客户端的支持。本文档描述了管道旨在解决的问题以及管道在 Redis 中的工作原理。
请求/响应协议和往返时间 (RTT)
Redis 是一个使用客户端-服务器模型和所谓的 *请求/响应* 协议的 TCP 服务器。
这意味着通常一个请求会按照以下步骤完成
- 客户端向服务器发送查询,并从套接字中读取,通常以阻塞方式,以获取服务器响应。
- 服务器处理命令并将响应发回给客户端。
例如,四个命令序列类似于以下情况
- 客户端: INCR X
- 服务器 1
- 客户端: INCR X
- 服务器 2
- 客户端: INCR X
- 服务器 3
- 客户端: INCR X
- 服务器 4
客户端和服务器通过网络连接连接。这种连接可以非常快(环回接口)也可以非常慢(通过互联网建立的连接,两个主机之间有许多跳跃)。无论网络延迟是多少,数据包从客户端传输到服务器,以及从服务器返回到客户端携带回复都需要时间。
这段时间被称为 RTT(往返时间)。当客户端需要连续执行许多请求时(例如,将许多元素添加到同一个列表中,或者用许多键填充数据库),很容易看到这将如何影响性能。例如,如果 RTT 时间为 250 毫秒(对于通过互联网的非常慢的连接),即使服务器能够每秒处理 100k 个请求,我们最多也只能每秒处理四个请求。
如果使用的接口是环回接口,则 RTT 会短得多,通常在毫秒以下,但即使这样,如果你需要连续执行许多写入,也会累加起来。
幸运的是,有一种方法可以改进这种情况。
Redis 管道
可以实现一个请求/响应服务器,使它能够处理新的请求,即使客户端还没有读取旧的响应。这样就可以向服务器发送 *多个命令*,而无需等待任何回复,最后一步再读取所有回复。
这叫做管道,是一种使用了数十年的技术。例如,许多 POP3 协议实现已经支持此功能,极大地加快了从服务器下载新电子邮件的过程。
Redis 从一开始就支持管道,因此无论你运行的是哪个版本,都可以使用 Redis 的管道。这是一个使用原始 netcat 实用程序的例子
$ (printf "PING\r\nPING\r\nPING\r\n"; sleep 1) | nc localhost 6379
+PONG
+PONG
+PONG
这次我们不用为每次调用都支付 RTT 的代价,而只需为三个命令支付一次。
明确地说,使用管道,我们第一个例子的操作顺序将如下所示
- 客户端: INCR X
- 客户端: INCR X
- 客户端: INCR X
- 客户端: INCR X
- 服务器 1
- 服务器 2
- 服务器 3
- 服务器 4
重要提示:虽然客户端使用管道发送命令,但服务器将被迫使用内存对回复进行排队。因此,如果你需要使用管道发送大量命令,最好将它们分成批次,每个批次包含合理数量的命令,例如 10k 个命令,读取回复,然后再次发送另外 10k 个命令,等等。速度几乎相同,但额外的内存使用最多为对这 10k 个命令的回复进行排队所需的内存量。
不仅仅是 RTT 的问题
管道不仅仅是一种减少与往返时间相关的延迟成本的方法,它实际上极大地提高了你在给定 Redis 服务器上每秒可以执行的操作数量。这是因为在不使用管道的情况下,从访问数据结构和生成回复的角度来看,为每个命令提供服务非常便宜,但在进行套接字 I/O 的角度来看,它非常昂贵。这涉及调用 `read()` 和 `write()` 系统调用,这意味着从用户空间到内核空间。上下文切换是一个巨大的速度惩罚。
当使用管道时,许多命令通常使用单个 `read()` 系统调用读取,多个回复使用单个 `write()` 系统调用传递。因此,每秒执行的总查询数量最初几乎随着管道长度线性增加,最终达到不使用管道时获得的基线的 10 倍,如该图所示。
一个真实的代码示例
在下面的基准测试中,我们将使用支持管道的 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
10_000.times do
r.ping
end
end
def with_pipelining
r = Redis.new
r.pipelined do |rp|
10_000.times do
rp.ping
end
end
end
bench('without pipelining') do
without_pipelining
end
bench('with pipelining') do
with_pipelining
end
在我的 Mac OS X 系统上运行上述简单脚本,通过环回接口运行,在环回接口上,管道的改进最小,因为 RTT 已经很低了
without pipelining 1.185238 seconds
with pipelining 0.250783 seconds
如你所见,使用管道,我们将传输速度提高了五倍。
管道与脚本
使用自 Redis 2.6 开始可用的 Redis 脚本,可以使用在服务器端执行大量必要工作的脚本,更有效地解决管道的许多用例。脚本的一大优势是它能够以最小的延迟读取和写入数据,使 *读取、计算、写入* 等操作非常快(管道在这种情况下无济于事,因为客户端需要读取命令的回复才能调用写入命令)。
有时应用程序可能还想在管道中发送 `EVAL` 或 `EVALSHA` 命令。这完全有可能,Redis 使用 SCRIPT LOAD 命令明确支持它(它保证可以调用 `EVALSHA`,而不会有失败的风险)。
附录:为什么即使在环回接口上,繁忙循环也很慢?
即使了解了本页中介绍的所有背景知识,你可能仍然想知道为什么即使在环回接口中执行时,下面的 Redis 基准测试(用伪代码表示)也很慢,而服务器和客户端运行在同一台物理机器上
FOR-ONE-SECOND:
Redis.SET("foo","bar")
END
毕竟,如果 Redis 进程和基准测试都在同一台机器上运行,难道不是仅仅在内存中从一个地方复制消息到另一个地方,没有任何实际的延迟或网络参与吗?
原因是系统中的进程并不总是在运行,实际上是内核调度程序让进程运行。因此,例如,当允许基准测试运行时,它会读取来自 Redis 服务器的回复(与上次执行的命令相关),并写入一个新命令。该命令现在位于环回接口缓冲区中,但为了让服务器读取它,内核应该调度服务器进程(当前在系统调用中阻塞)运行,等等。因此,在实际操作中,环回接口仍然涉及类似网络的延迟,因为内核调度程序的工作方式就是这样。
基本上,当在联网服务器上测量性能时,繁忙循环基准测试是最愚蠢的事情。明智的做法是避免以这种方式进行基准测试。