1 背景
Redis是知名的、应用广泛的NoSQL数据库。但是在集群环境下,我们发现mget的效率实在过低,很多时候甚至还不如for循环进行get、set。
2 分析原因
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进行了拆分执行和结果合并,代码如下:
mget涉及多个key的时候,主要有三个步骤:
1、按照槽位 将key进行拆分;
2、分别对相同槽位的key去对应的槽位mget获取数据;
3、将所有执行的结果按照传参的key顺序排序返回。
所以Lettuce客户端,执行mget获取跨槽位的数据,是通过槽位分发执行mget,并合并结果实现的。而Lettuce基于Netty的NIO框架实现,发送命令不会阻塞IO,但是处理请求是单连接串行发送命令:
所以Lettuce的mget的key数量越多,涉及的槽位数量越多,性能就会越差。Codis也是拆分执行mget,不过是并发发送命令,并使用pipeline提高性能,进而减少了网络的开销。
3 解决问题
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 效果展示
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操作,只需要维持单一的连接即可高效支持并发请求,不需要业务考虑连接池的配置。
5 总结
Redis Cluster在架构设计上对Multi-Key进行的限制,导致无法跨槽位执行mget等命令。我们对客户端JedisCluster的Multi-Key命令进行改造,通过分别对Redis节点执行pipeline操作,提升了mget命令的性能。