pg_column_size(): 眼见不一定为实

2 阅读5分钟

pg_column_size(): 眼见不一定为实

摘要: 本文探讨了 PostgreSQL 的 pg_column_size() 函数,并揭示了一个令人惊讶的行为:对于以行外方式存储的 TOASTed 值,该函数仅返回 18 字节的指针大小,而非实际数据大小,这可能导致在估算表存储需求时出现重大误差。

原文链接

感谢我的同事 Ozair,他给我发了一张 JIRA 工单说"我需要删除那个大字段,有什么后果?"我的第一个问题是:有多大?就在这时候,发现了这个问题。

看起来很简单。确实也很简单。只需使用管理函数 pg_column_size()。直到你遇到 toast 属性。这时候就有意思了。

一点历史

pg_column_size() 由 Mark Kirkwood 在 PostgreSQL 8.1 中添加(commit a9236028)。发布说明简单写道:

添加 pg_column_size() (Mark Kirkwood)

没什么特别的。就是一个默默工作了二十年的有用管理函数。

基本用法

函数签名很简单:

pg_column_size(any) -> integer

它返回存储给定值所使用的字节数。让我们从显而易见的用例开始。

定长类型

select pg_column_size(1::smallint),
       pg_column_size(1::integer),
       pg_column_size(1::bigint);
 pg_column_size | pg_column_size | pg_column_size
----------------+----------------+----------------
              2 |              4 |              8

不出所料。定长类型总是返回其类型大小。

变长类型

create table t (id integer, content text);
insert into t values (1, 'hello');
insert into t values (2, repeat('x', 10));
insert into t values (3, repeat('x', 100));
select id, pg_column_size(content) from t;
 id | pg_column_size
----+----------------
  1 |              6
  2 |             11
  3 |            101

大小反映了实际内容长度加上 varlena 头。到目前为止一切正常。

TOAST 开始介入

当值超过 TOAST1 阈值时,PostgreSQL 首先尝试行内压缩。如果压缩后能放入,就留在主元组中:

insert into t values (4, repeat('x', 10000));
select id, pg_column_size(content), length(content) from t where id = 4;
 id | pg_column_size | length
----+----------------+--------
  4 |            125 |  10000

用 125 字节存储 10000 个字符。值仍在主元组中,已压缩。如果压缩不够,PostgreSQL 将值移出行外到单独的 TOAST 表。剩下的就是一个指针,总是恰好 18 字节。我尝试用重复的文本数据来演示。PostgreSQL 的压缩效果足够好,以至于我无法通过这种方式触发行外存储。想一想,这其实是一个特性。

文档问题

官方文档说:

显示存储任何单个数据值所使用的字节数。如果直接应用于表列,则反映任何已应用的压缩。

最后一句话作用很大。"任何已应用的压缩"。好的。但是 toast 属性呢?文档只字未提。

这很重要。

源代码实际说了什么

让我们看看 src/backend/utils/adt/varlena.c。对于 varlena 类型,pg_column_size() 委托给 toast_datum_size()

/* varlena type, possibly toasted */
result = toast_datum_size(value);

src/backend/access/common/detoast.ctoast_datum_size() 的注释明确写道:

返回 varlena 数据的物理存储大小(可能已压缩)

物理存储大小。不是逻辑大小。也不是未压缩大小。

对于行外存储的 toast 值,主元组中剩下的是一个 TOAST 指针,总是恰好 18 字节,无论原始值有多大。

解读聚合结果

这就是陷阱闭合的地方。

真正的信号是物理大小和逻辑大小之间的差距:

select avg(pg_column_size(content)) as physical_avg,
       avg(length(content))         as logical_avg
from t;

大差距意味着 TOAST 压缩在起作用。小差距意味着值确实很小,或者压缩没起什么作用。

还有一点:pg_column_size(NULL) 返回 NULL。它是一个普通函数,不是聚合函数。因此,列为 NULL 的行会被静默排除在 avg() 之外。你的平均值只反映非 NULL 行。如果你的列有很多 NULL,这个平均值比看起来的代表性要差。一定要同时检查非 NULL 比率。

大表上的 TABLESAMPLE

当你在大型生产表上运行 pg_column_size() 进行诊断时,不要对整个表运行。扫描数百万行代价很高。使用 TABLESAMPLE:

-- Bernoulli:随机行级采样,约 1% 的行
select avg(pg_column_size(content))             as physical_avg,
       avg(length(content))                     as logical_avg,
       count(content)::float / count(*)         as non_null_ratio
from t tablesample bernoulli(1);

-- System:块级采样,更快,随机性更差,约 0.1% 的块
select avg(pg_column_size(content))             as physical_avg,
       avg(length(content))                     as logical_avg,
       count(content)::float / count(*)         as non_null_ratio
from t tablesample system(0.1);

BERNOULLI(1) 给你一个 proper 统计样本,每行有 1% 的概率被选中。SYSTEM(0.1) 更快,因为它在块级别采样,但样本不那么均匀。对于粗略的平均值,两者都可以用。对于要放在报告里的东西,使用 BERNOULLI

补丁

文档应该把这个说清楚。现在的措辞让任何处理 toast 列的人都摸不着头脑。

这是为 func.sgmlpg_column_size() 条目提出的澄清:

对于变长(varlena)类型,此函数返回物理存储大小。对于行内存储在主元组中的值,这反映了实际数据大小,包括任何已应用的 TOAST 压缩。对于行外存储的 toast 值,这返回的是 TOAST 指针的大小(18 字节),而不是原始数据的大小。要获取值的逻辑大小,根据类型使用 length()octet_length()

我计划将此作为文档补丁提交。如果你之前踩过这个坑,或者对措辞有想法,我很乐意反馈。可以在 pgsql-hackers 列表或 Bluesky 上找到我。

Footnotes

  1. TOAST(The Oversized-Attribute Storage Technique,超大属性存储技术)是 PostgreSQL 存储大值的机制。当一行超过大小阈值(默认 2kB)时,PostgreSQL 压缩和/或移动大列值到单独的 TOAST 表,只在主元组中存储一个指针。详见官方文档