VLDB 顶会论文 Async-fork 解读与 Redis 实践 | 得物技术
1
背景
在 Redis 中,在 AOF 文件重写、生成 RDB 备份文件以及主从全量同步过程中,都需要使用系统调用 fork 创建一个子进程来获取内存数据快照,在 fork() 函数创建子进程的时候,内核会把父进程的「页表」复制一份给子进程,如果页表很大,复制页表的过程耗时会非常长,那么在此期间,业务访问 Redis 读写延迟会大幅增加。
最近,阿里云联合上海交大,在数据库顶级会议 VLDB 上发表了一篇文章《Async-fork: Mitigating Query Latency Spikes Incurred by the Fork-based Snapshot Mechanism from the OS Level》,文章介绍到,他们设计了一个新的 fork(称为 Async-fork),将 fork 调用过程中最耗时的页表拷贝部分从父进程移动到子进程,父进程因而可以快速返回用户态处理用户查询,子进程则在此期间完成页表拷贝,从而减少 fork 期间到达请求的尾延迟。所以该特性在类似 Redis 类型的内存数据库上均能取得不错的效果。
2
基本概念
2.1 物理内存地址
也即实际的物理内存地址空间。
2.2 虚拟地址空间
虚拟地址空间(Virtual Address Space)是每一个程序被加载运行起来后,操作系统为进程分配的虚拟内存,它为每个进程提供了一个假象,即每个进程都在独占地使用主存。
每个进程所能访问的最大的虚拟地址空间由计算机的硬件平台决定,具体地说是由 CPU 的位数决定的。比如 32 位的 CPU 就是我们常说的 4GB 虚拟内存空间。
程序访问内存地址使用虚拟地址空间,然后由操作系统将这个虚拟地址映射到适当的物理内存地址上。这样,只要操作系统处理好虚拟地址到物理内存地址的映射,就可以保证不同的程序最终访问的内存地址位于不同的区域,彼此没有重叠,就可以达到内存地址空间隔离的效果。
当进程创建时,每个进程都会有一个自己的 4GB 虚拟地址空间。要注意的是这个 4GB 的地址空间是“虚拟”的,并不是真实存在的,而且每个进程只能访问自己虚拟地址空间中的数据,无法访问别的进程中的数据,通过这种方法实现了进程间的地址隔离。
对于 Linux,4GB 的虚拟地址空间包含用户态虚拟内存空间和内核态虚拟内存空间两部分,默认分配状态如下:
2.3 内存页表
「页表」保存的是虚拟内存地址与物理内存地址的映射关系。
CPU 访问数据的时候,CPU 发出的地址是虚拟地址,CPU 中内存管理单元(MMU)通过查询页表,把虚拟地址转换为物理地址,再去访问物理内存条。
2.3.1 内存分页
分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小,这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为 4KB。
在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万(2^20)个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表。
这 4MB 大小的页表,看起来也不是很大。但是每个进程都是有自己的虚拟地址空间,也就说都有自己的页表。每个机器上同时运行多个进程,页表将占用大量内存。
2.3.2 多级页表
要解决上面提到的存储进程页表项占用大量内存空间的问题,就需要采用一种叫作多级页表(Multi-Level Page Table)的解决方案。
我们把这个 100 多万个「页表项」的单级页表再分页,将页表(一级页表)分为 1024 个页表(二级页表),每个二级页表中包含 1024 个「页表项」,形成二级分页。这样,一级页表就可以覆盖整个 4GB 虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。也就是,内存中只需要保存一级页表以及使用到的二级页表,大量的未被使用的二级页表则不需要分配内存并加载在内存中,因此,达到节省页表占用内存空间的目的。
页全局目录项 PGD(Page Global Directory); 页上级目录项 PUD(Page Upper Directory); 页中间目录项 PMD(Page Middle Directory); 页表项 PTE(Page Table Entry);
2.4 虚拟内存区域(VMA)
进程的虚拟内存空间包含一段一段的虚拟内存区域(Virtual memory area, 简称 VMA),每个 VMA 描述虚拟内存空间中一段连续的区域,每个 VMA 由许多虚拟页组成,即每个 VMA 包含许多页表项 PTE。
3
Fork 原理
在默认 fork 的调用过程中,父进程需要将许多进程元数据(例如文件描述符、信号量、页表等)复制到子进程,而页表的复制是其中最耗时的部分(占据 fork 调用耗时的 97% 以上)。
Linux 的 fork() 使用写时拷贝 (copy-on-write) 页的方式实现。写时拷贝是一种可以推迟甚至避免拷贝数据的技术。在创建子进程的过程中,操作系统会把父进程的「页表」复制一份给子进程,这个页表记录着虚拟地址和物理地址映射关系,此时,操作系统并不复制整个进程的物理内存,而是让父子进程共享同一个物理内存。同时,操作系统内核会把共享的所有的内存页的权限都设为 read-only。
那什么时候会发生物理内存的复制呢?
当父进程或者子进程在向共享内存发起写操作时,内存管理单元 MMU 检测到内存页是 read-only 的,于是触发缺页中断异常(page-fault),处理器会从中断描述符表(IDT)中获取到对应的处理程序。在中断程序中,内核就会把触发异常的物理内存页复制一份,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写,于是父子进程各自持有独立的一份,之后进程才会对内存进行写操作,这个过程也被称为写时复制(Copy On Write)。
4
Fork 的痛点
在原生 fork 下,在父进程调用 fork() 创建子进程的过程中,虽然使用了写时复制页表的方式进行优化,但由于要复制父进程的页表,还是会造成父进程出现短时间阻塞,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长。
我们在测试中很容易观察到 fork 产生的阻塞现象,以及 fork 造成的 Redis 访问抖动现象。
4.1 测试环境
Redis 版本:优化前 Redis-server
机器操作系统:无 Async-fork 特性的系统
127.0.0.1:6380> info memory
# Memory
used_memory:23220597688
used_memory_human:21.63G
4.2 阻塞现象复现
在使用 Redis-benchmark 压测的过程中,手动执行 bgsave 命令,观察 fork 耗时和压测指标 TP100。
使用 info stats
返回上次 fork 耗时:latest_fork_usec:183632
,可以看到 fork 耗时 183 毫秒。
在压测过程中分别不执行 bgsave 和执行 bgsave,结果如下:
# 压测过程中未执行 bgsave
[root@xxx bin]# Redis-benchmark -d 256 -t set -n 1000000 -a xxxxxx -p 6380
====== SET ======
1000000 requests completed in 8.15 seconds
50 parallel clients
256 bytes payload
keep alive: 1
99.90% <= 1 milliseconds
100.00% <= 1 milliseconds
122669.27 requests per second
# 压测过程中执行 bgsave
[root@xxx bin]# Redis-benchmark -d 256 -t set -n 1000000 -a xxxxxx -p 6380
====== SET ======
1000000 requests completed in 13.97 seconds
50 parallel clients
256 bytes payload
keep alive: 1
86.41% <= 1 milliseconds
86.42% <= 2 milliseconds
99.95% <= 3 milliseconds
99.99% <= 4 milliseconds
99.99% <= 10 milliseconds
99.99% <= 11 milliseconds
99.99% <= 12 milliseconds
100.00% <= 187 milliseconds
100.00% <= 187 milliseconds
71561.47 requests per second
strace 常用来跟踪进程执行时的系统调用和所接收的信号。
$ strace -p 32088 -T -tt -o strace00.out
14:01:33.623495 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7fbe5242fa50) = 37513 <0.183533>
14:01:33.807142 open("/data1/6380/6380.log", O_WRONLY|O_CREAT|O_APPEND, 0666) = 60 <0.000018>
14:01:33.807644 lseek(60, 0, SEEK_END) = 8512 <0.000017>
14:01:33.807690 stat("/etc/localtime", {st_mode=S_IFREG|0644, st_size=528, ...}) = 0 <0.000010>
14:01:33.807732 fstat(60, {st_mode=S_IFREG|0644, st_size=8512, ...}) = 0 <0.000007>
14:01:33.807756 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fbe52437000 <0.000009>
14:01:33.807787 write(60, "35994:M 21 Mar 14:01:33.807 * Ba"..., 69) = 69 <0.000015>
14:01:33.807819 close(60) = 0 <0.000008>
14:01:33.807845 munmap(0x7fbe52437000, 4096) = 0 <0.000013>
info stats
统计的 fork 耗时一致。5
Async-fork
鉴于以上 linux 原生 fork 系统调用的痛点,对于像 Redis 这样的高性能内存数据库,将会增加 fork 期间的用户访问延迟,论文中设计了一个新的 fork(称为 Async-fork)来解决上述问题。
Async-fork 设计的核心思想是将 fork 调用过程中最耗时的页表拷贝工作从父进程移动到子进程,缩短父进程调用 fork 时陷入内核态的时间,父进程因而可以快速返回用户态处理用户查询,子进程则在此期间完成页表拷贝。与 Linux 中的默认原生 fork 相比,Async-fork 显著减少了 Redis 快照期间到达请求的尾延迟。
5.1 Async-fork 的挑战
然而,Async-fork 的实现过程中,实际工作并非描述的这么简单。页表的异步复制操作可能导致快照不一致。以下图为例,Redis 在 T0 时刻保存内存快照,而某个用户请求在 T2 时刻向 Redis 插入了新的键值对(k2, v2),这将导致父进程修改它的页表项(PTE2)。假如 T2 时刻这个被修改的页表项(PTE2)还没有被子进程复制完成, 这个修改后的内存页表项及对应内存页后续将被复制到子进程,这个新插入的键值对将被子进程最终写入硬盘,破坏了快照一致性。(快照文件应该记录的是保存拍摄内存快照那一刻的内存数据)
图片来源于:参考资料[1] 第 8 页
5.2 Async-fork 详解
图片来源于:参考资料[1] 第 7 页
5.2.1 主动同步机制
5.2.2 错误处理
6
Redis 优化实践
6.1 Async-fork 阻塞现象
在支持 Async-fork 的操作系统(即 Tair 专属操作系统镜像)机器上测试,理论上来说,按照文章的预期,用户不需要作任何修改(Async-fork 使用了原生 fork 相同的接口,没有另外新增接口),就可以享受 Async-fork 优化带来的优势,但是,使用 Redis 实际测试过程中,结果不符合预期,在 Redis 压测过程中手动执行 bgsave 命令触发 fork 操作,还是观察到了 TP100 抖动现象。
测试环境
Redis 版本:优化前 Redis-Server
机器操作系统:Tair 专属操作系统镜像
测试数据量:54.38G
127.0.0.1:6679> info memory
# Memory
used_memory:58385641120
used_memory_human:54.38G
问题现象
info stats
返回上次 fork 耗时:latest_fork_usec:426
# 压测过程中执行 bgsave
[root@xxx ~]# /usr/bin/Redis-benchmark -d 256 -t set -n 1000000 -a xxxxxx -p 6679
====== SET ======
1000000 requests completed in 7.88 seconds
50 parallel clients
256 bytes payload
keep alive: 1
100.00% <= 411 milliseconds
100.00% <= 412 milliseconds
100.00% <= 412 milliseconds
126871.35 requests per second
追踪过程
$ strace -p 32088 -T -tt -o strace00.out
14:18:12.933441 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f461c0daa50) = 13772 <0.000380>
14:18:12.933884 open("/data1/6679/6679.log", O_WRONLY|O_CREAT|O_APPEND, 0666) = 60 <0.000019>
14:18:12.933948 lseek(60, 0, SEEK_END) = 11484 <0.000013>
14:18:12.933983 stat("/etc/localtime", {st_mode=S_IFREG|0644, st_size=556, ...}) = 0 <0.000016>
14:18:12.934032 fstat(60, {st_mode=S_IFREG|0644, st_size=11484, ...}) = 0 <0.000014>
14:18:12.934062 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f461c0e4000 <0.358768>
14:18:13.292883 write(60, "32088:M 21 Mar 14:18:12.933 * Ba"..., 69) = 69 <0.000032>
14:18:13.292951 close(60) = 0 <0.000014>
14:18:13.292980 munmap(0x7f461c0e4000, 4096) = 0 <0.000019>
$ strace -p 11559 -T -tt -e trace=memory -o trace00.out
14:18:12.934062 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f461c0e4000 <0.358768>
14:18:13.292980 munmap(0x7f461c0e4000, 4096) = 0 <0.000019>
定位问题
$ perf trace -p 11559 -o trace01.out --max-stack 15 -T
616821913.647 (358.740 ms): Redis-server_4/32088 mmap(len: 4096, prot: READ|WRITE, flags: PRIVATE|ANONYMOUS ) = 0x7f461c0e4000
__mmap64 (/usr/lib64/libc-2.17.so)
__GI__IO_file_doallocate (inlined)
__GI__IO_doallocbuf (inlined)
__GI__IO_file_overflow (inlined)
_IO_new_file_xsputn (inlined)
_IO_vfprintf_internal (inlined)
__GI_fprintf (inlined)
serverLogRaw (/usr/local/Redis/Redis-server)
serverLog (/usr/local/Redis/Redis-server)
rdbSaveBackground (/usr/local/Redis/Redis-server)
bgsaveCommand (/usr/local/Redis/Redis-server)
call (/usr/local/Redis/Redis-server)
processCommand (/usr/local/Redis/Redis-server)
processInputBuffer (/usr/local/Redis/Redis-server)
aeProcessEvents (/usr/local/Redis/Redis-server)
616822272.562 ( 0.010 ms): Redis-server_4/32088 munmap(addr: 0x7f461c0e4000, len: 4096 ) = 0
__munmap (inlined)
__GI__IO_setb (inlined)
_IO_new_file_close_it (inlined)
_IO_new_fclose (inlined)
serverLogRaw (/usr/local/Redis/Redis-server)
serverLog (/usr/local/Redis/Redis-server)
rdbSaveBackground (/usr/local/Redis/Redis-server)
bgsaveCommand (/usr/local/Redis/Redis-server)
call (/usr/local/Redis/Redis-server)
processCommand (/usr/local/Redis/Redis-server)
processInputBuffer (/usr/local/Redis/Redis-server)
aeProcessEvents (/usr/local/Redis/Redis-server)
aeMain (/usr/local/Redis/Redis-server)
main (/usr/local/Redis/Redis-server)
测试环境
Redis 版本:优化后 Redis-Server
机器操作系统:Tair 专属操作系统镜像
测试数据量:54.38G
127.0.0.1:6680> info memory
# Memory
used_memory:58385641144
used_memory_human:54.38G
现象
info stats
返回上次 fork 耗时:latest_fork_usec:414
# 压测过程中执行 bgsave
[root@xxx Redis]# /usr/bin/Redis-benchmark -d 256 -t set -n 1000000 -a dRedis123456 -p 6680
====== SET ======
1000000 requests completed in 7.50 seconds
50 parallel clients
256 bytes payload
keep alive: 1
99.99% <= 1 milliseconds
99.99% <= 2 milliseconds
100.00% <= 2 milliseconds
133386.69 requests per second
跟踪验证
# strace -p 14697 -T -tt -o strace04.out
14:42:00.723224 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7fa5340d0a50) = 15470 <0.000378>
# perf trace -p 14697 -o trace04.out --max-stack 15 -T
618249694.830 ( 0.423 ms): Redis-server/14697 ... [continued]: clone()) = 15470 (Redis-server)
__GI___fork (inlined)
rdbSaveBackground (/usr/local/Redis/Redis-server)
bgsaveCommand (/usr/local/Redis/Redis-server)
call (/usr/local/Redis/Redis-server)
processCommand (/usr/local/Redis/Redis-server)
processInputBuffer (/usr/local/Redis/Redis-server)
aeProcessEvents (/usr/local/Redis/Redis-server)
aeMain (/usr/local/Redis/Redis-server)
main (/usr/local/Redis/Redis-server)
由于我们的优化是将触发 mmap 的相关日志修改到子进程中,使用 Perf trace 跟踪 fork 产生的子进程,命令为:
strace -p 14697 -T -tt -f -ff -o strace05.out
通过 Redis 日志文件找到子进程 pid 为 15931;打开对应生成的保存子进程 strace 信息的文件strace05.out.15931(父进程 strace 信息保存在文件strace05.out.14697
)
# 以下为子进程 strace 信息
14:47:40.878387 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fa5340da000 <0.000008>
14:47:40.878415 write(6, "15931:C 21 Mar 14:47:40.878 * Ba"..., 69) = 69 <0.000015>
14:47:40.878447 close(6) = 0 <0.000006>
14:47:40.878467 munmap(0x7fa5340da000, 4096) = 0 <0.000010>
14:47:40.878494 open("temp-15931.rdb", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 6 <0.000020>
14:47:40.878563 fstat(6, {st_mode=S_IFREG|0644, st_size=0, ...}) = 0 <0.000006>
14:47:40.878584 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fa5340da000 <0.000006>
在子进程中看到了 mmap 调用,子进程中调用不会影响父进程对业务访问的响应。
7
性能测试
修改 Redis 代码,针对 Async-fork 适配优化后,我们针对 fork 与 Async-fork 进行了性能对比测试;测试包含不同数据量下 fork() 命令耗时与 fork() 操作对压测过程中 TP100 的影响。
7.1 fork() 命令耗时
fork() 命令耗时,即针对 Redis 执行 bgsave
命令后,通过 Redis 提供的 info stats
命令观察到的latest_fork_usec
用时。
注:由于 fork 与 Async-fork 系统下,fork() 操作产生的latest_fork_usec
数据差距悬殊非常大,使用单纵轴会导致 Async-fork 的数据在图表中显示不明显,不方便查看,因此,该图表使用了双纵轴;虽然 Async-fork 的图表看起来比较高,但是实际右纵轴范围小,所以数据小
从图表可以看出,使用支持 Async-fork 的操作系统,fork() 操作产生的耗时非常小,不管数据量多大,耗时都非常稳定,基本在 200 微秒左右;而原生 fork 产生的耗时会随着数据量增长而增长,而且是从几十毫秒增长到几百毫秒。
7.2 TP100 抖动
在使用 Redis-benchmark 压测过程中,手动执行 bgsave 命令,触发操作系统 fork() 操作,观察不同数据量下,fork 与 Async-fork 对 Redis 压测时 TP100 的影响。
从图上可以看出,使用支持 Async-fork 的操作系统,fork() 操作对 Redis 压测产生的性能影响非常小,性能提升非常明显,不管数据量多大,耗时都非常稳定,基本在 1-2 毫秒左右;而原生 fork 产生的抖动影响时间会随着数据量增长而增长, TP100 从几十毫秒增长到几百毫秒。
8
总结
latest_fork_usec
耗时均减少 98% 以上。基于论文中 Async-fork 的设计思想,Tair 专属操作系统镜像已支持该特性,并且将该特性集成在原生 fork 中,没有新增系统调用接口,理论上用户只需要使用支持 Async-fork 的操作系统,程序无需做任何修改,就可以享受到 Async-fork 特性带来的性能提升。对于 Redis 而言,我们也只需要对 Redis 稍加适配就可以获得该技术带来的红利。
参考资料:
*文/ Miro
关注得物技术,每周一三五晚18:30更新技术干货