Redis中的管道(pipeline)

1、请求/响应协议与往返时间(RTT)

Redis是使用c/s模型和实现请求响应协议的tcp服务器。
这就意味着通常一个请求会经历以下步骤:
1、客户端发送一个请求到服务器,等待服务器响应并从套接字(socket)读取数据,通常使用阻塞同步的方式处理;
2、服务器处理客户端发送过来的命令并响应客户端的相求;

举例来说,客户端发送四个命令序列:

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

客户端与服务器通过网络相连,如果客户端和服务器在同一台机器(请求通过会回环接口)速度会很快,如果客户端和服务器在网络上不同位置速度可能就比较慢(两个主机之间连接的建立中间可能会经过很多跳)。不管网络的延迟如何,客户端到服务器以及服务器到客户端之间的时间始终存在。
此过程称之为往返时间(RTT)。当客户端需要连续执行许多请求时,很容易看出对性能产生怎样的影响(比方说添加多个元素到List或者从库中根据key批量查询记录)。如果一个请求的往返时间为250毫秒,而服务端每秒能够处理1000个请求,那么最终能够处理的也仅仅是每秒4个请求。
如果接口网络使用的是本地回环的方式,虽然请求往返时间(RTT)将会大大缩短,但针对于大量的写入操作其性能也将会变慢。

针对于此,redis提供了pipeline功能来改善这种场景下的性能。

2、Redis管道(Redis Pipelining)

实现了请求/响应协议的服务器,即使客户端还未读取服务器返回的旧响应,服务器仍然能够继续接受新的请求。这样就可以发送多个命令到服务器,而根本不用等待服务器响应,只需要获取最终的响应即可。
这种技术在过去的几十年里频繁被使用,称之为管道(pipelining)技术。例如很多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

注意:
当客户端使用管道发送命令到服务器时,服务器在内存中会强制响应顺序是有序的。所以当需要使用管道发送大量命令时,最好批量发送合理数量的命令到服务器,服务器读取并回复,然后重复这个流程直到结束。处理速度上大致相等,但额外使用的内存用于对响应结果的排序。

3、使用管道的另一层原因(不只是往返时间的问题)

管道传输不仅仅降低了往返时间带来的延迟,实际上管道还可以极大地提高在给定Redis服务器上每秒执行的总操作数量。这种说法是基于以下事实:从访问数据结构和回复的角度来看,不使用管道时命令执行消耗的时间是很少的,但是从套接字(socket)I/O方面考虑的话时间消耗是比较多的。这涉及到read()和write()函数的系统调用,这就意味着内存需要从用户态切换到内核态,上下文的切换很大程度上降低了整体的响应速度。
当使用管道的时候,通常使用单个read()函数来读取多条命令,使用单个write()函数来实现多条命令的应答。因此一开始管道处理的查询总数随着传入命令呈线性增长直至达到不使用管道基准的10倍。

可以使用Java客户端对Redis的管道性能做个压测:

public class RedisPipeliningBenchTest {

    private Jedis jedis;

    @Before
    public void init(){
        jedis = new Jedis();
    }


    @Test
    public void pipelineBench(){
        long start1 = System.currentTimeMillis();
        for (int i = 0; i < 10000; i++){
            jedis.ping();
        }

        System.out.println("WithoutPipelineBench cost:" + (System.currentTimeMillis() - start1));

        long start2 = System.currentTimeMillis();
        Pipeline pipeline = jedis.pipelined();
        for (int i = 0; i < 10000; i++){
            pipeline.ping();
        }

        pipeline.sync();
        System.out.println("WithPipelineBench cost:" + (System.currentTimeMillis() - start2));
    }
}

执行以上代码需要引入jedis包,在本地单机环境下,管道对命令执行的往返时间的提升是最低的,本地机器执行结果(单位为毫秒):

WithoutPipelineBench cost:509
WithPipelineBench cost:34

此示例当中使用pipeline和不使用,时间上会有十几倍的差距。

4、管道 VS 脚本

管道当中使用Redis脚本(在Redis 2.6或更高的版本支持),可以高效地实现脚本在服务端的功能。脚本的一大有点在于它能够以最低的延迟读取和写入数据,从而使读取、计算和写入的操作更加快速(管道在这种场景下无法发挥作用,因为客户端在写操作之前需要响应读取的操作)。
在有些场景之下应用希望在管道中能够发送EVAL或者EVALSHA命令,而Redis已提供SCRIPT LOAD命令来显式支持这种场景的需求(该命令可以保证EVALSHA命令不存在执行失败的情况)。

附录:为何在本地回环接口上调用循环响应速度依然会慢?

在对Redis进行压测时会存在(以下用伪代码表示)即使在同一台物理机上通过回环地址循环调用Redis服务速度依然慢的情况,

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

毕竟在这种情况下Redis的进程与压测进程都在同一个环境之下运行,理论上它们之间的消息交互仅仅通过内存将信息从一块地方复制到另外一个物理地址上,这中间是不存在网络连接上的延迟,那到底是什么原因导致可能出现的速度慢呢?
原因在于内存中的进程不是一直都在运行状态当中,它需要内核的调度才能获取到cpu资源去执行。所以这里就可能存在一种情况,当压测的进程得到资源从Redis服务器读取服务器的响应并写入一个新的命令。这个时候命令通过回环地址进入到Redis服务器为每个链接建立的缓冲当中,为了让命令得到执行,需要让内核调度Redis的进程(此时处于阻塞状态)去执行,后面的流程以此类推。故因为内核调度机制的存在,通过回环地址的调用可能面临与通过网络访问相类似的延迟情况。

参见Redis官方文档:https://redis.io/topics/pipelining

你可能感兴趣的:(Redis入门及进阶,redis,pipeline)