查看原文
其他

PostgreSQL@K8s 性能优化记

The following article is from 云猿生聊技术 Author 蔡松露

本文作者蔡松露,是云猿生数据 CTO & 联合创始人,前阿里云数据库资深技术专家。目前负责云猿生数据产品研发工作,带领团队完成云原生数据库管理系统 KubeBlocks 的设计。在此文中,他对 PG on ECS(下文中以 ECS PG 代指)和  PG on K8s 两种方案做了性能对比,并提出了 PG on K8s 上的性能优化方案,以确保数据库在 K8s 上能满足用户对性能和稳定性的要求。


背景

近年来,很多企业的基础架构都有计划 all-in-K8s 的计划,希望采用基于 K8s 的数据库管控平台(如 KubeBlocks[1]——《给你介绍一个有趣的开源项目 - KubeBlocks》)作为自建 PostgreSQL 托管方案(下文以 KubeBlocks-PG 为例)。此外,数据库的容器化和 K8s 化是比较新的话题,很多人对有状态应用上 K8s 抱有比较大的怀疑态度,我们希望验证数据库在 K8s 上性能是否能满足生产要求。本文提供在公有云 ECS 上自建 PostgreSQL(下文中以 ECS PG 代指)和基于 K8s 的数据库管控平台作为自建 PostgreSQL 托管方案进行对比,并提出如何在 K8s 上优化 PG 性能的方案。

环境准备

‍‍‍‍


版本CPU内存磁盘网络规格族复制协议
ESC PG12.1416C64GESSD PL1 500GSLB独占主备异步
KubeBlocks PG12.1416C64GESSD PL1 300GSLB独占主备异步
  1. 在云厂商托管的 ACK 服务上购买 K8s 集群并部署 KubeBlocks[2],网络模式采用 Terway,Terway 生产出来的 Pod IP 为 VPC IP,保证一个 VPC 内的网络可达,简化了网络管理和应用开发的成本,node 的规格为 16C64G。
  2. 生产实例:一开始在独占的 node 上无法生产出 16C64G 的规格,因为 kubelet 等 agent 还消耗部分资源,所以调低 request 和 limit 到 14C56G 后生产成功。

使用 kubectl edit 编辑 pg cluster 的 resource spec,去掉对 request 和 limit 的限制,保证压测过程中可以使用到 16C CPU,buffers 设置为 16GB,创建 PG 实例:

kbcli cluster create --cluster-definition=postgresql

测试计划

Sysbench Read-intensive 测试:80% read + 20% write。

该测试场景读多写少,比较接近实际的生产场景。


第一轮压测:TPS 跌 0

从 ECS 压测机发起压测,通过 VPC IP 访问 PG。

Threads
Throughput
Latency (ms)

KubeBlocks PG
ECS PG
KubeBlocks PGECS PG
25
87264
91310
31.94
28.67
50
111063
140559
55.82
40.37
100
83032
159386
132.4992.42
15061865
140938
272.27
186.54
175
56487
134933
350.33
240.02

发现三个问题:

  1. CPU 无法打满:从 ECS 压测 DB,DB 所在 node CPU 无法压满。
  2. 并发衰减快:随着压测并发数上升,KubeBlocks PG 性能衰减要比 ECS PG 快。
  3. TPS 间歇性跌 0:在压测的过程中经常出现间歇性的 TPS 跌 0(307s 开始)。

此时因为 client 和 server 端的 CPU 都无法压满,所以怀疑是中间的网络链路有问题,尤其是怀疑 SLB 的规格是否到达上限,所以把 SLB 规格换成了 slb.s3.large 重新压测,ACK SLB 的默认规格是  slb.s2.small。

换成 slb.s3.large 之后继续压测,问题依然存在。


第二轮压测:网络链路排查

针对 SLB 延迟设计测试 case,使用 sysbench select 1 来模拟全链路网络延迟,单纯的 ping 测试虽然也能反映部分网络延迟,但是存在很多缺陷,而且不能保证刺穿全链路,比如 SLB 设备对 ping 产生的 ICMP 报文会直接返回,导致 SLB 到 Pod 的后续链路无法被探测到。

测试的发起端依然是 ECS,测试场景为: 

  1. ECS -> Pod IP 使用 VPC 访问,网络可直达。
  2. ECS -> SLB IP -> Pod IP 中间多了一层 SLB。

  3. ECS -> ECS SLB IP ECS 默认在 PG 前端内置了一层 SLB。

测试结果如下:

Threads
Throughput
Latency (ms)

KubeBlocks PG
ECS PG
KubeBlocks PG
ECS PG

Pod IP
SLB IP
SLB IP
Pod IP
SLB IPSLB IP
25
107309
105298
92163
0.30
0.30
0.32

结果说明 ACK 和 SLB 的网络都是正常的,性能波动的概率不大,所以对 SLB 的怀疑基本可以排除。


第三轮压测:IO 带宽调整

还是按照第一轮计划进行压测,这次从系统分析入手定性分析,查看云监控的 ECS 主机监控图。

发现两个现象:

  1. 磁盘读写带宽达到了对应规格的瓶颈,ESSD 带宽和磁盘容量正相关,具体计算公式为:min{120+0.5*容量, 350},300GB 磁盘对应的带宽为 270MB,从监控上看基本达到了瓶颈。
  2. 通过排查日志发现,在 TPS 跌 0 的时间点 CPU 使用率也有对应的下跌。

由于之前磁盘带宽到达了上限,所以针对 IO 带宽又加了一组测试,测试 500GB 磁盘的表现情况,500GB 磁盘对应的带宽为 min{120+0.5*500, 350} = 350MB,压测过程中发现在磁盘跑满的时候,CPU 依然有锯齿状波动,根据以往经验,这种抖动可能和 checkpoint 有关,但是也不至于到跌 0 的地步。

在不断增加磁盘带宽的过程中发现 TPS 跌 0 的现象得到缓解,因此针对这个发现一次性把磁盘带宽调到最高,换成 ESSD PL2 1TB 磁盘,对应带宽 620MB,从图上看抖动依然存在,但得到很大缓解,CPU 使用率跌幅收窄。

再激进一点,直接升级到了 ESSD PL3 2TB,磁盘带宽达到 700MB。

TPS 跌 0 基本缓解,但是依然有比较大的抖动,TPS 从 2400 到 1400,跌幅差不多 40%,CPU 抖动幅度收窄但依然存在(@8183s)。

这一轮测试的结论就是 IO 带宽对 CPU 和 TPS 的影响很大,随着 IO 带宽的增加抖动幅度不断减少,TPS 跌 0 的问题消失,但是即使 IO 带宽不做限制,TPS 依然有 40% 的下跌抖动,在排除了硬件的瓶颈约束之后,这种抖动只可能和 PG 本身有关。


第四轮压测:Checkpoint 与锁分析

这次把目光聚焦到 Checkpoint 上来,主要是把传导机制搞清楚,分析 IO 限流是如何反馈到 Checkpoint 和事务的:

  1. PostgreSQL Checkpoint 为何比其他数据库冲击要大?之前也测了一下 MySQL,发现 MySQL 在做 Checkpoint 时抖动相对要小很多。
  2. 即使 IO 限流,但是从监控看 IO 还是满的,事务不应该跌 0,是不是此时带宽都被 Checkpoint 占用了?为了更好地监控数据库和主机指标,打开 KubeBlocks 集成的 Node Exporter 监控。

再一次压测,发现跌 0 的时候有一次比较大的内存回收,内存一次性被回收了 10GB,这个量有点大,在不开 Huge Page 的时候,一个 page frame 4KB,10GB 大概是 2.5MB 的 page 数量,大量 page 的遍历和回收对 os kernel page reclaim 模块会有很大的压力,而且在那个时间点上 os 卡了几十秒,导致上面的进程也都 hang 住,这种回收一般和 dirty_background_ratio 设置不合理有关,具体原理不再赘述。

执行sysctl -a | grep dirty_background_ratio发现 vm.dirty_background_ratio = 10。

调整 background ratio 为 5%:sysctl -w vm.dirty_background_ratio=5。

这个调整会让一些脏掉的 page cache 尽早刷下去,这个比例设置之所以关键,和 PostgreSQL 的实现有很大关系,PostgreSQL 依赖 os page cache,与 Oracle、MySQL 这些数据库的 IO 架构不同。MySQL 使用 DirectIO,不依赖系统 page cache,给内存管理模块带来的压力和反过来受到的影响会小很多,当然某些场景下 DirectIO 延迟比写 buffer cache 会更大一些。

此时也开始关注 PostgreSQL 内核实现和日志,登录到 Pod 中,有如下发现:一个 WAL 日志默认大小为 16MB。

root@postgres-cluster-postgresql-0:/home/postgres/pgdata/pgroot/data/pg_wal# du -sh 0000000A000001F300000077 16M 0000000A000001F300000077

压测过程中,PostgreSQL 后台进程会清理 pg_wal 目录下的 WAL 日志以腾出空间,通过 strace 发现最多一次删除了几百个文件,总计大小 12GB(日志中的时间都要 +8 个时区,所以 5:42 对应北京时间 13:42):

2023-05-18 05:42:42.352 GMT,,,129,,64657f66.81,134,,2023-05-18 01:29:10 GMT,,0,LOG,00000,"checkpoint complete: wrote 680117 buffers (32.4%); 0 WAL file(s) added, 788 removed, 0 recycled; write=238.224 s, sync=35.28 6 s, total=276.989 s; sync files=312, longest=1.348 s, average=0.114 s; distance=18756500 kB, estimate=19166525 kB",,,,,,,,,"" 2023-05-18 05:42:42.362 GMT,,,129,,64657f66.81,135,,2023-05-18 01:29:10 GMT,,0,LOG,00000,"checkpoint starting: wal",,,,,,,,,"" 2023-05-18 05:42:44.336 GMT,"sysbenchrole","pgbenchtest",65143,"::1:43962",6465928f.fe77,1157,"SELECT",2023-05-18 02:50:55 GMT,36/46849938,0,LOG,00000,"duration: 1533.532 ms execute sbstmt1641749330-465186528: SEL ECT c FROM sbtest46 WHERE id=$1","parameters: $1 = '948136'",,,,,,,,"" 2023-05-18 05:42:44.336 GMT,"sysbenchrole","pgbenchtest",65196,"::1:44028",6465928f.feac,1137,"UPDATE",2023-05-18 02:50:55 GMT,57/43973954,949436561,LOG,00000,"duration: 1533.785 ms execute sbstmt493865735-6481814 15: UPDATE sbtest51 SET k=k+1 WHERE id=$1","parameters: $1 = '996782'",,,,,,,,""

可以看到,做 Checkpoint 的一瞬间,cpu idle 就飙涨到了 80%(对应 TPS 基本跌 0)。

日志中部分事务的 duration 上涨到 1s+。

TPS 跌 0 也在 13:44:20 这个时间点结束。

2023-05-18 05:44:20.693 GMT,"sysbenchrole","pgbenchtest",65145,"::1:43964",6465928f.fe79,1178,"SELECT",2023-05-18 02:50:55 GMT,48/45617265,0,LOG,00000,"duration: 1942.633 ms execute sbstmt-1652152656-473838068: SE LECT c FROM sbtest37 WHERE id=$1","parameters: $1 = '1007844'",,,,,,,,""

13:45:41 开始做 vacuum。

2023-05-18 05:45:41.512 GMT,,,87995,,646596d6.157bb,71,,2023-05-18 03:09:10 GMT,64/3879558,0,LOG,00000,"automatic aggressive vacuum of table ""pgbenchtest.public.sbtest45"": index scans: 1 pages: 0 removed, 66886 remain, 0 skipped due to pins, 2328 skipped frozen tuples: 14166 removed, 2005943 remain, 15904 are dead but not yet removable, oldest xmin: 944519757

13:47:04 checkpoint 真正完成。

2023-05-18 05:47:04.920 GMT,,,129,,64657f66.81,136,,2023-05-18 01:29:10 GMT,,0,LOG,00000,"checkpoint complete: wrote 680483 buffers (32.4%); 0 WAL file(s) added, 753 removed, 0 recycled; write=226.176 s, sync=32.53

整个过程的监控图:

发现 CPU busy 抖动和 Checkpoint 刷脏过程基本吻合。

全过程磁盘带宽一直打满:

跌 0 的时间段和 checkpoint 刷脏时间段基本一致:通过看内存的波动情况,发现内存回收导致的 hang 基本被消除,说明之前 dirty_background_ratio 的参数调整有效。

此外还发现,在刷脏过程中,锁的数量一直比较高,与非刷脏状态下的对比非常明显:

具体的锁有:

有时多个进程会抢同一把锁:

而且发现平时做 IO 的时候,磁盘带宽虽然会打满,但是事务之间很少抢锁,TPS 也不会跌 0,当锁竞争比较明显的时候,就很容易跌0,而锁的竞争又和 Checkpoint 直接相关。


第五轮压测:PG 内核代码分析与 trace
继续从 Checkpoint 实现入手分析跌 0 原因,阅读了大量 PostgreSQL Checkpoint 和 WAL 部分的代码实现,并对 PostgreSQL backend 进程进行 Trace,发现 WAL 日志创建存在问题,其中的 duration 数据是通过脚本分析日志计算得出的: 
duration:550 ms 11:50:03.951036 openat(AT_FDCWD, "pg_wal/archive_status/00000010000002EE000000E7.ready", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 22 
duration:674 ms 11:50:09.733902 openat(AT_FDCWD, "pg_wal/archive_status/00000010000002EF00000003.ready", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 22 
duration:501 ms 11:50:25.263054 openat(AT_FDCWD, "pg_wal/archive_status/00000010000002EF0000004B.ready", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 23 
duration:609 ms 11:50:47.875338 openat(AT_FDCWD, "pg_wal/archive_status/00000010000002EF000000A8.ready", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 25 
duration:988 ms 11:50:53.596897 openat(AT_FDCWD, "pg_wal/archive_status/00000010000002EF000000BD.ready", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 29 
duration:1119 ms 11:51:10.987796 openat(AT_FDCWD, "pg_wal/archive_status/00000010000002EF000000F6.ready", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 29 
duration:1442 ms 11:51:42.425118 openat(AT_FDCWD, "pg_wal/archive_status/00000010000002F000000059.ready", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 45 
duration:1083 ms 11:51:52.186613 openat(AT_FDCWD, "pg_wal/archive_status/00000010000002F000000071.ready", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 51 
duration:503 ms 11:52:32.879828 openat(AT_FDCWD, "pg_wal/archive_status/00000010000002F0000000D8.ready", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 75 
duration:541 ms 11:52:43.078011 openat(AT_FDCWD, "pg_wal/archive_status/00000010000002F0000000EB.ready", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 84 
duration:1547 ms 11:52:56.286199 openat(AT_FDCWD, "pg_wal/archive_status/00000010000002F10000000C.ready", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 84 
duration:1773 ms 11:53:19.821761 openat(AT_FDCWD, "pg_wal/archive_status/00000010000002F10000003D.ready", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 94 
duration:2676 ms 11:53:30.398228 openat(AT_FDCWD, "pg_wal/archive_status/00000010000002F10000004F.ready", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 101 
duration:2666 ms 11:54:05.693044 openat(AT_FDCWD, "pg_wal/archive_status/00000010000002F100000090.ready", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 122 
duration:658 ms 11:54:55.267889 openat(AT_FDCWD, "pg_wal/archive_status/00000010000002F1000000E5.ready", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 139 
duration:933 ms 11:55:37.229660 openat(AT_FDCWD, "pg_wal/archive_status/00000010000002F200000025.ready", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 163 
duration:2681 ms 11:57:02.550339 openat(AT_FDCWD, "pg_wal/archive_status/00000010000002F200000093.ready", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 197
这几个 WAL 日志文件从开始创建到 ready 需要 500ms 以上,有的甚至到了 2.6s,这也是我们观测到有些事务 duration 大于 2s 的原因,因为事务要挂起等待 WAL 文件 ready 才能继续写入。
WAL 创建的具体流程:
  1. stat(pg_wal/00000010000002F200000093) 找不到文件
  2. 使用 pg_wal/xlogtemp.129 来创建
  3. 清零 pg_wal/xlogtemp.129
  4. 建立软连接 link("pg_wal/xlogtemp.129", "pg_wal/00000010000002F200000093")
  5. 打开 pg_wal/00000010000002F200000093
  6. 在尾部写入元数据
  7. 加载并应用该 WAL 文件

查看 PostgreSQL 日志发现,那个时刻客户端有链接被重置,有的事务执行超过 10s。 

2023-05-22 11:56:08.355 GMT,,,442907,"100.127.12.1:23928",646b5858.6c21b,1,"",2023-05-22 11:56:08 GMT,,0,LOG,08006,"could not receive data from client: Connection reset by peer",,,,,,,,,"" 2023-05-22 11:56:10.427 GMT,,,442925,"100.127.12.1:38942",646b585a.6c22d,1,"",2023-05-22 11:56:10 GMT,,0,LOG,08006,"could not receive data from client: Connection reset by peer",,,,,,,,,"" 2023-05-22 11:56:12.118 GMT,,,442932,"100.127.13.2:41985",646b585c.6c234,1,"",2023-05-22 11:56:12 GMT,,0,LOG,08006,"could not receive data from client: Connection reset by peer",,,,,,,,,"" 2023-05-22 11:56:13.401 GMT,"postgres","pgbenchtest",3549,"::1:45862",646ae5d3.ddd,3430,"UPDATE waiting",2023-05-22 03:47:31 GMT,15/95980531,1420084298,LOG,00000,"process 3549 still waiting for ShareLock on transac tion 1420065380 after 1000.051 ms","Process holding the lock: 3588. Wait queue: 3549.",,,,"while updating tuple (60702,39) in relation ""sbtest44""","UPDATE sbtest44 SET k=k+1 WHERE id=$1",,,""

通过对比日志发现每次 WAL segment 耗时较长时,客户端就会产生一批慢查询(>1s)日志 PG 内核中清零的具体实现为:

/* do not use get_sync_bit() here --- want to fsync only at end of fill */
 fd = BasicOpenFile(tmppath, open_flags);
 if (fd < 0)
  ereport(ERROR,
    (errcode_for_file_access(),
     errmsg("could not create file \"%s\": %m", tmppath)));

 pgstat_report_wait_start(WAIT_EVENT_WAL_INIT_WRITE);
 save_errno = 0;
 if (wal_init_zero)
 {
  ssize_t  rc;

  /*
   * Zero-fill the file.  With this setting, we do this the hard way to
   * ensure that all the file space has really been allocated.  On
   * platforms that allow "holes" in files, just seeking to the end
   * doesn't allocate intermediate space.  This way, we know that we
   * have all the space and (after the fsync below) that all the
   * indirect blocks are down on disk.  Therefore, fdatasync(2) or
   * O_DSYNC will be sufficient to sync future writes to the log file.
   */
  rc = pg_pwrite_zeros(fd, wal_segment_size, 0); // buffer write

  if (rc < 0)
   save_errno = errno;
 }
 else
 {
  /*
   * Otherwise, seeking to the end and writing a solitary byte is
   * enough.
   */
  errno = 0;
  if (pg_pwrite(fd, "\0", 1, wal_segment_size - 1) != 1)
  {
   /* if write didn'
set errno, assume no disk space */
   save_errno = errno ? errno : ENOSPC;
  }
 }
 pgstat_report_wait_end();

 if (save_errno)
 {
  /*
   * If we fail to make the file, delete it to release disk space
   */
  unlink(tmppath);

  close(fd);

  errno = save_errno;

  ereport(ERROR,
    (errcode_for_file_access(),
     errmsg("could not write to file \"%s\": %m", tmppath)));
 }

 pgstat_report_wait_start(WAIT_EVENT_WAL_INIT_SYNC);
 if (pg_fsync(fd) != 0)  // fsync data to disk
 {
  save_errno = errno;
  close(fd);
  errno = save_errno;
  ereport(ERROR,
    (errcode_for_file_access(),
     errmsg("could not fsync file \"%s\": %m", tmppath)));
 }
 pgstat_report_wait_end();

从代码中可以看出 WAL 清零操作是先做异步写,每次写一个 page block,直到循环写完,然后再一次性做 fsync,异步写一般很快,当系统负载很低的时候,异步写 8KB 的数据响应时间是 us 级别,当系统负载比较重的时候,一个异步 IO 延迟甚至能达到 30ms+,异步写时延变长和 os kernel 的 io path 有很大关系,当内存压力大时,异步写可能会被 os 转成同步写,而且 IO 过程和 page reclaim 的 slowpath 交织在一起,所以理论上就有可能耗时很久,在实际 trace 中也确实如此。下面是监测到的紧邻的两次 WAL 清零 IO 操作,可以看到两次异步 IO 操作的间隔达到了 30ms+。

11:56:57.238340 write(3, "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 8192) = 8192 11:56:57.271551 write(3, "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"..., 8192) = 8192

当时的磁盘带宽:

我们可以测算一下,对于一个 16MB 的 WAL segment,需要 2K 次清零操作,如果每次操作耗时 1ms,那么需要至少 2s 来能完成整体清零。

以某个正在执行的事务为例子:trace 一个正在执行事务的 PostgreSQL Backend 进程,中间等锁耗时 1.5s

02:27:52.868356 recvfrom(10, "*\0c\304$Es\200\332\2130}\32S\250l\36\202H\261\243duD\344\321p\335\344\241\312/"..., 92, 0, NULL, NULL) = 92 
02:27:52.868409 getrusage(RUSAGE_SELF, {ru_utime={tv_sec=232, tv_usec=765624}, ru_stime={tv_sec=59, tv_usec=963504}, ...}) = 0 
02:27:52.868508 futex(0x7f55bebf9e38, FUTEX_WAIT_BITSET|FUTEX_CLOCK_REALTIME, 0, NULL, FUTEX_BITSET_MATCH_ANY) = 0 
02:27:54.211960 futex(0x7f55bebfa238, FUTEX_WAKE, 1) = 1 
02:27:54.215049 write(2, "\0\0\36\1\377\334\23\0T2023-05-23 02:27:54.215"..., 295) = 295 
02:27:54.215462 getrusage(RUSAGE_SELF, {ru_utime={tv_sec=232, tv_usec=765773}, ru_stime={tv_sec=59, tv_usec=963504}, ...}) = 0 

对应的 SQL 是:

2023-05-23 02:27:54.215 GMT,"postgres","pgbenchtest",1301759,"::1:56066",646c1ef3.13dcff,58,"SELECT",2023-05-23 02:03:31 GMT,43/198458539,0,LOG,00000,"duration: 1346.558 ms execute sbstmt-13047857631771152290: SEL ECT c FROM sbtest39 WHERE id=$1","parameters: $1 = '1001713'",,,,,,,,""

至此基本可以确定 Checkpoint 时 TPS 跌 0、CPU 抖动和 WAL 清零有关,具体传导机制是:

WAL 创建 -> WAL 清零 -> 刷脏和清零操作 IO 争抢 -> 事务等待变长 -> 持有锁时间变长 -> 被堵塞的事务进程越来越多 -> 事务大面积超时。

清零的最大问题是会产生大量 IO,并且需要所有事务挂起等待清零数据 sync 完成,直到新的 WAL 文件 ready,在这个过程中所有事务都要等待 WALWrite 和 wal_insert 锁,这是抖动的最大根源。不过问题的本质还是 IO 争抢,如果 IO 负载很低,清零速度比较快,观测到的抖动也不明显,问题也不会暴露,目前观测到的剧烈抖动也只出现在压测过程中,所以前面几轮测试中放大 IO 带宽也有助于缓解 TPS 跌 0 和 CPU 抖动。

由于在创建新的 WAL 文件的时候需要加锁,所以通过调整 WAL 文件大小来降低加锁的频率也是优化方向之一。


第六轮压测:关闭 wal_init_zero

问题定位后,解决方案也就比较好找了,WAL 日志清零和判断 WAL 日志槽是否正常有关,本质上是一种不良好但比较省力的实现,最好的解决方案应该是 WAL 日志能自解释,不依赖清零来保证正确性,这种方案需要修改 PG 内核,所以不大现实;还有一种方案是虽然还需要清零,但是可以由文件系统来完成,不需要 PG 内核显式调用,当然这需要文件系统支持该清零特性。

ZFS 和 XFS 正好具备这个特性[3] 。我们当前测试使用的 EXT4 并不具备这个特性,所以我们先尝试把文件系统改为 ZFS。

但是在测试 ZFS 的过程中,发现了好几次文件系统挂起的情况:

root@pgclusterzfs-postgresql-0:~# cat /proc/4328/stack 
[<0>] zil_commit_impl+0x105/0x650 [zfs] 
[<0>] zfs_fsync+0x71/0xf0 [zfs] 
[<0>] zpl_fsync+0x63/0x90 [zfs] 
[<0>] do_fsync+0x38/0x60 
[<0>] __x64_sys_fsync+0x10/0x20 
[<0>] do_syscall_64+0x5b/0x1d0 
[<0>] entry_SYSCALL_64_after_hwframe+0x44/0xa9 
[<0>] 0xffffffffffffffff

因此基于稳定性的考虑,ZFS 被暂时搁置,转而采用 XFS,并 set wal_init_zero = OFF,同时为了降低 WAL 日志文件创建的频率,我们把 wal_segment_size 从 16MB 调整到了 1GB,这样加锁频率也会降低。

经过测试,跌 0 和 CPU 抖动缓解很明显:

虽然消除清零操作和降低加锁频率能解决部分抖动问题,但是由于 Checkpoint 时刷脏和事务写 WAL 日志依然会抢带宽、抢锁,所以在 Checkpoint 时抖动依然存在,只是和之前相比有了很大的缓解,所以如果再继续优化,只能从降低单个事务的 IO 量上入手。

为了数据安全考虑,之前的压测都开启了 full_page_write,该特性用来保证断电时 page block 数据损坏场景下的数据恢复,具体原理可以参考《PG.特性分析.full page write 机制》(http://mysql.taobao.org/monthly/2015/11/05/),如果存储能保证原子写(不会出现部分成功、部分失败的情况)或 PG 能从某个备份集中恢复(正确的全量数据+增量WAL回放),那么在不影响数据安全的前提下可以尝试关闭 full_page_write。


第七轮压测:关闭 full_page_write

关闭 full_page_write 前后 CPU 和 IO 带宽对比都非常明显:

可以看出 IO 争抢对 PG 的影响很大,而且在关闭 full_page_write 之后即使有 Checkpoint,CPU 也几乎没有抖动。

又加测了三种场景:

  1. 开启 full_page_write+16MB WAL segment size;
  2. 开启 full_page_write+1GB WAL segment size;
  3. 关闭 full_page_write+1GB WAL segment size。

可以看出,在开启 full_page_write 时 1GB segment 比 16MB segment 表现要略好,也印证了通过增加 segment size 降低加锁频率的方案可行;关闭 full_page_write 后 PG 表现非常顺滑。

所以最终选择了一组 (wal_init_zero off + XFS) + (full_page_write off) + (wal_segment_size 1GB) 的组合测试,效果如下:

可以看到在 Checkpoint 时抖动消失,系统非常顺滑,PG 也从 IO-Bound 变成了 CPU-Bound,此时的瓶颈应该在 PG 的内部锁机制上。


第八轮压测:最终性能对比

不过根据以往的经验,PG 因为是进程模型,一个会话对应一个进程,当并发数比较高的时候,页表和进程上下文切换的代价会比较高,所以又引入了 pgBouncer;用户自建 ECS PG 为了解决并发问题,开启了 Huge Page,KubeBlocks PG 因为部署在 ACK 上,所以没有开启 Huge Page。

对比时为了公平,KubeBlocks 在下面的测试中开启了 full_page_write。

可以看出在引入 pgBouncer 之后,PG 能够承载更多的链接数而不会引起性能退化,KubeBlocks PG 比 PG 在性能上相差不大,在并发数比较低的时候性能上略好一些,整体稳定性上会更好一些。


结论
  1. WAL 清零对 PG 的性能和稳定性都有比较大的影响,如果文件系统支持清零特性,可以关闭 wal_init_zero 选项,可有效降低 CPU 和 TPS 抖动。
  2. full_page_write 对 PG 的性能和稳定性也有比较大的影响,如果能从存储或备份上能保证数据的安全性,可以考虑关闭,可有效降低 CPU 和 TPS 抖动。
  3. 增加 WAL segment size 大小,可降低日志轮转过程中加锁的频率,也可以降低 CPU 和 TPS 抖动,只是效果没那么明显。
  4. PG 是多进程模型,引入 pgBouncer 可支持更大的并发链接数,并大幅提升稳定性,如果条件允许,可以开启 Huge Page,虽然原理不同,但效果和 pgBouncer 类似。
  5. PG 在默认参数下,属于 IO-Bound,在经过上述优化后转化为 CPU-Bound。
  6. ACK 和 SLB 网络实现比较健壮,性能和稳定性上都满足要求。
  7. 在 K8s 上对文件系统、PG 参数等选项的调整非常方便,可以快速有效进行不同的组合测试,而且数据库跑在 K8s 上不会带来性能上的损耗,在做过通用调优之后可以达到很好的效果,对用户来说限制更少,有更强的自主性。

参考资料

[1]KubeBlocks: https://github.com/apecloud/kubeblocks

[2]部署 KubeBlocks: https://kubeblocks.io/docs/preview/user_docs/installation/install-with-kbcli/install-kbcli

[3]ZFS 和 XFS 正好具备这个特性: https://www.reddit.com/r/bcachefs/comments/fhws6h/the_state_of_linux_cow_file_systems_what_to_choose/



继续滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存