Redis latency spikes and the Linux kernel: a few more details

Salvatore Sanfilippo

Redis 延迟尖峰与 Linux 内核:更多细节

原文由 Salvatore Sanfilippo 发布,订阅该博客

今天我在 m3.medium 类型的 EC2 实例上测试 Redis 延迟。我复现了 BGSAVE 期间常见的延迟尖峰,也就是进程 fork、子进程开始把数据集保存到磁盘的那个阶段。不过有些现象并不符合预期:这次的尖峰既不是由磁盘 I/O 引起的,也不是发生在 fork() 调用本身。

测试时内存中有 1GB 数据,由另一台 EC2 实例以每秒 15 万次写入的速率发起请求,均匀地写入 500 万个 key。pipeline 设为 4 条命令。用 redis-benchmark 来表示,就是这样一条命令:

    ./redis-benchmark -P 4 -t set -r 5000000 -n 1000000000

每次触发 BGSAVE,我都能看到约 300 毫秒、来源不明的延迟尖峰,而 fork 本身只花了 6 毫秒。好在 Redis 有一个软件看门狗功能,能在延迟事件发生时生成进程的堆栈。这是个很简单但非常管用的技巧:我们让内核定时投递一个 SIGALRM。每次调用 serverCron() 时,都会清除这个已安排的信号,所以如果控制权足够快地回到 Redis 进程,实际上根本不会收到它。相反,如果出现了阻塞,内核就会投递该信号,信号处理函数便会打印堆栈。

结果堆栈并没有停在 fork 调用上,而是总是在 fork 之后、父进程上下文中执行 MOV* 指令的地方阻塞。我开始推测,Linux 在某种程度上做了“惰性 fork”,真正繁重的工作被推迟到了之后访问内存、需要对页面进行写时复制的时候。

下一步是去读 Linux 内核中 fork() 的实现。这个系统调用的确会复制所有已映射的区域(vm_area_struct 结构)。不过传统实现还会在此时复制 PTE,而这项工作传统上是由 copy_page_range() 完成的。然而多年前作为一项优化,情况发生了变化:如今 Linux 不仅仅像大多数现代内核那样做页面的惰性复制,PTE 也是在缺页时才惰性复制的。下面是 copy_range_range() 顶部的注释:

         * Don't copy ptes where a page fault will fill them correctly.
         * Fork becomes much lighter when there are big shared or private
         * readonly mappings. The tradeoff is that copy_page_range is more
         * efficient than faulting.

基本上,只要父进程访问了与子进程共享的区域,Linux 就会在缺页时去完成 fork 时跳过的大量工作,这就是为什么我在堆栈中总能看到 MOV 指令。

虽然这种行为对 Redis 不利,因为一次性把所有 PTE 复制完会更高效,但对于 POSIX 系统上 fork() 的传统用法——即 fork()+exec*() 来派生新进程——这种方式要好得多。

这个问题并非 EC2 独有,不过虚拟化实例复制 PTE 更慢,所以在物理服务器上没那么明显。

但这肯定不是全部原因。在我自己的 Linux 机器上测试这些时,我想起过去在某些条件下用 libc malloc 而非 jemalloc,测到的延迟尖峰反而更小。于是我试着看看这之间是否存在关联。

的确,用 MALLOC=libc 编译后,我在物理服务器上几乎测不到任何延迟,而用 jemalloc 时却能观察到与 EC2 实例上相同的现象。为了更清楚地对比差异,我用 1500 万个 key 和更大的 pipeline 搭了一组更重的测试,以便给系统更大压力,让所有 mmap 区域的缺页更有可能在极短时间内集中发生。然后分别用 jemalloc 和 libc malloc 重复了同样的测试:

物理机,675k/秒写入 1500 万个 key,jemalloc:最大尖峰 339 毫秒。
物理机,675k/秒写入 1500 万个 key,malloc:最大尖峰 21 毫秒。

我很快也在 EC2 上复现了同样的结果,情况相同,用 malloc 时尖峰只有原来的零头。

有了这些发现,接下来自然要看看使用 libc malloc 与使用 jemalloc 的 Redis 在内存布局上有何不同。Linux 的 proc 文件系统很适合用来探究进程内部(这次我用的是 /proc/<pid>/smaps 文件)。

jemalloc 分配的内存位于这个区域:

7f8002c00000-7f8062400000 rw-p 00000000 00:00 0
Size:            1564672 kB
Rss:             1564672 kB
Pss:             1564672 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:   1564672 kB
Referenced:      1564672 kB
Anonymous:       1564672 kB
AnonHugePages:   1564672 kB
Swap:                  0 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Locked:                0 kB
VmFlags: rd wr mr mw me ac sd

而 libc 的大块区域看起来像这样:

0082f000-8141c000 rw-p 00000000 00:00 0                                  [heap]
Size:            2109364 kB
Rss:             2109276 kB
Pss:             2109276 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:   2109276 kB
Referenced:      2109276 kB
Anonymous:       2109276 kB
AnonHugePages:         0 kB
Swap:                  0 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Locked:                0 kB
VmFlags: rd wr mr mw me ac sd

看起来这里有两点不同。

1) 只有 libc malloc 的第一行有 [heap]。
2) libc malloc 的 AnonHugePages 字段为零,而 jemalloc 情况下该字段的值等于该区域的大小。

<this is wrong>
基本上,延迟差异似乎是因为 malloc 使用了透明大页,这是一项内核特性,可以透明地把多个普通的 4k 页面合并成少数几个大页,每个 2048k。因此复制这些区域的 PTE 会快得多。
</this is wrong>

编辑:很遗憾,我刚刚发现自己完全搞错了,透明大页显然只有 jemalloc 在用:我把输出看反了,因为当时这看起来太理所当然了。所以恰恰相反,高延迟似乎是由于透明大页导致的,原因不明。也就是说,恰恰是*没有*使用透明大页的 malloc 要快得多。我完全不清楚这里发生了什么,所以请忽略上面的结论。

<wrong advice, read later EDIT2>
与此同时,对于低延迟应用,你或许想用“make MALLOC=libc”来编译 Redis,不过编译前一定要先执行“make distclean”,并且要注意,根据工作负载的不同,libc malloc 比 jemalloc 更容易产生碎片。
</wrong>

更多消息敬请关注……

EDIT2:等等……既然问题出在透明大页上,这反而*更好*办了,因为我们可以把它关掉。我刚刚验证过,这样确实有效:

echo never > /sys/kernel/mm/transparent_hugepage/enabled

这似乎成了新的 Redis 箴言。

UPDATE:虽然这在我看来不太可信,但我通过实验验证了,透明大页导致的内存尖峰是由于:在有 50 个客户端同时写入、每个客户端各有 N 个排队请求的情况下,Redis 进程可以在*单次事件循环迭代*的时间内触及进程的所有页面,从而对整个进程地址空间进行写时复制。这意味着,透明大页不仅对延迟极其不利,对内存占用也同样糟糕。

本文章由 muse-spark-1.2-contributor 进行翻译

评论