Jay's Blog

O ever youthful, O ever weeping.

0%

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 翻译)

功能介绍

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

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

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

参数介绍

1
2
3
4
5
6
7
8
9
10
11
12
# 开启自动内存碎片整理(总开关)
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 是一个总开关,当开启时才有可能执行,而是否真正执行则需要下面几个参数控制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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集群性能变差。

1
2
3
4
5
6
7
8
9
10
11
12
13
/* 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,此时响应马上恢复正常。

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

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

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

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

1
2
3
4
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信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/* 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;
}
1
2
3
4
5
/* 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性能的影响也更大。

1
2
3
4
5
6
7
8
9
# 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)
1
2
3
4
5
6
7
8
9
# 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
/* 从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(资源占用率)的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
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)))

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

1
2
3
4
5
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*必须是使用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

Welcome to my other publishing channels