关于数据库:Redis大集群扩容性能优化实践

40次阅读

共计 10437 个字符,预计需要花费 27 分钟才能阅读完成。

一、背景

在现网环境,一些应用 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

正文完
 0