别只会“加索引”了!这 3 个 PostgreSQL 反常识优化,能把性能和成本一起打下来

37 阅读6分钟

image

数据库性能优化这事儿,很多人条件反射就三板斧:改 SQL、加索引、再加索引。一通操作下来,查询快了,磁盘炸了;延迟降了,维护成本上天;更扎心的是——你还以为自己“优化得很专业”。😅

这篇文章的思路很“叛逆”:与其在常规套路里打转,不如换个角度,利用 PostgreSQL 本身的一些机制做“非常规优化”。下面挑 3 个最容易落地、同时最容易被忽略的点,讲透它们为什么能省钱又提速。


1)别再全表扫了

先看一个特别真实的场景:用户表只有两种 plan:freepro,并且写了约束,保证不会出现别的值。

CREATE TABLE users (
    id INT PRIMARY KEY,
    username TEXT NOT NULL,
    plan TEXT NOT NULL,
    CONSTRAINT plan_check CHECK (plan IN ('free', 'pro'))
);

然后某位大佬在报表工具里一顿操作猛如虎,写了个:

SELECT * FROM users WHERE plan = 'Pro';

注意大小写:Propro。结果当然是 0 行。问题是——它为了得到“0 行”,居然可能 把全表扫了一遍,这就很离谱:明明约束告诉你“根本不可能有 Pro”,你还扫什么扫?扫得我 CPU 风扇都快起飞了。

这时可以打开一个“看起来冷门但对报表场景很香”的开关:constraint_exclusion

SET constraint_exclusion to 'on';

开启后,PostgreSQL 会在生成执行计划时参考约束信息,发现条件永远为假,就直接变成“秒回 0 行”,彻底避免无意义的 Seq Scan。

为什么它默认不是 on?因为它会增加规划阶段开销:对“系统自动生成的简单查询”,大概率用不上;但对 BI/报表这种人肉手写 SQL的场景,写错值、写错条件太常见了。 结论很直白:如果你的数据库经常被报表工具/分析师/临时查询折腾,考虑在报表环境把它打开,能省不少冤枉资源。


2)只要“按天统计”,就别用“精确到秒”的索引

函数索引省 3 倍空间还更快

第二个场景更像日常优化现场:10M 的销售表 sale,分析师要做按天汇总:

SELECT date_trunc('day', sold_at AT TIME ZONE 'UTC'), SUM(charged)
FROM sale
WHERE '2025-01-01 UTC' <= sold_at AND sold_at < '2025-02-01 UTC'
GROUP BY 1;

大家的第一反应:给 sold_at 上 B-Tree 索引!

CREATE INDEX sale_sold_at_ix ON sale(sold_at);

查询确实快了,但你一看索引体积——214MB,心里也跟着“咯噔”一下:为了按天统计,建了个精确到毫秒级的索引,这属于用大炮打蚊子

更聪明的做法是:只索引“天”,别索引“秒”。直接上表达式索引(函数索引):

CREATE INDEX sale_sold_at_date_ix ON sale((date_trunc('day', sold_at AT TIME ZONE 'UTC'))::date);

然后把查询写成同样表达式:

SELECT date_trunc('day', sold_at AT TIME ZONE 'UTC'), SUM(charged)
FROM sale
WHERE date_trunc('day', sold_at AT TIME ZONE 'UTC')::date BETWEEN '2025-01-01' AND '2025-01-31'
GROUP BY 1;

结果:索引体积从 214MB 变成 66MB,直接小了 3 倍多,还更快。原因不只是 datetimestamptz 小,而是离散值更少,B-Tree 可以做去重优化(deduplication),索引变得更紧凑。

但函数索引有个“脾气”:表达式得一模一样,稍微换个写法就可能用不上索引,比如把 date_trunc 换成 ::date,就直接退化回 Seq Scan。现实里让全团队“严格写同一表达式”,基本等同于要求大家每天不犯错(这事比上班准点还难🙂)。

解决方案有两种:

方案 A:View,把表达式固化成列

CREATE VIEW v_sale AS
SELECT *, date_trunc('day', sold_at AT TIME ZONE 'UTC')::date AS sold_at_date
FROM sale;

方案 B:Generated Column(更像“官方自带的 view 列”)

从 PostgreSQL 14 起支持生成列;文章里提到 PostgreSQL 18 还支持虚拟生成列:看起来是列,实际上是每次访问时计算的表达式,既保证表达式一致,又不额外存储(主打一个“既要又要”)。

ALTER TABLE sale ADD sold_at_date DATE
GENERATED ALWAYS AS (date_trunc('day', sold_at AT TIME ZONE 'UTC'));

然后查询就统一写:

SELECT sold_at_date, SUM(charged)
FROM sale
WHERE sold_at_date BETWEEN '2025-01-01' AND '2025-01-31'
GROUP BY 1;

这类优化特别适合那种“指标按天/按周/按月统计”的系统:别让索引为你用不到的精度买单


3)长 URL 唯一约束把索引撑爆?

用排他约束 + Hash 索引,5 倍缩容(但有坑)

当你要对一个超长字段(比如 URL)做唯一约束时,B-Tree 索引可能接近表本体大小,因为 B-Tree 叶子节点会存储被索引值本身。URL 又长又几乎不重复,索引很容易“胖成球”。

那能不能用 Hash 索引?Hash 索引存的是 hash 值,通常小很多。问题来了:PostgreSQL 的 Hash 索引 不支持 unique index

CREATE UNIQUE INDEX urls_url_unique_hash ON urls USING HASH(url);
-- ERROR: access method "hash" does not support unique indexes

但 PostgreSQL 还有个很少人用、名字很霸气的约束:Exclusion Constraint(排他约束)。它能配合 Hash 索引做“等值排他”,效果等同唯一约束:

ALTER TABLE urls ADD CONSTRAINT urls_url_unique_hash EXCLUDE USING HASH (url WITH =);

于是你得到了一个“用 Hash 索引实现的唯一性”。索引体积可能从 154MB 掉到 32MB,约 5 倍缩水,而且等值查询同样能用索引:

SELECT * FROM urls WHERE url = 'https://hakibenita.com';

不过它不是银弹,有几个硬坑必须知道:

  • 不能被外键引用:外键要求引用“唯一约束”,而排他约束不算传统意义的 unique constraint,所以引用会失败。
  • INSERT ... ON CONFLICT 有限制ON CONFLICT (url) 可能不认;需要写 ON CONFLICT ON CONSTRAINT ...;更糟的是 DO UPDATE 不支持排他约束。
  • 更通用的替代写法是用 MERGE(如果你的版本支持):
MERGE INTO urls t
USING (VALUES (1000004, 'https://hakibenita.com')) AS s(id, url)
ON t.url = s.url
WHEN MATCHED THEN UPDATE SET id = s.id
WHEN NOT MATCHED THEN INSERT (id, url) VALUES (s.id, s.url);

适用场景一句话总结:超长字符串字段需要唯一性,但不需要被外键引用,且写入冲突处理可以接受用 MERGE/业务层逻辑替代


结语

真正的优化,不是“更快”,而是“更合适”✅

这 3 个技巧的共同点很朴素: 不是让数据库“更努力”,而是让数据库别做无意义的事

  • 报表查错值?让约束帮你秒判 false,别全表扫
  • 只按天统计?索引就按天建,别为秒级精度付账
  • 长字段唯一性撑爆索引?换思路,用排他约束把 Hash 索引用起来

下次你准备“再加一个索引”之前,不妨先问一句: 需求到底需要多精?这条查询真的值得我为它养一个 200MB 的索引吗? 能把性能、成本、维护复杂度一起优化的,才是最爽的优化😉


喜欢就奖励一个“👍”和“在看”呗~

image