PostgreSQL带EXISTS子句的随机行查询慢如何优化
问题描述
需要从表中抽取满足两个条件的随机行:
- table1中type字段为指定动态值,type共有上百种取值,无法针对单个特定值建专用索引
- 该行id在table2中存在
初始编写的查询SQL如下:
select * from table1 t1 where type='Other' and exists (select 1 from table2 t2 where t2.id = t1.id FETCH FIRST ROW ONLY) order by random() limit 1;
现有尝试与问题
- 在EXISTS子句中添加
FETCH FIRST ROW ONLY,期望匹配到table2中首条符合id关联规则的记录就终止子查询,未带来明显性能提升 - 尝试改用表关联写法,耗时接近原写法两倍
- 当前查询耗时略超1分钟,目标是将耗时压缩到10秒以内
基础信息
数据规模
- table1:超过1000万行数据,id全局唯一
- table2:超过9500万行数据
现有索引
CREATE INDEX type_idx ON table1 USING btree (type); CREATE INDEX id_idx ON table2 USING btree (id); CREATE UNIQUE INDEX table1_pkey ON table1 USING btree (id);
初始执行计划
Limit (cost=297536.80..297536.80 rows=1 width=51) (actual time=68436.446..68456.389 rows=1 loops=1) Buffers: shared hit=1503764 read=299217 I/O Timings: read=199577.751 -> Sort (cost=297536.80..297586.31 rows=19807 width=51) (actual time=68436.444..68456.386 rows=1 loops=1) Sort Key: (random()) Sort Method: top-N heapsort Memory: 25kB Buffers: shared hit=1503764 read=299217 I/O Timings: read=199577.751 -> Gather (cost=7051.90..297437.76 rows=19807 width=51) (actual time=117.271..68418.453 rows=58327 loops=1) Workers Planned: 2 Workers Launched: 2 Buffers: shared hit=1503764 read=299217 I/O Timings: read=199577.751 -> Nested Loop Semi Join (cost=6051.90..294407.54 rows=8253 width=43) (actual time=84.291..68358.619 rows=19442 loops=3) Buffers: shared hit=1503764 read=299217 I/O Timings: read=199577.751 -> Parallel Bitmap Heap Scan on table1 t1 (cost=6051.46..135601.49 rows=225539 width=43) (actual time=83.250..24802.725 rows=185267 loops=3) Recheck Cond: ((type)::text = 'Other'::text) Rows Removed by Index Recheck: 1119917 Heap Blocks: exact=20174 lossy=11038 Buffers: shared read=94319 I/O Timings: read=72301.594 -> Bitmap Index Scan on type_idx (cost=0.00..5916.13 rows=541293 width=0) (actual time=89.207..89.208 rows=555802 loops=1) Index Cond: ((type)::text = 'Other'::text) Buffers: shared read=470 I/O Timings: read=33.209 -> Index Only Scan using id_idx on events (cost=0.44..65.15 rows=257 width=8) (actual time=0.234..0.234 rows=0 loops=555802) Index Cond: (t2.id = t1.id) Heap Fetches: 461 Buffers: shared hit=1503764 read=204898 I/O Timings: read=127276.157 Planning: Buffers: shared hit=8 read=8 I/O Timings: read=3.139 Planning Time: 5.713 ms Execution Time: 68457.688 ms
将table1的type索引调整为包含id字段后的执行计划
Limit (cost=305876.92..305876.92 rows=1 width=51) (actual time=81055.897..81077.393 rows=1 loops=1) Buffers: shared hit=1501397 read=303247 I/O Timings: read=237093.600 -> Sort (cost=305876.92..305926.44 rows=19807 width=51) (actual time=81055.895..81077.390 rows=1 loops=1) Sort Key: (random()) Sort Method: top-N heapsort Memory: 25kB Buffers: shared hit=1501397 read=303247 I/O Timings: read=237093.600 -> Gather (cost=15392.02..305777.89 rows=19807 width=51) (actual time=87.662..81032.107 rows=58327 loops=1) Workers Planned: 2 Workers Launched: 2 Buffers: shared hit=1501397 read=303247 I/O Timings: read=237093.600 -> Nested Loop Semi Join (cost=14392.02..302747.67 rows=8253 width=43) (actual time=73.967..80990.425 rows=19442 loops=3) Buffers: shared hit=1501397 read=303247 I/O Timings: read=237093.600 -> Parallel Bitmap Heap Scan on table1 t1 (cost=14391.58..143941.61 rows=225539 width=43) (actual time=73.193..20476.307 rows=185267 loops=3) Recheck Cond: ((type)::text = 'Other'::text) Rows Removed by Index Recheck: 1124091 Heap Blocks: exact=20346 lossy=11134 Buffers: shared read=95982 I/O Timings: read=59211.444 -> Bitmap Index Scan on type_idx (cost=0.00..14256.26 rows=541293 width=0) (actual time=73.552..73.552 rows=555802 loops=1) Index Cond: ((type)::text = 'Other'::text) Buffers: shared read=2133 I/O Timings: read=6.812 -> Index Only Scan using id_idx on table2 (cost=0.44..65.15 rows=257 width=8) (actual time=0.326..0.326 rows=0 loops=555802) Index Cond: (t2.id = t1.id) Heap Fetches: 461 Buffers: shared hit=1501397 read=207265 I/O Timings: read=177882.156 Planning: Buffers: shared hit=29 read=10 I/O Timings: read=4.789 Planning Time: 11.993 ms Execution Time: 81078.404 ms
性能根因分析
- 核心逻辑问题:当前执行计划会先把所有符合type条件、且id在table2存在的5.8万余条记录全部查询出来,再对全量结果做
random()排序取1条,为了单条结果扫描了几十万行数据,99%的计算和IO都是无用开销。 - 索引使用问题:
- table1上的type索引(包括后续调整为(type,id)的联合索引)选择性不足,Bitmap扫描时因为work_mem容量不够产生了1.1万余个lossy块,需要回表做大量重校验,单这部分耗时就超过20秒。
- table2上的id是普通Btree索引,而非唯一索引,存在性判断时需要扫描可能的重复值,效率低于唯一索引。
- 语法认知问题:
EXISTS子查询本身的实现逻辑就是匹配到第一条符合条件的记录后立刻终止,不需要额外添加FETCH FIRST ROW ONLY,该写法不会带来任何性能提升。 - 加id到type索引后性能反而下降的原因:(type,id)联合索引的体积比单type索引大,Bitmap扫描时需要读取更多索引块,而执行逻辑没有变化,仍然要全量拉取所有符合条件的记录,索引体积增大带来的开销超过了覆盖索引的收益。
优化方案
按优先级从高到低落地:
1. 调整索引结构
- 将table1上的type索引替换为
(type, id)的联合索引,注意建索引时设置FILLFACTOR=90,减少索引碎片:DROP INDEX type_idx; CREATE INDEX type_idx ON table1 USING btree (type, id) WITH (FILLFACTOR=90); - 如果table2的id业务上唯一,将id普通索引替换为唯一索引,大幅提升存在性判断效率:
DROP INDEX id_idx; CREATE UNIQUE INDEX id_idx ON table2 USING btree (id) WITH (FILLFACTOR=90); - 对两张表执行统计信息更新和清理,确保Index Only Scan不需要回表取数据:
VACUUM ANALYZE table1; VACUUM ANALYZE table2;
2. 会话级参数调优
执行查询前,将会话的work_mem调整到64MB,避免Bitmap扫描产生lossy块,消除回表重校验开销:
SET work_mem = '64MB';
3. 改写查询逻辑,避免全量排序
放弃“先查全量符合条件的记录再排序取1条”的逻辑,改为“随机扫描table1中对应type的记录,找到第一条id存在于table2的记录就直接返回”,不需要遍历所有符合条件的行,绝大多数场景下扫描几十到几百行即可返回结果,耗时可降到秒级。
对于type对应记录量较大、符合id存在条件的记录占比不低的场景,直接用如下SQL即可,优化器会自动选择随机索引扫描,匹配到第一条符合条件的结果就终止:
SELECT * FROM table1 t1 WHERE type = 'Other' AND EXISTS (SELECT 1 FROM table2 t2 WHERE t2.id = t1.id) ORDER BY random() LIMIT 1;
如果部分type对应的记录中,id存在于table2的占比极低,可以用CTE逐次随机探测,避免扫描过多无效行:
WITH RECURSIVE random_search AS ( -- 第一次随机取1条 ( SELECT *, 1 as round FROM table1 WHERE type = 'Other' OFFSET floor(random() * (SELECT reltuples::bigint FROM pg_class WHERE relname='table1') * 0.01) LIMIT 1 ) UNION ALL -- 没找到就继续随机取,最多找20轮 ( SELECT t.*, rs.round + 1 as round FROM table1 t, random_search rs WHERE rs.round < 20 AND NOT EXISTS (SELECT 1 FROM table2 t2 WHERE t2.id = rs.id) AND t.type = 'Other' OFFSET floor(random() * (SELECT reltuples::bigint FROM pg_class WHERE relname='table1') * 0.01) LIMIT 1 ) ) SELECT * FROM random_search WHERE EXISTS (SELECT 1 FROM table2 t2 WHERE t2.id = random_search.id) LIMIT 1;
以上方案落地后,常规场景查询耗时可稳定在1-3秒,极端低匹配率场景也不会超过10秒。
内容的提问来源于stack exchange,提问作者aggaton
相关产品推荐
相关产品推荐

