Redis Cluster基于客戶端對mget的性能優化
- 1 背景
- 2 分析原因
- 2.1 現象
- 2.2 定位問題
- 3 解決問題
- 3.1使用hashtag
- 3.2 客戶端改造
- 4 效果展示
- 4.1 性能測試
- 4.2 結論
- 5 總結
一、 背景
Redis是知名的、應用廣泛的NoSQL數據庫,在轉轉也是作為主要的非關系型數據庫使用。我們主要使用Codis來管理Redis分布式集群,但隨著Codis官方停止更新和Redis Cluster的日益完善,轉轉也開始嘗試使用Redis Cluster,并選擇Lettuce作為客戶端使用。但是在業務接入過程中發現,使用Lettuce訪問Redis Cluster的mget、mset等Multi-Key命令時,性能表現不佳。
二、 分析原因
2.1 現象
業務在從Codis遷移到Redis Cluster的過程中,在Redis Cluster和Codis雙寫了相同的數據。結果Codis在比Redis Cluster多一次連接proxy節點的耗時下,同樣是mget獲取相同的數據,使用Lettuce訪問Redis Cluster還是比使用Jeds訪問Codis耗時要高,于是我們開始定位性能差異的原因。
2.2 定位問題
2.2.1 Redis Cluster的架構設計
導致Redis Cluster的mget性能不佳的根本原因,是Redis Cluster在架構上的設計導致的。Redis Cluster基于smart client和無中心的設計,按照槽位將數據存儲在不同的節點上
圖片
如上圖所示,每個主節點管理不同部分的槽位,并且下面掛了多個從節點。槽位是Redis Cluster管理數據的基本單位,集群的伸縮就是槽和數據在節點之間的移動。
通過CRC16(key) % 16384
來計算key屬于哪個槽位和哪個Redis節點。而且Redis Cluster的Multi-Key操作受槽位限制,例如我們執行mget,獲取不同槽位的數據,是限制執行的:
圖片
2.2.2 Lettuce的mget實現方式
lettuce對Multi-Key進行了支持,當我們調用mget方法,涉及跨槽位時,Lettuce對mget進行了拆分執行和結果合并,代碼如下:
public RedisFuture<List<KeyValue<K, V>>> mget(Iterable<K> keys) {
//將key按照槽位拆分
Map<Integer, List<K>> partitioned = SlotHash.partition(codec, keys);
if (partitioned.size() < 2) {
return super.mget(keys);
}
Map<K, Integer> slots = SlotHash.getSlots(partitioned);
Map<Integer, RedisFuture<List<KeyValue<K, V>>>> executions = new HashMap<>();
//對不同槽位的keys分別執行mget
for (Map.Entry<Integer, List<K>> entry : partitioned.entrySet()) {
RedisFuture<List<KeyValue<K, V>>> mget = super.mget(entry.getValue());
executions.put(entry.getKey(), mget);
}
// 獲取、合并、排序結果
return new PipelinedRedisFuture<>(executions, objectPipelinedRedisFuture -> {
List<KeyValue<K, V>> result = new ArrayList<>();
for (K opKey : keys) {
int slot = slots.get(opKey);
int position = partitioned.get(slot).indexOf(opKey);
RedisFuture<List<KeyValue<K, V>>> listRedisFuture = executions.get(slot);
result.add(MultiNodeExecution.execute(() -> listRedisFuture.get().get(position)));
}
return result;
});
}
mget涉及多個key的時候,主要有三個步驟:
1、按照槽位 將key進行拆分;
2、分別對相同槽位的key去對應的槽位mget獲取數據;
3、將所有執行的結果按照傳參的key順序排序返回。
所以Lettuce客戶端,執行mget獲取跨槽位的數據,是通過槽位分發執行mget,并合并結果實現的。而Lettuce基于Netty的NIO框架實現,發送命令不會阻塞IO,但是處理請求是單連接串行發送命令:
圖片
所以Lettuce的mget的key數量越多,涉及的槽位數量越多,性能就會越差。Codis也是拆分執行mget,不過是并發發送命令,并使用pipeline提高性能,進而減少了網絡的開銷。
三、 解決問題
3.1使用hashtag
我們首先想到的是 客戶端分別執行分到不同槽位的請求,導致耗時增加。我們可以將我們需要同時操作到的key,放到同一個槽位里去。我們是可以通過hashtag來實現
hashtag用于Redis Cluster中。hashtag 規定以key里{}里的內容來做hash,比如 user:{a}:zhangsan和user:{a}:lisi就會用
a
去hash,保證帶{a}的key都落到同一個slot里
利用hashtag對key進行規劃,使得我們mget的值都在同一個槽位里。
圖片
但是這種方式需要業務方感知到Redis Cluster的分片的存在,需要對Redis Cluster的各節點存儲做規劃,保證數據平均的分布在不同的Redis節點上,對業務方使用上太不友好,所以舍棄了這種方案。
3.2 客戶端改造
另一種方案是在客戶端做改造,這樣做成本較低。不需要業務方感知和維護hashtag。
我們利用pipeline對Redis節點批量發送get命令,相對于Lettuce串行發送mget命令來說,減少了多次跨槽位mget發送命令的網絡耗時。具體步驟如下:
1、把所有key按照所在的Redis節點拆分;
2、通過pipeline對每個Redis節點批量發送get命令;
3、獲取所有命令執行結果,排序、合并結果,并返回。
這樣改造,使用pipeline一次發送批量的命令,減少了串行批量發送命令的網絡耗時。
3.2.1 改造JedisCluster
由于Lettuce沒有原生支持pipeline批量提交命令,而JedisCluster原生支持pipeline,并且JedisCluster沒有對Multi-Key進行支持,我們對JedisCluster的mget進行了改造,代碼如下:
public List<String> mget(String... keys) {
List<Pipeline> pipelineList = new ArrayList<>();
List<Jedis> jedisList = new ArrayList<>();
try {
//按照key的hash計算key位于哪一個redis節點
Map<JedisPool, List<String>> pooling = new HashMap<>();
for (String key : keys) {
JedisPool pool = connectionHandler.getConnectionPoolFromSlot(JedisClusterCRC16.getSlot(key));
pooling.computeIfAbsent(pool, k -> new ArrayList<>()).add(key);
}
//分別對每個redis 執行pipeline get操作
Map<String, Response<String>> resultMap = new HashMap<>();
for (Map.Entry<JedisPool, List<String>> entry : pooling.entrySet()) {
Jedis jedis = entry.getKey().getResource();
Pipeline pipelined = jedis.pipelined();
for (String key : entry.getValue()) {
Response<String> response = pipelined.get(key);
resultMap.put(key, response);
}
pipelined.flush();
//保存所有連接和pipeline 最后進行close
pipelineList.add(pipelined);
jedisList.add(jedis);
}
//同步所有請求結果
for (Pipeline pipeline : pipelineList) {
pipeline.returnAll();
}
//合并、排序結果
List<String> list = new ArrayList<>();
for (String key : keys) {
Response<String> response = resultMap.get(key);
String o = response.get();
list.add(o);
}
return list;
}finally {
//關閉所有pipeline和jedis連接
pipelineList.forEach(Pipeline::close);
jedisList.forEach(Jedis::close);
}
}
3.2.2 處理異常case
上面的代碼還不足以覆蓋所有場景,我們還需要處理一些異常case
- Redis Cluster擴縮容導致的數據遷移
數據遷移會造成兩種錯誤
1、MOVED錯誤
代表數據所在的槽位已經遷移到另一個redis節點上了,服務端會告訴客戶端對應的槽的目標節點信息。此時我們需要做的是更新客戶端緩存的槽位信息,并嘗試重新獲取數據。
2、ASKING錯誤
代表槽位正在遷移中,且數據不在源節點中,我們需要先向目標Redis節點執行ASKING命令,才能獲取遷移的槽位的數據。
List<String> list = new ArrayList<>();
for (String key : keys) {
Response<String> response = resultMap.get(key);
String o;
try {
o = response.get();
list.add(o);
} catch (JedisRedirectionException jre) {
if (jre instanceof JedisMovedDataException) {
//此槽位已經遷移 更新客戶端的槽位信息
this.connectionHandler.renewSlotCache(null);
}
boolean asking = false;
if (jre instanceof JedisAskDataException) {
//獲取槽位目標redis節點的連接 設置asking標識,以便在重試前執行asking命令
asking = true;
askConnection.set(this.connectionHandler.getConnectionFromNode(jre.getTargetNode()));
} else {
throw new JedisClusterException(jre);
}
//重試獲取這個key的結果
o = runWithRetries(this.maxAttempts, asking, true, key);
list.add(o);
}
}
數據遷移導致的兩種異常,會進行重試。重試會導致耗時增加,并且如果達到最大重試次數,還沒有獲取到數據,則拋出異常。
- pipeline的某個命令執行失敗
不捕獲執行失敗的異常,拋出異常讓業務服務感知到異常發生。
四、 效果展示
4.1 性能測試
在改造完客戶端之后,我們對客戶端的mget進行了性能測試,測試了下面三種類型的耗時
1、使用Jedis訪問Codis
2、使用改造的JedisCluster訪問Redis Cluster
3、使用Lettuce同步方式訪問Redis Cluster
4.1.1 mget 100key
Codis | JedisCluster(改造) | Lettuce | |
avg | 0.411ms | 0.224ms | 0.61ms |
tp99 | 0.528ms | 0.35ms | 1.53ms |
tp999 | 0.745ms | 1.58ms | 3.87ms |
4.1.2 mget 500key
Codis | JedisCluster(改造) | Lettuce | |
avg | 0.96ms | 0.511ms | 2.14ms |
tp99 | 1.15ms | 0.723ms | 3.99ms |
tp999 | 1.81ms | 1.86ms | 6.88ms |
4.1.3 mget 1000key
Codis | JedisCluster(改造) | Lettuce | |
avg | 1.56ms | 0.92ms | 5.04ms |
tp99 | 1.83ms | 1.22ms | 8.91ms |
tp999 | 3.15ms | 3.88ms | 32ms |
4.2 結論
- 使用改造的客戶端訪問Redis Cluster,比使用Lettuce訪問Redis Cluster要快1倍以上;
- 改造的客戶端比使用codis稍微快一點,tp999不如codis性能好。
但是改造的客戶端相對于Lettuce也有缺點,JedisCluster是基于復雜的連接池實現,連接池的配置會影響客戶端的性能。而Lettuce是基于Netty的NIO框架實現,對于大多數的Redis操作,只需要維持單一的連接即可高效支持并發請求,不需要業務考慮連接池的配置。
五、 總結
Redis Cluster在架構設計上對Multi-Key進行的限制,導致無法跨槽位執行mget等命令。我們對客戶端JedisCluster的Multi-Key命令進行改造,通過分別對Redis節點執行pipeline操作,提升了mget命令的性能。