Redis 4.0 自动内存碎片整理(Active Defrag)源码分析

阅读本文前建议先阅读此篇博客: Redis源码从哪里读起

Redis 4.0 版本增加了许多不错的新功能,其中自动内存碎片整理功能 activedefrag 肯定是非常诱人的一个,这让 Redis 集群回收内存碎片相比 Redis 3.0 更加优雅,便利。我们升级 Redis 4.0 后直接开启了activedefrag,经过删除部分 key 测试,发现它确实能有效的释放内存碎片,但是并没有测试它其他相关参数。

一、问题现象

由于业务需要,我们删除了集群中占内存 2/3 的 Key,删除后集群平均碎片率在 1.3 ~ 1.4,内存明显下降,但是此时服务的响应猛然增高,我们通过 redis.cli -c -h 127.0.0.1 -p 5020 --latency 在服务端测试集群性能,发现响应(网络+排队)达到了 2-3ms,这对于 redis 来说已经非常高了,我们其他集群响应一般都在 0.2ms 左右。经过排查后,我们尝试将 activedefrag 功能关闭,并测试,发现 redis 服务端响应马上恢复正常,线上服务响应也降了下来,打开 activedefrag 响应马上飙高。

二、Redis 4.0 源码分析(基于分支 4.0)

Active Defrag 功能的核心代码都在 defrag.c 中的activeDefragCycle(void)函数

1. Active Defrag 介绍及相关参数

我们先看一下redis.conf 中关于 activedefrag 的注释(google 翻译)

功能介绍

警告此功能是实验性的。然而,即使在生产中也进行了压力测试,并且由多个工程师手动测试了一段时间。
什么是主动碎片整理?
-------------------------------
自动(实时)碎片整理允许Redis服务器压缩内存中小数据分配和数据释放之间的空间,从而允许回收内存。

碎片化是每个分配器都会发生的一个自然过程(幸运的是,对于Jemalloc来说却不那么重要)和某些工作负载。通常需要重新启动服务器以降低碎片,或者至少刷新所有数据并再次创建。
但是,由于Oran Agra为Redis 4.0实现了这一功能,这个过程可以在运行时以“热”的方式发生,而服务器正在运行。

基本上当碎片超过一定水平时(参见下面的配置选项),Redis将开始通过利用某些特定的Jemalloc功能在相邻的内存区域中创建值的新副本(以便了解分配是否导致碎片并分配它在一个更好的地方),同时,将释放数据的旧副本。对于所有键,以递增方式重复此过程将导致碎片回退到正常值。
需要了解的重要事项:
1.默认情况下,此功能处于禁用状态,仅在您编译Redis以使用我们随Redis源代码提供的Jemalloc副本时才有效。这是Linux版本的默认设置。
2.如果没有碎片问题,则永远不需要启用此功能。
3.一旦遇到碎片,可以在需要时使用命令“CONFIG SET activedefrag yes”启用此功能。配置参数能够微调其行为碎片整理过程。如果您不确定它们的含义,最好保持默认设置不变。

参数介绍

# 开启自动内存碎片整理(总开关)
activedefrag yes
# 当碎片达到 100mb 时,开启内存碎片整理
active-defrag-ignore-bytes 100mb
# 当碎片超过 10% 时,开启内存碎片整理
active-defrag-threshold-lower 10
# 内存碎片超过 100%,则尽最大努力整理
active-defrag-threshold-upper 100
# 内存自动整理占用资源最小百分比
active-defrag-cycle-min 25
# 内存自动整理占用资源最大百分比
active-defrag-cycle-max 75

2. Active Defrag Timer 在那个线程中执行的?

Redis 是基于事件驱动的,Timer事件和I/O事件会注册到主线程当中,其中内存碎片整理Timer也是在主线程当中执行的。

原文引用[1]

  • 注册timer事件回调。Redis作为一个单线程(single-threaded)的程序,它如果想调度一些异步执行的任务,比如周期性地执行过期key的回收动作,除了依赖事件循环机制,没有其它的办法。这一步就是向前面刚刚创建好的事件循环中注册一个timer事件,并配置成可以周期性地执行一个回调函数:serverCron。由于Redis只有一个主线程,因此这个函数周期性的执行也是在这个线程内,它由事件循环来驱动(即在合适的时机调用),但不影响同一个线程上其它逻辑的执行(相当于按时间分片了)。serverCron函数到底做了什么呢?实际上,它除了周期性地执行过期key的回收动作,还执行了很多其它任务,比如主从重连、Cluster节点间的重连、BGSAVE和AOF rewrite的触发执行,等等。这个不是本文的重点,这里就不展开描述了。
  • 注册I/O事件回调。Redis服务端最主要的工作就是监听I/O事件,从中分析出来自客户端的命令请求,执行命令,然后返回响应结果。对于I/O事件的监听,自然也是依赖事件循环。前面提到过,Redis可以打开两种监听:对于TCP连接的监听和对于Unix domain socket的监听。因此,这里就包含对于这两种I/O事件的回调的注册,两个回调函数分别是acceptTcpHandleracceptUnixHandler。对于来自Redis客户端的请求的处理,就会走到这两个函数中去。我们在下一部分就会讨论到这个处理过程。另外,其实Redis在这里还会注册一个I/O事件,用于通过管道(pipe)机制与module进行双向通信。这个也不是本文的重点,我们暂时忽略它。
  • 初始化后台线程。Redis会创建一些额外的线程,在后台运行,专门用于处理一些耗时的并且可以被延迟执行的任务(一般是一些清理工作)。在Redis里面这些后台线程被称为bio(Background I/O service)。它们负责的任务包括:可以延迟执行的文件关闭操作(比如unlink命令的执行),AOF的持久化写库操作(即fsync调用,但注意只有可以被延迟执行的fsync操作才在后台线程执行),还有一些大key的清除操作(比如flushdb async命令的执行)。可见bio这个名字有点名不副实,它做的事情不一定跟I/O有关。对于这些后台线程,我们可能还会产生一个疑问:前面的初始化过程,已经注册了一个timer事件回调,即serverCron函数,按说后台线程执行的这些任务似乎也可以放在serverCron中去执行。因为serverCron函数也是可以用来执行后台任务的。实际上这样做是不行的。前面我们已经提到过,serverCron由事件循环来驱动,执行还是在Redis主线程上,相当于和主线程上执行的其它操作(主要是对于命令请求的执行)按时间进行分片了。这样的话,serverCron里面就不能执行过于耗时的操作,否则它就会影响Redis执行命令的响应时间。因此,对于耗时的、并且可以被延迟执行的任务,就只能放到单独的线程中去执行了。

3.Active Defrag Timer 的逻辑什么时候会执行?

在参数介绍中我们能看出,activedefrag是一个总开关,当开启时才有可能执行,而是否真正执行则需要下面几个参数控制。

void activeDefragCycle(void) {
    /* ... */

    /* 每隔一秒,检查碎片情况,决定是否执行*/
    run_with_period(1000) {
        size_t frag_bytes;
        /* 计算碎片率和碎片大小*/
        float frag_pct = getAllocatorFragmentation(&frag_bytes);
        /* 如果没有运行或碎片低于阈值,则不执行 */
        if (!server.active_defrag_running) {
            /* 根据计算的碎片率和大小与我们设置的参数进行比较判断,决定是否执行 */
            if(frag_pct < server.active_defrag_threshold_lower || frag_bytes < server.active_defrag_ignore_bytes)
                return;
        }
    /* ... */
}

通过源码,我们可以看出碎片整理是否执行主要是通过active_defrag_running, active-defrag-ignore-bytes, active-defrag-threshold-lower 这几个参数共同决定的。
官方默认设置内存碎片率大于10%且内存碎片大小超过100mb。

4.Active Defrag 为什么会影响Redis集群的响应?

我们将 Redis 集群2/3的数据都删除了,碎片率很快降到 1.3 左右,内存也被很快释放,但是为什么 Redis 响应会变高呢?

首先,我们内存碎片整理是在主线程中执行的,通过源码发现,内存碎片整理操作会 scan (通过迭代进行)整个 redis 节点,并进行内存复制、转移等操作,因为 redis 是单线程的,所以这肯定会导致 redis 性能下降(通过调整相关配置可以控制内存整理对 redis 集群的影响,后面会详细说明)。

通过 redis 日志发现,碎片整理还在不停地执行,并使用了75%的CPU(我们将其解释为 redis 主线程资源的 75%),每次执行耗时82s(此处注意,虽然耗时82s,但是并不是 redis 主线程阻塞的这么久的时间,而是从第一次迭代到最后一次迭代之间的时间,在此时间之内主线程可能还会处理命令请求)。
从日志中可见frag=14%,我们配置的参数一直能达到内存碎片整理的阈值,主线程会不停的去进行内存碎片整理,导致redis集群性能变差。

/* redis 配置及日志
 * activedefrag yes
 * active-defrag-ignore-bytes 100mb
 * active-defrag-threshold-lower 10
 * active-defrag-threshold-upper 100
 * active-defrag-cycle-min 25
 * active-defrag-cycle-max 75 */
11:M 28 May 06:37:17.430 - Starting active defrag, frag=14%, frag_bytes=484401800, cpu=75%
11:M 28 May 06:38:40.424 - Active defrag done in 82993ms, reallocated=50, frag=14%, frag_bytes=484365248

# redis 性能
[service@bigdata src]$ ./redis-cli -h 127.0.0.1 -p 5020 --latency
min: 0, max: 74, avg: 7.38 (110 samples)

我们先将 activedefrag 置为 no,此时响应马上恢复正常。

# redis 性能
min: 0, max: 1, avg: 0.14 (197 samples)

5.Active Defrag 相关参数该怎么调整?

内存碎片整理的功能我们还是需要的,那么我们该如何调整参数才能在redis性能和内存碎片整理之间找到一个平衡点呢?于是我对这几个参数进行调整测试。

(1) 调整active-defrag-ignore-bytesactive-defrag-threshold-lower
此调整是相对简单的,仅用来判断是否进入内存碎片整理逻辑,如果将碎片率或碎片大小调大至一个能接受的阈值,redis 不进行内存碎片整理,则不会对集群有过多的影响。从下面的代码我们可以发现,当两个条件都满足时,则会进入内存碎片整理逻辑。

if (!server.active_defrag_running) {
    if(frag_pct < server.active_defrag_threshold_lower || frag_bytes < server.active_defrag_ignore_bytes)
        return;
}

此处需要注意,frag_pctfrag_bytes 并不等于 info 命令中的 mem_fragmentation_ratio,比如此次问题出现时,mem_fragmentation_ratio = 1.31, 而通过frag_pct计算的碎片率是 1.14,所以设置参数时不能完全参考info中的mem_fragmentation_ratio信息。

/* frag_pct 是从 jemalloc 获取的 */
/* Utility function to get the fragmentation ratio from jemalloc.
 * It is critical to do that by comparing only heap maps that belown to
 * jemalloc, and skip ones the jemalloc keeps as spare. Since we use this
 * fragmentation ratio in order to decide if a defrag action should be taken
 * or not, a false detection can cause the defragmenter to waste a lot of CPU
 * without the possibility of getting any results. */
float getAllocatorFragmentation(size_t *out_frag_bytes) {
    size_t epoch = 1, allocated = 0, resident = 0, active = 0, sz = sizeof(size_t);
    /* Update the statistics cached by mallctl. */
    je_mallctl("epoch", &epoch, &sz, &epoch, sz);
    /* Unlike RSS, this does not include RSS from shared libraries and other non
     * heap mappings. */
    je_mallctl("stats.resident", &resident, &sz, NULL, 0);
    /* Unlike resident, this doesn't not include the pages jemalloc reserves
     * for re-use (purge will clean that). */
    je_mallctl("stats.active", &active, &sz, NULL, 0);
    /* Unlike zmalloc_used_memory, this matches the stats.resident by taking
     * into account all allocations done by this process (not only zmalloc). */
    je_mallctl("stats.allocated", &allocated, &sz, NULL, 0);
    float frag_pct = ((float)active / allocated)*100 - 100;
    size_t frag_bytes = active - allocated;
    float rss_pct = ((float)resident / allocated)*100 - 100;
    size_t rss_bytes = resident - allocated;
    if(out_frag_bytes)
        *out_frag_bytes = frag_bytes;
    serverLog(LL_DEBUG,
        "allocated=%zu, active=%zu, resident=%zu, frag=%.0f%% (%.0f%% rss), frag_bytes=%zu (%zu%% rss)",
        allocated, active, resident, frag_pct, rss_pct, frag_bytes, rss_bytes);
    return frag_pct;
}
/* mem_fragmentation_ratio */
/* Fragmentation = RSS / allocated-bytes */
float zmalloc_get_fragmentation_ratio(size_t rss) {
    return (float)rss/zmalloc_used_memory();
}

(2)调整active-defrag-cycle-minactive-defrag-cycle-max
这两个参数是占用主线程资源比率的上下限,如果想保证内存碎片整理功能不过度影响 redis 集群性能,则需要仔细斟酌着两个参数的配置。
当我调整这两个参数时,我通过观察内存整理时的耗时、资源占用、redis响应等情况发现——当资源占用越多时,内存碎片整理力度越大,时间越短,当然对redis性能的影响也更大。

# active-defrag-cycle-min 10
# active-defrag-cycle-max 10

# 日志记录-耗时、资源占用
11:M 28 May 08:37:39.458 - Starting active defrag, frag=15%, frag_bytes=502210608, cpu=10%
11:M 28 May 08:45:26.160 - Active defrag done in 466700ms, reallocated=187804, frag=14%, frag_bytes=493183888

# redis 响应
min: 0, max: 27, avg: 2.69 (295 samples)
# active-defrag-cycle-min 5
# active-defrag-cycle-max 10

# 日志记录-耗时、资源占用
11:M 28 May 07:08:29.988 - Starting active defrag, frag=14%, frag_bytes=487298400, cpu=5%
11:M 28 May 07:22:58.225 - Active defrag done in 868237ms, reallocated=4555, frag=14%, frag_bytes=484875424

# redis 响应
min: 0, max: 6, avg: 0.44 (251 samples)

(3) 综合调整
在此之前,我们还需要再看一下activeDefragCycle(void)这个函数的具体逻辑
defrag.c
Tips: C 语言中被 static 修饰的变量是全局的,如下代码中的cursor

/* 从serverCron执行增量碎片整理工作。
 * 这与activeExpireCycle的工作方式类似,我们在调用之间进行增量工作。 */
void activeDefragCycle(void) {
    static int current_db = -1;
    /* 游标,通过迭代scan 整个 redis 节点*/
    static unsigned long cursor = 0;
    static redisDb *db = NULL;
    static long long start_scan, start_stat;
    /* 迭代计数器 */
    unsigned int iterations = 0;
    unsigned long long defragged = server.stat_active_defrag_hits;
    long long start, timelimit;

    if (server.aof_child_pid!=-1 || server.rdb_child_pid!=-1)
        return; /* Defragging memory while there's a fork will just do damage. */

    /* Once a second, check if we the fragmentation justfies starting a scan
     * or making it more aggressive. */
    run_with_period(1000) {
        size_t frag_bytes;
        float frag_pct = getAllocatorFragmentation(&frag_bytes);
        /* If we're not already running, and below the threshold, exit. */
        if (!server.active_defrag_running) {
            if(frag_pct < server.active_defrag_threshold_lower || frag_bytes < server.active_defrag_ignore_bytes)
                return;
        }

        /* 计算内存碎片整理所需要占用的主线程资源 */
        int cpu_pct = INTERPOLATE(frag_pct,
                server.active_defrag_threshold_lower,
                server.active_defrag_threshold_upper,
                server.active_defrag_cycle_min,
                server.active_defrag_cycle_max);
        /* 限制占用资源范围 */
        cpu_pct = LIMIT(cpu_pct,
                server.active_defrag_cycle_min,
                server.active_defrag_cycle_max);
         /* We allow increasing the aggressiveness during a scan, but don't
          * reduce it. */
        if (!server.active_defrag_running ||
            cpu_pct > server.active_defrag_running)
        {
            server.active_defrag_running = cpu_pct;
            serverLog(LL_VERBOSE,
                "Starting active defrag, frag=%.0f%%, frag_bytes=%zu, cpu=%d%%",
                frag_pct, frag_bytes, cpu_pct);
        }
    }
    if (!server.active_defrag_running)
        return;

    /* See activeExpireCycle for how timelimit is handled. */
    start = ustime();
    /* 计算每次迭代的时间限制 */
    timelimit = 1000000*server.active_defrag_running/server.hz/100;
    if (timelimit <= 0) timelimit = 1;

    do {
        if (!cursor) {
            /* Move on to next database, and stop if we reached the last one. */
            if (++current_db >= server.dbnum) {
                long long now = ustime();
                size_t frag_bytes;
                float frag_pct = getAllocatorFragmentation(&frag_bytes);
                serverLog(LL_VERBOSE,
                    "Active defrag done in %dms, reallocated=%d, frag=%.0f%%, frag_bytes=%zu",
                    (int)((now - start_scan)/1000), (int)(server.stat_active_defrag_hits - start_stat), frag_pct, frag_bytes);

                start_scan = now;
                current_db = -1;
                cursor = 0;
                db = NULL;
                server.active_defrag_running = 0;
                return;
            }
            else if (current_db==0) {
                /* Start a scan from the first database. */
                start_scan = ustime();
                start_stat = server.stat_active_defrag_hits;
            }

            db = &server.db[current_db];
            cursor = 0;
        }

        do {
            cursor = dictScan(db->dict, cursor, defragScanCallback, defragDictBucketCallback, db);
            /* Once in 16 scan iterations, or 1000 pointer reallocations
             * (if we have a lot of pointers in one hash bucket), check if we
             * reached the tiem limit. */
            /* 一旦进入16次扫描迭代,或1000次指针重新分配(如果我们在一个散列桶中有很多指针),检查我们是否达到了tiem限制。*/
            if (cursor && (++iterations > 16 || server.stat_active_defrag_hits - defragged > 1000)) {
                /* 如果超时则退出,等待下次获取线程资源后继续执行,*/
                if ((ustime() - start) > timelimit) {
                    return;
                }
                iterations = 0;
                defragged = server.stat_active_defrag_hits;
            }
        } while(cursor);
    } while(1);
}

通过代码逻辑分析,我们注意到有两个计算cpu_pct(资源占用率)的函数

int cpu_pct = INTERPOLATE(frag_pct,
        server.active_defrag_threshold_lower,
        server.active_defrag_threshold_upper,
        server.active_defrag_cycle_min,
        server.active_defrag_cycle_max);
cpu_pct = LIMIT(cpu_pct,
        server.active_defrag_cycle_min,
        server.active_defrag_cycle_max);

/* 插值运算函数 */
#define INTERPOLATE(x, x1, x2, y1, y2) ( (y1) + ((x)-(x1)) * ((y2)-(y1)) / ((x2)-(x1)) )
/* 极值函数 */
#define LIMIT(y, min, max) ((y)<(min)? min: ((y)>(max)? max: (y)))

假设我们设置参数如下(产线配置)

active-defrag-ignore-bytes 500mb
active-defrag-threshold-lower 50
active-defrag-threshold-upper 100
active-defrag-cycle-min 5
active-defrag-cycle-max 10

(1) 我们可以得出第一个计算 cpu_pct的第一个函数 y = 0.1x
(2) 假设此时的 frag_pct = 100 & frag_bytes > 500mb, 则cpu_pct = 10
(3) 在经过求极值函数计算后,得到最后的 cpu_pct的值 10
(4) 然后通过这个值进而计算出timelimit = 1000000*server.active_defrag_running(10)/server.hz(in redis.conf 10)/100 = 10000μs = 10ms
(5) 最后 Redis 自动内存碎片整理功能通过timelimit的值来尽可能的保证不集中性地占用主线程资源

6.Memory Purge 手动整理内存碎片

此处顺便介绍一下 Memory Purge 功能。
memory purge是手动触发整理内存碎片的 Command,它会以一个I/O事件的形式注册到主线程当中去执行。值得注意的是,它和 activedefrag回收的并不是同一块区域的内存,它尝试清除脏页以便内存分配器回收使用
具体逻辑,我们来看一下源码中的实现,object.c

/*必须是使用jemalloc内存分配器时才可用*/
#if defined(USE_JEMALLOC)
    char tmp[32];
    unsigned narenas = 0;
    size_t sz = sizeof(unsigned);
    /*获取arenas的个数,然后调用jemalloc的接口进行清理 */
    if (!je_mallctl("arenas.narenas", &narenas, &sz, NULL, 0)) {
        sprintf(tmp, "arena.%d.purge", narenas);
        if (!je_mallctl(tmp, NULL, 0, NULL, 0)) {
            addReply(c, shared.ok);
            return;
        }
    }
    addReplyError(c, "Error purging dirty pages");
#else
    addReply(c, shared.ok);
    /* Nothing to do for other allocators. */
#endif

关于arenas相关的知识,可以参考这篇文章的解释。原文引用[2]

从产线实际使用的情况中来看,memory purge 的效果相比于activedefrag并没有那么的理想,这也是其机制决定的,但是某些内存碎片率比较极端的情况下,也会起到一定的作用。建议根据实际情况,和activedefrag配合使用。

三、Active Defrag 参数调整建议

综上,我们总结出,我们通过active-defrag-ignore-bytesactive-defrag-threshold-lower来控制是否进行内存碎片整理,通过active-defrag-cycle-minactive-defrag-cycle-max来控制整理内存碎片的力度。
由于各个公司的Redis集群大小,存储的数据结构都会存在差异,所以在开启自动的内存碎片整理的开关后,一定要依据自身的实际情况来设置整理内存碎片的力度的参数。

参考文章:
[1] Redis源码从哪里读起
[2] redis4支持内存碎片清理功能实现分析
[3] jemalloc 3.6.0源码详解—[1]Arena

展开阅读全文

没有更多推荐了,返回首页