理解 tuple 在表中的稀疏存储

本页目录

img

今天我们来讨论元组以及元组在页面中的位置——这是相当基础的内容,但在许多情况下非常有用。

了解表中行的物理布局在某些情况下可能非常重要,尤其是在进行性能优化时。

一些术语

元组头部中的有趣的"隐藏"或"系统"列 (文档):

ctid

如果想了解元组的存储稀疏程度,我们可以将 ctid 包含在查询的 SELECT 子句中。例如,我们有以下表和一个简单查询:

PSQL
nik=# \d t1
                      Table "public.t1"
 Column  |       Type       | Collation | Nullable | Default
---------+------------------+-----------+----------+---------
 id      | bigint           |           | not null |
 user_id | double precision |           |          |
Indexes:
    "t1_pkey" PRIMARY KEY, btree (id)
    "t1_user_id_idx" btree (user_id)

nik=# select * from t1 where user_id = 101469;
   id   | user_id
--------+---------
  28414 |  101469
 235702 |  101469
 478876 |  101469
 495042 |  101469
 555593 |  101469
 626491 |  101469
 635785 |  101469
 702725 |  101469
(8 rows)

为了了解这些行的物理位置,只需将 ctid 包含到相同查询的 SELECT 子句中:

PSQL
nik=# select ctid, * from t1 where user_id = 101469;
    ctid    |   id   | user_id
------------+--------+---------
 (153,109)  |  28414 |  101469
 (1274,12)  | 235702 |  101469
 (2588,96)  | 478876 |  101469
 (2675,167) | 495042 |  101469
 (3003,38)  | 555593 |  101469
 (3386,81)  | 626491 |  101469
 (3436,125) | 635785 |  101469
 (3798,95)  | 702725 |  101469
(8 rows)

每行存储在不同的页面中 (153, 1274 等)。这种情况对查询性能并不是最优的 — 需要大量的 IO 操作。

如果我们想查看页面 1274 中有哪些元组,我们可以通过将 ctid 转换为 point 类型 (通过文本转换,因为无法直接转换),并提取第一个数字 — 页面编号来实现:

PSQL
nik=# select ctid, * from t1 where (ctid::text::point)[0] = 1274 order by ctid limit 15;
   ctid    |   id   | user_id
-----------+--------+---------
 (1274,1)  | 235691 |  225680
 (1274,2)  | 235692 |  617397
 (1274,3)  | 235693 |  233968
 (1274,4)  | 235694 |  714957
 (1274,5)  | 235695 |  742857
 (1274,6)  | 235696 |  837441
 (1274,7)  | 235697 |  745413
 (1274,8)  | 235698 |  752474
 (1274,9)  | 235699 |  102335
 (1274,10) | 235700 |  908715
 (1274,11) | 235701 |  909036
 (1274,12) | 235702 |  101469
 (1274,13) | 235703 |  599451
 (1274,14) | 235704 |  359470
 (1274,15) | 235705 |  196437
(15 rows)

nik=# select count(*) from t1 where (ctid::text::point)[0] = 1274;
 count
-------
   185
(1 row)

总体上有 185 行,user_id=101469 的行也在其中,位于第 12 个偏移量处。

(注意,然而,对于大表来说,这将是一个非常慢的查询,因为它需要进行顺序扫描 (Seq Scan),并且我们不能在 ctid 或其他系统列上创建索引。但对于那些目标是查找特定 ctid 的查询 (例如 ...where ctid = '(123, 456)'),由于使用了 Tid Scan,性能会很好,参照 https://pgmustard.com/docs/explain/tid-scan)。

ctid 与执行计划中的 BUFFERS 指标

确认原始查询确实涉及到了许多缓冲区操作 (见第一天我们讨论了 BUFFERS 的重要性):

EXPLAIN
nik=# explain (analyze, buffers, costs off) select * from t1 where user_id = 101469;
                                                     QUERY PLAN
---------------------------------------------------------------------------------------------------------------------
 Index Scan using t1_user_id_idx on t1  (actual time=0.131..0.367 rows=8 loops=1)
   Index Cond: (user_id = '101469'::double precision)
   Buffers: shared hit=11
 Planning Time: 0.159 ms
 Execution Time: 0.383 ms
(5 rows)

11 次缓冲区命中,对应 88 KiB 的缓冲区访问量。下面的查询可以计算这 8 行数据的大小,这是上游示例中的结果:

PSQL
nik=# select sum(pg_column_size(t1.*)) from t1 where user_id = 101469;
 sum
-----
 317
(1 row)

也就是说,为返回 317 字节的数据,执行器处理了约 88 KiB 的缓冲区内容。这里使用的是 Index Scan,部分访问来自索引,部分来自堆表。这是缓冲区访问量,不应直接理解为实际磁盘读取量。

如何改善?

选项 0:什么也不做,但了解正在发生什么。可能你不需要进行显著改进,因为以下讨论的任何选项都不完美。避免过度优化,但要了解元组分布的稀疏程度,并准备好重新检查它。在某些情况下,目标元组存储过于稀疏可能是导致查询性能下降的一个重要因素,甚至可能导致超时。在这种情况下,可以考虑以下策略。

选项 1:保持表和索引的良好状态:

选项 2:使用索引仅扫描,而不是索引扫描。可以通过使用多列索引或覆盖索引来实现,以包含查询所需的所有列。对于我们的示例:

EXPLAIN
nik=# create index on t1(user_id) include (id);
CREATE INDEX

nik=# explain (analyze, buffers, costs off) select * from t1 where user_id = 101469;
                                       QUERY PLAN
-----------------------------------------------------------------------------------------
 Index Only Scan using t1_user_id_id_idx on t1 (actual time=0.040..0.044 rows=8 loops=1)
   Index Cond: (user_id = '101469'::double precision)
   Heap Fetches: 0
   Buffers: shared hit=4
 Planning Time: 0.179 ms
 Execution Time: 0.072 ms
(6 rows)

4 次缓冲区命中,而不是 11 次,效果更好。

选项 3:根据索引或列值对表进行物理重组:这是对表进行物理重组,但它有两个缺点:

有两种方法可以重组表:

以我们的示例为例:

EXPLAIN
nik=# cluster t1 using t1_user_id_idx;
CLUSTER

nik=# explain (analyze, buffers, costs off) select * from t1 where user_id = 101469;
                                   QUERY PLAN
---------------------------------------------------------------------------------
 Index Scan using t1_user_id_idx on t1 (actual time=0.035..0.040 rows=8 loops=1)
   Index Cond: (user_id = '101469'::double precision)
   Buffers: shared hit=4
 Planning Time: 0.141 ms
 Execution Time: 0.070 ms
(5 rows)

同样是 4 次缓冲区命中,与覆盖索引和 Index-Only Scan 的效果相同。不过,此处我们有的是 Index Scan。


今天的讨论到此为止——我们讨论了 ctid。以后,我们会继续探讨 xmin 和 xmax,以及出于实际原因对表/索引页面的深入检查。

别忘了,欢迎订阅、分享、点赞! 此外,我在 GitLab 上同步了这些技巧: https://gitlab.com/postgres-ai/postgresql-consulting/postgres-howtos/

讨论与补充

在 GitHub 查看讨论

支持作者

感谢支持 PostgreSQL Howto 的长期维护。

微信赞赏码

读者交流群

二维码若已过期,可通过公众号 PostgreSQL 学徒或邮件联系。

PostgreSQL 学徒读者交流群二维码