Redis 非同步使用者端選型及落地實踐

2023-02-08 18:01:09

作者:京東科技 王晨

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作為熱門的非同步使用者端,對於叢集中產生的一些突發狀況是具備處理能力的,只不過在使用的時候需要進行設定。本文目的在於將在開發快取操作功能時遇到的問題,以及將一些涉及到的底層知識做一下總結,也希望能給大家一些幫助。