使用管道加速redis的查询

请求/响应模型和RTT

Redis的TCP的服务采用Client/Server模型。意味着一次查询将遵循以下步骤:

  • 客户端发起请求阻塞直到服务器响应。
  • 服务器会处理请求操作的命令,并返回结果。

例如下面的四次的命令执行

1
2
3
4
5
6
7
8
127.0.0.1:6379> incr x
(integer) 1
127.0.0.1:6379> incr x
(integer) 2
127.0.0.1:6379> incr x
(integer) 3
127.0.0.1:6379> incr x
(integer) 4

客户端和服务端的通讯是建立在网络之上,如果是回环链路(本地网络)将是非常快的,相反互联网之上将相对较慢。但是无论在哪种网络下,数据包从客服端到服务端,再由服务端返回到客户端并接收的一个周期我们叫做RTT(Round Trip Time),这样我们可以参考RTT时间来优化性能(比如插入大量的元素,或者添加大量的数据库键值)在回环链路的RTT是非常短的(ping 127.0.0.1 可以看到结果),但是我们写入大量数据时,性能依旧是个问题。

Redis 管道

Redis 管道的就是将多次需要执行的指令合并成一次传输,服务端会一次返回每个指令的结果。如:

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

回到上面的例子,使用管道进行发送

1
2
3
4
$ (printf "incr x\r\nincr x\r\nincr x\r\n"; sleep 1) | redis-cli --pipe
All data transferred. Waiting for the last reply...
Last reply received from server.
errors: 0, replies: 3

特别注意: 如果客户端通过管道发送大量的命令,服务端将会使用内存队列缓存每个命令结果。所以在发送时需要控制命令的数量。

不只是RTT的问题

使用管道是可以上少网络请求,从而减少RTT耗时。实际上除了网络层上面的时间消耗,使用非管道进行操作时,redis需要频繁的进行Socket I/O的read()和write() 频繁的上下文切换,是一笔巨大的开销。如果使用了管道,只需要一次的read(),,同理write()也只需一次系统调用,极大的减少了开销。这样每秒执行的总查询数,从最初几乎呈线性增加,最终达到不使用管道方法基准的10倍,

xdebug-listening

但是实际测试中却达到了惊人的400倍?基于Go语言的测试代码:

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// Redis server版本为5.0.4 On Mac OS X
package main

import (
"fmt"
"net"
"strings"
"time"
)

func main() {
start := time.Now()
pipelining()
fmt.Printf("with pipelining: %s\n", time.Now().Sub(start))
start = time.Now()
withoutPipelining()
fmt.Printf("without pipelining: %s\n", time.Now().Sub(start))
}

func withoutPipelining() {
conn, err := net.Dial("tcp4", "127.0.0.1:6379")
if err != nil {
return
}
defer conn.Close()
for i := 0; i < 10000; i++ {
_, _ = conn.Write([]byte("PING\r\n"))
var b = make([]byte, 7)
_, _ = conn.Read(b)
}
}

func pipelining() {
conn, err := net.Dial("tcp4", "127.0.0.1:6379")
if err != nil {
return
}
defer conn.Close()
var pings strings.Builder
for i := 0; i < 10000; i++ {
pings.WriteString("PING\r\n")
}
_, _ = conn.Write([]byte(pings.String()))
var b = make([]byte, 7000)
_, _ = conn.Read(b)
}
1
2
with pipelining: 20.74797ms
without pipelining: 8.502332207s

管道和脚本对比

Redis在2.6之后的版本中支持脚本。管道的一些例子可以直接转换到脚本方式更高的效率执行。特别是需要在服务端进行大量计算时,脚本可以最小延迟的执行,使得读取,计算,写入等操作变的非常快。

扩展:为什么在本地回环链路上循环执行操作还是很慢?

1
2
3
4
# 伪代码
FOR-ONE-SECOND:
Redis.SET("foo","bar")
END

这段代码不停的进行SET操作,本地回环链路中,都是在同一台机子测试时,只有内存的数据拷贝,实际上应该是不会有额外的损耗的?但是却却相反,依然很慢。这是为什么呢?真实原因是系统不是一直都运行某个程序的,任何程序都是在系统的内核调度下运行,所以频繁SET系统要进行频繁的调度才能最终完成任务,本质上还是操作太多的IO操作,才导致性能下降。这就是为什么本地环境下测试依然很慢的原因。