速度不夠,管道來湊——Redis管道技術

面向Google程式設計發表於2019-04-30

Redis客戶端與伺服器之間使用TCP協議進行通訊,並且很早就支援管道(pipelining)技術了。在某些高併發的場景下,網路開銷成了Redis速度的瓶頸,所以需要使用管道技術來實現突破。

在介紹管道之前,先來想一下單條命令的執行步驟:

  • 客戶端把命令傳送到伺服器,然後阻塞客戶端,等待著從socket讀取伺服器的返回結果
  • 伺服器處理命令並將結果返回給客戶端

按照這樣的描述,每個命令的執行時間 = 客戶端傳送時間+伺服器處理和返回時間+一個網路來回的時間

其中一個網路來回的時間是不固定的,它的決定因素有很多,比如客戶端到伺服器要經過多少跳,網路是否擁堵等等。但是這個時間的量級也是最大的,也就是說一個命令的完成時間的長度很大程度上取決於網路開銷。如果我們的伺服器每秒可以處理10萬條請求,而網路開銷是250毫秒,那麼實際上每秒鐘只能處理4個請求。最暴力的優化方法就是使客戶端和伺服器在一臺物理機上,這樣就可以將網路開銷降低到1ms以下。但是實際的生產環境我們並不會這樣做。而且即使使用這種方法,當請求非常頻繁時,這個時間和伺服器處理時間比較仍然是很長的。

Redis Pipelining

為了解決這種問題,Redis在很早就支援了管道技術。也就是說客戶端可以一次傳送多條命令,不用逐條等待命令的返回值,而是到最後一起讀取返回結果,這樣只需要一次網路開銷,速度就會得到明顯的提升。管道技術其實已經非常成熟並且得到廣泛應用了,例如POP3協議由於支援管道技術,從而顯著提高了從伺服器下載郵件的速度。

在Redis中,如果客戶端使用管道傳送了多條命令,那麼伺服器就會將多條命令放入一個佇列中,這一操作會消耗一定的記憶體,所以管道中命令的數量並不是越大越好(太大容易撐爆記憶體),而是應該有一個合理的值。

深入理解Redis互動流程

管道並不只是用來網路開銷延遲的一種方法,它實際上是會提升Redis伺服器每秒操作總數的。在解釋原因之前,需要更深入的瞭解Redis命令處理過程。

圖片來源:掘金小冊《Redis 深度歷險:核心原理與應用實踐》

一個完整的互動流程如下:

  1. 客戶端程式呼叫write()把訊息寫入到作業系統核心為Socket分配的send buffer中
  2. 作業系統會把send buffer中的內容寫入網路卡,網路卡再通過閘道器路由把內容傳送到伺服器端的網路卡
  3. 服務端網路卡會把接收到的訊息寫入作業系統為Socket分配的recv buffer
  4. 伺服器程式呼叫read()讀取訊息然後進行處理
  5. 處理完成後呼叫write()把返回結果寫入到伺服器端的send buffer
  6. 伺服器作業系統再將send buffer中的內容寫入網路卡,然後傳送到客戶端
  7. 客戶端作業系統將網路卡內容讀到recv buffer中
  8. 客戶端程式呼叫read()從recv buffer中讀取訊息並返回

現在我們把命令執行的時間進一步細分:

命令的執行時間 = 客戶端呼叫write並寫網路卡時間+一次網路開銷的時間+服務讀網路卡並呼叫read時間++伺服器處理資料時間+服務端呼叫write並寫網路卡時間+客戶端讀網路卡並呼叫read時間

這其中除了網路開銷,花費時間最長的就是進行系統呼叫write()read()了,這一過程需要作業系統由使用者態切換到核心態,中間涉及到的上下文切換會浪費很多時間。

使用管道時,多個命令只會進行一次read()wrtie()系統呼叫,因此使用管道會提升Redis伺服器處理命令的速度,隨著管道中命令的增多,伺服器每秒處理請求的數量會線性增長,最後會趨近於不使用管道的10倍。

圖片來源:Redis官方pipeline文件

和Scripting對比

對於管道的大部分應用場景而言,使用Redis指令碼(Redis2.6及以後的版本)會使伺服器端有更好的表現。使用指令碼最大的好處就是可以以最小的延遲讀寫資料。

有時我們也需要在管道中使用EVAL和EVALSHA命令,這是完全有可能的。因此Redis提供了SCRIPT LOAD命令來支援這種情況。

眼見為實

多說無益,還是眼見為實。下面就來對比一下使用管道和不使用管道的速度差異。

public class JedisDemo {

    private static int COMMAND_NUM = 1000;
    
    private static String REDIS_HOST = "Redis伺服器IP";

    public static void main(String[] args) {

        Jedis jedis = new Jedis(REDIS_HOST, 6379);
        withoutPipeline(jedis);
        withPipeline(jedis);
    }

    private static void withoutPipeline(Jedis jedis) {
        Long start = System.currentTimeMillis();
        for (int i = 0; i < COMMAND_NUM; i++) {
            jedis.set("no_pipe_" + String.valueOf(i), String.valueOf(i), SetParams.setParams().ex(60));
        }
        long end = System.currentTimeMillis();
        long cost = end - start;
        System.out.println("withoutPipeline cost : " + cost + " ms");
    }

    private static void withPipeline(Jedis jedis) {
        Pipeline pipe = jedis.pipelined();
        long start_pipe = System.currentTimeMillis();
        for (int i = 0; i < COMMAND_NUM; i++) {
            pipe.set("pipe_" + String.valueOf(i), String.valueOf(i), SetParams.setParams().ex(60));
        }
        pipe.sync(); // 獲取所有的response
        long end_pipe = System.currentTimeMillis();
        long cost_pipe = end_pipe - start_pipe;
        System.out.println("withPipeline cost : " + cost_pipe + " ms");
    }
}
複製程式碼

結果也符合我們的預期:

withoutPipeline cost : 11791 ms
withPipeline cost : 55 ms
複製程式碼

總結

  1. 使用管道技術可以顯著提升Redis處理命令的速度,其原理就是將多條命令打包,只需要一次網路開銷,在伺服器端和客戶端各一次read()write()系統呼叫,以此來節約時間。
  2. 管道中的命令數量要適當,並不是越多越好。
  3. Redis2.6版本以後,指令碼在大部分場景中的表現要優於管道。

擴充套件

前面我們提到,為了解決網路開銷帶來的延遲問題,可以把客戶端和伺服器放到一臺物理機上。但是有時用benchmark進行壓測的時候發現這仍然很慢。

這時客戶端和服務端實際是在一臺物理機上的,所有的操作都在記憶體中進行,沒有網路延遲,按理來說這樣的操作應該是非常快的。為什麼會出現上面的情況的呢?

實際上,這是由核心排程導致的。比如說,benchmark執行時,讀取了伺服器返回的結果,然後寫了一個新的命令。這個命令就在迴環介面的send buffer中了,如果要執行這個命令,核心需要喚醒Redis伺服器程式。所以在某些情況下,本地介面也會出現類似於網路延遲的延遲。其實是核心特別繁忙,一直沒有排程到Redis伺服器程式。

參考

Redis官方文件

Redis原始碼

掘金小冊:《Redis 深度歷險:核心原理與應用實踐》

相關文章