3

Considering the following query:

select a.id from a
where
    a.id in (select b.a_id from b where b.x='x1' and b.y='y1') and
    a.id in (select b.a_id from b where b.x='x2' and b.y='y2')
order by a.date desc
limit 20

Which should be rewritable to that faster one:

select a.id from a inner join b as b1 on (a.id=b1.a_id) inner join b as b2 on (a.id=b2.a_id)
where
    b1.x='x1' and b1.y='y1' and
    b2.x='x2' and b2.y='y2'
order by a.date desc
limit 20

We would prefer not to rewrite our queries by changing our source code as it complicates a lot (especially when using Django).

Thus, we wonder when PostgreSQL collapses subqueries to joins and when not?

That is the simplified data model:

                                      Table "public.a"
      Column       |          Type          |                          Modifiers
-------------------+------------------------+-------------------------------------------------------------
 id                | integer                | not null default nextval('a_id_seq'::regclass)
 date              | date                   | 
 content           | character varying(256) | 
Indexes:
    "a_pkey" PRIMARY KEY, btree (id)
    "a_id_date" btree (id, date)
Referenced by:
    TABLE "b" CONSTRAINT "a_id_refs_id_6e634433343d4435353" FOREIGN KEY (a_id) REFERENCES a(id) DEFERRABLE INITIALLY DEFERRED


       Table "public.b"
  Column  |   Type    | Modifiers 
----------+-----------+-----------
 a_id     | integer   | not null
 x        | text      | not null
 y        | text      | not null
Indexes:
    "b_x_y_a_id" UNIQUE CONSTRAINT, btree (x, y, a_id)
Foreign-key constraints:
    "a_id_refs_id_6e634433343d4435353" FOREIGN KEY (a_id) REFERENCES a(id) DEFERRABLE INITIALLY DEFERRED
  • a has 7 million rows
  • b has 70 million rows
  • cardinality of b.x = ~100
  • cardinality of b.y = ~100000
  • cardinality of b.x, b.y = ~150000
  • imagine tables c, d and e that have the same structure as b and could be used additionally to further reduce the resulting a.ids

Versions of PostgreSQL, we tested the queries.

  • PostgreSQL 9.2.7 on x86_64-suse-linux-gnu, compiled by gcc (SUSE Linux) 4.7.2 20130108 [gcc-4_7-branch revision 195012], 64-bit
  • PostgreSQL 9.4beta1 on x86_64-suse-linux-gnu, compiled by gcc (SUSE Linux) 4.7.2 20130108 [gcc-4_7-branch revision 195012], 64-bit

Query Plans (with empty file cache and mem cache):

tbz
  • 197
  • 2
  • 10
  • 3
    Are you sure it's not collapsing it? Please post the query plans. (And be so kind to reformat the queries.) – Denis de Bernardy Dec 08 '14 at 17:22
  • Show `EXPLAIN ANALYZE` output for both queries please. Please also show your PostgreSQL version - `SELECT version()`. – Craig Ringer Dec 08 '14 at 19:30
  • @Denis Yes but see for yourself. Done. – tbz Dec 08 '14 at 22:30
  • @craig-ringer Done. Done. – tbz Dec 08 '14 at 22:30
  • The second query could possibly result in 70M rows in the result set(more than one b.a_id tuple could refer to the same a.id tuple). Ergo: the queries are different (But `EXISTS(...)` should be preferred over `IN(...)`, IMnshO) – wildplasser Dec 08 '14 at 23:00
  • @wildplasser Would a unique constraint on (b.x, b.y, b.a_id) help here? – tbz Dec 08 '14 at 23:12
  • Please show us the data model (DDL) And maybe add some semantics... – wildplasser Dec 08 '14 at 23:17
  • `(cost=0.57..36.35 rows=789 width=4) (actual time=28.892..77.701 rows=15195 loops=1)` hints that you may have forgotten to run `analyze` on your tables: the stats are off. Can you try that and re-post the plans after doing so? If relevant, alter the tables and `SET STATISTICS` to increase the amount of stats collected on it so the planner has more to work on. – Denis de Bernardy Dec 08 '14 at 23:23
  • @denis I thought the same when examining these plans later yesterday. I ran vacuum analyze once more just for you and the situation did not improve: "Nested Loop (cost=34.46..86.83 rows=776 width=12) (actual time=126.639..8110.324 rows=15195 loops=1)". Thanks for the hint of statistics, however it looks like default is 'on' for everything and I have not tinkered with none of them. – tbz Dec 08 '14 at 23:35
  • BTW: `order by a.date limit 20` is a potential performance killer. (and `date` should **not** be used as a column name, since it is a type name) – wildplasser Dec 08 '14 at 23:40
  • @wildplasser I would be glad for suggestions. I substituted the original name by date to make the query more readable. – tbz Dec 08 '14 at 23:43
  • Still no DDL. BTW: what is the cardinality / specificity of `where b.x='x2' and b.y='y2'` et.al. ? – wildplasser Dec 08 '14 at 23:51
  • 1
    @wildplasser The hint with that in queries are not necessarily equal to join was very helpful. I added a UNIQUE constraint, added the query plans and the data model. As it seems now, PostgreSQL can collapse the query. Thanks for the help. – tbz Dec 09 '14 at 11:31
  • @wildplasser Furthermore, the now resulting query plan is still not as fast as the join query. How come? What is a Merge Semi Join? – tbz Dec 09 '14 at 11:32
  • @Denis I raised statistics to 10000 for all columns and posted the query plans. As it seems, PostgreSQL now uses a different method "Nested Loop Semi Join" instead of "Merge Semi Join" but it still performs worse. Note, that the planning for the optimal query plan now takes a lot of time (1440.224 ms as opposed to 309.566 ms) which makes this option unfavorable. – tbz Dec 09 '14 at 22:55
  • @wildplasser I hope I improved the post to your satisfaction. – tbz Dec 09 '14 at 23:01
  • @tbz: I'm somewhat at a loss as to what to suggest atm, short of suggesting to post the url to this thread in the pg performance mailing list. I'm pretty sure you'll get a thorough explanation as to why this is occurring and why the two query plans differ from no less than the one and only Tom Lane himself. Upon doing so, please post the final outcome as an answer. And a url to the discussion as a comment. I'm quite sure both will get up-voted quite heavily -- if only by the SO Postgres junkies. – Denis de Bernardy Dec 09 '14 at 23:54
  • I re-did the queries with setting statitics to 10000 also for the column a.id and a.date. It helped in reducing planning cost. So, my preliminary conclusion is PostgreSQL collapses if the results are really equal and which needs to be indicated by UNIQUE constraints. – tbz Dec 10 '14 at 10:47

2 Answers2

1

Your last comment nails the reason, I think: The two queries are not equivalent unless a unique constraint kicks in to make them equivalent.

Example of an equivalent schema:

denis=# \d a
                         Table "public.a"
 Column |  Type   |                   Modifiers                    
--------+---------+------------------------------------------------
 id     | integer | not null default nextval('a_id_seq'::regclass)
 d      | date    | not null
Indexes:
    "a_pkey" PRIMARY KEY, btree (id)
Referenced by:
    TABLE "b" CONSTRAINT "b_a_id_fkey" FOREIGN KEY (a_id) REFERENCES a(id)

denis=# \d b
       Table "public.b"
 Column |  Type   | Modifiers 
--------+---------+-----------
 a_id   | integer | not null
 val    | integer | not null
Foreign-key constraints:
    "b_a_id_fkey" FOREIGN KEY (a_id) REFERENCES a(id)

Equivalent offending data using that schema:

denis=# select * from a order by d;
 id |     d      
----+------------
  1 | 2014-12-10
  2 | 2014-12-11
  3 | 2014-12-12
  4 | 2014-12-13
  5 | 2014-12-14
  6 | 2014-12-15
(6 rows)

denis=# select * from b order by a_id, val;
 a_id | val 
------+-----
    1 |   1
    1 |   1
    2 |   1
    2 |   1
    2 |   2
    3 |   1
    3 |   1
    3 |   2
(8 rows)

Rows using two IN clauses:

denis=# select a.id, a.d from a where a.id in (select b.a_id from b where b.val = 1) and a.id in (select b.a_id from b where b.val = 2) order by d;
 id |     d      
----+------------
  2 | 2014-12-11
  3 | 2014-12-12
(2 rows)

Rows using two joins:

denis=# select a.id, a.d from a join b b1 on a.id = b1.a_id join b b2 on a.id = b2.a_id where b1.val = 1 and b2.val = 2 order by d;
 id |     d      
----+------------
  2 | 2014-12-11
  2 | 2014-12-11
  3 | 2014-12-12
  3 | 2014-12-12
(4 rows)

I see you've a unique constraint on b (a_id, x, y) already, though. Perhaps highlight the issue to the Postgres performance list to get the reason why it's not collapsed in your particular case -- or at least not generating the exact same plan.

Denis de Bernardy
  • 75,850
  • 13
  • 131
  • 154
0
        -- The table definitions
CREATE TABLE table_a (
        id     SERIAL NOT NULL PRIMARY KEY
        , d      DATE NOT NULL
        );

CREATE TABLE table_b (
        id     SERIAL NOT NULL PRIMARY KEY
        , a_id INTEGER NOT NULL REFERENCES table_a(id)
        , x VARCHAR NOT NULL
        , y VARCHAR NOT NULL
        );
        -- fake some data
INSERT INTO table_a(d)
SELECT gs
FROM generate_series( '1904-01-01'::timestamp ,'2015-01-01'::timestamp, '1 day'::interval) gs;
INSERT INTO table_b(a_id, x, y) SELECT a.id, 'x1' , 'y1' FROM table_a a;
INSERT INTO table_b(a_id, x, y) SELECT a.id, 'x2' , 'y2' FROM table_a a;
INSERT INTO table_b(a_id, x, y) SELECT a.id, 'x3' , 'y3' FROM table_a a;
DELETE FROM table_b WHERE RANDOM() > 0.3;

CREATE UNIQUE INDEX ON table_a(d, id);  -- date first
CREATE INDEX ON table_b(a_id);          -- supporting the FK

        -- For initialising the statistics
VACUUM ANALYZE table_a;
VACUUM ANALYZE table_b;

        -- original query
EXPLAIN ANALYZE
SELECT a.id
FROM table_a a
WHERE a.id IN (SELECT b.a_id FROM table_b b WHERE b.x='x1' AND b.y='y1')
  AND a.id IN (SELECT b.a_id FROM table_b b WHERE b.x='x2' AND b.y='y2')
order by a.d desc
limit 20;

        -- EXISTS() version
EXPLAIN ANALYZE
SELECT a.id
FROM table_a a
WHERE EXISTS (SELECT * FROM table_b b WHERE b.a_id= a.id AND b.x='x1' AND b.y='y1')
  AND EXISTS (SELECT * FROM table_b b WHERE b.a_id= a.id AND b.x='x2' AND b.y='y2')
order by a.d desc
limit 20;

Resulting query plan:

 Limit  (cost=0.87..491.23 rows=20 width=8) (actual time=0.080..0.521 rows=20 loops=1)
   ->  Nested Loop Semi Join  (cost=0.87..15741.40 rows=642 width=8) (actual time=0.080..0.518 rows=20 loops=1)
         ->  Nested Loop Semi Join  (cost=0.58..14380.54 rows=4043 width=12) (actual time=0.017..0.391 rows=74 loops=1)
               ->  Index Only Scan Backward using table_a_d_id_idx on table_a a  (cost=0.29..732.75 rows=40544 width=8) (actual time=0.008..0.048 rows=231 loops=1)
                     Heap Fetches: 0
               ->  Index Scan using table_b_a_id_idx on table_b b_1  (cost=0.29..0.34 rows=1 width=4) (actual time=0.001..0.001 rows=0 loops=231)
                     Index Cond: (a_id = a.id)
                     Filter: (((x)::text = 'x2'::text) AND ((y)::text = 'y2'::text))
                     Rows Removed by Filter: 0
         ->  Index Scan using table_b_a_id_idx on table_b b  (cost=0.29..0.34 rows=1 width=4) (actual time=0.001..0.001 rows=0 loops=74)
               Index Cond: (a_id = a.id)
               Filter: (((x)::text = 'x1'::text) AND ((y)::text = 'y1'::text))
               Rows Removed by Filter: 1
 Total runtime: 0.547 ms

  • The two queries cause exactly the same query plan and results (because of the NOT NULL on tableb.a_id )
  • The index table_b(a_id) is absolutely necessary once you prefer index joins over hashjoins (with 7M//70M tuples, I think you should prefer index scans)
  • The sort (expensive) in the outer query has been avoided (using the index table_a(d, id) )
wildplasser
  • 43,142
  • 8
  • 66
  • 109