Redis Cluster基于客戶端對(duì)mget的性能優(yōu)化
- 1 背景
- 2 分析原因
- 2.1 現(xiàn)象
- 2.2 定位問(wèn)題
- 3 解決問(wèn)題
- 3.1使用hashtag
- 3.2 客戶端改造
- 4 效果展示
- 4.1 性能測(cè)試
- 4.2 結(jié)論
- 5 總結(jié)
一、 背景
Redis是知名的、應(yīng)用廣泛的NoSQL數(shù)據(jù)庫(kù),在轉(zhuǎn)轉(zhuǎn)也是作為主要的非關(guān)系型數(shù)據(jù)庫(kù)使用。我們主要使用Codis來(lái)管理Redis分布式集群,但隨著Codis官方停止更新和Redis Cluster的日益完善,轉(zhuǎn)轉(zhuǎn)也開始嘗試使用Redis Cluster,并選擇Lettuce作為客戶端使用。但是在業(yè)務(wù)接入過(guò)程中發(fā)現(xiàn),使用Lettuce訪問(wèn)Redis Cluster的mget、mset等Multi-Key命令時(shí),性能表現(xiàn)不佳。
二、 分析原因
2.1 現(xiàn)象
業(yè)務(wù)在從Codis遷移到Redis Cluster的過(guò)程中,在Redis Cluster和Codis雙寫了相同的數(shù)據(jù)。結(jié)果Codis在比Redis Cluster多一次連接proxy節(jié)點(diǎn)的耗時(shí)下,同樣是mget獲取相同的數(shù)據(jù),使用Lettuce訪問(wèn)Redis Cluster還是比使用Jeds訪問(wèn)Codis耗時(shí)要高,于是我們開始定位性能差異的原因。
2.2 定位問(wèn)題
2.2.1 Redis Cluster的架構(gòu)設(shè)計(jì)
導(dǎo)致Redis Cluster的mget性能不佳的根本原因,是Redis Cluster在架構(gòu)上的設(shè)計(jì)導(dǎo)致的。Redis Cluster基于smart client和無(wú)中心的設(shè)計(jì),按照槽位將數(shù)據(jù)存儲(chǔ)在不同的節(jié)點(diǎn)上
圖片
如上圖所示,每個(gè)主節(jié)點(diǎn)管理不同部分的槽位,并且下面掛了多個(gè)從節(jié)點(diǎn)。槽位是Redis Cluster管理數(shù)據(jù)的基本單位,集群的伸縮就是槽和數(shù)據(jù)在節(jié)點(diǎn)之間的移動(dòng)。
通過(guò)CRC16(key) % 16384
來(lái)計(jì)算key屬于哪個(gè)槽位和哪個(gè)Redis節(jié)點(diǎn)。而且Redis Cluster的Multi-Key操作受槽位限制,例如我們執(zhí)行mget,獲取不同槽位的數(shù)據(jù),是限制執(zhí)行的:
圖片
2.2.2 Lettuce的mget實(shí)現(xiàn)方式
lettuce對(duì)Multi-Key進(jìn)行了支持,當(dāng)我們調(diào)用mget方法,涉及跨槽位時(shí),Lettuce對(duì)mget進(jìn)行了拆分執(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<>();
//對(duì)不同槽位的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涉及多個(gè)key的時(shí)候,主要有三個(gè)步驟:
1、按照槽位 將key進(jìn)行拆分;
2、分別對(duì)相同槽位的key去對(duì)應(yīng)的槽位mget獲取數(shù)據(jù);
3、將所有執(zhí)行的結(jié)果按照傳參的key順序排序返回。
所以Lettuce客戶端,執(zhí)行mget獲取跨槽位的數(shù)據(jù),是通過(guò)槽位分發(fā)執(zhí)行mget,并合并結(jié)果實(shí)現(xiàn)的。而Lettuce基于Netty的NIO框架實(shí)現(xiàn),發(fā)送命令不會(huì)阻塞IO,但是處理請(qǐng)求是單連接串行發(fā)送命令:
圖片
所以Lettuce的mget的key數(shù)量越多,涉及的槽位數(shù)量越多,性能就會(huì)越差。Codis也是拆分執(zhí)行mget,不過(guò)是并發(fā)發(fā)送命令,并使用pipeline提高性能,進(jìn)而減少了網(wǎng)絡(luò)的開銷。
三、 解決問(wèn)題
3.1使用hashtag
我們首先想到的是 客戶端分別執(zhí)行分到不同槽位的請(qǐng)求,導(dǎo)致耗時(shí)增加。我們可以將我們需要同時(shí)操作到的key,放到同一個(gè)槽位里去。我們是可以通過(guò)hashtag來(lái)實(shí)現(xiàn)
hashtag用于Redis Cluster中。hashtag 規(guī)定以key里{}里的內(nèi)容來(lái)做hash,比如 user:{a}:zhangsan和user:{a}:lisi就會(huì)用
a
去hash,保證帶{a}的key都落到同一個(gè)slot里
利用hashtag對(duì)key進(jìn)行規(guī)劃,使得我們mget的值都在同一個(gè)槽位里。
圖片
但是這種方式需要業(yè)務(wù)方感知到Redis Cluster的分片的存在,需要對(duì)Redis Cluster的各節(jié)點(diǎn)存儲(chǔ)做規(guī)劃,保證數(shù)據(jù)平均的分布在不同的Redis節(jié)點(diǎn)上,對(duì)業(yè)務(wù)方使用上太不友好,所以舍棄了這種方案。
3.2 客戶端改造
另一種方案是在客戶端做改造,這樣做成本較低。不需要業(yè)務(wù)方感知和維護(hù)hashtag。
我們利用pipeline對(duì)Redis節(jié)點(diǎn)批量發(fā)送get命令,相對(duì)于Lettuce串行發(fā)送mget命令來(lái)說(shuō),減少了多次跨槽位mget發(fā)送命令的網(wǎng)絡(luò)耗時(shí)。具體步驟如下:
1、把所有key按照所在的Redis節(jié)點(diǎn)拆分;
2、通過(guò)pipeline對(duì)每個(gè)Redis節(jié)點(diǎn)批量發(fā)送get命令;
3、獲取所有命令執(zhí)行結(jié)果,排序、合并結(jié)果,并返回。
這樣改造,使用pipeline一次發(fā)送批量的命令,減少了串行批量發(fā)送命令的網(wǎng)絡(luò)耗時(shí)。
3.2.1 改造JedisCluster
由于Lettuce沒有原生支持pipeline批量提交命令,而JedisCluster原生支持pipeline,并且JedisCluster沒有對(duì)Multi-Key進(jìn)行支持,我們對(duì)JedisCluster的mget進(jìn)行了改造,代碼如下:
public List<String> mget(String... keys) {
List<Pipeline> pipelineList = new ArrayList<>();
List<Jedis> jedisList = new ArrayList<>();
try {
//按照key的hash計(jì)算key位于哪一個(gè)redis節(jié)點(diǎn)
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);
}
//分別對(duì)每個(gè)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 最后進(jìn)行close
pipelineList.add(pipelined);
jedisList.add(jedis);
}
//同步所有請(qǐng)求結(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
上面的代碼還不足以覆蓋所有場(chǎng)景,我們還需要處理一些異常case
- Redis Cluster擴(kuò)縮容導(dǎo)致的數(shù)據(jù)遷移
數(shù)據(jù)遷移會(huì)造成兩種錯(cuò)誤
1、MOVED錯(cuò)誤
代表數(shù)據(jù)所在的槽位已經(jīng)遷移到另一個(gè)redis節(jié)點(diǎn)上了,服務(wù)端會(huì)告訴客戶端對(duì)應(yīng)的槽的目標(biāo)節(jié)點(diǎn)信息。此時(shí)我們需要做的是更新客戶端緩存的槽位信息,并嘗試重新獲取數(shù)據(jù)。
2、ASKING錯(cuò)誤
代表槽位正在遷移中,且數(shù)據(jù)不在源節(jié)點(diǎn)中,我們需要先向目標(biāo)Redis節(jié)點(diǎn)執(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) {
//獲取槽位目標(biāo)redis節(jié)點(diǎn)的連接 設(shè)置asking標(biāo)識(shí),以便在重試前執(zhí)行asking命令
asking = true;
askConnection.set(this.connectionHandler.getConnectionFromNode(jre.getTargetNode()));
} else {
throw new JedisClusterException(jre);
}
//重試獲取這個(gè)key的結(jié)果
o = runWithRetries(this.maxAttempts, asking, true, key);
list.add(o);
}
}
數(shù)據(jù)遷移導(dǎo)致的兩種異常,會(huì)進(jìn)行重試。重試會(huì)導(dǎo)致耗時(shí)增加,并且如果達(dá)到最大重試次數(shù),還沒有獲取到數(shù)據(jù),則拋出異常。
- pipeline的某個(gè)命令執(zhí)行失敗
不捕獲執(zhí)行失敗的異常,拋出異常讓業(yè)務(wù)服務(wù)感知到異常發(fā)生。
四、 效果展示
4.1 性能測(cè)試
在改造完客戶端之后,我們對(duì)客戶端的mget進(jìn)行了性能測(cè)試,測(cè)試了下面三種類型的耗時(shí)
1、使用Jedis訪問(wèn)Codis
2、使用改造的JedisCluster訪問(wèn)Redis Cluster
3、使用Lettuce同步方式訪問(wèn)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é)論
- 使用改造的客戶端訪問(wèn)Redis Cluster,比使用Lettuce訪問(wèn)Redis Cluster要快1倍以上;
- 改造的客戶端比使用codis稍微快一點(diǎn),tp999不如codis性能好。
但是改造的客戶端相對(duì)于Lettuce也有缺點(diǎn),JedisCluster是基于復(fù)雜的連接池實(shí)現(xiàn),連接池的配置會(huì)影響客戶端的性能。而Lettuce是基于Netty的NIO框架實(shí)現(xiàn),對(duì)于大多數(shù)的Redis操作,只需要維持單一的連接即可高效支持并發(fā)請(qǐng)求,不需要業(yè)務(wù)考慮連接池的配置。
五、 總結(jié)
Redis Cluster在架構(gòu)設(shè)計(jì)上對(duì)Multi-Key進(jìn)行的限制,導(dǎo)致無(wú)法跨槽位執(zhí)行mget等命令。我們對(duì)客戶端JedisCluster的Multi-Key命令進(jìn)行改造,通過(guò)分別對(duì)Redis節(jié)點(diǎn)執(zhí)行pipeline操作,提升了mget命令的性能。