ClickHouse托管Postgres用直接I/O加速备份
DataHot 速览
ClickHouse Managed Postgres 备份时需要从服务查询的 NVMe 盘读取全库并传到对象存储,默认缓冲 I/O 会污染 page cache,进而影响查询延迟。直接 I/O 跳过 page cache,测试中把备份期间查询延迟影响降低约三分之二,并减少约14% CPU,但会关闭内核预读。ClickHouse 按 RAID0 stripe 大小设置直接读取,并扩展 reader 数;在 48 vCPU、4 块 NVMe、467GB 数据库测试中,直接 I/O 备份未驱逐 40GiB 热数据,71 秒完成。
为什么值得关注:数据库备份与在线查询争抢 I/O 是生产环境常见痛点;该文给出 direct I/O、预读、RAID0 stripe 与 reader 数的可复现调优思路和量化收益,对数据平台与数据库从业者有直接参考价值。
本文目录 11 节
译文
AI 逐段翻译什么是直接 I/O,为什么托管 Postgres 在备份时要在意它?每天,备份都会把整个数据库、数百 GB 数据,从那些正在回答你查询的 NVMe 驱动器上复制下来,并传送到对象存储。Linux 会像对待任何其他文件读取一样对待这些读取:它会在内存的页缓存中保留每个字节的副本,以防有人再次请求它。没有人会请求。为了给这些副本腾出空间,内核会丢弃 Postgres 已放在内存中、即将使用的数据,而下一次需要它的查询则改为访问磁盘。
直接 I/O 是告诉内核对这些读取跳过缓存的标志。打开它后,第二个问题出现了。这些读取也会失去内核的预读,而在一组四块条带化 NVMe 驱动器上,一次小读取会让一块驱动器忙碌,而另外三块闲置。
简要版本 备份会从正在服务查询的驱动器上读取实时数据库的每个字节。默认情况下,这些读取会经过内核的页缓存,挤掉数据库原本放在内存中的数据,并在查询运行期间为每个字节付出一次内核复制成本。 直接 I/O 会跳过缓存。在我们的测试机上,它让热数据保持原处,将备份期间查询所受的延迟影响削减了约三分之二,并减少了约 14% 的 CPU 使用。它还会关闭预读,因此在条带化 NVMe 上,读取大小和读取器数量必须与阵列匹配。 ClickHouse Managed Postgres 会将每次直接读取的大小设置为跨越整个 RAID0 条带,并根据硬件扩展读取器数量。在一台配备四个 NVMe 驱动器、48 vCPU 的机器上,运行着一个 467 GB 数据库和实时工作负载,缓冲备份驱逐了一个原本在内存中已热且空闲的表,全部 40 GiB。直接 I/O 备份没有驱逐任何数据,并在 71 秒内完成。
在同一个 i8ge.12xlarge 实例上,使用一个 467 GB 数据库和持续读取工作负载,测试了四种 wal-g 配置。A(红色):缓冲读取,24 个并行磁盘读取器。B(橙色):直接 I/O,128 KiB 读取,24 个读取器。C(绿色):直接 I/O,4 MiB 读取,48 个读取器。D(蓝色):直接 I/O,4 MiB 读取,24 个读取器。各次运行之间只有备份 I/O 模式、读取大小和读取器数量不同。
备份与数据库共用机器
在 ClickHouse Managed Postgres 上,本地 NVMe 是热路径:Postgres 在那里读写其堆、索引和 WAL,这正是该服务最初运行在本地磁盘上的原因。对象存储保存基础备份和归档 WAL,二者共同提供时间点恢复。归档 WAL 如何在不填满磁盘的情况下到达那里,是另一个故事。
在具有多个实例存储 NVMe 的实例上,我们使用 mdadm --level=0 将它们条带化到 /dev/md0,并将其挂载到 /dat。Postgres 位于 /dat。备份代理 wal-g 被指向同一目录以及对象存储中按时间线划分的存储桶。
基础备份不会等待一个安静时刻。它在数据库运行时读取整个数据目录,并且每个字节都来自与查询读取的每个字节相同的驱动器、通过相同的内核。
一台机器,一组驱动器。查询和备份同时从同一个 RAID0 阵列读取。
缓冲读取是对 Postgres 的一项无声税收
默认情况下,当进程在 Linux 上读取文件时,数据会经过页缓存:内核会在内存中保留一份副本,以防有人很快再次请求它。这几乎对每个程序都有帮助。但它会伤害备份,因为备份只读取每个文件一次,将其交给上传器,然后再也不碰它。页缓存是有限且共享的,因此当备份将数百 GB 数据流经它时,内核会通过驱逐 Postgres 已放在内存中的数据来腾出空间。
Postgres 在 shared_buffers 中有自己的缓存,在我们的服务器上占 RAM 的四分之一,并且固定在大页中,所以这些页是安全的。其他所有内容都依赖操作系统页缓存:无法放入共享缓冲区的关系文件、最近写入的 WAL、临时文件、可见性映射。双缓冲设计假设操作系统这一侧保持温暖。
缓冲备份读取会用没有人会再次读取的字节填满页缓存。它们替换掉的页原本属于数据库。
内核确实会保护最繁忙的页。任何被持续访问的内容都会留在其活动列表上,并在流式读取中幸存下来,因此一张繁忙表中最热的那几 GB 通常会留在原处。而在那一刻温暖且空闲的所有内容都会消失:一小时前报告查询过的表、夜间作业将需要的索引、备库即将请求的 WAL 段。在我们的测试机上,一次缓冲备份驱逐了一个几分钟前刚被读入内存、并在备份运行期间处于空闲状态的表,全部 40 GiB。
还有第二项较小的成本,即使对最热的页也适用:每个缓冲字节都会在备份进程看到它之前,经由内核复制到页缓存中,消耗的正是查询正在使用的同一批 CPU 和内存总线。这两项成本都很容易被忽视。备份完成、上传,并出现在备份列表中。与此同时,查询变慢了,下一次报告冷启动。
修复尝试:使用直接 I/O 跳过缓存
直接 I/O 是一种在读取文件时告诉内核不要缓存它的方式。进程使用 O_DIRECT 标志打开文件,数据直接从块设备移动到进程自己的缓冲区。页缓存中不会留下任何副本。在我们的 wal-g 配置中,这是一行:
1WALG_DIRECT_IO=true设置此项后,备份的读取会完全绕过页缓存,Postgres 会保留其热页。这解决了驱逐问题,但引入了一个新问题:直接 I/O 也放弃了内核预读。在缓冲 I/O 下,当进程顺序读取文件时,内核会注意到并按大块预读,使设备队列保持满载。在 O_DIRECT 下,进程只得到它请求的字节,仅此而已。
在单块驱动器上,这是可以应付的。在 RAID0 条带上,这就是一道吞吐量悬崖。RAID0 将数据切分成块,在我们的阵列上是 512 KiB,并将连续的块分散到各成员驱动器上。一次小的 O_DIRECT 读取落在某一个块内,而这个块位于一块驱动器上。对于该请求,其他驱动器处于空闲状态。如果没有预读来把请求批量合并,一个发出小规模直接读取的备份,就会在一台拥有四块 NVMe 的机器上一次只驱动一块 NVMe。
wal-g 的默认直接读取是 32 个 4 KiB 块,即 128 KiB。每一个都落在单个 512 KiB 块内,因此单个请求只触及一块驱动器。
所以“开启 Direct I/O”的第一个版本保护了页缓存,却让备份变慢了。
让直接 I/O 读取的大小与条带宽度相匹配
出路在于让每次直接读取都足够大,以跨越整个条带。如果一次读取覆盖每块成员驱动器上的一个块,那么每块驱动器都承担其中一部分,阵列就会表现得像它本应是的并行设备。以下是我们配置生成器中相关的部分:
1DIRECT_IO_BLOCKS_PER_DRIVE = 25623if direct_io4# RAID0 arrays attempt to evenly distribute the data blocks across the block devices.5# Therefore, larger block reads get fan out to more devices and produce higher throughput.6 direct_io_block_count = direct_io_drive_count * DIRECT_IO_BLOCKS_PER_DRIVE78 lines << "WALG_DIRECT_IO=true"9 lines << "WALG_DIRECT_IO_BLOCK_COUNT=#{direct_io_block_count}"10endWALG_DIRECT_IO_BLOCK_COUNT 是 wal-g 每次直接 I/O 请求读取多少个 4 KiB 块。我们为每块成员驱动器设置 256 个块,即每块驱动器 1 MiB:
| RAID0 中的驱动器数 | WALG_DIRECT_IO_BLOCK_COUNT | 读取大小 |
|---|---|---|
| 1 | 256 | 1 MiB |
| 4 | 1024 | 4 MiB |
| 8 | 2048 | 8 MiB |
驱动器数量来自服务器实时的存储设备列表:在 AWS 上,是服务器在移除 EBS 启动卷后发现的所有实例存储 NVMe。/dev/md0 中有四块驱动器时,读取为 4 MiB;一块驱动器时为 1 MiB。直接读取的宽度至少应与阵列一样宽,因为任何更小的读取都会在每个请求中让条带的一部分处于空闲状态。
一次 4 MiB 读取覆盖两个完整条带,因此每块驱动器都承担每个请求的一部分,而页缓存仍然不受影响。
并行读取器:当“一半 CPU 数”是错误的时候
读取大小是一个旋钮。另一个是有多少读取器在同时发出这些读取。WALG_UPLOAD_DISK_CONCURRENCY 控制有多少个并行磁盘读取器向 wal-g 的上传流水线供给数据。正确的数量取决于瓶颈是 CPU 还是设备,而 Direct I/O 会改变这种平衡。
1# WALG_UPLOAD_DISK_CONCURRENCY is CPU or Disk bound, hence different thresholds2disk_concurrency =3if direct_io4 (dense_nvme || vcpu_count <= 2) ? vcpu_count : (vcpu_count / 2)5else6 (vcpu_count / 2).clamp(1, 128)7end在缓冲 I/O 下,我们使用一半的 vCPU。预读会代表内核让设备保持忙碌,因此适度的读取器数量就足够了,剩余的 CPU 留给 Postgres。在 Direct I/O 下没有预读。每个读取器提交一个请求、等待它,然后再提交下一个,因此设备利用率直接取决于有多少读取器在途。这在两种情况下改变了算法:
- 高密度 NVMe 系列。 在 AWS 上,我们将 i8g、i8ge、i7i 和 i7ie 视为高密度 NVMe。它们相对于计算资源携带了大量本地存储,而且每块设备只有在大量未完成请求的情况下才能达到其上限。在这些机型上使用 Direct I/O 时,我们使用完整的 vCPU 数量。
- 非常小的机器。 在 2 vCPU 的服务器上,一半 vCPU 就意味着一个读取器。一个没有预读的同步直接读取器会让设备大部分时间处于空闲状态,因此对于两个或更少 vCPU,我们也使用完整数量。
在其他所有情况下,即拥有超过两个 vCPU 的通用 NVMe 实例,使用一半 vCPU 作为读取器是正确的取舍。将其翻倍会消耗 Postgres 的 CPU,而备份吞吐量几乎没有额外提升。在途读取器越多,意味着备份越早完成,同时在其运行期间,数据库自身的读取会在设备队列中排在更多备份请求之后;下面的测量展示了两个方面。
对于一台 i8ge.12xlarge,48 vCPU,384 GiB RAM,四块实例存储驱动器,生成器写入:
| 键 | 值 | 原因 |
|---|---|---|
| WALG_COMPRESSION_METHOD | lz4 | CPU 开销小,对堆页面的压缩比足够好 |
WALG_UPLOAD_DISK_CONCURRENCY | 48 | Direct I/O 下的高密度 NVMe 使用完整 vCPU 数量 |
| WALG_UPLOAD_CONCURRENCY | 4 | 固定的上传器数量 |
| WALG_UPLOAD_QUEUE | 2 | 固定的队列深度 |
| WALG_S3_MAX_PART_SIZE | 67108864 (64 MiB) | RAM 预算的 5%,并上限截断至 64 MiB 上限 |
| WALG_DOWNLOAD_CONCURRENCY | 48 | 恢复路径,每个核心一个流 |
| WALG_DIRECT_IO | true | 跳过页缓存 |
WALG_DIRECT_IO_BLOCK_COUNT | 1024 | 4 块驱动器 x 256 个块,每次读取 4 MiB |
工作负载保护:cgroups 加直接 I/O
备份与数据库争夺三样东西:CPU、内存和驱动器。我们对每一项都设置边界,而 Direct I/O 是最后一项的边界。
- CPU。 备份运行在自己的 cgroup 中,通过 systemd-run --scope 以 CPUWeight=25 启动,而默认权重为 100。当机器繁忙时,调度器分配给 Postgres 的 CPU 份额是备份的四倍;当机器空闲时,备份可以使用空闲的部分。压缩是开销大的部分,而 lz4 使其保持廉价。
- 内存。 wal-g 的上传缓冲区根据机器来设定大小:在途峰值分片数乘以分片大小被限制在 RAM 的 5%,并且该进程与我们在进程预算一文中描述的其他支持服务位于同一个有界 slice 中。
- 页缓存和驱动器。 直接 I/O。备份读取永远不会进入页缓存,因此 Postgres 放在那里的数据会留在那里,而且在送往压缩器的途中不会通过内核进行任何复制。
CPU 节省是可测量的。在下面的运行中,整机 CPU 在缓冲备份期间从 36% 忙碌升至 65%,在条带大小的 Direct I/O 备份期间升至 60%:大约 860 核心秒的工作量对大约 1,000,相同备份大约少用 14% 的 CPU。
在真实硬件上验证
我们在一个真实机器上验证了这一点:us-east-1 中的一台 i8ge.12xlarge,四块实例存储 NVMe 驱动器组成 512 KiB 块的 RAID0,Postgres 18.6 配置 shared_buffers = 96GB,上游 wal-g v3.0.9,以及一个 467 GB 的 pgbench 数据库,比该机器的 384 GiB RAM 更大。备份上传到同一区域的 S3,并使用 lz4 获得约 9 倍的压缩。
有两个东西代替真实工作负载。十六个客户端在整个运行期间对 accounts 表的四分之一进行点查询,其工作集大于 shared_buffers,因此每次查询都有一部分由 OS 页缓存提供。另外,一张单独的 40 GiB 表在每次运行前被读入页缓存,然后就不去动它,代表几分钟前还是热的数据。fincore 报告其中有多少仍然驻留。
每个测试组都从相同的状态开始:丢弃缓存、重启 Postgres、将空闲表和热点索引范围读入页缓存、运行五分钟工作负载以填充 shared_buffers。然后是三分钟基线、备份、再三分钟。每个测试组中的备份都在 CPUWeight=25 下运行,与生产环境一致。只有 wal-g 的读取路径发生变化:
| 测试组 | wal-g 设置 |
|---|---|
| A,缓冲读取 | 页缓存读取,24 个磁盘读取器(vCPU/2),即启用 Direct I/O 之前的配置 |
| B,Direct I/O 默认值 | WALG_DIRECT_IO=true,wal-g 默认的 32 块(128 KiB)读取,24 个读取器 |
| C,生产参数 | WALG_DIRECT_IO=true,WALG_DIRECT_IO_BLOCK_COUNT=1024(4 MiB),48 个读取器(密集 NVMe 规则) |
| D,条带大小读取,更少读取器 | 与 C 相同,但使用 24 个读取器,即非密集规则,以将读取大小与读取器数量区分开 |
热但空闲的表,对齐到备份开始的时刻。使用缓冲读取时,该表在备份开始后二十秒内就消失了。它只在接近尾声时重新出现,因为备份本身在一分钟后读取了这些文件,而这些副本位于内核的只用一次列表中,排在最前面,会再次被逐出。三个 Direct I/O 测试组从未触碰它。
逐出是完全且迅速的。在缓冲备份进行二十秒时,40 GiB 的表中有 0.3 GiB 仍在内存中。工作负载反复访问的页表现更好,因为内核会保留最繁忙的页,不过在缓冲备份完成后,工作负载仍在以基线速率四倍的速度从磁盘读取,并且其 p99 在接下来的两分半钟内保持三倍高,直到缓存重新填充。在 Direct I/O 测试组中,空闲表始终保持在 40 GiB,延迟在备份结束的那一刻就恢复到基线。
点查找的 p99 延迟,以 5 秒为桶。缓冲备份将 p99 从 0.04 ms 推高到峰值 0.33 ms,并在之后数分钟内保持在 0.13 ms。Direct I/O 备份在运行期间将其保持在接近 0.06 ms,并在结束后立即交还。
每次备份在运行期间都会让数据库付出代价,因为它从相同的驱动器读取,并在相同的 CPU 上压缩。缓冲读取在备份期间将 p99 推高到基线的 4.7 倍,并在之后留下长长的冷读取尾巴。Direct I/O 读取将其推高到基线的 1.5 倍,且未留下任何后遗症。缓冲测试组还使用了更多 CPU(65% 忙碌对 60%),因此额外的延迟来自页缓存搅动以及随之而来的冷读取。
备份期间四个 NVMe 驱动器各自的繁忙程度,以及每个测试组的阵列读取速率和挂钟时间。小的直接读取在移动最少数据的同时让驱动器最繁忙。条带大小读取在单位繁忙时间内移动更多数据,因此阵列能为数据库留下更多余量。
在有 24 个读取器在途的情况下,即使 128 KiB 的直接读取也能让全部四个驱动器保持繁忙;上图所示的单驱动器画面只是一个读取器的视角。小读取的代价体现在效率上:小读取测试组在 80% 繁忙时间下推送 5.8 GB/s,而 4 MiB 测试组在 56% 繁忙时间下推送 7.4 到 7.7 GB/s。更少、更大的请求能从每个驱动器中获得更多,并为其留出更多时间用于查询。使用默认读取时备份耗时 96 秒,使用条带大小读取和 48 个读取器时耗时 71 秒,使用 24 个读取器时耗时 75 秒。在这台机器上,读取大小起了主要作用,而密集 NVMe 规则以队列中多一些读取器为代价换来了几秒的备份时间。
| 测试组 | 备份挂钟时间 | 从磁盘读取 | 空闲表被逐出 | 备份期间的查询/秒 | 备份期间的查询 p99 |
|---|---|---|---|---|---|
| A,缓冲读取 | 70 秒 | 6.0 GB/s | 40 GiB | 469k | 0.18 ms |
| B,Direct I/O 默认值 | 96 秒 | 5.8 GB/s | 0 GiB | 542k | 0.06 ms |
| C,生产参数 | 71 秒 | 7.7 GB/s | 0 GiB | 544k | 0.06 ms |
| D,条带大小读取,24 个读取器 | 75 秒 | 7.4 GB/s | 0 GiB | 555k | 0.06 ms |
| 无备份运行 | 581k | 0.04 ms |
条带效应本身
上述测试组经过 wal-g 的整个流水线,读取背后还有压缩和上传。为了单独观察 RAID0 的扇出效应,我们还对阵列运行了 fio,使用同步读取,即 wal-g 的 Direct I/O 读取器发出读取的方式,仅改变读取大小和读取器数量。
从阵列的顺序读取带宽。单个 128 KiB 直接读取器获得 0.8 GB/s,每个驱动器大约有四分之一的时间繁忙。同样的读取器在 4 MiB 下获得 8.5 GB/s,每个驱动器都繁忙。
一个读取器就讲述了图中描述的故事:128 KiB 直接读取一次只触及一个驱动器,达到 0.8 GB/s,而 4 MiB 读取跨越条带,从相同的驱动器达到 8.5 GB/s。缓冲读取也接近最高值,因为内核预读为它们完成了批处理。在有 24 或 48 个读取器在途的情况下,即使小读取也能使阵列饱和,代价是更多的请求和更繁忙的设备队列。更大的读取大小让适度的读取器数量就能完成工作,这正是读取器数量规则所做的权衡。
这在生产环境中如何落地
所有这些都是按服务器生成的。配置生成器获取服务器的 vCPU 数量、内存、驱动器数量,以及是否属于密集 NVMe 系列,并将结果写入 /etc/postgresql/wal-g.env,紧邻存储桶前缀。备份和恢复工具会读取该文件。
Direct I/O 支持在某个 wal-g 版本中到来,并在我们轮换携带 wal-g 的机器镜像时到达我们的机群,与 我们今年发布的其他备份改进 一同到来。从新镜像构建的服务器会在其生成的配置中获得 Direct I/O 行。备份计划和恢复路径没有改变。
有两个特性标志作为逃生舱。一个关闭 Direct I/O 行并回退到使用 vCPU/2 个读取器的缓冲读取。另一个进一步回退到 wal-g 的默认值。两者都存在,因为如此贴近数据库的读取路径值得拥有一种快速退出的方式,以防内核、驱动或实例类型出现意外行为。
下一步:只备份发生变化的内容
上面的每一次运行都是完整备份。它读取全部 467 GB 并上传 54 GB 的压缩输出,无论自昨天以来是有一行发生了变化还是十亿行发生了变化。对于多 TB 级数据库而言,这是备份保留成本中最大的一项。我们目前正在原型设计增量备份:读取数据目录,找出自上次备份以来发生变化的页面,只上传这些页面,这样一天的备份成本就等于一天变更量的大小。
本文中的读取路径保持不变。增量备份仍然读取实时数据目录以找出发生了哪些变化,从相同的驱动器上读取,与相同的查询并行进行。直接 I/O、条带大小的读取以及 cgroup 权重让它在数据库毫无察觉的情况下完成这些操作,无论上传的是 54 GB 还是 500 MB。
要点
备份中最困难的部分发生在本地:从 Postgres 正在对外提供服务的驱动器上读取实时数据目录的每一个字节。缓冲读取会把数据库的热页面从页缓存中挤出去。直接 I/O 绕过缓存,并且由于它也绕过了预读,因此在 RAID0 上会损失吞吐量。按驱动器数量缩放的条带大小读取,以及考虑到密集 NVMe 的读取器数量,把吞吐量又找了回来。
结果是,备份把页缓存留给 Postgres,让阵列中的每一块驱动器都保持忙碌,并且完成速度与它所取代的缓冲备份一样快。每一台 ClickHouse Managed Postgres 服务器都默认搭载此功能。开通一个 Postgres 即可亲眼看到它的实际效果: ClickHouse Managed Postgres
这篇内容对你有用吗?
反馈只用于改善内容筛选,不等同于收藏