So as far as I understand, with a regular index scan (not index-only scan), Postgres will read the index and immediately fetch the referenced rows from the heap. For a bitmap index scan + bitmap heap scan (which I will just call bitmap scan, taken together), Postgres reads the index and collects the list of relevant heap pages and tuple offsets in a bitmap. Then it reads all the relevant heap pages in order.
The advantage for a bitmap scan is:
- every heap page is only fetched once, while an index scan may need to read a heap page multiple times
- heap pages are read in physical order, which is faster than random access, especially on magnetic disks.
With that, I would expect a bitmap scan to always be faster than a normal index scan, except maybe if a very small number of rows is needed, or if the bitmap doesn't fit into working memory.
But now I'm optimizing a query that uses bitmap scans by default, but switches to regular index scans when I set random_page_cost
to 1 (i.e, tell postgres that random page access costs the same as sequential page access). The db runs on an SSD, so random_page_cost
==1 is warranted. But the regular index scan is almost twice as fast as the bitmap index scan, which I didn't expect. This is a pair of tables with timeseries data for multiple sensors. Selecting all the data for one sensor takes ± 20 seconds with an index scan, and ± 40 seconds with a bitmap scan. That is for reading 75k rows from tables containing either 50M or 15M rows in total.
So when and why is a regular index scan ever faster than a bitmap scan?
EDIT: EXPLAIN plans
By request, here are EXPLAIN (ANALYZE,BUFFERS)
plans with track_io_timing
enabled. However I am not too interested in the specific performance of this query, this question is about bitmap scans vs index scans in general.
There are two tables, both with the same columns:
timestamp timestamp with timezone PRIMARY KEY
station_id character varying(50) PRIMARY KEY
value numeric(10) NOT NULL
Both have an additional index on station_id
.
The query:
SELECT *
FROM
-- This first part doesn't do anything in this example,
-- but this query was reduced from a more complex one
-- with parameters. This part is needed to make Postgres
-- choose a hash join in both cases. If you forcibly disable
-- merge join and nested loop join, running just the second
-- subquery should give similar results.
(select * from unnest(array['station_1']) stations(station_id)
where station_id = 'station_1'
) as stations
join lateral (
SELECT *
FROM series_measurements_gauge AS gauge
INNER JOIN series_measurements_radar AS radar
USING (timestamp, station_id)
WHERE station_id = stations.station_id
AND (gauge.value > 0 OR radar.value > 0)
AND gauge.value != 'NaN'::NUMERIC
AND radar.value != 'NaN'::NUMERIC
order by timestamp desc
) windowed
using (station_id)
The explain plan with SET random_page_cost TO 1.0;
:
"Nested Loop (cost=108636.01..108672.92 rows=1638 width=46) (actual time=21093.479..21095.271 rows=8063 loops=1)"
" Buffers: shared hit=2440 read=98925 written=52"
" I/O Timings: read=20491.173 write=0.407"
" -> Function Scan on unnest stations (cost=0.00..0.05 rows=1 width=32) (actual time=0.007..0.010 rows=1 loops=1)"
" Filter: (station_id = 'station_1'::text)"
" -> Sort (cost=108636.01..108640.11 rows=1638 width=27) (actual time=21093.469..21094.032 rows=8063 loops=1)"
" Sort Key: gauge.""timestamp"" DESC"
" Sort Method: quicksort Memory: 822kB"
" Buffers: shared hit=2440 read=98925 written=52"
" I/O Timings: read=20491.173 write=0.407"
" -> Result (cost=34932.45..108548.56 rows=1638 width=27) (actual time=3243.803..21086.255 rows=8063 loops=1)"
" One-Time Filter: (stations.station_id = 'station_1'::text)"
" Buffers: shared hit=2440 read=98925 written=52"
" I/O Timings: read=20491.173 write=0.407"
" -> Hash Join (cost=34932.45..108548.56 rows=1638 width=27) (actual time=3243.801..21083.338 rows=8063 loops=1)"
" Hash Cond: (gauge.""timestamp"" = radar.""timestamp"")"
" Join Filter: ((gauge.value > '0'::numeric) OR (radar.value > '0'::numeric))"
" Rows Removed by Join Filter: 69061"
" Buffers: shared hit=2440 read=98925 written=52"
" I/O Timings: read=20491.173 write=0.407"
" -> Index Scan using series_measurement_gauge_station_id_idx on series_measurements_gauge gauge (cost=0.56..73416.75 rows=76164 width=24) (actual time=0.457..17735.138 rows=77253 loops=1)"
" Index Cond: ((station_id)::text = 'station_1'::text)"
" Filter: (value <> 'NaN'::numeric)"
" Rows Removed by Filter: 92"
" Buffers: shared hit=428 read=33054"
" I/O Timings: read=17474.535"
" -> Hash (cost=34430.41..34430.41 rows=40118 width=24) (actual time=3242.824..3242.825 rows=77216 loops=1)"
" Buckets: 131072 (originally 65536) Batches: 1 (originally 1) Memory Usage: 5247kB"
" Buffers: shared hit=2012 read=65871 written=52"
" I/O Timings: read=3016.639 write=0.407"
" -> Index Scan using series_measurement_radar_station_id_idx on series_measurements_radar radar (cost=0.43..34430.41 rows=40118 width=24) (actual time=2.383..3210.465 rows=77216 loops=1)"
" Index Cond: ((station_id)::text = 'station_1'::text)"
" Filter: (value <> 'NaN'::numeric)"
" Rows Removed by Filter: 10"
" Buffers: shared hit=2012 read=65871 written=52"
" I/O Timings: read=3016.639 write=0.407"
"Planning:"
" Buffers: shared hit=48"
"Planning Time: 0.447 ms"
"Execution Time: 21095.783 ms"
The explain plan with SET random_page_cost TO 4.0;
:
"Nested Loop (cost=272182.23..272219.14 rows=1638 width=46) (actual time=57709.314..57711.121 rows=8063 loops=1)"
" Buffers: shared hit=1041 read=100324 written=1189"
" I/O Timings: read=55283.990 write=16.970"
" -> Function Scan on unnest stations (cost=0.00..0.05 rows=1 width=32) (actual time=0.007..0.010 rows=1 loops=1)"
" Filter: (station_id = 'station_1'::text)"
" -> Sort (cost=272182.23..272186.32 rows=1638 width=27) (actual time=57709.303..57709.851 rows=8063 loops=1)"
" Sort Key: gauge.""timestamp"" DESC"
" Sort Method: quicksort Memory: 822kB"
" Buffers: shared hit=1041 read=100324 written=1189"
" I/O Timings: read=55283.990 write=16.970"
" -> Result (cost=80099.49..272094.78 rows=1638 width=27) (actual time=38642.951..57701.677 rows=8063 loops=1)"
" One-Time Filter: (stations.station_id = 'station_1'::text)"
" Buffers: shared hit=1041 read=100324 written=1189"
" I/O Timings: read=55283.990 write=16.970"
" -> Hash Join (cost=80099.49..272094.78 rows=1638 width=27) (actual time=38642.950..57698.167 rows=8063 loops=1)"
" Hash Cond: (gauge.""timestamp"" = radar.""timestamp"")"
" Join Filter: ((gauge.value > '0'::numeric) OR (radar.value > '0'::numeric))"
" Rows Removed by Join Filter: 69061"
" Buffers: shared hit=1041 read=100324 written=1189"
" I/O Timings: read=55283.990 write=16.970"
" -> Bitmap Heap Scan on series_measurements_gauge gauge (cost=1053.96..192849.32 rows=76164 width=24) (actual time=11.281..18961.484 rows=77253 loops=1)"
" Recheck Cond: ((station_id)::text = 'station_1'::text)"
" Filter: (value <> 'NaN'::numeric)"
" Rows Removed by Filter: 92"
" Heap Blocks: exact=33368"
" Buffers: shared hit=440 read=33041 written=1189"
" I/O Timings: read=18250.155 write=16.970"
" -> Bitmap Index Scan on series_measurement_gauge_station_id_idx (cost=0.00..1034.92 rows=79781 width=0) (actual time=5.150..5.151 rows=77392 loops=1)"
" Index Cond: ((station_id)::text = 'station_1'::text)"
" Buffers: shared hit=113"
" -> Hash (cost=78544.05..78544.05 rows=40118 width=24) (actual time=38629.094..38629.096 rows=77217 loops=1)"
" Buckets: 131072 (originally 65536) Batches: 1 (originally 1) Memory Usage: 5247kB"
" Buffers: shared hit=601 read=67283"
" I/O Timings: read=37033.835"
" -> Bitmap Heap Scan on series_measurements_radar radar (cost=451.37..78544.05 rows=40118 width=24) (actual time=46.071..38527.975 rows=77217 loops=1)"
" Recheck Cond: ((station_id)::text = 'station_1'::text)"
" Filter: (value <> 'NaN'::numeric)"
" Rows Removed by Filter: 10"
" Heap Blocks: exact=67821"
" Buffers: shared hit=601 read=67283"
" I/O Timings: read=37033.835"
" -> Bitmap Index Scan on series_measurement_radar_station_id_idx (cost=0.00..441.34 rows=40121 width=0) (actual time=31.258..31.258 rows=77227 loops=1)"
" Index Cond: ((station_id)::text = 'station_1'::text)"
" Buffers: shared hit=19 read=44"
" I/O Timings: read=19.450"
"Planning:"
" Buffers: shared hit=22"
"Planning Time: 0.393 ms"
"Execution Time: 57712.895 ms"
Environment
After posting this question initially, I found out that this query is quite slow because it runs on Amazon RDS with 3000 IOPS and 125 MB/s throughput. When I run the same query on my local laptop, the query times drop from 20s/50s to 0.7s/1.4s (after a few repeats, so the data is mostly cached. The first (uncached) run took 16s instead of 0.7s). So RDS is an order of magnitude slower than my local machine, most likely due to the IOPS limit and limited caching.
My own conclusions from the explain plans
Both plans read about the same number of rows in their respective scans, and also the same number of blocks. The main difference is that one of the bitmap heap scans also writes 1189 blocks, and strangely enough the other bitmap heap scan doesn't even though it reads a lot more blocks. But one thousand is small compared to the 100k total blocks read, so I don't think that can explain a 2x slowdown.
Reiterating: I am not so interested in optimizing this query itself, I want to understand the performance trade offs between index scans and bitmap scans and how a bitmap scan can be twice as slow as an index scan.