explain analyze delete from events where created_at < '2025-10-11' 在 rollback 之前确实删除了 27,397 行(并弄脏了 482 个页)。没有任何东西被复制回来:这些行被一个随后中止的事务盖上了标记,因此它们仍然可见(更新就是插入)。回滚撤销的是删除,不是一切:行确实被锁定了,触发器确实触发了,序列确实前进了,WAL 也确实写入了。
动手验证 · lab 18-explain
begin;createtemptablep_delasselectlab.plan($$deletefromeventswherecreated_at<'2025-10-11'$$)asp;selectlab.prove('explain analyze of a delete really deletes (about 27,000 rows here)',(selectcount(*)fromevents)<2000000and(selectlab.field(p,'Seq Scan','Actual Rows')::numericbetween20000and60000fromp_del));rollback;selectlab.prove('and rollback puts them back',(selectcount(*)fromevents)=2000000);selectlab.prove('PostgreSQL 18 includes Buffers without asking',lab.plan_text($$select*fromeventswhereid=1$$,'analyze, costs off')like'%Buffers: shared%');
Seq Scan on events e (cost=0.00..60173.53 rows=39599 width=8)
──────────────────── ──── ──────── ────────── ───────
1 2 3 4 5
(actual time=80.537..82.898 rows=38357.00 loops=1)
────── ────── ───────────── ───────
6 7 8 9
Filter: (created_at >= '2026-09-29 00:00:00+00'::timestamp with time zone)
Rows Removed by Filter: 1961643
───────
10
Buffers: shared hit=2641 read=32532
──────────────────────────
11
1 the node: what this step does
2 estimated startup cost (before the first row)
3 estimated total cost (all rows)
4 estimated rows, per loop
5 estimated average row width, in bytes
6 ms until the first row, per loop
7 ms until the last row, per loop
8 actual rows, per loop (an average, hence two decimals in 18)
9 times this node ran: multiply 7 and 8 by it for totals
10 rows read and thrown away
11 8 KB pages: this node plus its children, all loops
selectlab.prove('buffers are cumulative: the top node reports about 35,367, the events scan 35,173 of them',(selectpg_temp.bufs(p)between35364and35400fromp_tree)and(select(n->>'Shared Hit Blocks')::bigint+(n->>'Shared Read Blocks')::bigint=35173from(selectpg_temp.node(p,'{"Node Type": "Seq Scan", "Relation Name": "events"}')nfromp_tree)x));
selectlab.prove('the plan''s total cost equals relpages x 1.0 + reltuples x 0.01 + reltuples x 0.0025, to the cent',(selectround((relpages*1.0+reltuples*0.01+reltuples*0.0025)::numeric,2)frompg_classwhererelname='events')=(select(p->'Plan'->>'Total Cost')::numericfrom(selectlab.plan($$select*fromeventswherekind='alert'$$)asp)x));
selectlab.prove('the inner Limit ran 20 times, each a full Seq Scan with Filter (user_id = u.id)',(selectlab.field(p,'Limit','Actual Loops')::int=20andpg_temp.node(p,'{"Node Type": "Seq Scan", "Relation Name": "events"}')->>'Filter'='(user_id = u.id)'and(lab.field(p,'Limit','Shared Hit Blocks')::bigint+lab.field(p,'Limit','Shared Read Blocks')::bigint)=20*(pg_relation_size('events')/8192)fromp_ex2));selectlab.prove('before: about 703,714 pages for 20 rows',(selectpg_temp.bufs(p)between703500and704500and(p->'Plan'->>'Actual Rows')::numeric=20fromp_ex2));createindexevents_user_created_idxonevents(user_id,created_at);-- (lab.plan of the same query, after the index)
selectlab.prove('each loop now costs 4 pages: 80 for 20 loops',(select(lab.field(p,'Limit','Shared Hit Blocks')::int+coalesce(lab.field(p,'Limit','Shared Read Blocks')::int,0))=80andlab.field(p,'Limit','Actual Loops')::int=20fromp_ex2_after));
selectlab.prove('the events scan estimated about 49 rows: at least 20x too few',(select(n->>'Actual Rows')::numeric/(n->>'Plan Rows')::numeric>20and(n->>'Plan Rows')::intbetween30and70from(selectpg_temp.node(p,'{"Index Name": "events_created_at_idx"}')nfromp_ex3)x));selectlab.prove('and the planner switches to a Hash Join and a hashed aggregate on its own',(selectlab.field(p,'Hash Join','Node Type')isnotnullandlab.field(p,'Nested Loop','Node Type')isnullandlab.field(p,'Aggregate','Strategy')='Hashed'fromp_ex3b));
Limit ... (actual time=7.958..7.961 rows=10.00 loops=1)
Buffers: shared hit=975
...
-> Hash Join (... rows=2558 ...) (actual ... rows=2402.00 loops=1)
-> Index Scan using events_created_at_idx on events e (...)
Rows Removed by Filter: 35955
统计信息来自抽样,所以你的估计会接近 2,558,但不会正好等于它。Rows Removed by Filter: 35955 表示扫描仍然要读取整整一周的数据,才能找出失败部署;如果这个仪表板使用频繁,部分索引也能把这部分去掉。(表达式索引同样会为规划器提供统计信息,但要付出维护索引的代价。)
动手验证 · lab 18-explain
selectlab.prove('the default selectivity for an expression without statistics is 0.5%; the real share of failed is 25%',(selectavg((payload->>'status'='failed')::int)between0.24and0.26fromevents));selectlab.prove('about 975 pages instead of 7,990',lab.warm_buffers($$selectp.id,p.name,count(*)asfailuresfromeventsejoinprojectsponp.id=e.project_idwheree.kind='deploy'ande.payload->>'status'='failed'ande.created_at>='2026-09-29'groupbyp.id,p.nameorderbyfailuresdesclimit10$$)between900and1050);selectlab.prove('the estimate (about 245,000) is within a few percent of the actual rows (5% allows for sampling)',(selectabs((p->'Plan'->>'Plan Rows')::numeric-249714)/249714<0.05fromp_alert));
selectlab.prove('hash join: 100,000 users hashed in 1 batch at the default work_mem (about 7,274 kB)',(selectlab.field(p,'Hash','Actual Rows')::numeric=100000andlab.field(p,'Hash','Hash Batches')::int=1andlab.field(p,'Hash','Peak Memory Usage')::intbetween7000and7500fromp_hash));begin;setlocalwork_mem='1MB';createtemptablep_hash1oncommitdropasselectlab.plan($$selecte.id,u.emailfromeventsejoinusersuonu.id=e.user_idwheree.created_at>='2026-09-06'$$)asp;selectlab.prove('at work_mem = 1MB it splits into 4 batches and writes temp files',(selectlab.field(p,'Hash','Hash Batches')::int=4andlab.field(p,'Hash','Temp Written Blocks')::int>0fromp_hash1));commit;
selectlab.prove('sorting 30 days of events (about 164,000 rows) spills: external merge, about 5,480 kB on disk',(selectlab.field(p,'Sort','Sort Method')='external merge'andlab.field(p,'Sort','Sort Space Type')='Disk'andlab.field(p,'Sort','Sort Space Used')::intbetween4800and6000andlab.field(p,'Sort','Actual Rows')::numericbetween150000and170000andlab.field(p,'Sort','Temp Written Blocks')::int>0fromp_sort));selectlab.prove('at work_mem = 32MB it is a quicksort in memory, about 12,566 kB',(selectlab.field(p,'Sort','Sort Method')='quicksort'andlab.field(p,'Sort','Sort Space Type')='Memory'andlab.field(p,'Sort','Sort Space Used')::intbetween11000and13500fromp_sort32));
selectlab.prove('the Limit is costed near 20,860; the full nested loop over four billion',(select(p->'Plan'->>'Total Cost')::numericbetween19000and23000andlab.field(p,'Nested Loop','Total Cost')::numeric>4e9fromp_limit));selectlab.prove('users 1 and 2 have no events: the third user found the ten rows (loops=3)',(selectmin(user_id)=3fromevents)and(selectlab.field(p,'Materialize','Actual Loops')::int=3fromp_limit));createindexevents_user_id_idxonevents(user_id);-- The two plans cost within a few percent of each other, so which one wins
-- depends on the kit's sampled statistics. Both read 16 pages.
selectlab.prove('with the index, it reads 16 pages: the events index feeds a merge join, or a nested loop with a Memoize (9 hits, 1 miss)',lab.warm_buffers($$selecte.id,e.kind,u.emailfromeventsejoinusersuonu.id=e.user_idorderbye.user_idlimit10$$)=16and(selectpg_temp.node(p,'{"Index Name": "events_user_id_idx"}')isnotnullandlab.field(p,'Materialize','Node Type')isnullandlab.field(p,'Sort','Node Type')isnulland(lab.field(p,'Merge Join','Node Type')isnotnullor(lab.field(p,'Memoize','Cache Hits')::int=9andlab.field(p,'Memoize','Cache Misses')::int=1))fromp_limit2));