Redis是一个CS结构的TCP服务器,使用”请求-应答”的模式。,客户端发起一个请求是这样的步骤:
- 客户端发送一个请求给服务器,然后等待服务器的响应,一般客户端使用阻塞模式来等待服务器响应。
- 服务器收到请求并处理完毕后,发送结果给客户端。
举个例子,发送下面4个命令大概就是这样的顺序:
- 客户端发送: INCR X
- 服务器响应: 1
- 客户端发送: INCR X
- 服务器响应: 2
- 客户端发送: INCR X
- 服务器响应: 3
- 客户端发送: INCR X
- 服务器响应: 4
客户端和服务器通过网络连接,网速可以非常快, 也可以非常慢。不管是快还是慢,消息包从客户端到服务器,再从服务器返回到客户端,总是要需要时间的。这个时间被称之为RTT(Round Trip Time,往返延时)。显然,当客户端需要发送多条请求时(比如往一个list中加很多元素,或者往一个数据库中填充很多keys),这个往返延时会影响到性能。假设网络非常慢,往返延时达到250毫秒,就算服务器每秒可以处理10万个请求,客户端也只能每秒处理4个请求。就算使用环回接口,往返延时非常小,如果需要执行很多写的操作, 也是要浪费许多时间的。
幸好我们有办法改进这种情况。
Redis Pipelining
“请求-响应”模式的服务器在处理完一个请求后就开始处理下一个请求,不管客户端是否读取到前一个请求的响应结果。这让客户端不需要发一个请求等一个响应的串行,可以一次发送多个请求,再最后一次性读取所有响应。这就叫pipelining(管道化),这种技术几十年来广泛的使用。比如很多POP3协议支持这个特性,大大的加速了从服务器上下载新邮件的速度。
Redis在很早的版本就支持pipeling,所以无论你用的是什么版本的redis,都可以用pipelining。下面是一个使用netcat的演示例子:
$ (printf "PING\r\nPING\r\nPING\r\n"; sleep 1) | nc localhost 6379
+PONG
+PONG
+PONG
我们的第一个例子,如果使用pipeline,客户端的请求和服务器的响应顺序就是如下:
- 客户端发送: INCR X
- 客户端发送: INCR X
- 客户端发送: INCR X
- 客户端发送: INCR X
- 服务器响应: 1
- 服务器响应: 2
- 服务器响应: 3
- 服务器响应: 4
注意:当客户端使用pipelining发送很多请求时,服务器将在内存中使用队列存储这些指令的响应。所以批量发送的指令数量,最好在一个合理的范围内,比如每次发1万条指令,读取完响应后再发送另外1万条指令。2万条指令,一次性发送和分2次发送,对客户端来说速度是差不多的,但是对服务器来说,内存占用差了1万条响应的大小。
性能测试
下面是我们用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
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系统中运行上面的脚本,由于使用环回接口往返延时非常小,这样pipeling带来的优化非常小。可以得到下面的结果:
without pipelining 1.185238 seconds
with pipelining 0.250783 seconds
从结果可以看到,使用pipelining技术,我们的传输速度提高了5倍。
管道化 VS 脚本
大部分使用pipelining的情况都可以用Redis脚本(2.6或高于2.6的版本才支持)来代替,使之更高效的在服务器端执行。使用脚本的最大好处是,在最小的延迟下可以读和写,比如可以:让“读,计算,写”这样一个流程非常快(pipeling不能处理这种情景,因为客户端需要得到响应之后才能计算和写)。有时候,应用程序可能需要在一个pipeline中发送多个EVAL或EVALSHA指令,redis的SCRITP LOAD指令能很好的满足这种需求(它保证了EVALSHA不会有调用失败的风险)。