Redis Pipelining

如何通过批量处理 Redis 命令来优化往返时间

Redis Pipelining 是一种通过一次性发出多个命令而无需等待每个单独命令的响应来提高性能的技术。大多数 Redis 客户端都支持 Pipelining。本文档描述了 Pipelining 旨在解决的问题以及 Pipelining 在 Redis 中的工作原理。

请求/响应协议和往返时间 (RTT)

Redis 是一个 TCP 服务器,使用客户端-服务器模型和所谓的 请求/响应 协议。

这意味着通常一个请求通过以下步骤完成

  • 客户端向服务器发送查询,并(通常以阻塞方式)从套接字读取服务器响应。
  • 服务器处理命令并将响应发送回客户端。

例如,一个四命令序列如下所示

  • 客户端: INCR X
  • 服务器 1
  • 客户端: INCR X
  • 服务器 2
  • 客户端: INCR X
  • 服务器 3
  • 客户端: INCR X
  • 服务器 4

客户端和服务器通过网络链接连接。这样的链接可能非常快(如环回接口)或非常慢(如通过互联网建立的具有许多跳段的连接)。无论网络延迟如何,数据包从客户端传送到服务器,以及服务器将回复传回客户端都需要时间。

这个时间称为 RTT(往返时间)。很容易看出,当客户端需要连续执行许多请求时(例如向同一个列表中添加许多元素,或用许多键填充数据库),这会如何影响性能。例如,如果 RTT 时间为 250 毫秒(在通过互联网的非常慢的链接的情况下),即使服务器每秒能够处理 10 万个请求,我们最多也只能每秒处理四个请求。

如果使用的接口是环回接口,RTT 会短得多,通常是亚毫秒级,但即使如此,如果您需要连续执行许多写入操作,积累起来也会很多。

幸运的是,有一种方法可以改善这种情况。

Redis Pipelining

请求/响应服务器可以实现为即使客户端尚未读取旧响应,也能处理新请求。这样一来,就可以向服务器发送 多个命令,而无需等待任何回复,最后一次性读取所有回复。

这称为 Pipelining(管道),是一种几十年来广泛使用的技术。例如,许多 POP3 协议实现已经支持此功能,极大地加快了从服务器下载新电子邮件的过程。

Redis 从早期版本就支持 Pipelining,因此无论您运行哪个版本,都可以使用 Redis 的 Pipelining 功能。以下是使用原始 netcat 工具的示例

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

这次我们不需要为每个调用支付 RTT 的成本,而只需为这三个命令支付一次。

更明确地说,使用 Pipelining,我们最初示例的操作顺序如下

  • 客户端: INCR X
  • 客户端: INCR X
  • 客户端: INCR X
  • 客户端: INCR X
  • 服务器 1
  • 服务器 2
  • 服务器 3
  • 服务器 4

重要提示:当客户端使用 Pipelining 发送命令时,服务器将被迫将回复排队,这将占用内存。因此,如果您需要使用 Pipelining 发送大量命令,最好分批发送,每批包含合理的数量,例如 1 万个命令,读取回复,然后再发送另外 1 万个命令,以此类推。速度几乎相同,但占用的额外内存最多是排队这 1 万个命令的回复所需的量。

这不仅仅是 RTT 的问题

Pipelining 不仅是一种减少与往返时间相关的延迟成本的方法,它实际上极大地提高了给定 Redis 服务器每秒可以执行的操作数量。这是因为如果不使用 Pipelining,从访问数据结构和生成回复的角度来看,处理每个命令的成本非常低,但从进行套接字 I/O 的角度来看,成本非常高。这涉及调用 read()write() 系统调用,这意味着从用户空间切换到内核空间。上下文切换会带来巨大的速度损失。

使用 Pipelining 时,通常通过一次 read() 系统调用读取多个命令,并通过一次 write() 系统调用发送多个回复。因此,每秒执行的总查询数量最初随着管道长度的增加而几乎线性增加,最终达到不使用 Pipelining 时基线的 10 倍,如图所示。

Pipeline size and IOPs

一个真实世界的代码示例

在下面的基准测试中,我们将使用支持 Pipelining 的 Redis Ruby 客户端来测试由于 Pipelining 带来的速度提升。

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 系统上通过环回接口运行上述简单脚本,结果如下所示。在这种情况下,Pipelining 带来的提升最小,因为 RTT 已经相当低。

without pipelining 1.185238 seconds
with pipelining 0.250783 seconds

如您所见,使用 Pipelining 后,传输速度提高了五倍。

Pipelining 与脚本

自 Redis 2.6 起可用的 Redis 脚本 功能,可以通过在服务器端执行大量所需工作的脚本,更高效地解决 Pipelining 的许多用例。脚本的一个巨大优势是它能够以最小的延迟读写数据,使 读取、计算、写入 等操作变得非常快(Pipelining 在这种场景下无法提供帮助,因为客户端需要先获取读取命令的回复,然后才能调用写入命令)。

有时应用程序可能还想在管道中发送 EVALEVALSHA 命令。这是完全可能的,Redis 通过 SCRIPT LOAD 命令明确支持这一点(它保证 EVALSHA 可以被调用而不会有失败的风险)。

附录:为什么即使在环回接口上,忙等待循环也很慢?

即使了解了本文涵盖的所有背景知识,您可能仍然想知道,当服务器和客户端运行在同一台物理机器上时,为什么像下面这样的 Redis 基准测试(伪代码)即使在环回接口上执行也很慢?

FOR-ONE-SECOND:
    Redis.SET("foo","bar")
END

毕竟,如果 Redis 进程和基准测试都运行在同一个盒子中,难道不只是在内存中从一个地方向另一个地方复制消息,而不涉及任何实际延迟或网络吗?

原因是系统中的进程并非总是运行,实际上是内核调度器允许进程运行。因此,例如,当允许基准测试运行时,它会从 Redis 服务器读取回复(与最后执行的命令相关),然后写入新命令。该命令现在在环回接口缓冲区中,但为了被服务器读取,内核应该调度服务器进程(当前阻塞在系统调用中)运行,依此类推。因此,实际上,环回接口仍然涉及类似网络的延迟,这是由于内核调度器的工作方式造成的。

基本上,在对网络服务器进行性能测量时,忙等待循环基准测试是最愚蠢的做法。明智的做法是避免以这种方式进行基准测试。

为此页评分
返回顶部 ↑