写点什么

得物技术浅谈 Redis 集群下 mget 的性能问题

用户头像
得物技术
关注
发布于: 3 小时前

Redis 问题

最近优惠服务的 Redis 经常会间歇性的抖动,具体表现为在短时间内 redis rt 上涨明显,RedisCommandTimeoutException 异常陡增,如下图:




监控面板是按照分钟级别进行统计,所以 rt 上涨看起来不是很明显。


这种情况肯定不太正常,并且在近期出现的频率有上升趋势。



定位原因

遇到这种问题,首先会想到是不是 redis 本身抖动造成的,看表象其实很像,无规律,间歇性,影响时间很短,所以第一时间找了 DBA 确认当时是不是 redis 实例发生了问题,或者网络出现了抖动,同时也去 dms redis 的监控面板上看下运行指标是否正常。很遗憾,得到的恢复是服务抖动这段时间内,redis 运行情况正常,网络状况也无任何异常,而且从监控面板上看,redis 运行状况非常好,cpu 负载不高,io 负载也不高,内核运行 rt 也都正常,无明显波动。(下图选择了 redis 集群中的一个节点实例,16 个节点的状况基本一致)

redis cpu:



redis io:



redis maxRT



到此,中间件本身的原因基本上是可以排除的了。那么,只能是使用姿势的问题了。使用姿势这块可能造成的影响,首先要定位是不是有 hot key 还有 big key,如果一个 big key 又同时是 hot key,那么极有可能在流量尖刺的同时造成这种现象。

先去阿里云 redis 监控面板上看 hot key 统计



发现一周内并无热点 key,也没有大 key,显然,缓存内容本身还是比较合理的。这就有点头疼了,redis 本身,以及缓存内容都没什么问题,那只能把目光放到代码中了,由代码异常来逆推原因。


天眼监控上,发现很多 RedisCommandTimeoutException 异常,那么先采样看下产生异常的请求上下文

异常接口是:会场商品流批量算价服务



这个请求中用到了 redis mget 同时获取多个 keys,大概有几十个 key,竟然超时了,500ms 的时间都不够。

换个存在异常接口



可以这两个接口都用到了 mget 批量拉取 keys ,从 key 的命名看来,还是依赖同样的数据,当然这不影响。上面我们看到了 redis 缓存的数据是没问题的,无大 key 热点 key,redis 本身运行状态也健康,网络也正常,那么,只有一种可能,是不是这个 mget 有问题,mget 是如何一次获取多个 key 的,带着疑问,我们追一下 mget 的源码(系统用的是 Lettuce pool)

public RedisFuture<List<KeyValue<K, V>>> mget(Iterable<K> keys) {        //获取分区slot和key的映射关系        Map<Integer, List<K>> partitioned = SlotHash.partition(codec, keys);         //如果分区数小于2也就是只有一个分区即所有key都落在一个分区就直接获取        if (partitioned.size() < 2) {            return super.mget(keys);        }         //每个key与slot映射关系        Map<K, Integer> slots = SlotHash.getSlots(partitioned);        Map<Integer, RedisFuture<List<KeyValue<K, V>>>> executions = new HashMap<>();         //遍历分片信息,逐个发送        for (Map.Entry<Integer, List<K>> entry : partitioned.entrySet()) {            RedisFuture<List<KeyValue<K, V>>> mget = super.mget(entry.getValue());            executions.put(entry.getKey(), mget);        }         // restore order of key 恢复key的顺序        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 操作其实分为了以下几步:

  1. 获取分区 slot 和 key 的映射关系,遍历出所需 key 对应的每个分区 slot。

  2. 判定,slot 个数是不是小于 2,也就是是否所有的 key 都在同一分区,如果是,发起一次 mget 命令,直接获取。

  3. 如果分区数量大于 2,keys 对应多个分区,那么遍历所有分区,分别向 redis 发起 mget 请求获取数据。

  4. 等待所有请求执行结束,重新组装结果数据,保持跟入参 key 的顺序一致,然后返回结果。

可以看到,当使用 mget 方法获取多个 key,并且这些 key 还存在于不同的 slot 分区中,那么一次 mget 操作其实会对 redis 发起多次 mget 命令的请求,有多少个 slot,就发起多少次,然后在所有请求执行完毕之后,整个 mget 方法才会能够继续执行下去。看似一次 mget 方法调用,其实底层对应的是多次 redis 调用和多次 io 交互。



(图 a)

这张图就能很直观的看出 redis 在集群模式下,mget 的弊病。


问题优化:

方案 1 - hashtag

hashtag 强制将 key 放在一个 redis node 上。这个方案,相当于将 redis 集群退化成了单机 redis,系统的高可用,容灾能力就大打折扣了,只能尝试使用主从,哨兵等其他分布式架构来缓减,但是,既然选择了集群,肯定集群模式是相比于其他模式是最符合当前系统架构现状的,使用这种方案,可能会引发更大的问题。不推荐。


方案 2 -并发调用

我们从图 a,以及上面的代码中可以看到,for 循环内多次串行的 redis 调用,是导致执行 rt 上涨的原因,那么,自然而然可以想到,是否可以用并行替代底层串行的逻辑。也就是将 mget 中的 keys,根据 slot 分片规则,先 groupBy 一下,然后用多线程的方式并行执行。



那么 rt 最理想的情况其实就是一次单机 mget 的 rt 耗时,也就是一次网络 io 耗时,一次 redis mget 命令耗时。

看似比较完美的解决方案,其实不尽然,我们考虑一下实际场景:首先,这个方案中,用于并发调用提交 redis mget 任务的线程池的设计非常重要,各种参数的调校,势必需要非常充分的压测,这本身难度就比较大。其次,我们在日常使用中,一次 mget 的 key 基本上在几十到 100,相比于 redis 16384 的固定槽位数量,是数量级上的差距,所以,我们一次请求的这些 key,基本上是分布在不同的 slot 中的,换句话讲,如果按照这么拆分 keys,大概率是相当于拆出了等于 key 数量的 get 请求。。也就丧失了 mget 的意义。


两种方案各有利弊吧,方案一简单,但是架构层面的隐患比较大,方案二实现复杂,但是可靠性相对比较好一点。mget 一直是让人又爱又恨,关键还是看使用场景,key 分散到的 redis 集群节点越多,性能就越差,但是对于小数量级别,比如 5~20 个这种,其实问题都不大。


文/Hulk

关注得物技术,携手走向技术的云端

发布于: 3 小时前阅读数: 3
用户头像

得物技术

关注

得物APP技术部 2019.11.13 加入

关注微信公众号「得物技术」

评论

发布
暂无评论
得物技术浅谈Redis集群下mget的性能问题