PostgreSQL处理大表慢查询时,很多性能问题的根源都出在扫描范围过大上。当一张表的数据量达到几千万甚至上亿行,即使有索引,如果查询条件无法有效过滤,优化器依然可能选择全表扫描或者扫描大量无效数据。分区表通过把一张逻辑大表拆分成多个物理小表,让查询只访问相关的分区,从源头上缩小扫描范围。这篇文章围绕PostgreSQL的分区机制展开,讲清楚分区裁剪的原理、常见的分区方式、落地时的建表细节以及容易踩的坑。

为什么分区能减少扫描范围:分区裁剪的原理
PostgreSQL从10版本开始引入声明式分区,12版本之后逐步完善了对主键、外键的支持。它的核心机制叫分区裁剪(partition pruning):当查询条件中包含分区键时,规划器会在规划阶段直接排除掉不可能包含目标数据的分区,实际执行时根本不会去碰这些分区对应的物理文件。
举个直观的例子。假设有一张订单表按月做了范围分区,一共分成了36个月的分区。执行下面这条查询时:
SELECT count(*) FROM orders WHERE created_at >= '2024-03-01' AND created_at < '2024-04-01';
因为created_at是分区键,规划器能确定3月份的数据只可能落在2024_03这个分区里,其余35个分区会被直接裁剪掉。用EXPLAIN可以看到执行计划里只出现了一个分区,扫描的数据量从全表的几亿行直接降到单月分区的几百万行,这个量级的差距不是加一两个索引能弥补的。
分区裁剪分两种:一种发生在规划期,也就是上面这种情况;另一种发生在执行期,当条件是运行时才能确定的参数时(比如Prepared Statement的参数),数据库会在执行阶段动态裁剪。执行期裁剪同样有效,但规划期裁剪能生成更精确的计划,所以尽量让查询条件里直接带上分区键的常量或可推导的表达式。
需要特别注意的是,如果查询条件对分区键做了函数包装,比如写成date_trunc('month', created_at) = '2024-03-01',裁剪可能失效,因为规划器无法从函数结果反推出分区键的取值范围。这类写法是分区表慢查询回潮的常见原因。
按时间范围分区:大表最常见的落地方案
时间序列数据是最适合分区的场景,订单、日志、埋点、消息记录都属于这一类,因为查询几乎总是限定在某段时间内,而且旧数据可以按分区整体归档删除。下面是一个完整的按月范围分区建表语句:
-- 创建分区母表,分区键必须包含在主键里
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY,
order_no varchar(64) NOT NULL,
user_id bigint NOT NULL,
amount numeric(12,2) NOT NULL,
created_at timestamptz NOT NULL,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
-- 创建具体分区
CREATE TABLE orders_2024_01 PARTITION OF orders
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE orders_2024_02 PARTITION OF orders
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
CREATE TABLE orders_2024_03 PARTITION OF orders
FOR VALUES FROM ('2024-03-01') TO ('2024-04-01');
-- 为高频查询条件建索引,索引会自动下发到每个分区
CREATE INDEX idx_orders_user ON orders (user_id, created_at);
-- 创建默认分区兜底,避免插入超出范围的数据报错
CREATE TABLE orders_default PARTITION OF orders DEFAULT;
这里有几个细节值得展开。第一,分区表的主键必须包含分区键,所以上面的主键是(id, created_at)而不是单独的id,这会导致全局唯一性只能靠id配合created_at保证,业务上要能接受这一点。第二,在母表上建的索引会自动同步到所有现存分区,之后新建的分区也会自动继承,不需要逐个分区手动建。第三,DEFAULT分区是兜底用的,但它有个隐患:对DEFAULT分区以外的分区做维护操作时可能锁住DEFAULT分区,生产上更推荐提前批量创建好未来几个月的分区,配合定时任务自动维护。
分区数量也要控制。官方建议单表分区数在几百个以内比较稳妥,分区太多会拉长规划时间,因为优化器要评估每个分区的计划。如果按月不够细,可以先按月,等单分区数据量还是太大再考虑按周,不要一上来就按天切出上千个分区。
哈希分区与查询写法:让裁剪真正生效的关键
如果查询不是按时间走,而是集中在某个用户或某个租户上,范围分区就不合适了,这时候用哈希分区更合理:
CREATE TABLE user_events (
id bigint GENERATED ALWAYS AS IDENTITY,
user_id bigint NOT NULL,
event text NOT NULL,
payload jsonb,
PRIMARY KEY (id, user_id)
) PARTITION BY HASH (user_id);
-- 创建8个哈希分区
CREATE TABLE user_events_0 PARTITION OF user_events
FOR VALUES WITH (MODULUS 8, REMAINDER 0);
CREATE TABLE user_events_1 PARTITION OF user_events
FOR VALUES WITH (MODULUS 8, REMAINDER 1);
-- 以此类推到 user_events_7
哈希分区下,查询条件WHERE user_id = 12345会通过内部哈希函数直接定位到某一个分区,扫描范围缩小为原来的八分之一,而且数据分布均匀,不会出现范围分区常见的热点问题。但哈希分区的缺点是不支持按范围裁剪,做时间范围统计时仍然要扫全部分区,所以选择哪种分区方式,本质上取决于查询条件里最稳定的过滤维度是什么。
最后整理几条让分区真正发挥作用的实践要点:
- 查询条件必须命中分区键:分区裁剪只在条件包含分区键本身时生效,
WHERE order_no = 'xxx'这种条件再精准也裁剪不了时间分区,仍需依赖索引。 - 避免对分区键做函数或隐式类型转换:timestamptz字段用字符串比较时注意时区设置,隐式转换可能导致裁剪失效,先用
EXPLAIN确认计划里的分区数量。 - 分区和索引是互补而不是二选一:分区负责把扫描范围从TB级缩到GB级,分区内的索引再负责精确定位到行,两者配合才有最好的效果。
- 定期清理旧分区:
DROP TABLE orders_2022_01秒级完成,比DELETE删除千万行数据快几个数量级,还不会产生膨胀,这是分区表在数据生命周期管理上的额外红利。
总结一下,分区表优化慢查询的本质是把一次大扫描拆解成多次可选的小扫描,规划器通过分区裁剪跳过无关数据。落地前先用EXPLAIN分析现有慢查询的过滤条件,找到最高频的过滤维度作为分区键,再决定用范围还是哈希分区,这样才能确保优化真正命中查询模式,而不是为了分区而分区。
PostgreSQL慢查询分区表优化表分区修改时间:2026-09-08 20:59:25