To make things simple here is a statically built version of the query that runs perfectly fast:
SELECT * FROM media WHERE tag_ids @@ '123&321'::query_int ORDER BY rating DESC LIMIT 20;
The catch is that the tag IDs are kept in another table and of course the app's UI accepts tags as strings. So the actual query looks something like this:
SELECT * FROM media WHERE tag_ids
@@ (SELECT format('%s', (
SELECT ARRAY_TO_STRING((
SELECT ARRAY(
WITH tag_ids as (SELECT id FROM tags WHERE tag IN ('kittens', 'puppies')
)
SELECT id FROM tag_ids
UNION
SELECT 0 FROM tag_ids WHERE NOT EXISTS (SELECT 1 FROM tag_ids)
)), '&')
)))::query_int
ORDER BY rating DESC LIMIT 20;
So the problem is that the query planner always predicts that the tag_ids @@ ... clause will return around 32k rows. Considering that there is a LIMIT of 20 then an index scan on rating, followed by a sequential scan makes total sense. But! Some, indeed most, tag filters return way less than that, making an index scan problematically slow, eg:
-> Index Scan using media_rating_index on media (cost=0.43..2741321.93 rows=32509 width=11) (actual time=70.816..24270.968 rows=20 loops=1)
Filter: (tag_ids @@ ($5)::query_int)
Rows Removed by Filter: 1565931
Planning Time: 0.514 ms
Execution Time: 24271.155 ms
Of course when using a statically built tag_ids @@ ... clause for tags that are known to have a low occurrence a tag-based index is chosen by the query planner, eg:
Limit (cost=9163.00..9163.05 rows=20 width=11) (actual time=3.838..3.841 rows=3 loops=1)
-> Sort (cost=9163.00..9168.96 rows=2384 width=11) (actual time=3.834..3.836 rows=3 loops=1)
Sort Key: rating DESC
Sort Method: quicksort Memory: 25kB
-> Bitmap Heap Scan on media (cost=38.48..9099.57 rows=2384 width=11) (actual time=2.780..3.796 rows=3 loops=1)
Recheck Cond: (tag_ids @@ '654&321'::query_int)
Heap Blocks: exact=3
-> Bitmap Index Scan on media_tag_ids_gin__int_ops_index (cost=0.00..37.88 rows=2384 width=0) (actual time=2.023..2.023 rows=3 loops=1)
Index Cond: (tag_ids @@ '654&321'::query_int)
Planning Time: 2.218 ms
Execution Time: 3.952 ms
One way to solve this is to just send 2 queries to the DB from the application, then the format() can even be done in the application code. But that just feels a bit like giving up. The other thing is an SQL function, but that seems overkill. Is there another more idiomatic way to give the query planner the tag IDs so it can better predict the number of returned rows and thus plan a better query?
'123&321'::query_int- is it a user defined type? – Vérace Oct 28 '19 at 05:31intarrayextension: https://www.postgresql.org/docs/11/intarray.html – tombh Oct 28 '19 at 07:20query_intlength. Is the query always built with&as your query suggests? – Erwin Brandstetter Oct 29 '19 at 01:17