Redis 是一个使用 CS 模型和请求/响应协议的 TCP 服务器。

也就是说一般通过下列步骤完成一次请求:

  • 客户端向服务器发送请求,之后通常使用阻塞的方式从 socket 中读取服务器响应。

  • 服务处理命令并将响应发送会客户端。

比如有如下四条这样的命令:

  • Client: INCR X

  • Server: 1

  • Client: INCR X

  • Server: 2

  • Client: INCR X

  • Server: 3

  • Client: INCR X

  • Server: 4

客户端和服务器通过网络互连。 像这样的连接可以非常快(例如本地回环接口)也可以非常慢(两台中间间隔很多跳的主机通过互联网相连)。 不管网络延迟如何,将数据包从客户端发送到服务器,然后再从服务器返回响应都需要耗费时间。

这段耗时被称为 RTT (往返时间) 当一个客户端要连续执行大量请求时(比如,添加大量元素到同一列表,再或者向数据库中填充大量键)可以很明显的看到其对性能的影响。 比如,RTT 为 250 毫秒时(这在互联网上算是很慢的连接速度了),即便服务器的性能每秒能处理10万个请求,但实际上我们每秒只能处理4个请求。

1000 毫秒 = 1 秒

如果使用的是本地回环接口,那么RTT会非常短(比如,在我自己的机器上ping 127.0.0.1时显示44毫秒)

幸运的是我们还有办法进行改进。

Redis 流水线(Redis Pipelining)

可以实现一个基于请求/响应将的服务器,这样一来即使客户端还没有读取之前的响应,也能处理心请求。 这样我们就可以在不等待回复的情况下向服务器发送多个命令,最后一次性读取所有响应。

这一技术称为流水线,而且是一种广泛使用了几十年的技术了。 比如,许多 POP3 协议的实现已经支持这一功能,从而显著提高了从服务器下载新邮件的速度。

Redis 在很早的时候就支持了流水线,所以不管你用的是那个版本,都可以在 Redis 中使用流水线。 下边是使用原始 netcat (netcat有好多变体)的一个 demo:

$ (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

重要说明:当客户端使用流水线发送命令时,服务器将被迫使用内存对回复进行排队。 所以,如果你要用流水线发送大量命令,最好是分批发送,每一批包含一个合理的数字,比如1万条命令,然后读取回复,然后再发送1万条命令,依次类推。 速递几乎不变,但是额外使用的内存量将达到存储1万个命令响应所需要的最大内存用量。

这一切不只是RTT的问题

流水线不只可以减少RTT成本,它实际上极大的提高了在给定 Redis 服务器中每秒可以执行的操作数量。 实际上,不使用流水线的话,从访问数据结构和生成回复的角度看,执行每个命令的开销是非常低的,但从套接字I/O的角度看成本则是非常高的。 其中涉及到 read()write() 系统调用,这也就意味这从用户态到内核态的切换。 上下文切换是非常拖慢速度的。

正应如此,每秒执行的总查询数最开始随着管道内容纳元素数量的几乎成线性增长,最终达到不使用流水线的大约10倍左右,如下图所示:

pipeline_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
    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

正如你所见,使用流水线,我们将传输性能提高了五倍。

流水线和脚本

当需要在服务端做大量工作时的很多场景中,使用 Redis 脚本可以比流水线更有效的解决问题。 使用脚本的一大优势是可以以非常低的延迟读取和写入数据,从而让读,写,计算等操作非常快(流水线在这种情况下无能为力,因为客户端在调用写命令前需要读取命令的回复)。

有时应用可能还想在流水线中发送 EVAL 或者 EVALSHA 命令。 这是完全可行的, Redis 使用 SCRIPT LOAD 命令明确支持此操作(其保证可以调用 EVALSHA 而没有失败的风险)。

附录: 为什么即便是在本地环回接口上调用,使用忙循环还是很慢?

即便本页面涵盖了所有背景知识,你可能仍然想知道为什么像下面这样的 Redis 基准测试(伪代码),即便在环回接口中执行,且服务器和客户端运行在同一台物理机器上时,速度仍旧很慢 :

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

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

原因是系统中的进程并不总是在运行,实际上是内核调度程序让进程运行,所以实际发生的事情是,基准测试被允许运行,读取来自 Redis 服务器的回复(与上次执行的命令相关),并写入一个新命令。 该命令现在在环回接口缓冲区中,但为了被服务器读取,内核应该调度服务器进程(当前在系统调用中被阻止)运行,依此类推。 所以,实际上,由于内核调度程序的工作方式,本地环回接口仍然涉及类似网络的延迟。

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