赞
踩
Redis是知名的、应用广泛的NoSQL数据库,在转转也是作为主要的非关系型数据库使用。我们主要使用Codis来管理Redis分布式集群,但随着Codis官方停止更新和Redis Cluster的日益完善,转转也开始尝试使用Redis Cluster,并选择Lettuce作为客户端使用。但是在业务接入过程中发现,使用Lettuce访问Redis Cluster的mget、mset等Multi-Key命令时,性能表现不佳。
业务在从Codis迁移到Redis Cluster的过程中,在Redis Cluster和Codis双写了相同的数据。结果Codis在比Redis Cluster多一次连接proxy节点的耗时下,同样是mget获取相同的数据,使用Lettuce访问Redis Cluster还是比使用Jeds访问Codis耗时要高,于是我们开始定位性能差异的原因。
导致Redis Cluster的mget性能不佳的根本原因,是Redis Cluster在架构上的设计导致的。Redis Cluster基于smart client和无中心的设计,按照槽位将数据存储在不同的节点上
如上图所示,每个主节点管理不同部分的槽位,并且下面挂了多个从节点。槽位是Redis Cluster管理数据的基本单位,集群的伸缩就是槽和数据在节点之间的移动。
通过CRC16(key) % 16384
来计算key属于哪个槽位和哪个Redis节点。而且Redis Cluster的Multi-Key操作受槽位限制,例如我们执行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提高性能,进而减少了网络的开销。
我们首先想到的是 客户端分别执行分到不同槽位的请求,导致耗时增加。我们可以将我们需要同时操作到的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节点上。对业务方使用上太不友好,所以舍弃了这种方案。
另一种方案是在客户端做改造,这样做成本较低。不需要业务方感知和维护hashtag。
我们利用pipeline对Redis节点批量发送get命令,相对于Lettuce串行发送mget命令来说,减少了多次跨槽位mget发送命令的网络耗时。具体步骤如下:
1、把所有key按照所在的Redis节点拆分;
2、通过pipeline对每个Redis节点批量发送get命令;
3、获取所有命令执行结果,排序、合并结果,并返回。
这样改造,使用pipeline一次发送批量的命令,减少了串行批量发送命令的网络耗时。
由于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); } }
上面的代码还不足以覆盖所有场景,我们还需要处理一些异常case
数据迁移会造成两种错误
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); } }
数据迁移导致的两种异常,会进行重试。重试会导致耗时增加,并且如果达到最大重试次数,还没有获取到数据,则抛出异常。
不捕获执行失败的异常,抛出异常让业务服务感知到异常发生。
在改造完客户端之后,我们对客户端的mget进行了性能测试,测试了下面三种类型的耗时
1、使用Jedis访问Codis
2、使用改造的JedisCluster访问Redis Cluster
3、使用Lettuce同步方式访问Redis Cluster
Codis | JedisCluster(改造) | Lettuce | |
---|---|---|---|
avg | 0.411ms | 0.224ms | 0.61ms |
tp99 | 0.528ms | 0.35ms | 1.53ms |
tp999 | 0.745ms | 1.58ms | 3.87ms |
Codis | JedisCluster(改造) | Lettuce | |
---|---|---|---|
avg | 0.96ms | 0.511ms | 2.14ms |
tp99 | 1.15ms | 0.723ms | 3.99ms |
tp999 | 1.81ms | 1.86ms | 6.88ms |
Codis | JedisCluster(改造) | Lettuce | |
---|---|---|---|
avg | 1.56ms | 0.92ms | 5.04ms |
tp99 | 1.83ms | 1.22ms | 8.91ms |
tp999 | 3.15ms | 3.88ms | 32ms |
但是改造的客户端相对于Lettuce也有缺点,JedisCluster是基于复杂的连接池实现,连接池的配置会影响客户端的性能。而Lettuce是基于Netty的NIO框架实现,对于大多数的Redis操作,只需要维持单一的连接即可高效支持并发请求,不需要业务考虑连接池的配置。
Redis Cluster在架构设计上对Multi-Key进行的限制,导致无法跨槽位执行mget等命令。我们对客户端JedisCluster的Multi-Key命令进行改造,通过分别对Redis节点执行pipeline操作,提升了mget命令的性能。
关于作者
赵浩,转转架构部后台开发工程师
转转研发中心及业界小伙伴们的技术学习交流平台,定期分享一线的实战经验及业界前沿的技术话题。
关注公众号「转转技术」(综合性)、「大转转FE」(专注于FE)、「转转QA」(专注于QA),更多干货实践,欢迎交流分享~
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。