文档:管道

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

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

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

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

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

因此,例如,一个四命令序列是这样的:

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

客户端和服务器通过网络链接连接。这样的链接可以非常快(例如回环接口)或非常慢(例如通过互联网建立的连接,两个主机之间有许多跳)。无论网络延迟如何,数据包从客户端传输到服务器,以及从服务器返回到客户端以携带回复都需要时间。

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

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

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

Valkey 管道

可以实现请求/响应服务器,使其即使在客户端尚未读取旧响应的情况下也能处理新请求。这样,就可以向服务器发送多个命令而完全无需等待回复,最后在一个步骤中读取所有回复。

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

Valkey 从早期版本开始就支持管道,因此无论您运行的是哪个版本,都可以在 Valkey 中使用管道。这是一个使用原始 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

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

这不仅仅是 RTT 的问题

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

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

Pipeline size and IOPs

一个真实的代码示例

在下面的基准测试中,我们将使用一个支持管道的 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
    10_000.times do
      r.ping
    end
  end
end

bench('without pipelining') do
  without_pipelining
end
bench('with pipelining') do
  with_pipelining
end

在我的 MacOS 系统上,通过回环接口运行上述简单脚本会得到以下数据,在这种情况下,由于 RTT 已经相当低,管道提供的改进将是最小的:

without pipelining 1.185238 seconds
with pipelining 0.250783 seconds

如您所见,使用管道,我们将传输速度提高了五倍。

管道 vs 脚本

使用 Valkey 脚本,许多管道用例可以通过在服务器端执行大量所需工作的脚本更高效地解决。脚本的一个巨大优势是它能够以最小的延迟读取和写入数据,使得像读取、计算、写入这样的操作非常快速(在这种情况下,管道无济于事,因为客户端在调用写入命令之前需要读取命令的回复)。

有时应用程序可能还希望在管道中发送 EVALEVALSHA 命令。这完全可行,并且 Valkey 使用 SCRIPT LOAD 命令明确支持这一点(它保证 EVALSHA 可以被调用而没有失败的风险)。

附录:为什么忙循环在回环接口上也很慢?

即使有了本页涵盖的所有背景知识,您可能仍然会疑惑,为什么像下面这样的 Valkey 基准测试(伪代码),即使在服务器和客户端运行在同一物理机器上的回环接口中执行,也会很慢:

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

毕竟,如果 Valkey 进程和基准测试都运行在同一个盒子里,那不就是简单地在内存中将消息从一个地方复制到另一个地方,不涉及任何实际的延迟或网络吗?

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

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