写点什么

Redis 大集群扩容性能优化实践

发布于: 2021 年 10 月 18 日
Redis大集群扩容性能优化实践

一、背景


在现网环境,一些使用 Redis 集群的业务随着业务量的上涨,往往需要进行节点扩容操作。


之前有了解到运维同学对一些节点数比较大的 Redis 集群进行扩容操作后,业务侧反映集群性能下降,具体表现在访问时延增长明显。


某些业务对 Redis 集群访问时延比较敏感,例如现网环境对模型实时读取,或者一些业务依赖读取 Redis 集群的同步流程,会影响业务的实时流程时延。业务侧可能无法接受。


为了找到这个问题的根因,我们对某一次的 Redis 集群迁移操作后的集群性能下降问题进行排查。


1.1 问题描述


这一次具体的 Redis 集群问题的场景是:某一个 Redis 集群进行过扩容操作。业务侧使用 Hiredis-vip 进行 Redis 集群访问,进行 MGET 操作。


业务侧感知到访问 Redis 集群的时延变高。


1.2 现网环境说明


  • 目前现网环境部署的 Redis 版本多数是 3.x 或者 4.x 版本;

  • 业务访问 Redis 集群的客户端品类繁多,较多的使用 Jedis。本次问题排查的业务使用客户端 Hiredis-vip 进行访问;

  • Redis 集群的节点数比较大,规模是 100+;

  • 集群之前存在扩容操作。


1.3 观察现象


因为时延变高,我们从几个方面进行排查:


  • 带宽是否打满;

  • CPU 是否占用过高;

  • OPS 是否很高;


通过简单的监控排查,带宽负载不高。但是发现 CPU 表现异常:


1.3.1 对比 OPS 和 CPU 负载


观察业务反馈使用的 MGET 和 CPU 负载,我们找到了对应的监控曲线。


从时间上分析,MGET 和 CPU 负载高并没有直接关联。业务侧反馈的是 MGET 的时延普遍增高。此处看到 MGET 的 OPS 和 CPU 负载是错峰的。



此处可以暂时确定业务请求和 CPU 负载暂时没有直接关系,但是从曲线上可以看出:在同一个时间轴上,业务请求和 cpu 负载存在错峰的情况,两者间应该有间接关系。


1.3.2 对比 Cluster 指令 OPS 和 CPU 负载


由于之前有运维侧同事有反馈集群进行过扩容操作,必然存在 slot 的迁移。


考虑到业务的客户端一般都会使用缓存存放 Redis 集群的 slot 拓扑信息,因此怀疑 Cluster 指令会和 CPU 负载存在一定联系。


我们找到了当中确实有一些联系:



此处可以明显看到:某个实例在执行 Cluster 指令的时候,CPU 的使用会明显上涨。


根据上述现象,大致可以进行一个简单的聚焦:


  • 业务侧执行 MGET,因为一些原因执行了 Cluster 指令;

  • Cluster 指令因为一些原因导致 CPU 占用较高影响其他操作;

  • 怀疑 Cluster 指令是性能瓶颈。


同时,引申几个需要关注的问题:


为什么会有较多的 Cluster 指令被执行?


为什么 Cluster 指令执行的时候 CPU 资源比较高?


为什么节点规模大的集群迁移 slot 操作容易“中招”?


二、问题排查


2.1 Redis 热点排查


我们对一台现场出现了 CPU 负载高的 Redis 实例使用 perf top 进行简单的分析:



从上图可以看出来,函数(ClusterReplyMultiBulkSlots)占用的 CPU 资源高达 51.84%,存在异常。


2.1.1 ClusterReplyMultiBulkSlots 实现原理


我们对 clusterReplyMultiBulkSlots 函数进行分析:


void clusterReplyMultiBulkSlots(client *c) {    /* Format: 1) 1) start slot     *            2) end slot     *            3) 1) master IP     *               2) master port     *               3) node ID     *            4) 1) replica IP     *               2) replica port     *               3) node ID     *           ... continued until done     */     int num_masters = 0;    void *slot_replylen = addDeferredMultiBulkLength(c);     dictEntry *de;    dictIterator *di = dictGetSafeIterator(server.cluster->nodes);    while((de = dictNext(di)) != NULL) {        /*注意:此处是对当前Redis节点记录的集群所有主节点都进行了遍历*/        clusterNode *node = dictGetVal(de);        int j = 0, start = -1;         /* Skip slaves (that are iterated when producing the output of their         * master) and  masters not serving any slot. */        /*跳过备节点。备节点的信息会从主节点侧获取。*/        if (!nodeIsMaster(node) || node->numslots == 0) continue;        for (j = 0; j < CLUSTER_SLOTS; j++) {            /*注意:此处是对当前节点中记录的所有slot进行了遍历*/            int bit, i;            /*确认当前节点是不是占有循环终端的slot*/            if ((bit = clusterNodeGetSlotBit(node,j)) != 0) {                if (start == -1) start = j;            }            /*简单分析,此处的逻辑大概就是找出连续的区间,是的话放到返回中;不是的话继续往下递归slot。              如果是开始的话,开始一个连续区间,直到和当前的不连续。*/            if (start != -1 && (!bit || j == CLUSTER_SLOTS-1)) {                int nested_elements = 3; /* slots (2) + master addr (1). */                void *nested_replylen = addDeferredMultiBulkLength(c);                 if (bit && j == CLUSTER_SLOTS-1) j++;                 /* If slot exists in output map, add to it's list.                 * else, create a new output map for this slot */                if (start == j-1) {                    addReplyLongLong(c, start); /* only one slot; low==high */                    addReplyLongLong(c, start);                } else {                    addReplyLongLong(c, start); /* low */                    addReplyLongLong(c, j-1);   /* high */                }                start = -1;                 /* First node reply position is always the master */                addReplyMultiBulkLen(c, 3);                addReplyBulkCString(c, node->ip);                addReplyLongLong(c, node->port);                addReplyBulkCBuffer(c, node->name, CLUSTER_NAMELEN);                 /* Remaining nodes in reply are replicas for slot range */                for (i = 0; i < node->numslaves; i++) {                    /*注意:此处遍历了节点下面的备节点信息,用于返回*/                    /* This loop is copy/pasted from clusterGenNodeDescription()                     * with modifications for per-slot node aggregation */                    if (nodeFailed(node->slaves[i])) continue;                    addReplyMultiBulkLen(c, 3);                    addReplyBulkCString(c, node->slaves[i]->ip);                    addReplyLongLong(c, node->slaves[i]->port);                    addReplyBulkCBuffer(c, node->slaves[i]->name, CLUSTER_NAMELEN);                    nested_elements++;                }                setDeferredMultiBulkLength(c, nested_replylen, nested_elements);                num_masters++;            }        }    }    dictReleaseIterator(di);    setDeferredMultiBulkLength(c, slot_replylen, num_masters);} /* Return the slot bit from the cluster node structure. *//*该函数用于判断指定的slot是否属于当前clusterNodes节点*/int clusterNodeGetSlotBit(clusterNode *n, int slot) {    return bitmapTestBit(n->slots,slot);} /* Test bit 'pos' in a generic bitmap. Return 1 if the bit is set, * otherwise 0. *//*此处流程用于判断指定的的位置在bitmap上是否为1*/int bitmapTestBit(unsigned char *bitmap, int pos) {    off_t byte = pos/8;    int bit = pos&7;    return (bitmap[byte] & (1<<bit)) != 0;}typedef struct clusterNode {    ...    /*使用一个长度为CLUSTER_SLOTS/8的char数组对当前分配的slot进行记录*/    unsigned char slots[CLUSTER_SLOTS/8]; /* slots handled by this node */    ...} clusterNode;
复制代码


每一个节点(ClusterNode)使用位图(char slots[CLUSTER_SLOTS/8])存放 slot 的分配信息。


简要说一下 BitmapTestBit 的逻辑:clusterNode->slots 是一个长度为 CLUSTER_SLOTS/8 的数组。CLUSTER_SLOTS 是固定值 16384。数组上的每一个位分别代表一个 slot。此处的 bitmap 数组下标则是 0 到 2047,slot 的范围是 0 到 16383。


因为要判断 pos 这个位置的 bit 上是否是 1,因此:


  • off_t byte = pos/8:拿到在 bitmap 上对应的哪一个字节(Byte)上存放这个 pos 位置的信息。因为一个 Byte 有 8 个 bit。使用 pos/8 可以指导需要找的 Byte 在哪一个。此处把 bitmap 当成数组处理,这里对应的便是对应下标的 Byte。

  • int bit = pos&7:拿到是在这个字节上对应哪一个 bit 表示这个 pos 位置的信息。&7 其实就是 %8。可以想象对 pos 每 8 个一组进行分组,最后一组(不满足 8)的个数对应的便是在 bitmap 对应的 Byte 上对应的 bit 数组下标位置。

  • (bitmap[byte] & (1<<bit)):判断对应的那个 bit 在 bitmap[byte]上是否存在。


以 slot 为 10001 进行举例:



因此 10001 这个 slot 对应的是下标 1250 的 Byte,要校验的是下标 1 的 bit。


对应在 ClusterNode->slots 上的对应位置:



图示绿色的方块表示 bitmap[1250],也就是对应存放 slot 10001 的 Byte;红框标识(bit[1])对应的就是 1<<bit 的位置。bitmap[byte] & (1<<bit),也就是确认红框对应的位置是否是 1。是的话表示 bitmap 上 10001 已经打标。


总结 ClusterNodeGetSlotBit 的概要逻辑是:判断当前的这个 slot 是否分配在当前 node 上。因此 ClusterReplyMultiBulkSlots 大概逻辑表示如下:



大概步骤如下:


  • 对每一个节点进行遍历;

  • 对于每一个节点,遍历所有的 slots,使用 ClusterNodeGetSlotBit 判断遍历中的 slot 是否分配于当前节点;


从获取 CLUSTER SLOTS 指令的结果来看,可以看到,复杂度是<集群主节点个数> *<slot 总个数>。其中 slot 的总个数是 16384,固定值。


2.1.2 Redis 热点排查总结


就目前来看,CLUSTER SLOTS 指令时延随着 Redis 集群的主节点个数,线性增长。而这次我们排查的集群主节点数比较大,可以解释这次排查的现网现象中 CLUSTER SLOTS 指令时延为何较大。


2.2 客户端排查


了解到运维同学们存在扩容操作,扩容完成后必然涉及到一些 key 在访问的时候存在 MOVED 的错误。


当前使用的 Hiredis-vip 客户端代码进行简单的浏览,简要分析以下当前业务使用的 Hiredis-vip 客户端在遇到 MOVED 的时候会怎样处理。由于其他的大部分业务常用的 Jedis 客户端,此处也对 Jedis 客户端对应流程进行简单分析。


2.2.1 Hiredis-vip 对 MOVED 处理实现原理


Hiredis-vip 针对 MOVED 的操作:


查看 Cluster_update_route 的调用过程:


此处的 cluster_update_route_by_addr 进行了 CLUSTER SLOT 操作。可以看到,当获取到 MOVED 报错的时候,Hiredis-vip 会重新更新 Redis 集群拓扑结构,有下面的特性:


  • 因为节点通过 ip:port 作为 key,哈希方式一样,如果集群拓扑类似,多个客户端很容易同时到同一个节点进行访问;

  • 如果某个节点访问失败,会通过迭代器找下一个节点,由于上述的原因,多个客户端很容易同时到下一个节点进行访问。


2.2.2 Jedis 对 MOVED 处理实现原理


对 Jedis 客户端代码进行简单浏览,发现如果存在 MOVED 错误,会调用 renewSlotCache。


继续看 renewSlotCache 的调用,此处可以确认:Jedis 在集群模式下在遇到 MOVED 的报错时候,会发送 Redis 命令 CLUSTER SLOTS,重新拉取 Redis 集群的 slot 拓扑结构。


2.2.3 客户端实现原理小结


由于 Jedis 是 Java 的 Redis 客户端,Hiredis-vip 是 c++的 Redis 客户端,可以简单认为这种异常处理机制是共性操作。


对客户端集群模式下对 MOVED 的流程梳理大概如下:



总的来说:


1)使用客户端缓存的 slot 拓扑进行对 key 的访问;


2)Redis 节点返回正常:

  • 访问正常,继续后续操作


3)Redis 节点返回 MOVED:

  • 对 Redis 节点进行 CLUSTER SLOTS 指令执行,更新拓扑;

  • 使用新的拓扑对 key 重新访问。


2.2.3 客户端排查小结


Redis 集群正在扩容,也就是必然存在一些 Redis 客户端在访问 Redis 集群遇到 MOVED,执行 Redis 指令 CLUSTER SLOTS 进行拓扑结构更新。


如果迁移的 key 命中率高,CLUSTER SLOTS 指令会更加频繁的执行。这样导致的结果是迁移过程中 Redis 集群会持续被客户端执行 CLUSTER SLOTS 指令。


2.3 排查小结


此处,结合 Redis 侧的 CLUSTER SLOTS 机制以及客户端对 MOVED 的处理逻辑,可以解答之前的几个个问题:


为什么会有较多的 Cluster 指令被执行?


  • 因为发生过迁移操作,业务访问一些迁移过的 key 会拿到 MOVED 返回,客户端会对该返回重新拉取 slot 拓扑信息,执行 CLUSTER SLOTS。


为什么 Cluster 指令执行的时候 CPU 资源比较高?


  • 分析 Redis 源码,发现 CLUSTER SLOT 指令的时间复杂度和主节点个数成正比。业务当前的 Redis 集群主节点个数比较多,自然耗时高,占用 CPU 资源高。


为什么节点规模大的集群迁移 slot 操作容易“中招”?


  • 迁移操作必然带来一些客户端访问 key 的时候返回 MOVED;

  • 客户端对于 MOVED 的返回会执行 CLUSTER SLOTS 指令;

  • CLUSTER SLOTS 指令随着集群主节点个数的增加,时延会上升;

  • 业务的访问在 slot 的迁移期间会因为 CLUSTER SLOTS 的时延上升,在外部的感知是执行指令的时延升高。


三、优化


3.1 现状分析


根据目前的情况来看,客户端遇到 MOVED 进行 CLUSTER SLOTS 执行是正常的流程,因为需要更新集群的 slot 拓扑结构提高后续的集群访问效率。


此处流程除了 Jedis,Hiredis-vip,其他的客户端应该也会进行类似的 slot 信息缓存优化。此处流程优化空间不大,是 Redis 的集群访问机制决定。


因此对 Redis 的集群信息记录进行分析。


3.1.1 Redis 集群元数据分析


集群中每一个 Redis 节点都会有一些集群的元数据记录,记录于 server.cluster,内容如下:


typedef struct clusterState {    ...    dict *nodes;          /* Hash table of name -> clusterNode structures */    /*nodes记录的是所有的节点,使用dict记录*/    ...    clusterNode *slots[CLUSTER_SLOTS];/*slots记录的是slot数组,内容是node的指针*/    ...} clusterState;
复制代码


2.1 所述,原有逻辑通过遍历每个节点的 slot 信息获得拓扑结构。


3.1.2 Redis 集群元数据分析


观察 CLUSTER SLOTS 的返回结果:


/* Format: 1) 1) start slot *            2) end slot *            3) 1) master IP *               2) master port *               3) node ID *            4) 1) replica IP *               2) replica port *               3) node ID *           ... continued until done */
复制代码


结合 server.cluster 中存放的集群信息,笔者认为此处可以使用 server.cluster->slots 进行遍历。因为 server.cluster->slots 已经在每一次集群的拓扑变化得到了更新,保存的是节点指针。


3.2 优化方案


简单的优化思路如下:


  • 对 slot 进行遍历,找出 slot 中节点是连续的块;

  • 当前遍历的 slot 的节点如果和之前遍历的节点一致,说明目前访问的 slot 和前面的是在同一个节点下,也就是是在某个节点下的“连续”的 slot 区域内;

  • 当前遍历的 slot 的节点如果和之前遍历的节点不一致,说明目前访问的 slot 和前面的不同,前面的“连续”slot 区域可以进行输出;而当前 slot 作为下一个新的“连续”slot 区域的开始。


因此只要对 server.cluster->slots 进行遍历,可以满足需求。简单表示大概如下:



这样的时间复杂度降低到<slot 总个数>。


3.3 实现


优化逻辑如下:


void clusterReplyMultiBulkSlots(client * c) {    /* Format: 1) 1) start slot     *            2) end slot     *            3) 1) master IP     *               2) master port     *               3) node ID     *            4) 1) replica IP     *               2) replica port     *               3) node ID     *           ... continued until done     */    clusterNode *n = NULL;    int num_masters = 0, start = -1;    void *slot_replylen = addReplyDeferredLen(c);     for (int i = 0; i <= CLUSTER_SLOTS; i++) {        /*对所有slot进行遍历*/        /* Find start node and slot id. */        if (n == NULL) {            if (i == CLUSTER_SLOTS) break;            n = server.cluster->slots[i];            start = i;            continue;        }         /* Add cluster slots info when occur different node with start         * or end of slot. */        if (i == CLUSTER_SLOTS || n != server.cluster->slots[i]) {            /*遍历主节点下面的备节点,添加返回客户端的信息*/            addNodeReplyForClusterSlot(c, n, start, i-1);            num_masters++;            if (i == CLUSTER_SLOTS) break;            n = server.cluster->slots[i];            start = i;        }    }    setDeferredArrayLen(c, slot_replylen, num_masters);}
复制代码


通过对 server.cluster->slots 进行遍历,找到某个节点下的“连续”的 slot 区域,一旦后续不连续,把之前的“连续”slot 区域的节点信息以及其备节点信息进行输出,然后继续下一个“连续”slot 区域的查找于输出。


四、优化结果对比


对两个版本的 Redis 的 CLUSTER SLOTS 指令进行横向对比。

4.1 测试环境 &压测场景


操作系统:manjaro 20.2


硬件配置:


  • CPU:AMD Ryzen 7 4800H

  • DRAM:DDR4 3200MHz 8G*2


Redis 集群信息:


1)持久化配置

  • 关闭 aof

  • 关闭 bgsave


2)集群节点信息:

  • 节点个数:100

  • 所有节点都是主节点


压测场景:


  • 使用 benchmark 工具对集群单个节点持续发送 CLUSTER SLOTS 指令;

  • 对其中一个版本压测完后,回收集群,重新部署后再进行下一轮压测。


4.2 CPU 资源占用对比


perf 导出火焰图。原有版本:



优化后:



可以明显看到,优化后的占比大幅度下降。基本符合预期。


4.3 耗时对比


在上进行测试,嵌入耗时测试代码:


else if (!strcasecmp(c->argv[1]->ptr,"slots") && c->argc == 2) {        /* CLUSTER SLOTS */        long long now = ustime();        clusterReplyMultiBulkSlots(c);        serverLog(LL_NOTICE,            "cluster slots cost time:%lld us", ustime() - now);    }
复制代码


输入日志进行对比;


原版的日志输出:


37351:M 06 Mar 2021 16:11:39.313 * cluster slots cost time:2061 us。


优化后版本日志输出:


35562:M 06 Mar 2021 16:11:27.862 * cluster slots cost time:168 us。


从耗时上看下降明显:从 2000+us 下降到 200-us;在 100 个主节点的集群中的耗时缩减到原来的 8.2%;优化结果基本符合预期。


五、总结


这里可以简单描述下文章上述的动作从而得出的这样的一个结论:性能缺陷。


简单总结下上述的排查以及优化过程:


  • Redis 大集群因为 CLUSTER 命令导致某些节点的访问延迟明显;

  • 使用 perf top 指令对 Redis 实例进行排查,发现 clusterReplyMultiBulkSlots 命令占用 CPU 资源异常;

  • 对 clusterReplyMultiBulkSlots 进行分析,该函数存在明显的性能问题;

  • 对 clusterReplyMultiBulkSlots 进行优化,性能提升明显。


从上述的排查以及优化过程可以得出一个结论:目前的 Redis 在 CLUSTER SLOT 指令存在性能缺陷。


因为 Redis 的数据分片机制,决定了 Redis 集群模式下的 key 访问方法是缓存 slot 的拓扑信息。优化点也只能在 CLUSTER SLOTS 入手。而 Redis 的集群节点个数一般没有这么大,问题暴露的不明显。


其实 Hiredis-vip 的逻辑也存在一定问题。如 2.2.1 所说,Hiredis-vip 的 slot 拓扑更新方法是遍历所有的节点挨个进行 CLUSTER SLOTS。如果 Redis 集群规模较大而且业务侧的客户端规模较多,会出现连锁反应:


1)如果 Redis 集群较大,CLUSTER SLOTS 响应比较慢;


2)如果某个节点没有响应或者返回报错,Hiredis-vip 客户端会对下一个节点继续进行请求;


3)Hiredis-vip 客户端中对 Redis 集群节点迭代遍历的方法相同(因为集群的信息在各个客户端基本一致),此时当客户端规模较大的时候,某个 Redis 节点可能存在阻塞,就会导致 hiredis-vip 客户端遍历下一个 Redis 节点;


4)大量 Hiredis-vip 客户端挨个地对一些 Redis 节点进行访问,如果 Redis 节点无法负担这样的请求,这样会导致 Redis 节点在大量 Hiredis-vip 客户端的“遍历”下挨个请求:


结合上述第 3 点,可以想象一下:有 1w 个客户端对该 Redis 集群进行访问。因为某个命中率较高的 key 存在迁移操作,所有的客户端都需要更新 slot 拓扑。由于所有客户端缓存的集群节点信息相同,因此遍历各个节点的顺序是一致的。这 1w 个客户端都使用同样的顺序对集群各个节点进行遍历地操作 CLUSTER SLOTS。由于 CLUSTER SLOTS 在大集群中性能较差,Redis 节点很容易会被大量客户端请求导致不可访问。Redis 节点会根据遍历顺序依次被大部分的客户端(例如 9k+个客户端)访问,执行 CLUSTER SLOTS 指令,导致 Redis 节点挨个被阻塞。


5)最终的表现是大部分 Redis 节点的 CPU 负载暴涨,很多 Hiredis-vip 客户端则继续无法更新 slot 拓扑。


最终结果是大规模的 Redis 集群在进行 slot 迁移操作后,在大规模的 Hiredis-vip 客户端访问下业务侧感知是普通指令时延变高,而 Redis 实例 CPU 资源占用高涨。这个逻辑可以进行一定优化。


目前上述分节 3 的优化已经提交并合并到 Redis 6.2.2 版本中。


六、参考资料


1、Hiredis-vip: https://github.com

2、Jedis: https://github.com/redis/jedis

3、Redis: https://github.com/redis/redis

4、Perf:https://perf.wiki.kernel.org


作者:vivo 互联网数据库团队—Yuan Jianwei

发布于: 2021 年 10 月 18 日阅读数: 23
用户头像

官方公众号:vivo互联网技术,ID:vivoVMIC 2020.07.10 加入

分享 vivo 互联网技术干货与沙龙活动,推荐最新行业动态与热门会议。

评论

发布
暂无评论
Redis大集群扩容性能优化实践