Redis 流水线
如何通过批处理 Redis 命令来优化往返时间
Redis 流水线是一种通过一次发出多个命令而无需等待每个命令的响应来提高性能的技术。大多数 Redis 客户端都支持流水线。本文档介绍了流水线旨在解决的问题以及流水线在 Redis 中的工作原理。
请求/响应协议和往返时间 (RTT)
Redis 是一个使用客户端-服务器模型和所谓的请求/响应协议的 TCP 服务器。
这意味着请求通常通过以下步骤完成:
- 客户端向服务器发送查询,并从套接字读取(通常以阻塞方式),以获得服务器响应。
- 服务器处理该命令并将响应发送回客户端。
例如,四个命令序列如下所示:
- 客户: INCR X
- 服务器: 1
- 客户: INCR X
- 服务器: 2
- 客户: INCR X
- 服务器: 3
- 客户: INCR X
- 服务器: 4
客户端和服务器通过网络链路连接。这样的链路可能非常快(环回接口)或非常慢(通过 Internet 建立的连接,两个主机之间有许多跳数)。无论网络延迟是多少,数据包从客户端传输到服务器,再从服务器返回到客户端以传送回复都需要时间。
这个时间称为 RTT(往返时间)。当客户端需要连续执行许多请求(例如,向同一列表添加许多元素,或用许多键填充数据库)时,很容易看出这会如何影响性能。例如,如果 RTT 时间为 250 毫秒(在互联网连接速度非常慢的情况下),即使服务器每秒能够处理 10 万个请求,我们每秒最多也只能处理 4 个请求。
如果使用的接口是环回接口,则 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 脚本(自 Redis 2.6 开始提供),可以使用执行大量服务器端所需工作的脚本更有效地解决许多流水线用例。脚本的一大优势是它能够以最小的延迟读取和写入数据,使读取、计算、写入等操作非常快(流水线在这种情况下无济于事,因为客户端需要读取命令的回复才能调用写入命令)。
有时应用程序可能还想在管道中发送EVAL或命令。这是完全可能的,Redis 使用SCRIPT LOAD命令明确支持它(它保证可以调用而不会失败)。EVALSHAEVALSHA
附录:为什么即使在环回接口上忙循环也很慢?
即使了解了本页中介绍的所有背景知识,您可能仍然想知道为什么当服务器和客户端在同一台物理机器上运行时,即使在环回接口中执行如下的 Redis 基准测试(伪代码)仍然很慢:
FOR-ONE-SECOND:
Redis.SET("foo","bar")
END
毕竟,如果 Redis 进程和基准测试都在同一个盒子里运行,那么它不是只是将内存中的消息从一个地方复制到另一个地方,而没有任何实际的延迟或网络参与吗?
原因是系统中的进程并非始终在运行,实际上是内核调度程序让进程运行。因此,例如,当允许基准测试运行时,它会读取来自 Redis 服务器的回复(与上次执行的命令相关),并写入新命令。该命令现在位于环回接口缓冲区中,但为了被服务器读取,内核应该调度服务器进程(当前在系统调用中被阻止)运行,依此类推。因此,从实际角度来看,由于内核调度程序的工作方式,环回接口仍然涉及类似网络的延迟。
基本上,繁忙循环基准测试是衡量网络服务器性能时最愚蠢的做法。明智的做法是避免以这种方式进行基准测试。