Index Scan using events_pkey on events (actual rows=1.00 loops=1)
Index Cond: (id = 1234567)
Buffers: shared hit=3 read=4
Planning:
Buffers: shared hit=22 read=1
Append (actual rows=1.00 loops=1)
Buffers: shared hit=31 read=8
-> Index Scan using events_p_2025_10_pkey on ... (actual rows=0.00 ...)
Index Cond: (id = 1234567)
Buffers: shared hit=3
... (one scan per partition, 14 in all)
Planning:
Buffers: shared hit=202 read=4 dirtied=4
规划时间从 0.055 ms 增加到 0.284 ms(在新会话中测量;你的数字会略有不同),因为规划器必须考虑每个分区。
动手验证 · lab 38-partitioning
selectlab.prove('a lookup by id alone: one index scan per partition on events_p',(selectcount(distinctr)fromunnest(rels('select * from events_p where id = 1234567'))r)=:nparts);selectlab.prove('a lookup by id: about 7 pages on events, several times that on events_p',lab.buffers('select * from events where id = 1234567')<=10andlab.buffers('select * from events_p where id = 1234567')>3*lab.buffers('select * from events where id = 1234567'));selectlab.prove('with BRIN, the unpartitioned month read is within 15% of the pruned partition read',lab.buffers(:'q_mid')<1.15*lab.buffers(:'q_mid_p'));
selectformat('create table events_p_%s partition of events_p ''for values from (%L) to (%L)',to_char(m,'YYYY_MM'),m,m+interval'1 month')fromgenerate_series(date_trunc('month',timestamptz'2025-10-06'),date_trunc('month',timestamptz'2026-11-01'),interval'1 month')asm\gexec
然后用 insert ... overriding system value select ... from events 复制数据,用 setval 把标识列(identity column)的序列推进到已复制的 id 之后,再执行 vacuum analyze。按 tableoid::regclass(指出行实际存放在哪张表中的系统列)统计行数,可以确认路由结果:
如果行的键不匹配任何分区,列表分区会拒绝它(no partition of relation "audit_log" found for row)。哈希分区表 sessions_h 使用 modulus 4,把 400,000 行分到四个桶中,分别为 100,504、99,912、99,884 和 99,700 行;where user_id = 42 恰好只扫描一个桶。
动手验证 · lab 38-partitioning
selectlab.prove('every row lands in exactly one partition, and the copy keeps all 2,000,000',(selectcount(*)fromevents_p)=2000000and(selectcount(*)fromonlyevents_p)=0);selectlab.prove('a full month is about 3,000 pages; the unpartitioned events is about 35,000',(selectmax(pg_relation_size(inhrelid))/8192frompg_inheritswhereinhparent='events_p'::regclass)between2800and3100andpg_relation_size('events')/8192between34000and36500);
selectlab.prove('one month on events_p: only that partition appears in the plan',(selectcount(distinctr)fromunnest(rels(:'q_mid_p'))r)=1);selectlab.prove('one month reads about 3,000 pages on events_p and all ~35,000 on events',lab.buffers(:'q_mid_p')<3100andlab.buffers(:'q_mid')>34000);
Aggregate (actual rows=1.00 loops=1)
Buffers: shared hit=12 read=737
-> Append (actual rows=38235.00 loops=1)
Subplans Removed: 11
-> Index Only Scan using events_p_2026_09_pkey on events_p_2026_09 ...
-> Index Only Scan using events_p_2026_10_pkey on events_p_2026_10 ...
-> Seq Scan on events_p_2026_11 events_p_3 (actual rows=0.00 loops=1)
Limit (actual rows=20.00 loops=1)
Buffers: shared hit=21 read=2
-> Append (actual rows=20.00 loops=1)
-> Index Scan Backward using events_p_2026_11_account_id_created_at_idx
Buffers: shared hit=2
-> Index Scan Backward using events_p_2026_10_account_id_created_at_idx
Buffers: shared hit=19 read=2
-> Index Scan Backward using events_p_2026_09_account_id_created_at_idx
Index Searches: 0
... (the other 11 also never executed)
Planning:
Buffers: shared hit=431 read=24
Planning Time: 1.251 ms
规划耗时约为普通表的十倍(新会话中,1.25 ms 对比 0.1 ms)。
动手验证 · lab 38-partitioning
selectlab.prove('a filter on now() prunes when execution starts: Subplans Removed > 0',(lab.plan($$selectcount(*)fromevents_pwherecreated_at>=now()-interval'7 days'$$)@?'$.** ? (@."Subplans Removed" > 0)'));
selectlab.prove('default: the events side prunes to 3 months, the deliveries side scans every partition',(selectcount(distinctr)filter(whererlike'events_p%')fromunnest(rels(:'q_join'))r)=3and(selectcount(distinctr)filter(whererlike'deliveries_p%')fromunnest(rels(:'q_join'))r)=:nparts);setenable_partitionwise_join=on;selectlab.prove('partition-wise: only the 3 matching deliveries partitions are read',(selectcount(distinctr)filter(whererlike'deliveries_p%')fromunnest(rels(:'q_join'))r)=3);
ERROR: unique constraint on partitioned table must include all partitioning columns
DETAIL: UNIQUE constraint on table "events_p" lacks column "created_at"
which is part of the partition key.
Postgres 没有全局索引。分区表上的索引,其实是每个分区各有一个索引,因此唯一性只在单个分区内检查。如果唯一键包含分区键,相同的键就会落在一起;否则,重复值可以藏在不同月份里,不被发现。(同一个 id 分别插入五月和九月,没有报错。)on conflict 的目标也遵循同样的规则。
索引也有自己的陷阱:分区父表不支持 create index concurrently。先以 on only 在父表上建索引,再并发构建各分区的索引并逐个挂接;最后一个挂接完成后,父表索引才变为有效。
深入阅读:约束错误,以及逐分区构建索引
动手验证 · lab 38-partitioning
selectlab.prove('a unique index on id alone is rejected: it lacks the partition key',lab.try('create unique index on events_p (id)')='0A000: unique constraint on partitioned table must include all partitioning columns');selectlab.prove('a foreign key to events_p (id) alone is rejected (42830)',lab.try($$createtableevent_comments(idbigintgeneratedalwaysasidentityprimarykey,event_idbigintnotnullreferencesevents_p(id),bodytextnotnull)$$)='42830: there is no unique constraint matching given keys for referenced table "events_p"');selectlab.prove('the same id can exist twice, in different partitions',(selectcount(*)=2andcount(distincttableoid)=2fromevents_pwhereid=1234567));
出现重复值,需要显式提供 id(如导入、overriding system value);标识列自行分配的值是唯一的。
为每个分区生成索引构建语句:
createindexevents_p_kind_idxononlyevents_p(kind);-- indisvalid = f
selectformat('create index concurrently %I on %s (kind)',c.relname||'_kind_idx',c.oid::regclass),format('alter index events_p_kind_idx attach partition %I',c.relname||'_kind_idx')frompg_inheritsijoinpg_classconc.oid=i.inhrelidwherei.inhparent='events_p'::regclassorderbyc.relname\gexec
create index concurrently 不能在事务块内运行,所以要逐条执行(psql 的 \gexec 在 begin 外就是这样做的)。如果一次构建失败,会在该分区留下无效索引;删除后重建(并发构建失败时)。
ERROR: updated partition constraint for default partition "events_p_default"
would be violated by some row
ERROR: cannot detach partitions concurrently when a default partition exists
ERROR: cannot drop table events_p_default because other objects depend on it
DETAIL: constraint deliveries_p_event_id_event_created_at_fkey on table
deliveries_p depends on table events_p_default
(我们先清空默认分区,再执行 detach partition events_p_default,随后 drop table 就成功了。)
动手验证 · lab 38-partitioning
selectlab.prove('with a stray row in the default, creating its month fails (23514)',lab.try(format('create table events_p_future partition of events_p for values from (%L) to (%L)',:'last_month'::timestamptz+interval'2 months',:'last_month'::timestamptz+interval'3 months'))like'23514: updated partition constraint for default partition "events_p_default" would be violated%');selectlab.prove('a default partition blocks DETACH ... CONCURRENTLY',lab.try(format('select dblink_exec(%L, %L)','dbname='||current_database(),format('alter table events_p detach partition %I concurrently','events_p_'||to_char(:'first_month'::timestamptz,'YYYY_MM'))))='55000: cannot detach partitions concurrently when a default partition exists');
selectlab.wal_bytes(format($$deletefromeventswherecreated_at>=%Landcreated_at<%L$$,:'old_month'::timestamptz,:'old_month'::timestamptz+interval'1 month'))asdelete_wal\gsetvacuumevents;altertabledeliveries_pdetachpartition:old_deliveriesconcurrently;droptable:old_deliveries;altertableevents_pdetachpartition:old_eventsconcurrently;droptable:old_events;-- vacuum_wal and drop_wal come from pg_stat_statements
selectlab.prove('deleting a month writes about 32 MB of WAL',:delete_walbetween25e6and40e6);selectlab.prove('detach + drop writes over 100 times less WAL than delete + vacuum',:drop_wal>0and(:delete_wal+:vacuum_wal)>100*:drop_wal);
tuples: 164383 removed, 1835626 remain, 0 are dead but not yet removable
WAL usage: 11670 records, 2098 full page images, 12504570 bytes, 181 buffers full
ERROR: removing partition "events_p_2025_12" violates foreign key constraint
"deliveries_p_event_id_event_created_at_fkey_3"
DETAIL: Key (event_id, event_created_at)=(306216, 2025-12-01 00:00:38.330131+00)
is still referenced from table "deliveries_p".
动手验证 · lab 38-partitioning
selectlab.prove('foreign keys set the order: the events partition can''t go before its deliveries (23503)',lab.try(format('alter table events_p detach partition %I',:'old_events'))like'23503: removing partition%violates foreign key constraint%');
我们第一次尝试挂接时失败了两次,先是 The new partition may not contain an identity column.,再是 multiple primary keys for table "events_copy_old" are not allowed。必须移除旧的 (id) 主键,把步骤 1 的索引变成新主键(attach 会直接采用它,避免持锁建索引),并把标识列定义移到父表:
-- scan_nocheck / scan_check: seq scans of the old table counted (in
-- pg_stat_xact_user_tables) across the attach, without and with the constraint
selectlab.prove('without the check constraint, attach scans the whole table',:scan_nocheck=1);selectlab.prove('with a validated check constraint, attach skips the scan',:scan_check=0);
-- planning: median planning time of 15 runs per table, with and without the key
selectlab.prove('without the key, planning grows with the partition count (1,000 is over 20 times 12)',(selectwithout_keyfromplanningwheren=1000)>20*(selectwithout_keyfromplanningwheren=12)and(selectwithout_keyfromplanningwheren=365)>5*(selectwithout_keyfromplanningwheren=12));selectlab.prove('with the key, pruning keeps 1,000 partitions planning over 20 times faster than without it',(selectwith_key*20<without_keyfromplanningwheren=1000));