Redis客戶端與伺服器之間使用TCP協議進行通訊,並且很早就支援管道(pipelining)技術了。在某些高併發的場景下,網路開銷成了Redis速度的瓶頸,所以需要使用管道技術來實現突破。
在介紹管道之前,先來想一下單條命令的執行步驟:
- 客戶端把命令傳送到伺服器,然後阻塞客戶端,等待著從socket讀取伺服器的返回結果
- 伺服器處理命令並將結果返回給客戶端
按照這樣的描述,每個命令的執行時間 = 客戶端傳送時間+伺服器處理和返回時間+一個網路來回的時間
其中一個網路來回的時間是不固定的,它的決定因素有很多,比如客戶端到伺服器要經過多少跳,網路是否擁堵等等。但是這個時間的量級也是最大的,也就是說一個命令的完成時間的長度很大程度上取決於網路開銷。如果我們的伺服器每秒可以處理10萬條請求,而網路開銷是250毫秒,那麼實際上每秒鐘只能處理4個請求。最暴力的優化方法就是使客戶端和伺服器在一臺物理機上,這樣就可以將網路開銷降低到1ms以下。但是實際的生產環境我們並不會這樣做。而且即使使用這種方法,當請求非常頻繁時,這個時間和伺服器處理時間比較仍然是很長的。
Redis Pipelining
為了解決這種問題,Redis在很早就支援了管道技術。也就是說客戶端可以一次傳送多條命令,不用逐條等待命令的返回值,而是到最後一起讀取返回結果,這樣只需要一次網路開銷,速度就會得到明顯的提升。管道技術其實已經非常成熟並且得到廣泛應用了,例如POP3協議由於支援管道技術,從而顯著提高了從伺服器下載郵件的速度。
在Redis中,如果客戶端使用管道傳送了多條命令,那麼伺服器就會將多條命令放入一個佇列中,這一操作會消耗一定的記憶體,所以管道中命令的數量並不是越大越好(太大容易撐爆記憶體),而是應該有一個合理的值。
深入理解Redis互動流程
管道並不只是用來網路開銷延遲的一種方法,它實際上是會提升Redis伺服器每秒操作總數的。在解釋原因之前,需要更深入的瞭解Redis命令處理過程。
一個完整的互動流程如下:
- 客戶端程式呼叫
write()
把訊息寫入到作業系統核心為Socket分配的send buffer中 - 作業系統會把send buffer中的內容寫入網路卡,網路卡再通過閘道器路由把內容傳送到伺服器端的網路卡
- 服務端網路卡會把接收到的訊息寫入作業系統為Socket分配的recv buffer
- 伺服器程式呼叫
read()
讀取訊息然後進行處理 - 處理完成後呼叫
write()
把返回結果寫入到伺服器端的send buffer - 伺服器作業系統再將send buffer中的內容寫入網路卡,然後傳送到客戶端
- 客戶端作業系統將網路卡內容讀到recv buffer中
- 客戶端程式呼叫
read()
從recv buffer中讀取訊息並返回
現在我們把命令執行的時間進一步細分:
命令的執行時間 = 客戶端呼叫write並寫網路卡時間+一次網路開銷的時間+服務讀網路卡並呼叫read時間++伺服器處理資料時間+服務端呼叫write並寫網路卡時間+客戶端讀網路卡並呼叫read時間
這其中除了網路開銷,花費時間最長的就是進行系統呼叫write()
和read()
了,這一過程需要作業系統由使用者態切換到核心態,中間涉及到的上下文切換會浪費很多時間。
使用管道時,多個命令只會進行一次read()
和wrtie()
系統呼叫,因此使用管道會提升Redis伺服器處理命令的速度,隨著管道中命令的增多,伺服器每秒處理請求的數量會線性增長,最後會趨近於不使用管道的10倍。
和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
複製程式碼
總結
- 使用管道技術可以顯著提升Redis處理命令的速度,其原理就是將多條命令打包,只需要一次網路開銷,在伺服器端和客戶端各一次
read()
和write()
系統呼叫,以此來節約時間。 - 管道中的命令數量要適當,並不是越多越好。
- Redis2.6版本以後,指令碼在大部分場景中的表現要優於管道。
擴充套件
前面我們提到,為了解決網路開銷帶來的延遲問題,可以把客戶端和伺服器放到一臺物理機上。但是有時用benchmark進行壓測的時候發現這仍然很慢。
這時客戶端和服務端實際是在一臺物理機上的,所有的操作都在記憶體中進行,沒有網路延遲,按理來說這樣的操作應該是非常快的。為什麼會出現上面的情況的呢?
實際上,這是由核心排程導致的。比如說,benchmark執行時,讀取了伺服器返回的結果,然後寫了一個新的命令。這個命令就在迴環介面的send buffer中了,如果要執行這個命令,核心需要喚醒Redis伺服器程式。所以在某些情況下,本地介面也會出現類似於網路延遲的延遲。其實是核心特別繁忙,一直沒有排程到Redis伺服器程式。
參考
Redis原始碼
掘金小冊:《Redis 深度歷險:核心原理與應用實踐》