表分区(PARTITION BY)
本教程共 50 篇 · 第 48 篇 · 更新于 2026-07-31 · 约 8 分钟阅读
48. 表分区(PARTITION BY)
本节目标:学完你能用 PARTITION BY 把一张大表按范围、列表或哈希拆成若干分区,并懂得日常管理分区和它与表继承的区别。
当一张表涨到几千万行,查询和运维都会变慢。表分区(Partitioning)的思路是:逻辑上还是一张表,物理上把它拆成多个小「分区」。查询时 PG 会自动只扫相关的分区,这叫「分区裁剪(Partition Pruning)」,能省下大量 IO。
PostgreSQL 10 之后主推的是声明式分区(Declarative Partitioning),写法就是在建表时加 PARTITION BY。更早的 9.x 时代要靠触发器手动路由,现在完全不用了。
Note分区不是「银弹」。表只有几万行时分区反而增加规划开销。一般经验:单表超过千万行、或按时间/地区访问模式明显时,才值得分区。
三种分区策略
- RANGE(范围):按一个连续的范围切,最常见的是按时间,比如按月存订单。
- LIST(列表):按某个列的离散取值切,比如按地区、按状态。
- HASH(哈希):按哈希值取模均分,适合没有明显范围规律、只想把数据打散的场景。
RANGE 分区:按时间拆订单
父表只定义结构,不存数据,数据都落在分区里。
-- 父表:按 created_at 做范围分区
CREATE TABLE orders (
id INT GENERATED ALWAYS AS IDENTITY,
user_id INT,
amount NUMERIC(10,2),
status TEXT,
created_at DATE
) PARTITION BY RANGE (created_at);
-- 2024 年的数据进这个分区
CREATE TABLE orders_2024 PARTITION OF orders
FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
-- 2025 年的数据进这个分区
CREATE TABLE orders_2025 PARTITION OF orders
FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');
插入时你只管写父表,PG 按 created_at 自动路由:
postgres=# INSERT INTO orders(user_id, amount, status, created_at)
VALUES (1, 99.00, 'paid', '2025-03-12');
postgres=# SELECT * FROM orders_2025; -- 这条记录就在这里
Tip
FROM ... TO ...是「左闭右开」区间,包含起点、不含终点。TO ('2025-01-01')表示只到 2024-12-31。
分区裁剪:只扫相关分区
分区最大的好处是裁剪。把 EXPLAIN 打在查询前,能看到 PG 只访问匹配的分区:
postgres=# EXPLAIN SELECT * FROM orders WHERE created_at = '2025-03-12';
-- 执行计划里只列出 orders_2025,不会去碰 orders_2024
如果查询没带分区键条件,PG 就只能扫全部分区,分区的优势就没了。所以分区键一定要选进最常用的查询条件。
默认分区兜底
如果插了一条分区键不在任何分区范围内的值,PG 会报错。可以建一个「默认分区」接住漏网之鱼:
CREATE TABLE orders_default PARTITION OF orders
FOR VALUES FROM (MINVALUE) TO (MAXVALUE);
不过默认分区一建,新加的具体分区要和它「抢范围」,PG 会要求默认分区里不能已经存在该范围的数据,否则 ATTACH 会失败。
LIST 分区:按地区拆用户
CREATE TABLE users_by_region (
id INT GENERATED ALWAYS AS IDENTITY,
username TEXT,
email TEXT,
age INT,
created_at DATE,
region TEXT
) PARTITION BY LIST (region);
CREATE TABLE users_east PARTITION OF users_by_region
FOR VALUES IN ('east', 'north');
CREATE TABLE users_west PARTITION OF users_by_region
FOR VALUES IN ('west', 'south');
IN (...) 里写死该分区接纳哪些取值,不在清单里的值插入会报错(除非有默认分区)。
HASH 分区:把数据打散
HASH 分区用「取模 + 余数」来分配,常用于没有明显业务范围的均分,比如日志、传感器数据。
CREATE TABLE sensor_readings (
id INT GENERATED ALWAYS AS IDENTITY,
payload TEXT,
created_at DATE
) PARTITION BY HASH (id);
-- 4 个分区,modulus 都是 4,remainder 依次是 0、1、2、3
CREATE TABLE sensor_readings_0 PARTITION OF sensor_readings
FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE sensor_readings_1 PARTITION OF sensor_readings
FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE sensor_readings_2 PARTITION OF sensor_readings
FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE sensor_readings_3 PARTITION OF sensor_readings
FOR VALUES WITH (MODULUS 4, REMAINDER 3);
MODULUS 必须是所有分区一致的同一个数,REMAINDER 在每个分区里各不相同,覆盖 0 到 modulus-1。HASH 分区没有范围概念,查询条件用「等于」才能命中单个分区,范围条件同样会扫全部。
分区键的更多玩法
除了单列,分区键还能用表达式,比如按「年」分区而不是按具体日期:
CREATE TABLE orders_by_year (
id INT GENERATED ALWAYS AS IDENTITY,
user_id INT,
amount NUMERIC(10,2),
created_at DATE
) PARTITION BY RANGE (EXTRACT(YEAR FROM created_at));
-- 表达式分区键,分区范围写成年份数字
CREATE TABLE orders_y2024 PARTITION OF orders_by_year
FOR VALUES FROM (2024) TO (2025);
Note用表达式当分区键时,查询条件也得写成同样的表达式形式,分区裁剪才能命中。直接用
created_at过滤可能命不中分区键。
主键、唯一约束与分区键
声明式分区有个硬规矩:主键(PRIMARY KEY)和唯一约束(UNIQUE)必须包含分区键列。原因很好懂——约束要在每个分区内独立保证,如果不含分区键,PG 没法跨分区确认唯一性。
-- 错误写法:主键只含 id,不含分区键 created_at
CREATE TABLE orders (
id INT,
created_at DATE,
PRIMARY KEY (id)
) PARTITION BY RANGE (created_at);
-- 报错:主键必须包含分区键
-- 正确写法:把分区键加进主键
CREATE TABLE orders (
id INT,
created_at DATE,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
UPDATE 可能把行搬到别的分区
分区键的值是可以改的。但改了分区键,行可能不再适合当前分区,PG 会把它「搬」到正确的分区:
-- 把 2024 年的某条订单改到 2025
postgres=# UPDATE orders SET created_at = '2025-06-01' WHERE id = 5;
-- 这条记录会从 orders_2024 移到 orders_2025
如果目标分区不存在,这条 UPDATE 会报错。所以改分区键要慎重,高频改分区键的列不适合当分区键。
分区表的日常管理
加新分区(ATTACH)
新的一年到了,加个新分区:
postgres=# CREATE TABLE orders_2026 (LIKE orders INCLUDING DEFAULTS);
postgres=# ALTER TABLE orders ATTACH PARTITION orders_2026
FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');
如果你的分区表已经是 RANGE/LIST,而新分区已经有数据,ATTACH 时 PG 会校验这些数据是否都落在指定范围内,不合规会报错。这其实是个保护,防止脏数据混进错的分区。
摘掉旧分区(DETACH)
旧数据要归档,把分区摘出去、父表就不再管它,但数据还在原表里,可以单独搬走或备份:
postgres=# ALTER TABLE orders DETACH PARTITION orders_2024;
DETACH 之后 orders_2024 变成一张普通表,跟父表再无关系。
一次性查看所有分区
想知道一张分区表下挂了哪些分区,查系统视图:
postgres=# SELECT inhrelid::regclass AS partition
FROM pg_inherits
WHERE inhparent = 'orders'::regclass;
Tip分区越来越多时,建议用定时任务(如 cron 或调度扩展)提前建好未来几个月的分区,别等数据插不进去才手忙脚乱建。
Note给分区加索引,建议在父表上建,PG 会自动把索引同步到每个分区;主键和唯一约束必须包含分区键列,否则建不了(见上文)。
和表继承(INHERITS)的区别
PostgreSQL 还有一种更老的做法叫表继承(Table Inheritance),用 CREATE TABLE 子表 INHERITS (父表) 让子表继承父表结构。但它和声明式分区是两码事,本文只把它当「反面教材」对照:
- 表继承不会自动按值把数据路由到子表,插入父表不会自动分发,你得自己写触发器。
- 表继承没有查询时的分区裁剪,查父表照样扫所有子表。
- 表继承对唯一约束和外键支持不完整,官方也不推荐用它来当分区用。
Warning现在要做「大表拆小表」这种分区需求,一律用本文的声明式分区(PARTITION BY)。表继承只适合「子类复用父类结构」这种面向对象式的建模,别拿它当分区方案。
常见误区
- 以为分区键可以随便选:选不进查询条件的列当分区键,等于白分,查询照样扫全表。
- 忘记主键要含分区键:建主键报「必须包含分区键」,把分区键加进主键即可。
- 给小表强行分区:千万行以下通常没必要,反而拖慢规划。
- 忘记建默认分区:插入越界数据直接报错,按业务决定要不要默认分区兜底。
- 以为分区能自动提速:前提是查询带上分区键,否则裁剪不发生,该慢还慢。
- 随意改分区键列:可能触发行跨分区迁移,甚至因目标分区不存在而报错。