Redis效能提高之批量和管道

FeelTouch發表於2019-03-20

批量的意義

Redis協議採取的是客戶端-伺服器方式,即在一次round trip中,客戶端傳送一條指令,服務端解析指令並執行,然後向客戶端返回結果。這是一種典型的tcp互動方式。

粗略的分,客戶端發起一次Redis請求主要有如下開銷:

socket IO導致的上下文切換開銷

熟悉OS/Linux的童鞋都知道,一次redis請求在客戶端和服務端分別至少會存在一次read()和一次write(),作為系統呼叫,read/write的成本高於普通的函式呼叫,因此,在單個命令重複呼叫場景下,大量的read/write系統呼叫會產生明顯的系統開銷。
指令執行開銷

Redis採用C實現,使用了輕量級的hash表、skipList跳錶等資料結構實現了高效的快取。因此,單條執行大多數指令的成本非常低。因此,相對而言,IO的開銷顯得更加無法忽略。
(高併發下)資源競爭和系統排程排程開銷

一般來說,這一開銷在客戶端的影響更為明顯,在高壓力下,如果採用迴圈(loop)方式呼叫多次指令來完成某個服務請求,那麼在高併發下,多個請求會在多個執行緒中同時競爭redis連線資源多次,導致連線池壓力增加,執行緒上下文切換更加頻發,最終會導致請求RTT(round-trip time)急劇惡化。如果每個請求只搶佔一次redis連線並通過批量執行的方式一次處理多個請求,則單次請求的RTT會有顯著提升。

在服務端,因為我們通常將redis繫結到CPU(不管是通過物理機還是通過docker),因此一般而言不存在系統排程/資源競爭的開銷。但是由於redis對qps敏感,如果因為客戶端使用不合理而造成qps放大效應,則redis可能更早觸及效能瓶頸而導致系統響應嚴重下降。

筆者曾經在一次效能調優中發現,每次服務請求訪問redis次數高達數十次,使得redis請求次數達到服務qps的數十倍,觸發了redis伺服器的極限(大概5~10萬qps)而導致服務效能低下,多個請求對redis連線池進行了激烈競爭,並且由於redis響應速度的下降導致大量執行緒在獲取連線處阻塞並頻繁進行執行緒切換。在改進實現採用了批量指令處理後,服務效能瞬間達到了數十倍的提升。

因此,如果每次服務掉用需要觸發多次redis請求,合理地適用批量執行技術,可以使系統執行更加有效,資料吞吐得到明顯提升。

Redis中批量

Redis中涉及到的批量處理指令如下

  1. 批量get/set(multi get/set)
  2. 管道(pipelining)
  3. 事務(transaction)
  4. 基於事務的管道(transaction in pipelining)

其中3,4都是事務相關的更多的目的在於安全和資料一致,而不是提高效能,所以本節不做講解。

m批處理

批量命令即redis對應的命令:

mget(適用於string型別)
mset(適用於string型別)
hmget(適用於hash型別)
hmset(適用於hash型別)
嚴格來說上述命令不屬於批量操作,而是在一個指令中處理多個key。

優勢:
效能優異,因為是單條指令操作,因此效能略優於其他批量操作指令。
缺點:
批量命令不保證原子性,存在部分成功部分失敗的情況,需要應用程式解析返回的結果並做相應處理。
批量命令在key數目巨大時存在RRT與key數目成比例放大的效能衰減,會導致單例項響應效能(RRT)嚴重下降,更多分析請參考之前的文章。
叢集行為
客戶端分片場景下,Jedis不支援客戶端mget拆分,需要在業務程式碼中根據分片規則自行拆分併傳送到對應得redis例項,會導致業務邏輯程式碼中夾雜著jedis分片邏輯
中介軟體分片場景下,Codis等中介軟體分片服務中,會將mget/mset的多個key拆分成多個命令發往不同得redis例項,事實上已經喪失了mget強大的聚合執行能力。
原生Cluster場景下,mget僅支援單個slot內批量執行,否則將會獲得一個錯誤資訊。

Jedis jedis = pool.getResource();
try{
    long duration = System.currentTimeMillis();
    jedis.mget(keys);
    duration = System.currentTimeMillis() - duration;
    log(duration);
}finally {
    if(jedis!=null) jedis.close();
}

管道(pipelining)

管道(pipelining)方式意味著客戶端可以在一次請求中傳送多個命令。

例如在下例中,一次將多個命令傳給redis,redis將在一個round trip中完成多命令並依次返回結果。

$ printf "incr x\r\nincr x\r\nincr x\r\n" | nc localhost 6379
:1
:2
:3
$ printf "get x\r\ndel x\r\n" | nc localhost 6379
$1
3
:1
在上面的例子中,首先通過管道執行了三次incr x指令,第二次通過管道執行了get x和del x兩個指令。

優勢
通過管道,可以將多個redis指令聚合到一個redis請求中批量執行
可以使用各種redis命令,使用更靈活
客戶端一般會將命令打包,並控制每個包的大小,在執行大量命令的場景中,可以有效提升執行效率。
比如在採用jedis客戶端時,每個包大小大約為8K
大量命令會被分為多個包,以包為單位逐批傳送到redis伺服器執行
由於所有命令被分批次傳送到伺服器端執行,因此相比較事務型別的操作先逐批傳送,再一次執行(或取消),管道擁有微弱的效能優勢。
缺點
沒有任何事務保證,其他client的命令可能會在本pipeline的中間被執行。
叢集行為
客戶端分片,需要由應用程式或client對命令按分片拆分並通過多個管道傳送到不同的分片redis伺服器執行
中介軟體分片,一般由中介軟體對管道進行拆分和結果合併
原生Cluster場景下,對pipeline的支援等同於單機,可以將同一節點中不同slot分片的節點通過批量操作一次執行,但是從實踐來說,情況更加複雜,除非有充分的理由,否則不建議 (將來Jedis可能會支援對同一slot的所有key支援pipeline)。
目前jedis不支援叢集下的pipeline
如果一定要使用pipeline,可以根據client端快取的hashslots <-> ip:port(node),對所有key進行分組,並將屬於同一節點的命令打包通過jedis物件執行
如果發生了resharding(rebalance),會導致slot變動,則打包好的管道中的部分命令可能會收到MOVED或ASK錯誤,需要在程式碼中處理。一般而言,遇到MOVED需要觸發一次對映重新整理,遇到ASK則需要一次ASKING操作。
在Jedis標準cluster操作中,JedisCluster整合了對JedisRedirectionException的處理,如果要使用pipeline,需要自己封裝相應介面,並通過Jedis物件進行pipeline操作,處理相應的重定向錯誤,並對發生重定向的 部分 子命令進行重試,複雜度將會明顯上升。

批處理和管道效能對比和分析

1. 普通情況下,使用管道比不使用管道快幾十到上百倍

2. 普通情況下,使用批處理要比使用管道更快些 

3. 在非叢集下,批處理key的數量小於100時,其效能接近單個key的非處理指令。

    隨著批處理key的數量的上升,其處理效能會出現顯著下降。

    原生的Redis Cluster不支援多節點的批處理,對於阿里雲、codis的Cluster儘管支援批處理,其效能相對非叢集下,有一定下降,其中來自多節點資料遍歷和最終彙集返回到client。

參考:

1. https://blog.csdn.net/think2me/article/details/77185723

2. https://blog.csdn.net/jinlu_npu/article/details/79744689

3. https://blog.csdn.net/Jinlu_npu/article/details/79984127

相關文章