(译)使用流水线加速Redis查询

本文翻译自Using pipelining to speedup Redis queries

请求/响应协议和RTT

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

这意味着一个处理一个请求通常要经过以下几个步骤:

  • 客户端向服务器发送一个查询,并从套接字上读取从服务器返回的响应,这一般是阻塞的。
  • 服务器处理命令并返回响应给客户端。

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

  • Client: INCR X
  • Server: 1
  • Client: INCR X
  • Server: 2
  • Client: INCR X
  • Server: 3
  • Client: INCR X
  • Server: 4

客户端和服务器使用网络连接在一起。连接可以是非常快速的(比如一个回环接口),也可以是非常慢的(Internet中中间需要很多跳的两台主机之间)。无论网络的延迟是多少,请求数据包从客户端到达服务器,和应答数据包从服务器返回客户端都需要时间。

这个时间叫做RTT(往返时间)。很容易发现当客户端需要一次执行多个请求时(比如向同一个列表中添加多个元素,或者向数据库中填充多个键),RTT对性能的影响。比如假设RTT是250ms(Internet中的非常慢速的连接),即使服务器每秒能执行100k个请求,我们一秒中最多只能处理四个请求。

如果使用回环接口,RTT会小很多(比如我的主机上ping 127.0.0.1的时间是0.044ms),但如果你要一次执行多个写入请求,延时还是太高。

幸运的是有一种方式可以改进这个问题。

Redis流水线

一个请求/响应服务器可以被实现成尽管客户端还没从上一个请求中读取到响应时也可以处理新的请求。使用这种方式就可以在不等待响应的情况下x向服务器发送多个命令,最后一次性读取多有响应。

这种方式叫做流水线,是十几年来广泛使用的技术。比如很多POP3协议的实现已经支持这种特性,大大加快了从服务器上下载新邮件的速度。

Redis支从很早就开始支持流水线,所以不论你使用哪个版本,你都可以使用Redis的流水线功能。下面是一个使用netcat工具的例子:

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

这时我们不需要在每个命令上都等待RTT,取而代之是三个命令只需要一次RTT。

为了更清楚地说明,我们最初那个例子通过使用流水线会变成下面这样:

  • Client: INCR X
  • Client: INCR X
  • Client: INCR X
  • Client: INCR X
  • Server: 1
  • Server: 2
  • Server: 3
  • Server: 4

特别注意:当客户端使用流水线发送命令,将迫使服务器使用内存在将响应信息排队。所以如果你需要使用流水线发从大量命令,比较好的做法是将它们按照一个合理的大小分成多个批次,比如使用流水线一次性发送10k个命令,然后读取响应,再发送另外的10k个命令,以此类推。这样每个批次的处理速度几乎是相同的,但是为了排队这10k个命令的响应需要消耗大量内存。

不仅仅是减少了RTT

流水线不仅仅是为了减少了由于往返时间造成的延迟成本,它实际上提高了一个给定的Redis服务器每秒能执行的操作数。事实是,不使用流水线的情况下,从访问数据结构和产生响应的角度看,处理每个命令的代价都非常低廉,但从套接字I/O的角度看,代价却非常高。因为这涉及到read()和write()系统调用,这需要从用户态陷入内核态。这种上下文切换的代价非常高。

当使用流水线时,使用一个read()系统调用即可读取多个命令,并且使用一个write()系统调用即可发送多个应答。因此,Redis每秒执行的查询总数随着流水线的长度增加几乎呈线性增长,相比不使用流水线,最终能达到10倍的增长,如下图:

流水线长度和每秒操作数的关系图

一些现实的代码样例

在下面的基准测试中我们将使用支持流水线的Redis的Ruby客户端,来测试使用流水线带来的速度提升:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
require 'rubygems'
require 'redis'

def bench(descr)
start = Time.now
yield
puts "#{descr} #{Time.now-start} seconds"
end

def without_pipelining
r = Redis.new
10000.times {
r.ping
}
end

def with_pipelining
r = Redis.new
r.pipelined {
10000.times {
r.ping
}
}
end

bench("without pipelining") {
without_pipelining
}
bench("with pipelining") {
with_pipelining
}

在Mac OS X上运行上面的简单脚本将提供给我们下面的数据,运行在回环接口上对速度的提升最小,因为回环接口的RTT已经非常小了:

without pipelining 1.185238 seconds
with pipelining 0.250783 seconds

如你所见,使用流水线我们将传输效率提高了5倍。

流水线 VS 脚本

使用Redis脚本(Redis 2.6或更高版本支持),一些原本使用流水线的情况如果使用服务端脚本来做效率会更高。脚本的一大优点是它能够以最小的延迟读取和写入数据,使像读、计算、写这类操作非常快(流水线在这种情况下帮助不大,因为客户端在执行写入之前需要先读取应答)。

有时应用可能需要在一个流水线中发送EVAL或EVALSHA命令。Redis明确地支持SCRIPT LOAD命令来应对这种情况(它保证EVALSHA可以在没有失败风险的情况下被调用)。

附录:为什么忙循环即使是在回环接口上运行还是很慢?

尽管阅读了本文的所有信息,你可能还是奇怪为什么像下面这种基准测试(伪代码中),即使是在回环接口上运行(客户端和服务器运行在同一台物理机上)还是很慢:

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

毕竟如果Redis进程和基准测试在同一个环境下运行,这种情况下消息在内存中从一处拷贝到另一处不是不会受到任何延迟和网络的影响吗?

原因是系统中的进程并不总是在运行,实际上是内核调度器让进程运行,因此情况是,比如基准测试被允许运行,从Redis服务器中读取响应(与最后一个被执行的命令相关的),并且发送一个新命令给服务器。现在命令被存储在回环接口缓冲区中,但是为了让服务器读取命令,内核将调度Redis服务器进程(当前正在被一个系统调用阻塞)运行,以此类推。因此,实际上由于内核调度器的工作原理,回环接口还是会有类似网络延迟的延迟存在。

基本上使用回环接口对网络服务器的性能做基准测试是最愚蠢的事情了。明智的做法是避免使用这种方式做基准测试。