Postgres 查询中的读取效率问题
原文地址:https://postgr.es/p/7tt
作者:Michael Christofides
分类:性能
在数据库领域,很多时候我们的查询都受限于 I/O。因此,性能优化工作通常涉及减少页面读取次数。索引就是一个典型的例子,但它们并不能解决所有问题(我们将探讨其中几个)。
Postgres 在处理并发查询时保持一致性的方式,是在表的主要部分("堆")以及索引中维护多个行版本(文档)。旧的行版本会占用空间,至少直到不再需要它们时,空间才能被重用。这种额外的空间通常被称为"膨胀"(bloat)。下面我们将探讨堆膨胀和索引膨胀,它们如何影响查询性能,以及你可以采取哪些措施来预防和应对这些问题。
在 pgMustard 中,我们最初将与此相关的提示称为"膨胀可能性"(Bloat Likelihood),但我们发现,膨胀并不是查询最终读取不必要数据的唯一方式。另一种方式与数据局部性 (data locality)有关------例如,如果一个查询需要读取的几行数据恰好都在同一个页面(page)上,这比这些行各自分布在不同的页面上要快得多。我们稍后也会探讨这个问题。因此,pgMustard 中的提示现在更名为"读取效率"(Read Efficiency)。
这些读取效率问题可能很难发现,尤其是在不查看 EXPLAIN ANALYZE 和 pg_stat_statements 中报告的缓冲区(buffer)数量的情况下,所以我发现讨论这些问题的人不多。然而,在我看到的慢查询计划中,它们相对常见,因此我认为值得写一篇文章。
膨胀
为了演示膨胀问题,让我们创建一个简单的表并填充一些数据:
sql
CREATE TABLE read_efficiency_demo (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
text1 text NOT NULL,
text2 text NOT NULL,
text3 text NOT NULL);
INSERT INTO read_efficiency_demo (text1, text2, text3)
SELECT
md5(random()::text),
md5(random()::text),
md5(random()::text)
FROM generate_series(1, 1_000_000);
VACUUM ANALYZE read_efficiency_demo;
稍后我们将通过更新每一行来人为制造一些膨胀。但是,(即使使用其保守的默认设置)自动清理(autovacuum)可能是一个尽职的小守护进程,会在我们操作时在后台进行清理。为了简单起见,并且仅用于演示目的,让我们禁用它(请务必不要在生产环境中这样做)。
[一个简单的流程图]
"我应该关闭 autovacuum 吗?" → "不" → "但如果......" → "求你了,别!"
sql
ALTER SYSTEM SET autovacuum = off;
SELECT pg_reload_conf();
让我们检查一下最初的 100 万行占用的堆空间和索引空间:
sql
SELECT pg_size_pretty(pg_relation_size('read_efficiency_demo')) heap_space,
pg_size_pretty(pg_relation_size('read_efficiency_demo_pkey')) index_space;
heap_space | 135 MB
index_space | 21 MB
让我们为此表上的一个查询建立缓冲区读取和执行时间的基线:
sql
EXPLAIN (ANALYZE, BUFFERS, SERIALIZE)
SELECT * FROM read_efficiency_demo;
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------
Seq Scan on read_efficiency_demo (cost=0.00..27242.00 rows=1000000 width=107) (actual time=0.037..47.737 rows=1000000.00 loops=1)
Buffers: shared hit=17242
Planning Time: 0.121 ms
Serialization: time=134.561 ms output=118165kB format=text
Execution Time: 233.598 ms
因此,要读取整个表,我们命中了 17242 个缓冲区(每个是 8KB 页,所以大约 135 MB),总执行时间约为 230 毫秒。
现在,如果我们更新每一行,预计会有 100 万个新的行版本被添加到堆和索引中。通过运行 9 次此操作,我们可以看到堆和索引都变大了大约 10 倍:
sql
UPDATE read_efficiency_demo
SET id = id + 1_000_000;
-- 重复运行上述命令 9 次
SELECT pg_size_pretty(pg_relation_size('read_efficiency_demo')) heap_space,
pg_size_pretty(pg_relation_size('read_efficiency_demo_pkey')) index_space;
heap_space | 1347 MB
index_space | 255 MB
让我们再次运行相同的查询:
sql
EXPLAIN (ANALYZE, BUFFERS, SERIALIZE)
SELECT * FROM read_efficiency_demo;
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------------
Seq Scan on read_efficiency_demo (cost=0.00..267356.17 rows=9814117 width=107) (actual time=78.955..967.435 rows=1000000.00 loops=1)
Buffers: shared hit=119782 read=49433
I/O Timings: shared read=643.876
Planning Time: 5.525 ms
Serialization: time=106.633 ms output=118165kB format=text
Execution Time: 1116.107 ms
总缓冲区增加了近 10 倍,执行时间增加了近 5 倍。诚然,部分时间变慢是由于需要从磁盘或操作系统缓存读取一些数据。然而,当数据膨胀时,这自然会随之发生,所以对我来说这并不算不公平。
这个示例查询只对我们的表进行了顺序扫描,因此只从堆中读取数据。在探讨解决问题的方法之前,让我们快速看一个索引示例。
sql
-- 收集统计信息,帮助规划器选择索引扫描
ANALYZE read_efficiency_demo;
EXPLAIN (ANALYZE, BUFFERS, SERIALIZE)
SELECT text1 FROM read_efficiency_demo where id < 9_001_000;
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------------
Index Scan using read_efficiency_demo_pkey on read_efficiency_demo (cost=0.42..221.08 rows=723 width=33) (actual time=29.460..29.547 rows=999.00 loops=1)
Index Cond: (id < 9001000)
Index Searches: 1
Buffers: shared hit=24623
Planning:
Buffers: shared hit=24595
Planning Time: 74.871 ms
Serialization: time=0.032 ms output=38kB format=text
Execution Time: 29.657 ms
现在,只关注索引扫描的缓冲区读取,你可以看到它扫描了 24623 个页面来读取 999 行。
我们可以通过重建索引(reindex)来解决索引膨胀问题,使用 CONCURRENTLY 关键字可以在不锁表的情况下执行此操作:
sql
REINDEX INDEX CONCURRENTLY read_efficiency_demo_pkey;
如果我们再次检查大小,可以看到索引回到了最初的大小,但堆大小没有变化:
sql
SELECT pg_size_pretty(pg_relation_size('read_efficiency_demo')) heap_space,
pg_size_pretty(pg_relation_size('read_efficiency_demo_pkey')) index_space;
heap_space | 1347 MB
index_space | 21 MB
现在再次运行该查询:
sql
EXPLAIN (ANALYZE, BUFFERS, SERIALIZE)
SELECT text1 FROM read_efficiency_demo where id < 9_001_000;
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------------
Index Scan using read_efficiency_demo_pkey on read_efficiency_demo (cost=0.42..149.08 rows=723 width=33) (actual time=0.023..0.343 rows=999.00 loops=1)
Index Cond: (id < 9001000)
Index Searches: 1
Buffers: shared hit=33
Planning:
Buffers: shared hit=5
Planning Time: 0.216 ms
Serialization: time=0.114 ms output=38kB format=text
Execution Time: 0.590 ms
现在,只关注索引扫描的缓冲区读取,你可以看到它现在只需要扫描 33 个页面就能读取相同的数据。因此,执行时间也显著下降了。
虽然我们可以使用 VACUUM FULL(或 CLUSTER)来完全重建表及其索引(消除所有膨胀),但这些操作会持有重度锁 ,甚至会阻止并发读取!因此,有一些流行的扩展,如 pg_repack 和 pg_squeeze,可以在允许并发读写的同时消除膨胀。
在继续之前,值得指出的是,一定程度的膨胀是自然的。旧的行版本需要用来服务并发查询,而且运行清理(vacuum)有一定的成本,摊销一下是有好处的。许多系统即使所有东西都膨胀了 2 倍,也可能完全健康。然而,很多数据库最终会出现远高于健康水平的膨胀,特别是一些索引由于对索引中不太可能被重用的部分进行更新和删除,很容易变得极度膨胀。
一些常见的根本原因是:
- 一个长时间运行的事务阻止了清理
- 自动清理(autovacuum)跟不上(需要调优)
- 自动清理(autovacuum)已被关闭(全局或针对某个表)
所有这些(以及更多)在 Cybertec 的一篇优秀博客文章中都得到了很好的阐述。
数据局部性
如果你发现某个扫描的读取量过大,这不一定就是膨胀问题,也可能(部分地)是由于每个页面包含的相关行数,或者换句话说,我们需要的行在物理位置上有多"近"。
为了演示这一点,让我们查询之前为其中一个随机文本字段创建的数据,这些数据应该分散在整个堆中(并添加一个索引,至少使其更真实):
sql
CREATE INDEX text1_idx ON read_efficiency_demo (text1);
EXPLAIN (ANALYZE, BUFFERS, SERIALIZE)
SELECT id, text1 FROM read_efficiency_demo
ORDER BY text1 LIMIT 100;
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=0.42..73.97 rows=100 width=41) (actual time=0.031..0.248 rows=100.00 loops=1)
Buffers: shared hit=103
-> Index Scan using text1_idx on read_efficiency_demo (cost=0.42..733404.53 rows=997277 width=41) (actual time=0.029..0.226 rows=100.00 loops=1)
Index Searches: 1
Buffers: shared hit=103
Planning Time: 0.120 ms
Serialization: time=0.045 ms output=5kB format=text
Execution Time: 0.340 ms
虽然不是极度过量,但我们的索引扫描需要读取 103 个页面来返回 100 行。这比每页一行还要差,部分原因是这是一个两步过程(先在索引中查找,然后到堆中获取),部分原因是这些行在堆中是随机分散的。
在一个全新的"请勿在生产环境执行"剧集中,让我们拿出 CLUSTER 这把大锤,按照 text1 的顺序重建整个表,然后再次运行相同的查询:
sql
CLUSTER read_efficiency_demo USING text1_idx;
EXPLAIN (ANALYZE, BUFFERS, SERIALIZE)
SELECT id, text1 FROM read_efficiency_demo
ORDER BY text1 LIMIT 100;
QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=0.42..11.71 rows=100 width=41) (actual time=0.031..0.098 rows=100.00 loops=1)
Buffers: shared hit=5
-> Index Scan using text1_idx on read_efficiency_demo (cost=0.42..112807.32 rows=1000000 width=41) (actual time=0.029..0.075 rows=100.00 loops=1)
Index Searches: 1
Buffers: shared hit=5
Planning Time: 0.121 ms
Serialization: time=0.039 ms output=5kB format=text
Execution Time: 0.183 ms
这一次,Postgres 能够仅读取 5 个页面(索引和堆合计)就完成相同的查询。执行时间本来已经很快,但这次也因此减少了约一半。
数据局部性问题的一个常见表现形式是某些读取查询的性能随着时间的推移而下降。这在高写入负载下尤其明显,数据最初是按自然顺序(按插入时间)排列的,但更新后的新行版本最终会离得很远。
为了缓解这个问题,Postgres 确实有一个称为 HOT 更新 的优化,只要被更改的数据没有 被索引,并且页面上有可用空间,它允许新的行版本留在同一个页面 上。后一个条件意味着我们可用的另一个调优工具是表的 fillfactor 设置,它控制每个页面初始填充的满度(或者说,默认情况下留出多少空间)。
与某些数据库引擎不同,Postgres 没有自动将数据保持在特定顺序的方法。由于 CLUSTER 会持有如此重的锁,甚至阻止读取,我之前提到的扩展(pg_repack 和 pg_squeeze)具有等效的功能,并且锁定行为对正常运行时间更友好。
如果你进行批量插入,预先为它们显式设置一个顺序可能非常有益,这样你的数据至少一开始就以适合你查询的良好顺序存在。
我们拥有的另一个维护良好数据局部性的工具是分区 ------例如,在按时间分区的数据集中,更新旧数据将保留在原始分区中,而不再接收新数据的旧分区甚至可以重新排序以更好地适应工作负载(例如 TimescaleDB 的 reorder_chunk 功能)。
最后,你可能会认为这是作弊,另一个选择是为你重要的查询添加一个覆盖索引 (按照你需要的顺序)。这样,Postgres 可以使用超高效的 Index Only Scan ,扫描尽可能少的页面。与堆不同,Postgres 确实维护索引顺序(当然),但需要注意的是索引可能会膨胀------我们已经讨论过了!
结论
如果你发现某个查询随着时间的推移性能下降,并且查询计划中的缓冲区数量看起来过大,你可能遇到了膨胀和/或数据局部性问题。
检测问题:
- 检查
EXPLAIN ANALYZE中的缓冲区数量 - 监控堆和索引膨胀
补救问题:
- 对任何严重膨胀(或局部性退化)的表执行 repack 或 squeeze
- 对任何严重膨胀的索引进行重建(并发地!)
预防进一步问题:
- 确保自动清理(autovacuum)已开启
- 避免阻止自动清理(autovacuum)的事情(如长时间运行的事务)
- 调优自动清理(autovacuum)(使其运行更频繁)
- 对任何严重膨胀的索引进行重建(并发地)
- 尝试为重要查询维护数据局部性
- 添加覆盖索引(并同样,随时间推移进行重建)