已经试过并排除的办法:索引、改写、汇总表、连接池、配置、清理,以及等待分布指向 CPU 时,把逐行处理的工作移出主库。每项写一句话,不能只打个勾。
把申请写成工单
把这个模板粘进工单并填好。每个空白都是上面清单中的一个数字或一句话。
UPSIZE REQUEST: <database> <current size> -> <requested size>
Bound by <CPU | I/O | memory>, because <evidence> shows <number>
Window <dates, peak hours>; stats diffed, not reset
Top statements (by total time, then by shared blocks)
<queryid, short text> <% of time> <blocks/call> <done / why not>
...
Fixes made (blocks per call)
<statement> <before> -> <after> <index / rewrite / rollup>
Wait profile at peak (average active sessions)
CPU <n> IO <n> Lock <n> LWLock <n> Client <n> cores <n>
Working set vs RAM
hot data <size> RAM <size> shared_buffers <size>
read trend <rising / flat>
Headroom
peak use of <resource> today: <percent>
new size gives <x>; at <growth>/month it lasts <months>
measured with <script, data shape, duration, runs>
Tried and ruled out
indexes / rewrites / rollups / pooling / config / vacuum
<one line each>
-- s1 updated the counter row and hasn't committed; s2, then s3, send the same update
selectlab.prove('the second writer of the counter row waits on Lock transactionid (the first writer''s transaction)',(selectwait_event_type='Lock'andwait_event='transactionid'frompg_stat_activitywherepid=:s2_pid));selectlab.prove('the third waits on Lock tuple, queued behind the second',(selectwait_event_type='Lock'andwait_event='tuple'frompg_stat_activitywherepid=:s3_pid));
selectlab.prove('with no index, one account''s month reads the whole table: over 35,000 pages',:raw_pages>35000);createindexevents_account_created_idxonevents(account_id,created_at);selectlab.buffers(:'dashboard')asindexed_pages\gsetselectlab.prove('with (account_id, created_at), the same chart reads under 300 pages',:indexed_pages<300);
selectlab.prove('the rollup answers the same month in under 10 pages',:rollup_pages<10);selectlab.prove('the cross-tenant quarterly report touches under a third of the raw pages on the rollup',:q_rollup<:q_raw/3.0);
汇总表好不好,取决于数据够不够新。最稳妥的刷新方式,是定时任务从昨天零点开始重算最近的时间窗口,再用 on conflict (account_id, day) do update ... where (...) is distinct from (excluded...) 插入或更新(upsert)。它是幂等的(idempotent):执行两次,得到的表相同。午夜前后新增 5,000 个事件后,它用约 16 ms 重算了 1,998 个账户日:新增 1,000 行,改变 299 行,相同而跳过的有 699 行。再执行一次,就不会重写任何行。
那段 is distinct from 虽长,却值得写:少了它,每次刷新都会重写窗口中的每一行,死元组和 WAL 一个不少(更新就是插入)。要重算完整的天,而不是“上次看到的 id 之后的所有数据”,后者会漏掉晚提交的事务。每次只运行一个刷新任务,并显示“更新于一分钟前”。
selectlab.plan(:'refresh')->'Plan'assecond_run\gsetselectlab.prove('run it again and nothing is written: 0 rows out of the Insert node',(:'second_run'::jsonb->>'Actual Rows')::numeric=0and(:'second_run'::jsonb->>'Tuples Inserted')::numeric=0);selectlab.prove('every conflicting row was skipped by the is distinct from filter',(:'second_run'::jsonb->>'Rows Removed by Conflict Filter')::numeric=(:'second_run'::jsonb->>'Conflicting Tuples')::numeric);
creatematerializedviewproject_activity_30dasselectproject_id,count(*)asevents,count(*)filter(wherekind='deploy')asdeploys,max(created_at)aslast_event_atfromevents-- now() in a live system
wherecreated_at>=timestamptz'2026-10-06'-interval'30 days'groupbyproject_id;refreshmaterializedviewconcurrentlyproject_activity_30d;
ERROR: cannot refresh materialized view "public.project_activity_30d" concurrently
HINT: Create a unique index with no WHERE clause on one or more columns of the
materialized view.
执行 create unique index on project_activity_30d (project_id) 后,pg_stat_statements 中的记录如下:
selectlab.prove('a plain refresh holds ACCESS EXCLUSIVE on the view until it commits',exists(select1frompg_lockswherepid=:s1_pidandrelation='project_activity_30d'::regclassandmode='AccessExclusiveLock'));selectlab.prove('the concurrent refresh wrote under a tenth of the plain refresh''s WAL',(selectsum(wal_bytes)filter(wherequerylike'%concurrently%')<sum(wal_bytes)filter(wherequerynotlike'%concurrently%')/10frompg_stat_statementswheredbid=(selectoidfrompg_databasewheredatname=current_database())andquerylike'refresh materialized view%'));
query | rows | wal
----------------------------------------------------+--------+---------
insert into import_staging_logged select * from ev | 476712 | 77 MB
insert into import_staging select * from ev | 476712 | 0 bytes
动手验证 · lab 36-scale
selectlab.prove('the same rows into the same shape: tens of megabytes of WAL logged',:logged_wal>20000000);selectlab.prove('and next to none unlogged (under 0.1 percent of it)',:unlogged_wal<:logged_wal/1000.0);
-- 1,000 single-row inserts, each its own statement, then:
selectlab.prove('a single-row insert into events writes a few hundred bytes of WAL (we saw about 600)',(selectsum(wal_bytes)/sum(calls)frompg_stat_statementswheredbid=(selectoidfrompg_databasewheredatname=current_database())andquerylike'insert into events (account_id, project_id, user_id, kind) values%')between200and1200);
frozen: 35029 pages from table (99.56% of total) had 1996020 tuples frozen
visibility map: 4962 pages set all-visible, 35008 pages set all-frozen
(30046 were all-visible)
...
WAL usage: 70383 records, 35190 full page images, 291082046 bytes,
2126 buffers full
system usage: CPU: user: 0.22 s, system: 0.24 s, elapsed: 6.01 s
约 35,200 页,产生了 35,190 个整页镜像:几乎每页都完整记入了日志。
动手验证 · lab 36-scale
vacuum(freeze)events;selectlab.prove('freezing every page of events writes WAL on the order of the table''s own size (over half of it)',(selectsum(wal_bytes)frompg_stat_statementswheredbid=(selectoidfrompg_databasewheredatname=current_database())andquerylike'vacuum%')>:events_bytes/2);