Andrew Pavlo:警告!不要使用mmap代替数据库的缓冲IO,那Prometheus呢?
作者:我叫 Steve, 我喜欢接触新事物,学习新知识,目前的学习兴趣是学习马来语以及机器学习。除此之外我擅长解决问题,希望能帮到你。
联系方式:https://github.com/bo-er
最近读了 Andrew Pavlo 的一篇文章[1],警告年轻的开发人员不要使用 mmap 来替代 DBMS 中的缓冲 IO(pread/pwrite)。他的论点本质上是,使用 mmap 来管理 DMBS 中的文件 I/O 是错误的。这立即让我想起了 Prometheus,它使用 mmap 将其数据块从磁盘映射到内存。
Prometheus 的 TSDB 使用到了 mmap,因为它继承了 levelDB 和 RocksDB 的思想。我们公司的 DMP[2](一款优秀的通用数据库管理平台)使用 Prometheus 存储监控指标,它被集成到名为“umon”的 DMP 组件中。如果 Prometheus 出现问题,我们希望成为第一个知道的人。在本文中,我们将深入探讨 mmap,进而得出 Prometheus 在使用 mmap 上是否犯错的结论。
1使用 mmap 的优缺点
mmap 是如何出现的?
在 20 世纪 80 年代末,一台典型的计算机有 128 KB RAM,典型的计算机内存既稀缺又昂贵。接着在操作系统领域,SunOS 4.0 中出现了将文件映射到内存的巧妙想法:内核将库文件加载到物理内存中一次,并在不同进程之间共享它们,这样内核就不必为每个进程单独加载库文件到物理内存中,进而可以重用物理内存。
使用 mmap 的优点
1 避免系统调用
基于安全考虑操作系统划分了内核空间与用户空间。内核空间的概念如内核数据、内核代码、内核堆栈和内核堆段,都位于内核内存区域,对用户程序不可见,这称为内存分段管理机制。这种分段就是需要系统调用的原因。
系统调用可以防止用户程序跳转到内存中的任意位置并执行操作系统代码,如果用户程序需要调用内核代码,只能通过预定义的接口将控制权安全地移交给内核代码。当用户程序执行系统调用时,就会发生"环境切换"。
当我们使用 mmap 将文件映射到虚拟地址空间时,只需要一个系统调用:mmap(2)
。相反,如果我们使用 pread/pwrite 进行文件 I/O,则程序必须为每个读/写操作进行环境切换。这是为什么 mmap 应该提供更好的性能的原因。
环境切换缓慢的原因主要是由于 TLB(Translation Lookaside Buffer) 刷新。TLB 是 CPU 中利用局部性的最快的高速缓存。它缓存页表条目,这些条目存储在每个进程名为 的内存描述符中 mm_struct
,该描述符包含进程的所有内存区域。因此,当发生环境切换时,TLB 的 PTE(Page Table Entry) 应该失效。
这种失效和随后的缓存重新填充是环境切换慢的原因。然而,这些都是古老的事实,现代计算机上的 TLB 是有标签 的,因此环境切换不一定会刷新计算机的 TLB。就像 armV8 文档中描述的那样:
对于非全局条目,当更新 TLB 并将该条目标记为非全局时,除了正常的翻译信息之外,TLB 条目中还会存储一个值。该值称为地址空间 ID (ASID),它是操作系统分配给每个单独任务的编号。如果当前 ASID 与条目中存储的 ASID 匹配,则后续 TLB 查找仅在该条目上匹配。这允许针对标记为非全局但具有不同 ASID 值的特定页面存在多个有效的 TLB 条目。换句话说,当进行环境切换时,我们不一定需要刷新 TLB。
当发生环境切换时,CPU 可能会简单地更改活动 ASID,这就是 TLB 现在称为 Tagged TLB 的原因。这里的要点是,在现代计算机上,环境切换不像过去那么昂贵了,在 Intel 处理器上通常涉及以下步骤:
将 SP(堆栈指针)切换到内核堆栈。 保存用户模式 SP、PC(程序计数器)、特权模式 CPL(当前特权级别)从3更改为0。该级别与内存分段相关。 将新 PC 设置为系统调用处理程序(通过代码映射)
这些步骤的开销并不大,甚至进程级的环境切换在现代计算机上也是低开销的。在阿里云的 Intel(R) Xeon(R) Gold 6148 CPU @ 2.40GHz 云机器(2 核)上,运行 context_switch.cpp[3] 显示进程级别的环境切换平均需要约 1800ns,运行 syscall_switch.cpp[4] 显示系统调用 getpid
平均需要约 380ns。这两个数字看起来并不可怕。
2 不需要用户缓冲区
在进行读/写操作时,默认情况下,内核实际上并不从磁盘读取或写入磁盘,而只是在用户空间的缓冲区和内核空间的页缓存之间复制数据。pread
需要用到 Linux 用户手册 read(2)
显示需要一个名为 buf
的用户空间缓冲区,这是数据复制到的用户空间缓冲区。
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
Linux 需要用户空间缓冲区的原因与处理器需要 L1 和 L2 缓存的原因相同:从文件系统读取(无论是通过网络(例如 NFS)还是本地磁盘)与从内存读取相比非常慢。如果数据库执行大量 I/O 密集型操作,那么使用 mmap 会更快,因为不涉及用户缓冲区,因此无需将数据从内核缓冲区高速缓存复制到用户缓冲区。这也有助于节省计算机内存。
在 Prometheus 中,mmaped 文件定义为:
type MmapFile struct {
f *os.File // MmapFile is closable with os.File
b []byte
}
...
func (f *MmapFile) Bytes() []byte {
return f.b
}
// mmap is how Prometheus calls mmap system call
// it returns a pointer to the mmaped memory if there is no error
func mmap(f *os.File, length int) ([]byte, error) {
return unix.Mmap(int(f.Fd()), 0, length, unix.PROT_READ, unix.MAP_SHARED)
}
不要被 b []byte
误导了,这里的 b 只不过是一个指向我们虚拟内存的指针,Prometheus 调用 unix.Map 时并没有将数据复制到用户空间。另外,从上面的内存保护文件中 unix.PROT_READ
可以看出,Prometheus 并没有映射可写内存,内存映射是只读的。然而,具有缓冲池的 DBMS 通常用于 O_DIRECT
读/写文件,作为“双缓存”问题的另一种解决方案。
它在用户空间中代表自己缓冲数据对象,并绕过虚拟内存系统提供的页面缓存。例如,MySQL innodb_flush_method=O_DIRECT
默认使用读取/写入数据文件。这里我明确提到了数据文件,因为它的使用 O_DIRECT
绕过了页面缓存,但它并不能保证文件被刷新到磁盘,因此 MySQL 仍然使用 fsync
它的 redo log。
3 便于使用
为什么 mmap 方便使用?让我们看看 Prometheus 是如何使用它的。以下是一些 Prometheus 代码,prometheus/tsdb/index/index.go
展示了如何使用 mmap:
// code that shows how Bytes() may be used
hash := crc32.Checksum(w.symbolFile.Bytes()[w.toc.Symbols+4:hashPos], castagnoliTable)
可以从代码中看到,Prometheus 将文件直接映射为一个普通的数组。接着来看一段体现了 mmap 易用性的 Python 代码:
import mmap
# Open the file for reading
with open('example.txt', 'r') as f:
# Memory-map the file
mmapped_file = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
# Now you can read the file as if it were a string or array
print(mmapped_file[:10]) # Prints the first 10 bytes
使用 mmap 的缺点
正如前面提到的,mmap 是由 SunOS 人员引入 Unix 的,目的是在进程之间共享库对象文件。当多个进程共享底层相同的库对象时,数据一致性就成为一个问题,这当然可以通过被称为 private sharing(copy-on-write and demand paging)
的方法来解决。但是当涉及到 DBMS 时,我们仍然可以简单地使用 copy-on-write mmap
并假装操作系统已经神奇地为我们解决了所有问题吗?答案是不。
操作系统提供了三种地址空间操作:mmap 和 munmap 系统调用(统称为“内存映射操作”)和页面错误。
mmap 创建内存映射区域并将它们添加到区域树中。 munmap 从树中删除区域并使硬件页表结构中的条目无效。 页错误在进程的区域树中查找导致页错误的虚拟地址,如果虚拟地址已映射,则在页表中添加一条虚拟地址-物理地址的映射并恢复应用程序。
Andy 在论文中提到了几个主要问题:
事务安全 I/O 停顿 没有异步IO接口。 IO 停顿是不可预测的,因为页面驱逐取决于操作系统。 错误处理 性能问题 页表争用 单线程页面驱逐 TLB 被击落
前几个问题可以总结为 DBMS 失去了对页面错误和驱逐的控制,下面我们重点关注性能问题。针对性能问题 Andy 的论文中并没有给到清楚的说明,由于 Prometheus 是单写入模式,因此我们不关注事务安全。
1 页表争用
Linux 中最重要的资源之一是虚拟内存,其中进程的虚拟内存空间由(内存描述符)描述。该结构包含与进程地址空间相关的所有信息。由于单个进程只有一个 mm_struct
,因此使用 mmap 的多线程进程会受到名为 mmap_lock
(在 Linux 内核 5.8 中更名为 mmap_sem
to mmap_lock
)的读写信号量带来的争用问题的危害,mmap_lock
是专门用来控制进程内存映射更改的读写信号量。像任何其他的 rw_semaphore
一样,它可以由任意数量的读者共享或单个写者独占持有。
struct mm_struct {
...
struct rw_semaphore mmap_lock; /* memory area semaphore */
...
}
/*
The caller must write-lock current->mm->mmap_lock.
*/
unsigned long do_mmap(struct file *file, unsigned long addr,
unsigned long len, unsigned long prot,
unsigned long flags, vm_flags_t vm_flags,
unsigned long pgoff, unsigned long *populate,
struct list_head *uf)
mmap_lock
的职责可以概括为:
保护重要的 mm_struct
,如红黑树、进程 VMA 列表和 mm 结构的许多其他文件。了解更多[5]保护 VMA 在页面错误处理期间不发生更改(我们可以通过查看源码[6] 中的函数 do_page_fault
来证实这一点)。
为什么 Linux 使用“重锁”—— mmap_lock
来保护 VMA?
原因是历史性的。几十年来,mmap_lock
一直是 mm
结构的一部分。当时,程序并没有使用多线程模型,而是使用“多进程模型”,不同的进程有不同的虚拟内存空间。当时 mmap_lock
争用不是问题。另外,当时的计算机内存要小得多,因此 mmap_lock
需要锁定的页数较少。
现在,你可能有另一个问题:pread/pwrite
系统调用和 mmap 都会与 VMA 打交道,为什么 mmap 尤其是 mmap_lock 的受害者?这是因为,在页面错误的情况下,mmap_lock
被作为读锁获取。在 mmap/munmap
的情况下,它被作为写锁获取[7]。当写锁出现时,性能会急剧下降。
顺便说一句,不要被 mm-struct
中另一个名为 page_table_lock
的锁弄糊涂了,它们有不同的用途:
mmap_lock
这是一个会被长期持有的锁,可以保护读者和写者的 VMA 列表。由于它的持有者需要持有较长时间并且可能需要休眠,因此自旋锁是不合适的。VMA链表的读者使用 down_read()
获取此信号量。如果需要写入,则使用 down_write()
进行写入,并在更新 VMA 链表时获取 page_table_lock
自旋锁。
page_table_lock (页表锁)
这可以保护 mm_struct
中的大多数字段。与页表一样,它还保护 RSS(见下文)计数和 VMA 不被修改。
2 单线程页面驱逐
虽然使用 mmap 给我们带来了不使用用户空间缓冲池的好处,但它仍然依赖于页面缓存。Linux 内核维护一组最近最少使用 (LRU) 列表来跟踪页面缓存。因此,对于依赖 mmap 的数据库来说,它也依赖于 kswapd
高效执行。kswapd
是内核线程,负责在内存不足时将页面从内存逐出到磁盘。由于磁盘比内存慢得多,更是远远慢于CPU,因此 Linux 的作者并没有将 kswapd
设计为多线程的,也就是说 Linux 的页驱逐是单线程作业的。
Andy 指责这种单线程的 kswapd
是 mmap 输掉了“fio 与 mmap 顺序读取之战”的原因。这里需要补充的背景信息是常见的关系型数据库例如 MySQL 跟 PostgreSQL 使用 Direct IO
, 因此他们在进行文件读写的时候可以绕过页面缓存,因此不会受到单线程页面驱逐的影响。另外就是,Linux 中的 LRU 链表受到 LRU 锁的保护。虽然 Andy 没有提到但是我想这也可能是另一个影响因素。
3 TLB 被击落
这是共享内存计算机体系结构的图片。一台计算机有多个核心,但它们都共享一个物理内存。
在共享内存架构中,每个处理器都包含一个名为翻译后备缓冲区(TLB)的缓存,正如我们之前提到的。TLB 使虚拟地址转换变得快速,这对于数据库应用程序性能至关重要。
当内存映射发生变化时,例如标签 0x001 指向物理页号 0x0011,之后又指向 0x0012,必须保证处理器之间的一致性。由于 CPU 没有硬件机制来确保 TLB 一致性,所以这项工作就留给了操作系统。为了确保 TLB 一致性,操作系统会执行 TLB 射落,这是一种使其他内核上的远程 TLB 记录无效的机制。
TLB 射落可以由修改页表项的各种内存操作触发,其中一个操作是 munmap : 它会删除指定地址范围的映射,并导致引用该范围内的地址的操作产生无效的内存引用。另一方面,当进程调用 mmap 并且操作系统在进程的虚拟地址空间中创建新记录时,操作系统不需要刷新 TLB,因为此时没有旧的 TLB 记录。
2Prometheus 如何存储数据?
内存映射的内容
在运行 umon(集成了 Prometheus 的 DMP 组件)的机器上,运行 cat /proc/{pid of umon}/maps
,你能观察到 Prometheus 使用的连续虚拟内存区域。下面的表格列出了来自我的 DMP 环境的输出(去掉了未使用的虚拟内存或着由共享对象文件使用的虚拟地址)。
00400000-044e3000 r-xp 00000000 fd:01 675313727 /opt/umon/bin/umon
046e2000-047ae000 rw-p 040e2000 fd:01 675313727 /opt/umon/bin/umon
7fcf9136d000-7fcf9936d000 r--s 00000000 fd:01 834677674 /opt/umon/prometheus-data/chunks_head/000061
7fcf9936d000-7fcf9f1ea000 r--s 00000000 fd:01 268448993 /opt/umon/prometheus-data/01GRFAGZGWZPMMEC1RZ3KRR8PD/chunks/000001
7fcfa69c8000-7fcfac3e0000 r--s 00000000 fd:01 180719137 /opt/umon/prometheus-data/01GRN3XKJBFGP68Z19KSR6H2TQ/chunks/000001
7fcfaf0dd000-7fcfb70dd000 r--s 00000000 fd:01 834677679 /opt/umon/prometheus-data/chunks_head/000060
7fcfb70dd000-7fcfb75a6000 r--s 00000000 fd:01 177000146 /opt/umon/prometheus-data/01GRN3XKJBFGP68Z19KSR6H2TQ/index
7fcfb75a6000-7fcfb790f000 r--s 00000000 fd:01 151420465 /opt/umon/prometheus-data/01GRN3XHFNBSEJ57MSP7106R2Q/chunks/000001
7fcfb790f000-7fcfb7c84000 r--s 00000000 fd:01 134347049 /opt/umon/prometheus-data/01GRMX1T82JHV2HC7YFGYYPVJN/chunks/000001
7fcfc82d0000-7fcfc87aa000 r--s 00000000 fd:01 264251793 /opt/umon/prometheus-data/01GRFAGZGWZPMMEC1RZ3KRR8PD/index
7fcfc87aa000-7fcfc887d000 r--s 00000000 fd:01 398511374 /opt/umon/prometheus-data/01GR9H4AK312ZTDC81ZBHVMARH/index
7fcfc887d000-7fcfc8c69000 r--s 00000000 fd:01 402839492 /opt/umon/prometheus-data/01GR9H4AK312ZTDC81ZBHVMARH/chunks/000001
7fcfc8d5f000-7fcfc8e2a000 r--s 00000000 fd:01 255988632 /opt/umon/prometheus-data/01GRNHMXNS52464DJX5WRYSZ6A/index
7fcfc8e2a000-7fcfc8ef5000 r--s 00000000 fd:01 146873066 /opt/umon/prometheus-data/01GRN3XHFNBSEJ57MSP7106R2Q/index
7fcfc8ef5000-7fcfc8fc0000 r--s 00000000 fd:01 130031109 /opt/umon/prometheus-data/01GRMX1T82JHV2HC7YFGYYPVJN/index
7fcfc9040000-7fcfc93c3000 r--s 00000000 fd:01 260059119 /opt/umon/prometheus-data/01GRNHMXNS52464DJX5WRYSZ6A/chunks/000001
7fcfc93c3000-7fcfc948e000 r--s 00000000 fd:01 234981699 /opt/umon/prometheus-data/01GRNAS8RDR82J0X9V7H352736/index
7fcfc948e000-7fcfc97fb000 r--s 00000000 fd:01 239093592 /opt/umon/prometheus-data/01GRNAS8RDR82J0X9V7H352736/chunks/000001
7fd020346000-7fd02034b000 rw-s 00000000 fd:01 826468484 /opt/umon/prometheus-data/queries.active
共享的对象文件是内存映射的,因此可以快速 fork 子进程,并且可以节省内存,因为进程不必将重复的代码复制到其文本段中,这对于几十年前的计算机是至关重要的。
在进一步讨论之前,我们需要知道一个名为 vm_area_struct
的结构体,它描述了一个连续的虚拟内存区域,来自进程的每个用户空间的 mmap 系统调用都会创建一个 vm_area_struct
,它存储在每个进程的 task_struct
内核维护中。cat /proc/pid/maps
的输出列出了 vm_area_struct
的一部分,让我们选择一行输出并说明该结构的一些重要字段:
7fcf9136d000-7fcf9936d000 r--s 00000000 fd:01 834677674 /opt/umon/prometheus-data/chunks_head/000061
该行有六个字段,其中两个不是 vm_area_struct
的一部分:设备号(fd:01)和 inode 号(834677674)。
vm_start, vm_end
7fcf9136d000是内存区域的起始地址,7fcf9936d000 是内存区域的结束地址。vm_file, /opt/umon/prometheus-data/chunks_head/000061
是指向关联文件结构的指针。vm_pgoff,00000000 是该区域在文件内的偏移量。 pgprot_t, r--s
定义内存区域的访问权限。后缀 s 表示该区域是“共享的”。这意味着对该区域的更改将被写回文件并对所有进程可见。r--
表示该区域是只读的,这里第二个占位符代表 w(写入),第三个 id 代表 x(执行)。该权限表明此 Prometheus 内存映射文件是只读且共享的。vm_flags,一组标志,未在输出中显示。 vm_ops,该区域的一组工作函数,未在输出中显示 vm_next, vm_prev
umon 的内存区域通过列表结构链接起来,未在输出中显示。
到目前为止,我们已经了解到 Prometheus 在 prometheus-data
文件夹下映射了多个文件,这些文件是什么以及为什么要进行内存映射?为了解释它,需要解释一些基本的 Prometheus 101 知识。
Prometheus 如何保存其数据?
Prometheus 的 TSDB 是一个时间序列数据库,其数据可以被视为点流。
Series_A -> (t0,A0), (t1, A1), (t2, A2), (t3, A3)...
Series_B -> (t0,B0), (t1, B1), (t2, B2), (t3, B3)...
Series_C -> (t0,C0), (t1, C1), (t2, C2), (t3, C3)...
对于一个监控系统来说,每秒可能有数百万个数据点,Prometheus 可以直接将所有内容写入存储设备吗?
想想看:对于传统磁盘来说,随机写入是一场灾难,因为磁头需要时间寻道,盘需要时间旋转。对于SSD,写入非空块是通过首先写入空块然后将数据移回来完成的。这称为“写入放大”,会显着缩短 SSD 的使用寿命。因此,Prometheus除了使用缓冲区批量写入之外别无选择。否则,性能惩罚会很大。
Prometheus 中的内存缓冲区在哪里?
答案是“头块”。更准确地说,它实际上是 head chunk 中的一个 32KB 的页面。该行为与 InnoDB 引擎类似,InnoDB 引擎也不在行上工作,而是在页面上工作。但与 InnoDB 不同的是,Prometheus 没有自己的缓冲池,它只有一个页面用于写入。一旦该缓冲区页满了,就会将其刷新到 WAL 内的文件(也称为段)中。
WAL文件夹的布局是这样的:
17728 -rw-r--r-- 1 actiontech-universe actiontech 18153472 2月 9 03:00 00000076
17728 -rw-r--r-- 1 actiontech-universe actiontech 18153472 2月 9 05:00 00000077
17728 -rw-r--r-- 1 actiontech-universe actiontech 18153472 2月 9 07:00 00000078
4032 -rw-r--r-- 1 actiontech-universe actiontech 3621996 2月 9 07:24 00000079
0 drwxr-xr-x 2 actiontech-universe actiontech 22 2月 9 05:00 checkpoint.00000075
你可能想知道为什么这个文件夹叫 WAL,为什么有一个名为 checkpoint.xxxxxxxx
的文件?更令人困惑的是,为什么 00000076 之前的段都没有了?
这时就需要提到预写日志记录 (WAL) 和检查点(checkpoint)了。WAL 是几乎所有 DBMS 的必经之路,以确保数据完整性(搜索 ARIES 进一步阅读相关概念)。这里的核心概念是,在向数据文件写入任何内容之前,必须记录操作,以防系统崩溃或计算机关闭。当 Prometheus 从崩溃中恢复时,它首先从 chunks_head 中读取数据,然后再从 WAL 中读取数据。
作为数据库管理员,你一定听说过 InnoDB 的重做日志(Redo Log)。重做日志是崩溃恢复期间使用的基于磁盘的数据结构。上面列出的文件与重做日志具有类似的用途,它们为头块提供持久性(所有其他块已经保存在磁盘上,只有可写入的头块在内存中)。这也是为什么所有这些文件都有顺序名称:WAL 中的每个日志记录都有一个全局唯一的日志序列号 (LSN)。
检查点是 DBMS 中的另一个概念,总是与 WAL 一起出现。如果没有检查点,WAL 日志将永远增长。如果 DBMS 使用 WAL,那么它还必须在将 WAL 缓冲区刷新到磁盘后定期获取检查点。否则,在崩溃后,普罗米修斯将需要一段漫长而痛苦的启动时间。这也是为什么多年来 Prometheus 开发人员做出了一些决定,例如 减少 WAL 大小[8](从 6 小时数据减少到 3 小时)并在 头块满后将其刷新到磁盘[9]。
head 是否可压缩由以下代码确定,因为 chunkRange 默认为 2h,因此 head 每 3 小时可压缩一次。
// compactable returns whether the head has a compactable range.// The head has a compactable range when the head time range is 1.5 times the chunk range.// The 0.5 acts as a buffer of the appendable window.
func (h *Head) compactable() bool {
return h.MaxTime()-h.MinTime() > h.chunkRange.Load()/2*3
}
由于 Prometheus 默认每 15 秒抓取一次数据,又由于每个 chunk 最多可以容纳 120 个样本(prometheus#11219[10] 提到这个数字可能应该增加),因此,我们可以很容易地得出结论,单个 Prometheus chunk 默认情况下最多可以保存 30 分钟的数据。
另外,我们刚刚发现 Prometheus 的 head chunk 最多可以保存 3 小时的数据。因此,我们可以得出结论,Prometheus 的 chunks_head 最多可以有 6 个 chunk,并且最多将其中 5 个刷新到磁盘并进行内存映射。由于 Prometheus 采用单写入模式,因此始终只有一个块可以写入,并且不会刷新到磁盘和内存映射。
当发生头部压缩时,“chunks_head”目录中的块将被写入新的持久块,然后由 head.truncateMemory 删除它们。在 Prometheus 中,检查点会遍历自 minTime 以来的所有 WAL 文件。这意味着 db.CompactHead 中的 minTime 等于 maxTime。这意味着每次 Prometheus 压缩其头部时,WAL 检查点都会收集在上次压缩的 maxTime 之后附加的 WAL 数据。WAL 检查点适用于以下数据类型:
record.Series,指标名称和一组特定键值标签的组合。 record.Samples,一系列的稀疏记录。 record.HistogramSamples,[直方图样本是直方图度量的样本](https://github.com/prometheus/prometheus/issues/11210 "prometheus#11210") record.Tombstones,阻止查询的删除标记。 record.Exemlars,Exemlars 是对 MetricSet 之外的数据的引用。一个常见的用例是程序跟踪的 ID。 record.Metadata,[元数据都是关于系列及其标签](https://github.com/prometheus/prometheus/issues/10972 "prometheus#10972")然后WAL检查点创建一个新的检查点文件夹,其名称类似于 checkpoint.00000xxx,并将收集的所有数据写入此文件夹,就像写入 WAL 文件一样“wal”,所以如果你打开该文件夹并在其中找到名为 00000000 的文件,请不要感到惊讶。00000xxx 之前的 WAL 文件可以删除。
prometheus-data
|____wal
|___000000072
|___000000073
|___000000074
|___000000075
|___000000076
|___000000077
为了弄清楚为什么下面三分之二的段被设置了检查点,我们必须阅读 prometheus/tsdb/head.go
中的一些源代码:
// truncateWAL removes old data before mint from the WAL.
func (h *Head) truncateWAL(mint int64) error {
1 first, last, err := wlog.Segments(h.wal.Dir())
...
2 last-- // Never consider last segment for checkpoint.
3 if last < 0 {
4 return nil // no segments yet.
5 }
// The lower two thirds of segments should contain mostly obsolete samples.
// If we have less than two segments, it's not worth checkpointing yet.
// With the default 2h blocks, this will keeping up to around 3h worth
// of WAL segments.
6 last = first + (last-first)*2/3
7 if last <= first {
8 return nil
9 }
...
}
为了使事实更清楚,这里有一个检查点之前和一个检查点之后的两个 ls -l
输出(在下一个检查点之前):
-rw-r--r-- 1 actiontech-universe actiontech 19300352 2月 11 07:00 00000008
-rw-r--r-- 1 actiontech-universe actiontech 19300352 2月 11 09:00 00000009
-rw-r--r-- 1 actiontech-universe actiontech 19300352 2月 11 11:00 00000010
-rw-r--r-- 1 actiontech-universe actiontech 16314758 2月 11 12:41 00000011
drwxr-xr-x 2 actiontech-universe actiontech 22 2月 11 09:00 checkpoint.00000007
下一个检查点之后:
-rw-r--r-- 1 actiontech-universe actiontech 17334272 2月 11 12:47 00000011
-rw-r--r-- 1 actiontech-universe actiontech 1966080 2月 11 13:00 00000012
-rw-r--r-- 1 actiontech-universe actiontech 19300352 2月 11 15:00 00000013
-rw-r--r-- 1 actiontech-universe actiontech 1694088 2月 11 15:10 00000014
drwxr-xr-x 2 actiontech-universe actiontech 22 2月 11 13:00 checkpoint.00000010
这两个输出表明 checkpoint.00000010 是在 00000012 之后创建的。这是因为,从上面的源代码我们可以看到它 (last-first)*2/3
必须大于 2(如果没有第 6 行,这个数字应该是 1),否则函数调用将返回。因此 (12-8) x 2/3 > 2
,当日志 00000012 创建时,下一个检查点也被创建。之后,检查点之前的所有旧 WAL 文件将被删除 h.wal.Truncate
,旧检查点将被删除 wlog.DeleteCheckpoints
。
到这里我们已经弄清楚了 Prometheus 中数据是如何流动的,新数据不断写入 Prometheus 的 head chunk,并且定期压缩到磁盘中,而 head chunk 确实是缓冲在内存中的,不是内存映射的。因此 Andy 在论文中提到的使用 mmap 的缺点并不会影响到 Prometheus 的性能。
3结论
mmap 是一个古老的概念,它诞生于计算机内存较小且程序还没有多线程的时代。由于页表竞争、单线程页面驱逐、TLB 击落等问题,基于 mmap 的文件 IO 在基准测试中被直接 IO 击败。
Prometheus 的 TSDB 的工作原理类似于日志结构的合并树。与 MySQL、PostgreSQL 等关系型数据库不同,Prometheus 的工作负载是重写的,新数据不断写入 Prometheus 的 head chunk,而 head chunk 确实是缓冲的,不是内存映射的,损害 mmap 的性能问题与此无关。
Andy 的论文引用了 VictoriaMetrics 的技术文章“mmap 可能会减慢你的 Go 应用程序”,并试图表明 mmap 是 VictoriaMetrics 的一个问题。然而,事实并非如此,VictoriaMetrics 仍然默认使用 mmap 进行文件 IO。
参考资料
It-there-a-problem-for-prometheus-to-use-mmap: https://bo-er.github.io/2023-12-21-It-there-a-problem-for-prometheus-to-use-mmap/
[2]CloudTreeDMP: https://www.actionsky.com/cloudTreeDMP
[3]context_switch-cpp: https://gist.github.com/bo-er/66efeff6dad1649f70aca5c3ac9dc42f#file-context_switch-cpp
[4]syscall_switch-cpp: https://gist.github.com/bo-er/66efeff6dad1649f70aca5c3ac9dc42f#file-syscall_switch-cpp
[5]The LRU lock and mmap_sem: https://lwn.net/Articles/753058
[6]fault.c: https://android.googlesource.com/kernel/msm/+/3ab322a9e0a419e7f378770c9edebca17821bf6e/arch/arm/mm/fault.c
[7]mmap.c#L1205: https://github.com/torvalds/linux/blob/3b8a9b2e6809d281890dd0a1102dc14d2cd11caf/mm/mmap.c#L1205
[8]prometheus#7098: https://github.com/prometheus/prometheus/issues/7098
[9]prometheus#6679: https://github.com/prometheus/prometheus/issues/6679
[10]prometheus#11219: https://github.com/prometheus/prometheus/issues/11219
微信扫码关注该文公众号作者