作者:京東科技 王晨
Redis異步客户端選型及落地實踐
可視化服務編排系統是能夠通過線上可視化拖拽、配置的方式完成對接口的編排,可在線完成服務的調試、測試,實現業務需求的交付,詳細內容可參考:https://mp.weixin.qq.com/s/5oN9JqWN7n-4Zv6B9K8kWQ。
為了支持更加廣泛的業務場景,可視化編排系統近期需要支持對緩存的操作功能,為保證編排系統的性能,服務的執行過程採用了異步的方式,因此我們考慮使用Redis的異步客户端來完成對緩存的操作。
Redis客户端
Jedis/Lettuce
Redis官方推薦的Redis客户端有Jedis、Lettuce等等,其中Jedis 是老牌的 Redis 的 Java 實現客户端,提供了比較全面的 Redis 命令的支持,在spring-boot 1.x 默認使用Jedis。
但是Jedis使用阻塞的 IO,且其方法調用都是同步的,程序流需要等到 sockets 處理完 IO 才能執行,不支持異步,在併發場景下,使用Jedis客户端會耗費較多的資源。
此外,Jedis 客户端實例不是線程安全的,要想保證線程安全,必須要使用連接池,每個線程需要時從連接池取出連接實例,完成操作後或者遇到異常歸還實例。當連接數隨着業務不斷上升時,對物理連接的消耗也會成為性能和穩定性的潛在風險點。因此在spring-boot 2.x中,redis客户端默認改用了Lettuce。
我們可以看下 Spring Data Redis 幫助文檔給出的對比表格,裏面詳細地記錄了兩個主流Redis客户端之間的差異。
異步客户端Lettuce
Spring Boot自2.0版本開始默認使用Lettuce作為Redis的客户端。Lettuce客户端基於Netty的NIO框架實現,對於大多數的Redis操作,只需要維持單一的連接即可高效支持業務端的併發請求 —— 這點與Jedis的連接池模式有很大不同。同時,Lettuce支持的特性更加全面,且其性能表現並不遜於,甚至優於Jedis。
Netty是由JBOSS提供的一個java開源框架,現為 Github上的獨立項目。Netty提供異步的、事件驅動的網絡應用程序框架和工具,用以快速開發高性能、高可靠性的網絡服務器和客户端程序。
也就是説,Netty 是一個基於NIO的客户、服務器端的編程框架,使用Netty 可以確保你快速和簡單的開發出一個網絡應用,例如實現了某種協議的客户、服務端應用。Netty相當於簡化和流線化了網絡應用的編程開發過程,例如:基於TCP和UDP的socket服務開發。
上圖展示了Netty NIO的核心邏輯。NIO通常被理解為non-blocking I/O的縮寫,表示非阻塞I/O操作。圖中Channel表示一個連接通道,用於承載連接管理及讀寫操作;EventLoop則是事件處理的核心抽象。一個EventLoop可以服務於多個Channel,但它只會與單一線程綁定。EventLoop中所有I/O事件和用户任務的處理都在該線程上進行;其中除了選擇器Selector的事件監聽動作外,對連接通道的讀寫操作均以非阻塞的方式進行 —— 這是NIO與BIO(blocking I/O,即阻塞式I/O)的重要區別,也是NIO模式性能優異的原因。
Lettuce憑藉單一連接就可以支持業務端的大部分併發需求,這依賴於以下幾個因素的共同作用:
1.Netty的單個EventLoop僅與單一線程綁定,業務端的併發請求均會被放入EventLoop的任務隊列中,最終被該線程順序處理。同時,Lettuce自身也會維護一個隊列,當其通過EventLoop向Redis發送指令時,成功發送的指令會被放入該隊列;當收到服務端的響應時,Lettuce又會以FIFO的方式從隊列的頭部取出對應的指令,進行後續處理。
2.Redis服務端本身也是基於NIO模型,使用單一線程處理客户端請求。雖然Redis能同時維持成百上千個客户端連接,但是在某一時刻,某個客户端連接的請求均是被順序處理及響應的。
3.Redis客户端與服務端通過TCP協議連接,而TCP協議本身會保證數據傳輸的順序性。
如此,Lettuce在保證請求處理順序的基礎上,天然地使用了管道模式(pipelining)與Redis交互 —— 在多個業務線程併發請求的情況下,客户端不必等待服務端對當前請求的響應,即可在同一個連接上發出下一個請求。這在加速了Redis請求處理的同時,也高效地利用了TCP連接的全雙工特性(full-duplex)。而與之相對的,在沒有顯式指定使用管道模式的情況下,Jedis只能在處理完某個Redis連接上當前請求的響應後,才能繼續使用該連接發起下一個請求。
在併發場景下,業務系統短時間內可能會發出大量請求,在管道模式中,這些請求被統一發送至Redis服務端,待處理完成後統一返回,能夠大大提升業務系統的運行效率,突破性能瓶頸。R2M採用了Redis Cluster模式,在通過Lettuce連接R2M之前,應該先對Redis Cluster模式有一定的瞭解。
Redis Cluster模式
在redis3.0之前,如果想搭建一個集羣架構還是挺複雜的,就算是基於一些第三方的中間件搭建的集羣總感覺有那麼點差強人意,或者基於sentinel哨兵搭建的主從架構在高可用上表現又不是很好,尤其是當數據量越來越大,單純主從結構無法滿足對性能的需求時,矛盾便產生了。
隨着redis cluster的推出,這種海量數據+高併發+高可用的場景真正從根本上得到了有效的支持。
cluster 模式是redis官方提供的集羣模式,使用了Sharding 技術,不僅實現了高可用、讀寫分離、也實現了真正的分佈式存儲。
集羣內部通信
在redis cluster集羣內部通過gossip協議進行通信,集羣元數據分散的存在於各個節點,通過gossip進行元數據的交換。
不同於zookeeper分佈式協調中間件,採用集中式的集羣元數據存儲。redis cluster採用分佈式的元數據管理,優缺點還是比較明顯的。在redis中集中式的元數據管理類似sentinel主從架構模式。集中式有點在於元數據更新實效性更高,但容錯性不如分佈式管理。gossip協議優點在於大大增強集羣容錯性。
redis cluster集羣中單節點一般配置兩個端口,一個端口如6379對外提供api,另一個一般是加1w,比如16379進行節點間的元數據交換即用於gossip協議通訊。
gossip協議包含多種消息,如ping pong,meet,fail等。
1.meet:集羣中節點通過向新加入節點發送meet消息,將新節點加入集羣中。
2.ping:節點間通過ping命令交換元數據。
3.pong:響應ping。
4.fail:某個節點主觀認為某個節點宕機,會向其他節點發送fail消息,進行客觀宕機判定。
分片和尋址算法
hash slot即hash槽。redis cluster採用的正式這種hash槽算法實現的尋址。在redis cluster中固定的存在16384個hash slot。
如上圖所示,如果我們有三個節點,每個節點都是一主一從的主從結構。redis cluster初始化時會自動均分給每個節點16384個slot。當增加一個節點4,只需要將原來node1~node3節點部分slot上的數據遷移到節點4即可。在redis cluster中數據遷移並不會阻塞主進程。對性能影響是十分有限的。總結一句話就是hash slot算法有效的減少了當節點發生變化導致的數據漂移帶來的性能開銷。
集羣高可用和主備切換
主觀宕機和客觀宕機:
某個節點會週期性的向其他節點發送ping消息,當在一定時間內未收到pong消息會主觀認為該節點宕機,即主觀宕機。然後該節點向其他節點發送fail消息,其他超過半數節點也確認該節點宕機,即客觀宕機。十分類似sentinel的sdown和odown。
客觀宕機確認後進入主備切換階段及從節點選舉。
節點選舉:
檢查每個 slave node 與 master node 斷開連接的時間,如果超過了 cluster-node-timeout * cluster-slave-validity-factor,那麼就沒有資格切換成 master。
每個從節點,都根據自己對 master 複製數據的 offset,來設置一個選舉時間,offset 越大(複製數據越多)的從節點,選舉時間越靠前,優先進行選舉。
所有的 master node 開始 slave 選舉投票,給要進行選舉的 slave 進行投票,如果大部分 master node(N/2 + 1)都投票給了某個從節點,那麼選舉通過,那個從節點可以切換成 master。
從節點執行主備切換,從節點切換為主節點。
Lettuce的使用
建立連接
使用Lettuce大致分為以下三步:
1.基於Redis連接信息創建RedisClient
2.基於RedisClient創建StatefulRedisConnection
3.從Connection中獲取Command,基於Command執行Redis命令操作。
由於Lettuce客户端提供了響應式、同步和異步三種命令,從Connection中獲取Command時可以指定命令類型進行獲取。
在本地創建Redis Cluster集羣,設置主從關係如下:
7003(M) --> 7001(S)
7004(M) --> 7002(S)
7005(M) --> 7000(S)
List<RedisURI> servers = new ArrayList<>();
servers.add(RedisURI.create("127.0.0.1", 7000));
servers.add(RedisURI.create("127.0.0.1", 7001));
servers.add(RedisURI.create("127.0.0.1", 7002));
servers.add(RedisURI.create("127.0.0.1", 7003));
servers.add(RedisURI.create("127.0.0.1", 7004));
servers.add(RedisURI.create("127.0.0.1", 7005));
//創建客户端
RedisClusterClient client = RedisClusterClient.create(servers);
//創建連接
StatefulRedisClusterConnection<String, String> connection = client.connect();
//獲取異步命令
RedisAdvancedClusterAsyncCommands<String, String> commands = connection.async();
//執行GET命令
RedisFuture<String> future = commands.get("test-lettuce-key");
try {
String result = future.get();
log.info("Get命令返回:{}", result);
} catch (Exception e) {
log.error("Get命令執行異常", e);
}
可以看到成功地獲取到了值,由日誌可以看出該請求發送到了7004所在的節點上,順利拿到了對應的值並進行返回。
作為一個需要長時間保持的客户端,保持其與集羣之間連接的穩定性是至關重要的,那麼集羣在運行過程中會發生哪些特殊情況呢?作為客户端又應該如何應對呢?這就要引出智能客户端(smart client)這個概念了。
智能客户端
在Redis Cluster運行過程中,所有的數據不是永遠固定地保存在某一個節點上的,比如遇到cluster擴容、節點宕機、數據遷移等情況時,都會導致集羣的拓撲結構發生變化,此時作為客户端需要對這一類情況作出應對,來保證連接的穩定性以及服務的可用性。隨着以上問題的出現,smart client這個概念逐漸走到了人們的視野中,智能客户端會在內部維護hash槽與節點的映射關係,大家耳熟能詳的Jedis和Lettuce都屬於smart client。客户端在發送請求時,會先根據CRC16(key)%16384計算key對應的hash槽,通過映射關係,本地就可實現鍵到節點的查找,從而保證IO效率的最大化。
但如果出現故障轉移或者hash槽遷移時,這個映射關係是如何維護的呢?
客户端重定向
MOVED
當Redis集羣發生數據遷移時,當對應的hash槽已經遷移到變的節點時,服務端會返回一個MOVED重定向錯誤,此時並告訴客户端這個hash槽遷移後的節點IP和端口是多少;客户端在接收到MOVED錯誤時,會更新本地的映射關係,並重新向新節點發送請求命令。
ASK
Redis集羣支持在線遷移槽(slot)和數據來完成水平伸縮,當slot對應的數據從源節點到目標節點遷移過程中,客户端需要做到智能識別,保證鍵命令可正常執行。例如當一個slot數據從源節點遷移到目標節點時,期間可能出現一部分數據在源節點,而另一部分在目標節點,如下圖所示
當出現上述情況時,客户端鍵命令執行流程將發生變化,如下所示:
1)客户端根據本地slots緩存發送命令到源節點,如果存在鍵對象則直 接執行並返回結果給客户端
2)如果鍵對象不存在,則可能存在於目標節點,這時源節點會回覆 ASK重定向異常。
3)客户端從ASK重定向異常提取出目標節點信息,發送asking命令到目標節點打開客户端連接標識,再執行鍵命令。如果存在則執行,不存在則返回不存在信息。
在客户端收到ASK錯誤時,不會更新本地的映射關係
節點宕機觸發主備切換
上文提到,如果redis集羣在運行過程中,某個主節點由於某種原因宕機了,此時就會觸發集羣的節點選舉機制,選舉其中一個從節點作為新的主節點,進入主備切換,在主備切換期間,新的節點沒有被選舉出來之前,打到該節點上的請求理論上是無法得到執行的,可能會產生超時錯誤。在主備切換完成之後,集羣拓撲更新完成,此時客户端應該向集羣請求新的拓撲結構,並更新至本地的映射表中,以保證後續命令的正確執行。
有意思的是,Jedis在集羣主備切換完成之後,是會主動拉取最新的拓撲結構並進行更新的,但是在使用Lettuce時,發現在集羣主備切換完成之後,連接並沒有恢復,打到該節點上的命令依舊會執行失敗導致超時,必須要重啓業務程序才能恢復連接。
在使用Lettuce時,如果不進行設置,默認是不會觸發拓撲刷新的,因此在主備切換完成後,Lettuce依舊使用本地的映射表,將請求打到已經掛掉的節點上,就會導致持續的命令執行失敗的情況。
可以通過以下代碼來設置Lettuce的拓撲刷新策略,開啓基於事件的自適應拓撲刷新,其中包括了MOVED、 ASK、PERSISTENT_RECONNECTS等觸發器,當客户端觸發這些事件,並且持續時間超過設定閾值後,觸發拓撲刷新,也可以通過enablePeriodicRefresh()設置定時刷新,不過建議這個時間不要太短。
// 設置基於事件的自適應刷新策略
ClusterTopologyRefreshOptions topologyRefreshOptions = ClusterTopologyRefreshOptions.builder()
//開啓自適應拓撲刷新
.enableAllAdaptiveRefreshTriggers()
//自適應拓撲刷新事件超時時間,超時後進行刷新
.adaptiveRefreshTriggersTimeout(Duration.ofSeconds(30))
.build();
redisClusterClient.setOptions(ClusterClientOptions.builder()
.topologyRefreshOptions(topologyRefreshOptions)
// redis命令超時時間
.timeoutOptions(TimeoutOptions.enabled(Duration.ofSeconds(30)))
.build());
進行以上設置並進行驗證,集羣在主備切換完成後,客户端在段時間內恢復了連接,能夠正常存取數據了。
總結
對於緩存的操作,客户端與集羣之間連接的穩定性是保證數據不丟失的關鍵,Lettuce作為熱門的異步客户端,對於集羣中產生的一些突發狀況是具備處理能力的,只不過在使用的時候需要進行設置。本文目的在於將在開發緩存操作功能時遇到的問題,以及將一些涉及到的底層知識做一下總結,也希望能給大家一些幫助。