请求/响应网关和RTT
Redis是一个使用被称为请求/响应网关的客户机-服务器(client-server)模型的TCP服务器。
这意味着一个请求通常通过以下步骤完成的:
- 客户端像服务器端发送一个请求,然后从套接字中读取服务器端的响应,通常以阻塞方式读取。
- 服务器端执行完命令,然后发送响应返回给客户端。
例如,一个四个指令的序列是这样的:
- Client: INCR X
- Server: 1
- Client: INCR X
- Server: 2
- Client: INCR X
- Server: 3
- Client: INCR X
- Server: 4
客户端和服务器端通过网络连接起来。网络可能非常快(一个环回接口),或者非常慢(两个主机通过Internet建立起来的多跳连接)。不管延迟是多少,将包从客户端发送到服务端,然后携带服务器端的回复返回到客户端,都需要时间。
这个时间被称为RTT(往返时间 Round Trip Time)。当客户端需要连续执行许多请求时(例如,向同一个列表中添加许多元素,或使用很多键填充一个数据库),能够非常容易的看到它是如何影响性能的。例如,如果RTT时间是250毫秒(在这个例子中是一个非常慢的Internet连接),即使服务器能够每秒处理100K请求,我们也只能最多每秒处理4个请求。
如果使用的接口是环回接口,RTT将会非常短(例如,ping 127.0.0.1我的主机报告的0,044毫秒),但是当你执行大量的连续写入操作时RTT仍然是很多的。
幸运的是,有一个方法可以改进此用例。
Redis Pipelining Redis管道
可以实现一个请求/响应服务器,这样即使客户端还没有准备好读取旧的响应时,它都能够处理新的请求。这种方式能够给服务器发送多命令而不用等待响应,并且最后在单个步骤里读取应答。
这被称为管道,一个数十年前就被广泛使用的技术。例如,许多POP3网关的实现已经支持这个特性,显著的提升从服务器下载新邮件的过程。
Redis在很早之前就支持管道了,所以,不管你现在运行的是什么版本的Redis,都可以使用管道。这是一个使用netcat程序的例子:
$ (printf "PING\r\nPING\r\nPING\r\n"; sleep 1) | nc localhost 6379
+PONG
+PONG
+PONG
这次我们并没有在每次调用时在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服务的每秒最大处理数量提升一大截。这个事实的结果是,不使用管道时,从为每个命令提供访问数据结构和产生回复的视角看,提供服务的成本非常低,但是socket接口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
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
}
Running the above simple script will provide the following figures in my Mac OS X system, running over the loopback interface, where pipelining will provide the smallest improvement as the RTT is already pretty low:
在我的Mac OSX系统上运行上面的简单脚本,将会提供如下的图表,运行在环回接口上,管道将会提供最小的改进,因为RTT已经是相当的低了:
without pipelining 1.185238 seconds
with pipelining 0.250783 seconds
如你所见,使用管道,我们将传输提高了5倍。
管道 VS 脚本
有大量的用例在使用Redis脚本(Redis2.6及更高的版本可用)可以解决管道的问题,当服务器端有大量的任务需要执行时,使用脚本可以让管道变得更高效。脚本的一大优势是,可以使用最小化的延迟读写数据,以非常快的速度读,计算,写(在这个场景下管道可能没有太多用,因为客户端在可以调用写命令之前,需要获得读命令的回复)。
有时应用也可能要在管道中发送EVAL 和 EVALSHA命令。这是完全有可能的,Redis中可以使用SCRIPT LOAD命令(它确保EVALSHA可以被没有失败风险的调用)。
附录:为什么即使在环回接口上一个忙碌的循环也会慢
即使了解了本页的所有背景,你可能仍然想要知道为什么Redis基准测试会像下面这样(伪代码),展示了在环回接口中执行,服务器端和客户端运行在同样的物理机器上:
FOR-ONE-SECOND:
Redis.SET("foo","bar")
END
毕竟,当Redis进程和基准测试都运行在同样的盒子里,难道这不应该仅是从内存的一个地方复制到另外一个地方,而没有延迟和不涉及到网络吗?
原因在于,在内存中进程并不总是在运行中,实际上是内核的调度让它运行,因此发生了,例如,基准测试允许运行时从Redis服务器读取回复(关联到最近执行的命令),然后写入一个新的命令。现在命令在换回网络接口缓存上,但是为了从服务器读取,内核应该调度服务端进程运行(目前被系统调用阻塞),等等。因此在实际操作中,由于内存调度工作方式的原因,环回接口任务仍然涉及类网络延迟(network-alike)。
基本上,在测量服务端性能的时候,一个忙碌的循环基准测试是最笨的事情。聪明的方式是避免使用这种方式做基准测试。