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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章