3

The problem

Using PostgreSQL 13, I ran into a performance issue selecting the highest id from a view that joins two tables, depending on the select statement I execute.

Here's a sample setup:

CREATE TABLE test1 (
  id BIGSERIAL PRIMARY KEY,
  joincol VARCHAR
);

CREATE TABLE test2 (
  joincol VARCHAR
);

CREATE INDEX ON test1 (id);
CREATE INDEX ON test1 (joincol);
CREATE INDEX ON test2 (joincol);

CREATE VIEW testview AS (
SELECT test1.id,
       test1.joincol AS t1charcol,
       test2.joincol AS t2charcol
FROM   test1, test2
WHERE  test1.joincol = test2.joincol
);

What I found out

I'm executing two statements which result in completely different execution plans and runtimes. The following statement executes in less than 100ms. As far as I understand the execution plan, the runtime is independent of the rowcount, since Postgres iterates the rows one by one (starting at the highest id, using the index) until a join on a row is possible and immediately returns.

SELECT id FROM testview ORDER BY ID DESC LIMIT 1;

However, this one takes over 1 second on average (depending on rowcount), since the two tables are "joined completely", before Postgres uses the index to select the highest id.

SELECT MAX(id) FROM testview;

Please refer to this sample on dbfiddle to check the explain plans:
https://www.db-fiddle.com/f/bkMNeY6zXqBAYUsprJ5eWZ/1

My real environment

On my real environment test1 contains only a hand full of rows (< 100), having unique values in joincol. test2 contains up to ~10M rows, where joincol always matches a value of test1's joincol. test2's joincol is not nullable.

The actual question

Why does Postgres not recognize that it could use an Index Scan Backward on row basis for the second select? Is there anything I could improve on the tables/indexes?

Erwin Brandstetter
  • 605,456
  • 145
  • 1,078
  • 1,228
Dominik
  • 1,623
  • 11
  • 27
  • As a side note: the parentheses around the SELECT in the CREATE VIEW statement are totally useless –  Sep 09 '21 at 08:36
  • @a_horse_with_no_name thanks for the hint. I like to use this style since my IDE (IntelliJ IDEA) applies some better color schema making it easier to read. – Dominik Sep 09 '21 at 08:38
  • Then IntelliJ has a really strange assumption on how SQL should look like. Does it apply a different coloring for "stand-alone" queries in parentheses as well? e.g.: `(select 42);` vs `select 42;` –  Sep 09 '21 at 08:44
  • @a_horse_with_no_name Nope. The coloring basically only "separates". When I'm inside of the paranthesis with my cursor, "everything else" of the query is slightly blurred – Dominik Sep 09 '21 at 09:05
  • An answer to your question "why postgres does it like that" is: because this is how its optimizer is coded. Optimizer is not perfect and doesn't recognize and/or perform some transformations that it could. – Vladimir Baranov Sep 10 '21 at 01:47
  • @VladimirBaranov well then that was some really wasted 50 reputation... – Dominik Sep 10 '21 at 07:19
  • Please start any performance question by disclosing your version of Postgres. Cardinalities are also essential (number of rows, number of distinct values in key columns). – Erwin Brandstetter Sep 12 '21 at 19:54
  • @ErwinBrandstetter I edited the question and added the information. – Dominik Sep 12 '21 at 20:02
  • Still unclear. `table2 contains up to ~10M values,` A table contains *rows*, not *values*. `where joincol should always match a value of table1's joincol` Should? That would be less than 100 distinct values in `table2.joincol`? (And the whole query would be pointless.) Seems like a mix between describing your setup and the objective of the query. How many distinct values are there in `table2.joincol`? – Erwin Brandstetter Sep 12 '21 at 21:21
  • @ErwinBrandstetter `table2.joincol`'s joincol always matches a row in `table1.joincol`. The amount of distinct values in `table2.joincol` is less than 100 since `table1` has less than 100 rows (`unique table1.joincol, not nullable`). This view is basically made to join these two tables without the user having to know about them. The user of this view just wants to fetch the highest id returned by the view. – Dominik Sep 12 '21 at 21:37
  • We speak of `table1` and `table2`, but the test case in the question uses `test`and `test2`. It would be helpful to consolidate all this information and the names in the question to avoid confusion. Sounds like a standard one-to-many relationship. There should be a `UNIQUE` constraint and a FK constraint? Please consolidate your question. I have a couple of things to put in an answer, but please be clear first. – Erwin Brandstetter Sep 12 '21 at 22:09

2 Answers2

2

Queries not strictly equivalent

why does Postgres not recognize that it could use a Index Scan Backward on row basis for the second select?

To make the context clear:

  • max(id) excludes NULL values. But ORDER BY ... LIMIT 1 does not.
  • NULL values sort last in ascending sort order, and first in descending. So an Index Scan Backward might not find the greatest value (according to max()) first, but any number of NULL values.

The formal equivalent of:

SELECT max(id) FROM testview;

is not:

SELECT id FROM testview ORDER BY id DESC LIMIT 1;

but:

SELECT id FROM testview ORDER BY id DESC NULLS LAST LIMIT 1;

The latter query doesn't get the fast query plan. But it would with an index with matching sort order: (id DESC NULLS LAST).

That's different for the aggregate functions min() and max(). Those get a fast plan when targeting table test1 directly using the plain PK index on (id). But not when based on the view (or the underlying join-query directly - the view is not the blocker). An index sorting NULL values in the right place has hardly any effect.

We know that id in this query can never be NULL. The column is defined NOT NULL. And the join in the view is effectively an INNER JOIN which cannot introduce NULL values for id.
We also know that the index on test.id cannot contain NULL values.
But the Postgres query planner is not an AI. (Nor does it try to be, that could get out of hands quickly.) I see two shortcomings:

  • min() and max() get the fast plan only when targeting the table, regardless of index sort order, an index condition is added: Index Cond: (id IS NOT NULL)
  • ORDER BY ... LIMIT 1 gets the fast plan only with the exactly matching index sort order.

Not sure, whether that might be improved (easily).

db<>fiddle here - demonstrating all of the above

Indexes

Is there anything I could improve on the tables/indexes?

This index is completely useless:

CREATE INDEX ON "test" ("id");

The PK on test.id is implemented with a unique index on the column, that already covers everything the additional index might do for you.

There may be more, waiting for the question to clear up.

Distorted test case

The test case is too far away from actual use case to be meaningful.

In the test setup, each table has 100k rows, there is no guarantee that every value in joincol has a match on the other side, and both columns can be NULL

Your real case has 10M rows in table1 and < 100 rows in table2, every value in table1.joincol has a match in table2.joincol, both are defined NOT NULL, and table2.joincol is unique. A classical one-to-many relationship. There should be a UNIQUE constraint on table2.joincol and a FK constraint t1.joincol --> t2.joincol.

But that's currently all twisted in the question. Standing by till that's cleaned up.

Erwin Brandstetter
  • 605,456
  • 145
  • 1,078
  • 1,228
  • Thank you very much for the explaination and especially the elaboration on `NULLS LAST` to match the `max` function. I gave some more queries a go on my "real" data - however it seems that the optimizer just isn't "smart" enough to figure the better query plan. – Dominik Sep 13 '21 at 07:19
  • Thank you Erwin! Interesting, that index doesn't store NULL (If I know well). So if optimizer use just indexes, the optimizer must know, there isn't NULL in that column. I can create index like this: CREATE INDEX ON test (joincol, id DESC NULLS LAST); And no influence? There is an Aggregate row on the top of the plan. That we can't eliminate, can? It is about 8% cost. – László Tóth Sep 13 '21 at 15:10
  • @LászlóTóth: `Interesting, that index doesn't store NULL (If I know well).` Not sure I understand. Indexes do store NULL values. But the index in question is on the `PRIMARY KEY` column, which cannot be NULL by definition. – Erwin Brandstetter Sep 13 '21 at 19:44
  • I believed that bad. Sorry. Yes postgres index store null-s, I can specify first or last! :) CREATE INDEX ON test (joincol, id DESC NULLS LAST); Why doesn't influence the plan? – László Tóth Sep 13 '21 at 20:07
  • @LászlóTóth: An index with `id DESC NULLS LAST` as first or only expression *does* influence the query plan for `ORDER BY ... LIMIT 1`. See the updated explanation and the fiddle to go with it: https://dbfiddle.uk/?rdbms=postgres_13&fiddle=8748792e7a4947dfc10c56d564f7c3ad – Erwin Brandstetter Sep 13 '21 at 23:04
-1

This is a very good problem, and good testcase. I tested it in postgres 9.3 perhaps 13 is can it more more fast.

I used Occam's Razor and i excluded some possiblities

  • View (without view is slow to)
  • JOIN can filter some rows (unfortunatly in your test not, but more length md5 5-6 yes)
  • Other basic equivalent select statements not solve yout problem (inner query or exists)
  • I achieved to use just index, but because the tables isn't bigger than indexes it was not the solution.

I think

CREATE INDEX on "test" ("id");

is useless, because PK!

If you change this

CREATE INDEX on "test" ("joincol");

to this

CREATE INDEX ON TEST (joincol, id);

Than the second query use just indexes.

After you run this

REINDEX table test;
REINDEX table test2;
VACUUM ANALYZE test;
VACUUM ANALYZE test2;

you can achive some performance tuning. Because you created indexes before inserts.

I think the reason is the two aim of DB.

First aim optimalize just some row. So run Nested Loop. You can force it with limit x. Second aim optimalize whole table. Run this query fast for whole table.

In this situation postgres optimalizer didn't notice that simple MAX can run with NESTED LOOP. Or perhaps postgres cannot use limit in aggregate clause (can run on whole partial select, what is filtered with query).

And this is very expensive. But you have possiblities to write there other aggregates, like SUM, MIN, AVG stb.

Perhaps can help you the Window functions too.

László Tóth
  • 483
  • 5
  • 15