从一个案例聊聊FPI的危害
前言
前些天,群里有一位童鞋提到这样一个案例:"想请教一个问题,我的环境,我看后台只有一个delete在不停的执行,但是WAL日志涨的却非常快,2秒钟就切一个,这个表一共500多万条数据,这种情况正常么?",不用说肯定不正常🤔,再加上许久之前同事也曾遇到过UUID导致的WAL写放大,那也赶此机会唠唠这个问题。
根据过往经验,不同于WAL堆积无法回收,WAL生成速度暴涨一般是由于要么配置了过小的archive_timeout参数导致定时切换WAL的频率过高,要么就是WAL写放大导致实打实生成WAL速度过快,于是让这位童鞋使用pg_waldump看了一下,果然是FPI比例过高导致的WAL写放大。
可以看到,最后的汇总处,FPI的比例竟然占据了 97.51% 之大。
全页写的影响
下文译自:https://www.2ndquadrant.com/en/blog/on-the-impact-of-full-page-writes/
当在调整postgresql.conf的时候,你可能注意到了有一个叫full_page_writes的配置项。后面的论述是关于部分页面写入的(partial page writes),我们通常会将full_page_writes设置为on,这是一件正确的事情,我会在文章后面解释。然而,理解全页写操作是有用的,因为它对性能的影响可能是非常可观的。
不同于之前的文章 checkpoint tuning,本文不是关于如何调优服务器的指南。实际上,并没有太多可以调整的地方,但是我会向您展示一些应用程序级别的决策(例如数据类型的选择)是如何与全页写相互影响的。
部分写/块折断
那么全页写是什么呢?正如postgresql.conf中的注释所说,这是一种从**"部分写"**中恢复的方法,PostgreSQL使用8kB的页面(默认情况下),但其他部分使用不同的块大小。Linux文件系统通常情况下使用4kB的页面(可能使用更小的页面,但在x86上4kB是最大的),在硬件层面,旧的硬盘使用512B扇区,而新设备通常写入更大的数据块(通常是4kB甚至8kB)。
因此,当PostgreSQL写入8kB的页面时,存储栈的其他层可能会把它分成更小的块,以分别管理。这就提出了一个原子写的问题。8kB的PostgreSQL页面可能被分成了两个4kB的文件系统页面,然后被分成512B扇区。那么,如果服务器崩溃了怎么办?(电源故障、内核错误等)。
在这一点上,你现在可能在想,这不就是我们有事务日志(WAL)的原因吗,你是对的!所以在启动服务器后,数据库会读取WAL(自从上次完成检查点后),并再次应用变更,以确保数据文件是完整的。很简单。
但有一个问题是,恢复并不会盲目地应用更改,它经常需要读取数据页。它假设页面在某些方面没有被破坏,例如由于部分写。这似乎有点自相矛盾,因为为了修复数据损坏,我们假设没有数据损坏。
全页写便是这个难题的解决方式—在检查点之后,第一次进行修改的页面,会将整页记录到WAL中。这保证了在恢复过程中,触及一个页面的第一条WAL记录会包含整个页面,从而消除了从数据文件中读取到可能已经损坏的页面的需要。
写放大
当然,这样做的负面后果是增加了WAL的大小,在8kB页面上更改一个字节会将整个页面记录到WAL中。全页写仅发生在检查点之后的第一次写入,因此检查减少点的频率是一种改善这种情况的方式之一,通常,在检查点之后会有短暂的全页写的"爆发"情况,然后在检查点结束之前会有相对较少的全页写。
UUID vs. BIGSERIAL keys
但是,在应用程序级别上做出的设计决策会产生一些意想不到的影响。让我们假设现在有一个很简单的表,主键是BIGSERIAL
或者UUID
,然后插入数据。那么生成的WAL数量会有差异吗(假设我们插入的行数量相同)?
预期两种情况下产生的WAL数量大致相同貌似是合理的,但正如以下图表所示的,实际上存在着巨大的差异。
这里展示了1个小时的基准测试中,生成的WAL日志量,对于写入被限制在了5000次每秒。主键是BIGSERIAL
的生成了 ~2GB的WAL日志量,然而UUID
生成了超过40GB的量。这的确是一个十分明显的差异,而且很明显,大部分WAL都与主键索引相关。让我们看看WAL记录的类型。
显而易见,绝大多数的记录都是full-page images (FPI),也就是全页写的结果。但是为什么会这样?
当然,这是因为UUID固有的无序性导致。BIGSERIAL
是有序的,因此新数据会插入到Btree索引的相同叶子节点页面。因为只有对页面的第一次修改才会触发全页写的操作,所以WAL记录中只有很小一部分是FPIs。而UUID
则完全不同了,这些值完全是无序的,每个插入都可能触及新的索引叶子页(假设索引足够大)。
数据库能做的事情不多,工作负载在本质上是随机的,会触发许多全页写的操作。
当然,即使使用BIGSERIAL
,也不难看到类似的写放大现象。它只需要不同的工作负载,例如UPDATE
,随机更新均匀分布的记录,图表如下所示
突然,数据类型之前的差异消失了——在这两种情况下,访问都是随机的,从而产生了几乎完全相同数量的WAL。另一个区别是,大多数WAL都是与堆相关的,即表,而不是索引。"HOT"的设计是为了允许HOT UPDATE优化(即无需触及索引的更新),这几乎消除了所有与索引相关的WAL日志量。
但是你可能会争辩说,大多数应用程序不会更新整个数据集。通常,只有一小部分数据是活跃的,人们只访问最近几天在论坛上的帖子,在网上商店中未解决的订单,等等。这是如何改变结果的呢?
值得庆幸的是,pgbench支持非均匀分布,例如用指数分布触及1%的数据子集,~25%的时间,图表看起来是这样的。
在使分布更加倾斜后,接触1%子集,~75%的时间
这再次表明了数据类型的选择可能产生的巨大差异,以及针对HOT更新进行调优的重要性。
8kB和4kB的数据页
一个有趣的问题是,通过在PostgreSQL中使用更小的页面(这需要编译一个自定义包),我们可以节省多少WAL流量?在最好的情况下,由于只记录4kB而不是8kB的页面,它可以节省高达50%的WAL日志量。对于具有均匀分布的UPDATE
的工作负载,它看起来是这样的
因此,节省的量并不是完全的50%,但从~140GB减少到~90GB仍然是相当显著的。
是否需要全页写
在解释了部分写的危害之后,这可能看起来很荒谬,但禁用全页写可能是一个可行的选项,至少在某些情况下是这样。
首先,我想知道现代Linux文件系统是否仍然容易遇到部分写?这个参数是在2005年发布的PostgreSQL 8.1中引入的,所以也许从那时起许多文件系统引入的改进使它不再是一个问题。可能并不适用于任意的工作负载,但是假设一些额外的条件(例如在PostgreSQL中使用4kB的页面大小)是否就足够了?而且,PostgreSQL不会重写8kB页面的部分页面——整个页面总是被一起写出。
我最近做了很多测试,试图触发部分写,但我始终还没有复现。当然,这并不能真正证明这个问题不存在。但是,即使它仍然是一个问题,数据校验(checksum)可能是一个有效的保护措施(它不会解决这个问题,但至少会让你知道有一个损坏的页面)。
其次,现在许多系统依赖于流复制,而不是等待服务器在出现硬件问题后重新启动(这可能需要相当长的时间),然后花更多的时间执行恢复,系统只需切换到热备即可。如果删除了失败的主库上的数据库(然后从新的主服务器克隆了数据库),则不存在部分写的问题。
但我想,如果我们开始推荐这样做,那么 "我不知道数据是怎么被破坏的,我只是在系统上设置了full_page_writes=off!"就会成为DBA们临死前最常见的一句话(和 "我在reddit上见过这条蛇,它没有毒"一起)。
小结
要直接调优full-page writes,可以做的不多。对于大多数工作负载,大多数全页写操作发生在检查点之后,然后消失,直到下一个检查点。因此,重要的是要调优检查点,使其不要太频繁地发生。
一些应用层面的决定可能会增加对表和索引的写入的随机性——例如UUID值本身就是随机的,甚至将简单的INSERT变成了随机的索引更新。例子中使用的模式是相当普通的——在实践中,会有二级索引、外键等。但是在内部使用BIGSERIAL
主键(并保留UUID作为代替的键)至少可以减少写入放大。
我真的很想讨论一下当前内核/文件系统上全页写入的必要性。遗憾的是,我没有找到很多资源,所以如果你有相关的信息,请告诉我。
复现
全页写FPI是经常被吐槽和讨论的话题,会导致WAL的写放大,耗费更多的归档存储,也就意味着要更多money,恢复时间也可能变长。在之前同事也碰到了这样一起案例:通过 psql -f 的方式导入数据,结果导入了10多个小时却只导入了50GB的数据
postgres=# select * from pg_stat_activity where state = 'active';
-[ RECORD 1 ]----+-------------------------------------------------------
datid | 17072
datname | xxx
pid | 80513
leader_pid |
usesysid | 10
usename | postgres
application_name | psql
client_addr |
client_hostname |
client_port | -1
backend_start | 2022-04-12 19:25:32.023444+08
xact_start | 2022-04-13 15:50:29.028202+08
query_start | 2022-04-13 15:50:29.028202+08
state_change | 2022-04-13 15:50:29.028203+08
wait_event_type |
wait_event |
state | active
backend_xid | 548232
backend_xmin | 548232
query_id | 8486809351789120387
query | copy public.c4_am_t_xxx(id,model_id,item_id,qty) from stdin;
backend_type | client backend
表结构如下
postgres=# \d c4_am_t_xxx
Table "public.c4_am_t_xxx"
Column | Type | Collation | Nullable | Default
----------+------------------------+-----------+----------+---------
id | character varying(20) | | not null |
model_id | character varying(100) | | |
item_id | character varying(200) | | |
qty | integer | | |
Indexes:
"c4_am_t_xxx_pkey" PRIMARY KEY, btree (id)
"c4_am_t_xxx_idx" btree (model_id)
导入的数据类似如下,可以看到model_id字段的数据是杂乱的,没有"顺序"可言,而model_id列上的索引是Btree,因此导入过程中由于数据的无序必然会发生大量的索引页分裂、合并等。所以不难想象,WAL里面会存在大量的FPI,通过pg_waldump查看之后,果然FPI占据了95%左右!
COPY public.c4_am_t_xxx (id,model_id,item_id,qty) From stdin;
100000054 d26a5df4a2cb6bc2fa55e4944a2e6cbe 5102040-C1100 1
100000345 d26a5df4a2cb6bc2fa55e4944a2e6cbe 5810A-C1100 1
100002371 c650faaddbc299179f9e19963e34da60 29S16-02286 1
100002490 99ddabdb78148c38466468474017a574 RQ27101512-Z5 7
100004777 3fb3f0ef0ebb1d2d667142e8db23d496 C3285707 1
于是,让同事先删除索引,导入完之后再创建索引,分分钟就导完了。
网上某些案例写到关闭全页写,可以有20% ~ 30%的提升,其实在现在的硬件加持下,比如全闪存的NVME SSD,打开了全页写性能损失其实很小。不过前文也提到了,生产中一般都是主从的高可用架构,主库碰到坏块了,备库还可以用,也可以考虑关闭。但我个人还是不建议关闭,保不齐就碰到了,做个switch over、做个recovery都费神费力。与其挖掘关闭FPI所带来的性能提升,还不如多花点钱提升下硬件,调调参数优化一下等,毕竟多一层安全保护总不是坏事。
但是文中提到的使用UUID作为索引,进而导致的索引分裂、合并等,的确是需要尽量避免的。因为UUID本身的无序性,所以在做主键的类型选择时,尽量不要使用UUID类型的字段,如果要使用,请使用有序生成的UUID,危害也看到了,索引分裂、合并,导致大量的离散IO,和FPI。当然,假如你没有>,<,>=等需求,用哈希索引是可以的。有序UUID插件可以参考这个:https://github.com/tvondra/sequential-uuids,https://www.2ndquadrant.com/en/blog/sequential-uuid-generators/
在我们内部规范中,便要求使用bigserial以替代UUID,下图是同事之前测试的一个对比结果
TPS | WAL日志 | FPI Size | CPU | 索引大小 | |
---|---|---|---|---|---|
UUID | 7686 | 4883MB | 89.88% | 2 | 1176MB |
序列 | 9643 | 599MB | 0.34% | 1.12 | 476MB |
随机前缀 + 序列 | 8584 | 593MB | 0.16% | 1.2 | 887MB |
无索引 | 9478 | 469MB | 0.82% | 1.03 |
针对WAL写放大,有几种优化措施,优先级从高到低
从FPI的原理出发,延迟checkpoint的间隔(max_wal_size、checkpoint_timeout),但是拉长checkpoint周期实际上就是让周期内的WAL日志更多,这会导致数据库崩溃恢复的时间变长,这是一个典型的tradeoff
HOT update(减少索引IO),对于写负载较大的情形,其实HOT的优化效果也有限
wal_compression,时间换空间的典型,另外好消息是,在15的版本里新增了对FPI进行压缩的功能,Add support for LZ4 with compression of full-page writes in WAL。神医还特意去测了一下,第一个是zstd的结果,第二个是pglz的结果。
Total 10412279 725627822 [55.07%] 592016207 [44.93%] 1317644029 [100%]
Total 7789909 511504455 [29.29%] 1234611307 [70.71%] 1746115762 [100%]测试结果也符合 https://www.enterprisedb.com/blog/you-can-now-pick-your-favorite-compression-algorithm-your-wals 这篇文章所写 👇🏻
pglz provides slightly better compression than lz4 zstd provides better compression than pglz 减小block size,不过在密集写入的场景,可能会遇到更多extend block的等待事件,如果选择大的block size,可能又会导致某些需要小block size的表可能性能变差并浪费更多shared buffer
关闭full_page_write,转嫁给文件系统,治标不治本
Direct io + 硬件原子写,需要大刀阔斧改代码
假如要观察FPI的比例,我们可以使用pg_waldump -z
查看占比。
模拟块折断
假如要模拟partial write的话,可以参照吕海波前辈在DTCC上的分享《摸着Oracle过河 ----大幅提升PostgreSQL性能分享》,通过拦截系统调用模拟Partial write
与其他库的对比
相比之下
Oracle对于这种情况比较看得开,不会从数据库层面去避免发生断页问题,数据库内部没有机制保证断页的处理,依赖"检查"和介质恢复,比如ASM MySQL和openGuass采用的double write机制,将数据页写入磁盘之前先写入一个共享的空间 MongoDB(WiredTiger),根据资料,checkpoint是一个append only方式,WiredTiger会保存多个checkpoint版本,由于原page并没有被更新,所以即使发生partial write,也不用担心 RocksDB & InfluxDB这一类LSM架构的,并不是in-place update,数据page采用append only方式写入,直接回放WAL即可
结论
前面也提到了种种优化方式,不必过分纠结,与其挖掘关闭FPI所带来的性能提升,还不如多花点钱提升下硬件,调调参数优化一下等,毕竟多一层安全保护总不是坏事。
当然了,可以使用能减小部分页面写入风险的硬件(比如电池供电的磁盘控制器),也可以使用支持原子写的文件系统,比如Ext4(journal)或者ZFS,Btrfs这一类支持COW的文件系统,避免data block 出现partial write,不过这个不过是将数据库本该做的事情转嫁给文件系统了。
参考
https://www.2ndquadrant.com/en/blog/on-the-impact-of-full-page-writes/
https://github.com/digoal/blog/blob/master/202109/20210903_03.md
https://zhuanlan.zhihu.com/p/60230806