In a PostgreSQL 14 database I have a transactions table currently containing around 400 million elements and the following query on it:
SELECT "transactions"."id"
FROM "transactions"
WHERE ("wallet_id" = $1)
ORDER BY "transactions"."id" DESC
LIMIT 10
This works fine and the query is fast. The EXPLAIN ANALYZE
output:
Limit (cost=84991.29..84991.31 rows=10 width=146) (actual time=1.518..1.519 rows=2 loops=1)
-> Sort (cost=84991.29..85056.88 rows=26235 width=146) (actual time=1.517..1.518 rows=2 loops=1)
Sort Key: id DESC
Sort Method: quicksort Memory: 25kB
-> Index Scan using transactions_wallet_id_index on transactions (cost=0.57..84424.36 rows=26235 width=146) (actual time=1.080..1.497 rows=2 loops=1)
Index Cond: (wallet_id = $1)
Planning Time: 0.850 ms
Execution Time: 1.682 ms
If I add a second wallet id to the where, then the query takes several minutes when both wallets have a small number of transactions (2 for the first one, 57 for the second one):
SELECT "transactions"."id"
FROM "transactions"
WHERE ("wallet_id" = $1 OR "wallet_id" = $2)
ORDER BY "transactions"."id" DESC
LIMIT 10
Its analysis:
Limit (cost=0.57..117334.73 rows=10 width=146) (actual time=3683.524..944867.724 rows=10 loops=1)
-> Index Scan Backward using transactions_pkey on transactions (cost=0.57..615664040.26 rows=52471 width=146) (actual time=3683.523..944867.715 rows=10 loops=1)
Filter: ((wallet_id = $1) OR (wallet_id = $2))
Rows Removed by Filter: 117937409
Planning Time: 0.810 ms
Execution Time: 944867.797 ms
After investigating this for hours, it seems the issue comes from using ORDER BY
in combination with LIMIT
, and indeed if I remove one of the two, the query runs fast. If at least one of the wallets has a non-small number of transactions, it also runs fast. id
is the primary key and there’s an index on wallet_id
.
This is very disappointing and frustrating. It’s my first time using Postgres and the fact that the query planner does such a poor job on such a simple query is honestly hard to understand.
I’d appreciate some suggestions on how to make the query faster for all cases.
I tried different things for several hours now, including running VACUUM
(just in case) and ANALYZE
on the table, but to no avail.
3
Answers
After some more digging, I've finally solved the issue myself thanks to this blog post. It only applies if the
ORDER BY
column is an integer (or maybe just a number), by adding+ 0
to it. Posting it in case it helps someone.I also found several issues reported to PostgreSQL about problems like or related to this one, and I find the disregard for some real-world usage issues like these from some of the core developers incredibly appalling. Apparently they find an acceptable design decision that a query that runs in 0.3s by adding a
+ 0
to anORDER BY
on an integer field might take 20 minutes otherwise due to some abort-early funcionality in the planner gone wrong.This has made me seriously reconsider our decision to migrate to Postgres, and we are already reviewing alternatives. Really sad.
Why?
The additional filter makes Postgres switch to a different query plan, that turns out to be extremely inefficient. The effect is triggered by chance, more or less. The underlying problem is that Postgres grossly under-estimates the selectivity of the
WHERE
clause. It expects that it can just walk the index on the PK (transactions_pkey
) in the requested sort order and will find the few rows (LIMIT 10
) soon enough. Turns out, the filter is extremely selective and Postgres has to skip over 118 million rows (!!!) before it finds enough matches (Rows Removed by Filter: 117937409
). In cases where there are not enough rows to satisfy the limit, all rows have to be visited before Postgres can finally give up. The worst case scenario.The decision was made based on wrong or misleading column statistics. If you can improve column statistics, that might fix the problem by itself. May be as simple as
ANALYZE transactions;
. There are various ways. Here is a related answer from just yesterday on dba.SE, with more on that (and links to more similar cases):Just so happens that I discussed the very same "brute force" hack with
ORDER BY id + 0
, which you found for your answer.Solution
For your particular case, there is a different approach, too. To get the best performance possible, create a multicolumn index (once):
Increases performance for your simple query a lot, too. (Even if that’s pretty fast already.) You’ll see an index-only scan (or at least an index scan) without "Sort" step.
Then use this query:
All parentheses required.
Now the second query is just about twice the cost of the first, i.e. very fast.
If you look at this issue from query planner’s perspective, it has two significant operations to do here:
For 1., the index you have created, transactions_wallet_id_index, is preferable. For 2., the index that comes with the primary key is better (well, scanning it backwards is). Note how your optimal query has a logical Sort operation after the data is filtered out, while the one for
OR
does not, it just has a limit.I created a 2 mil table to recreate your scenario.
Now if we select for a very small wallet, like say 2 and 3, it’ll look like what you’re expecting, a bitmap or of the two conditions:
But at some point, we see what you’re seeing, wallets 3 & 4:
The query planner just thinks sorting that set is going to be more expensive than filtering it.
There’s not really a way around it given this query (that I would know of),
OR
queries are just messy when it comes to statistics, and it’s definitely not just Postgres that suffers from it, believe me.You can try
ANALYZE transactions
, but chances are your stats are up to date. Extended statistics won’t help you here either. You could possibly tweak some of the performance settings (especiallywork_mem
and mayberandom_page_cost
) to force the correct plan here, but you run into messing up other queries.One sure way to optimize this is to do it yourself. As I said,
OR
queries are problematic. Try creating a supporting index for your query and we can try a couple options.This may come as a surprise to someone used to SQL Server for example, but you can get a different plan with Postgres by using
IN
(in SQL ServerIN
gets translated to a bunch ofORs
:And you get a nice:
You can also try messing with the planner’s head by telling "these are not the IDs you’re looking for" (or have an index for):
(note 0+id – 1*id, id+id would also work, anything that makes it look different from what the existing index / pk is on):
Under some circumstances, you could also get away with just splitting the query into two and unioning them together:
(this might not work very well for bigger wallets though)
You have plenty of options,
IN
should work best, but you might want to try them all to see which produces the best results.