1 背景
Redis是知名的、應用廣泛的NoSQL數(shù)據(jù)庫,在轉(zhuǎn)轉(zhuǎn)也是作為主要的非關(guān)系型數(shù)據(jù)庫使用。我們主要使用Codis來管理Redis分布式集群,但隨著Codis官方停止更新和Redis Cluster的日益完善,轉(zhuǎn)轉(zhuǎn)也開始嘗試使用Redis Cluster,并選擇Lettuce作為客戶端使用。但是在業(yè)務(wù)接入過程中發(fā)現(xiàn),使用Lettuce訪問Redis Cluster的mget、mset等Multi-Key命令時,性能表現(xiàn)不佳。
2 分析原因
2.1 現(xiàn)象
業(yè)務(wù)在從Codis遷移到Redis Cluster的過程中,在Redis Cluster和Codis雙寫了相同的數(shù)據(jù)。結(jié)果Codis在比Redis Cluster多一次連接proxy節(jié)點的耗時下,同樣是mget獲取相同的數(shù)據(jù),使用Lettuce訪問Redis Cluster還是比使用Jeds訪問Codis耗時要高,于是我們開始定位性能差異的原因。
2.2 定位問題
2.2.1 Redis Cluster的架構(gòu)設(shè)計
導致Redis Cluster的mget性能不佳的根本原因,是Redis Cluster在架構(gòu)上的設(shè)計導致的。Redis Cluster基于smart client和無中心的設(shè)計,按照槽位將數(shù)據(jù)存儲在不同的節(jié)點上
如上圖所示,每個主節(jié)點管理不同部分的槽位,并且下面掛了多個從節(jié)點。槽位是Redis Cluster管理數(shù)據(jù)的基本單位,集群的伸縮就是槽和數(shù)據(jù)在節(jié)點之間的移動。
通過CRC16(key) % 16384
來計算key屬于哪個槽位和哪個Redis節(jié)點。而且Redis Cluster的Multi-Key操作受槽位限制,例如我們執(zhí)行mget,獲取不同槽位的數(shù)據(jù),是限制執(zhí)行的:
2.2.2 Lettuce的mget實現(xiàn)方式
lettuce對Multi-Key進行了支持,當我們調(diào)用mget方法,涉及跨槽位時,Lettuce對mget進行了拆分執(zhí)行和結(jié)果合并,代碼如下:
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分別執(zhí)行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);
}
// 獲取、合并、排序結(jié)果
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獲取數(shù)據(jù);
3、將所有執(zhí)行的結(jié)果按照傳參的key順序排序返回。
所以Lettuce客戶端,執(zhí)行mget獲取跨槽位的數(shù)據(jù),是通過槽位分發(fā)執(zhí)行mget,并合并結(jié)果實現(xiàn)的。而Lettuce基于Netty的NIO框架實現(xiàn),發(fā)送命令不會阻塞IO,但是處理請求是單連接串行發(fā)送命令:
所以Lettuce的mget的key數(shù)量越多,涉及的槽位數(shù)量越多,性能就會越差。Codis也是拆分執(zhí)行mget,不過是并發(fā)發(fā)送命令,并使用pipeline提高性能,進而減少了網(wǎng)絡(luò)的開銷。
3 解決問題
3.1使用hashtag
我們首先想到的是 客戶端分別執(zhí)行分到不同槽位的請求,導致耗時增加。我們可以將我們需要同時操作到的key,放到同一個槽位里去。我們是可以通過hashtag來實現(xiàn)
hashtag用于Redis Cluster中。hashtag 規(guī)定以key里{}里的內(nèi)容來做hash,比如 user:{a}:zhangsan和user:{a}:lisi就會用
a
去hash,保證帶{a}的key都落到同一個slot里
利用hashtag對key進行規(guī)劃,使得我們mget的值都在同一個槽位里。
但是這種方式需要業(yè)務(wù)方感知到Redis Cluster的分片的存在,需要對Redis Cluster的各節(jié)點存儲做規(guī)劃,保證數(shù)據(jù)平均的分布在不同的Redis節(jié)點上。對業(yè)務(wù)方使用上太不友好,所以舍棄了這種方案。
3.2 客戶端改造
另一種方案是在客戶端做改造,這樣做成本較低。不需要業(yè)務(wù)方感知和維護hashtag。
我們利用pipeline對Redis節(jié)點批量發(fā)送get命令,相對于Lettuce串行發(fā)送mget命令來說,減少了多次跨槽位mget發(fā)送命令的網(wǎng)絡(luò)耗時。具體步驟如下:
1、把所有key按照所在的Redis節(jié)點拆分;
2、通過pipeline對每個Redis節(jié)點批量發(fā)送get命令;
3、獲取所有命令執(zhí)行結(jié)果,排序、合并結(jié)果,并返回。
這樣改造,使用pipeline一次發(fā)送批量的命令,減少了串行批量發(fā)送命令的網(wǎng)絡(luò)耗時。
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節(jié)點
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 執(zhí)行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);
}
//同步所有請求結(jié)果
for (Pipeline pipeline : pipelineList) {
pipeline.returnAll();
}
//合并、排序結(jié)果
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 {
//關(guān)閉所有pipeline和jedis連接
pipelineList.forEach(Pipeline::close);
jedisList.forEach(Jedis::close);
}
}
3.2.2 處理異常case
上面的代碼還不足以覆蓋所有場景,我們還需要處理一些異常case
- Redis Cluster擴縮容導致的數(shù)據(jù)遷移
數(shù)據(jù)遷移會造成兩種錯誤
1、MOVED錯誤
代表數(shù)據(jù)所在的槽位已經(jīng)遷移到另一個redis節(jié)點上了,服務(wù)端會告訴客戶端對應的槽的目標節(jié)點信息。此時我們需要做的是更新客戶端緩存的槽位信息,并嘗試重新獲取數(shù)據(jù)。
2、ASKING錯誤
代表槽位正在遷移中,且數(shù)據(jù)不在源節(jié)點中,我們需要先向目標Redis節(jié)點執(zhí)行ASKING命令,才能獲取遷移的槽位的數(shù)據(jù)。
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) {
//此槽位已經(jīng)遷移 更新客戶端的槽位信息
this.connectionHandler.renewSlotCache(null);
}
boolean asking = false;
if (jre instanceof JedisAskDataException) {
//獲取槽位目標redis節(jié)點的連接 設(shè)置asking標識,以便在重試前執(zhí)行asking命令
asking = true;
askConnection.set(this.connectionHandler.getConnectionFromNode(jre.getTargetNode()));
} else {
throw new JedisClusterException(jre);
}
//重試獲取這個key的結(jié)果
o = runWithRetries(this.maxAttempts, asking, true, key);
list.add(o);
}
}
數(shù)據(jù)遷移導致的兩種異常,會進行重試。重試會導致耗時增加,并且如果達到最大重試次數(shù),還沒有獲取到數(shù)據(jù),則拋出異常。
- pipeline的某個命令執(zhí)行失敗
不捕獲執(zhí)行失敗的異常,拋出異常讓業(yè)務(wù)服務(wù)感知到異常發(fā)生。
4 效果展示
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 結(jié)論
- 使用改造的客戶端訪問Redis Cluster,比使用Lettuce訪問Redis Cluster要快1倍以上;
- 改造的客戶端比使用codis稍微快一點,tp999不如codis性能好。
但是改造的客戶端相對于Lettuce也有缺點,JedisCluster是基于復雜的連接池實現(xiàn),連接池的配置會影響客戶端的性能。而Lettuce是基于Netty的NIO框架實現(xiàn),對于大多數(shù)的Redis操作,只需要維持單一的連接即可高效支持并發(fā)請求,不需要業(yè)務(wù)考慮連接池的配置。
5 總結(jié)
Redis Cluster在架構(gòu)設(shè)計上對Multi-Key進行的限制,導致無法跨槽位執(zhí)行mget等命令。我們對客戶端JedisCluster的Multi-Key命令進行改造,通過分別對Redis節(jié)點執(zhí)行pipeline操作,提升了mget命令的性能。
關(guān)于作者
趙浩,轉(zhuǎn)轉(zhuǎn)架構(gòu)部后臺開發(fā)工程師文章來源:http://www.zghlxwxcb.cn/news/detail-827402.html
轉(zhuǎn)轉(zhuǎn)研發(fā)中心及業(yè)界小伙伴們的技術(shù)學習交流平臺,定期分享一線的實戰(zhàn)經(jīng)驗及業(yè)界前沿的技術(shù)話題。
關(guān)注公眾號「轉(zhuǎn)轉(zhuǎn)技術(shù)」(綜合性)、「大轉(zhuǎn)轉(zhuǎn)FE」(專注于FE)、「轉(zhuǎn)轉(zhuǎn)QA」(專注于QA),更多干貨實踐,歡迎交流分享~文章來源地址http://www.zghlxwxcb.cn/news/detail-827402.html
到了這里,關(guān)于Redis Cluster基于客戶端對mget的性能優(yōu)化的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!