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.c 中 toast_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.sgml 中 pg_column_size() 条目提出的澄清:
对于变长(varlena)类型,此函数返回物理存储大小。对于行内存储在主元组中的值,这反映了实际数据大小,包括任何已应用的 TOAST 压缩。对于行外存储的 toast 值,这返回的是 TOAST 指针的大小(18 字节),而不是原始数据的大小。要获取值的逻辑大小,根据类型使用
length()或octet_length()。
我计划将此作为文档补丁提交。如果你之前踩过这个坑,或者对措辞有想法,我很乐意反馈。可以在 pgsql-hackers 列表或 Bluesky 上找到我。