Received: from malur.postgresql.org ([217.196.149.56]) by arkaria.postgresql.org with esmtps (TLS1.3:ECDHE_RSA_AES_256_GCM_SHA384:256) (Exim 4.92) (envelope-from ) id 1qCmOy-0000lr-Gq for pgsql-hackers@arkaria.postgresql.org; Fri, 23 Jun 2023 19:25:00 +0000 Received: from localhost ([127.0.0.1] helo=malur.postgresql.org) by malur.postgresql.org with esmtp (Exim 4.92) (envelope-from ) id 1qCmNy-0004KZ-2Z for pgsql-hackers@arkaria.postgresql.org; Fri, 23 Jun 2023 19:23:58 +0000 Received: from makus.postgresql.org ([2001:4800:3e1:1::229]) by malur.postgresql.org with esmtps (TLS1.3:ECDHE_RSA_AES_256_GCM_SHA384:256) (Exim 4.92) (envelope-from ) id 1qCmNx-0004KP-KE for pgsql-hackers@lists.postgresql.org; Fri, 23 Jun 2023 19:23:57 +0000 Received: from mail-ej1-x631.google.com ([2a00:1450:4864:20::631]) by makus.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (Exim 4.94.2) (envelope-from ) id 1qCmNp-0003c9-Ib for pgsql-hackers@lists.postgresql.org; Fri, 23 Jun 2023 19:23:55 +0000 Received: by mail-ej1-x631.google.com with SMTP id a640c23a62f3a-98377c5d53eso107348966b.0 for ; Fri, 23 Jun 2023 12:23:49 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=enterprisedb.com; s=google; t=1687548226; x=1690140226; h=content-transfer-encoding:subject:from:to:content-language :user-agent:mime-version:date:message-id:from:to:cc:subject:date :message-id:reply-to; bh=e33AANpyE3D1vrZEnfgbaxRrbZyB8UEHC5cZD21I1y8=; b=O8CHWsApPR1rZoppX4a38fu9YOpVr7NL5NGKb1mTeKgYmpBSR+UQQjdQBtBtNUsm6x YRrDEJSnPBZ42OhtJzTKDRv9Z4JBCv2EAxDDaflNDOwUpO35L42DOrRABLJRuM+Q2ZAG fyQ3ZN2LtZ0DPMnudjELygoBDwlA5KSJlAv7RIwzQ3tIhA8PrRwN+nB4oOeRzIBuCXeb 1qI6O8H5+pnWZK6+AiRLn9mjiupj5XLZWr54xReWbVqaB3065R8Yqyr+kkgVTpJHOxcC MPCnE96W0SeA2CcvWhiEn4XVJba2J6N8jxoGJZmEYZ1Fp9+maUNLtg/Vpe3N6Z7WJ5fd D8JQ== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20221208; t=1687548226; x=1690140226; h=content-transfer-encoding:subject:from:to:content-language :user-agent:mime-version:date:message-id:x-gm-message-state:from:to :cc:subject:date:message-id:reply-to; bh=e33AANpyE3D1vrZEnfgbaxRrbZyB8UEHC5cZD21I1y8=; b=fSeW2nQRpi5LbQoIVWAbN9Eh367JKpCsu4m2uQ8zW2ToxdfPCivTZkyCjkZcBkgla8 vM8QLLiW1kHi8C7xOUGNl9u1NN78V4Lk539X6KITBUe5FHMxtBiO/osIBMjwS6TWMaTk Nl7TD59ErmbJQ/LCNgwuOnsYCB2gu0uJo5uTg8PtMccmWtYCVneIyzMH+/Ts2GDQZwU7 /t4PZp3snAlY/mPi0SbzyA9iengaVKqdKOzAAI0zWsNk5CqB5wyg9aau6xJmx4O5IUSk UNCXTy8pyX7N2brnjaHKcc/Ci9Ji452wDSMP4UcT9EvIdrGDnzBerD9bFR+9jWSdO/wB eT7A== X-Gm-Message-State: AC+VfDxlVpC/+NW97mH2+/2jkvqy6I25RkB3pid1inOzhFMKjJZ4qZfA Egu7Ui3VxnoQLAxxtLCB5KNLhMVQ1pLyJNns+ruK2SAQv+8znHm6aWdEKlmXru99Syc2DBmBcqi NM/kkmYGAj6KhR97VqdggasozpJZnTQSXVNAhTt75W/NPZ9BeZMQAXmJo6/8l0IBT6jP0jnmLrz 2zDakJM9wEQaLPAU3LfaQBwbVttA== X-Google-Smtp-Source: ACHHUZ6YXNJEi5JZcr5dLt5c/SI0wiNmcjvEFEi8E7hqVKtblPIO0bI1ZuIusLQFcymM3OMnqhQwwQ== X-Received: by 2002:a17:907:3e90:b0:989:4cfe:313 with SMTP id hs16-20020a1709073e9000b009894cfe0313mr10119541ejc.14.1687548226144; Fri, 23 Jun 2023 12:23:46 -0700 (PDT) Received: from [10.137.0.17] (ip-86-49-228-220.bb.vodafone.cz. [86.49.228.220]) by smtp.gmail.com with ESMTPSA id a18-20020a170906685200b0098d6b867b32sm1465907ejs.214.2023.06.23.12.23.45 for (version=TLS1_3 cipher=TLS_AES_128_GCM_SHA256 bits=128/128); Fri, 23 Jun 2023 12:23:45 -0700 (PDT) Message-ID: <52b55b53-420a-722c-adbd-706922fc059b@enterprisedb.com> Date: Fri, 23 Jun 2023 21:23:43 +0200 MIME-Version: 1.0 User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Thunderbird/102.11.1 Content-Language: en-US To: PostgreSQL Hackers From: Tomas Vondra Subject: Problems with estimating OR conditions, IS NULL on LEFT JOINs Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 7bit List-Id: List-Help: List-Subscribe: List-Post: List-Owner: List-Archive: Archived-At: Precedence: bulk Hi, I ran into a pretty terrible case of LEFT JOIN estimate, resulting in pretty arbitrary underestimate. The query is pretty trivial, nothing overly complex, and the more I think about it the more I think this is a fairly fundamental flaw in how we estimate this type of joins. Imagine you have two trivial tables: CREATE TABLE large (id INT, a INT); INSERT INTO large SELECT i, i FROM generate_series(1,1000000) s(i); CREATE TABLE small (id INT, b INT); INSERT INTO small SELECT i, i FROM generate_series(1,100) s(i); The business meaning may be that "large" stores orders and "small" is for events related to tiny fraction of the large table (e.g. returns). And let's do a couple simple LEFT JOIN queries, adding conditions to it. Let's start with no condition at all: EXPLAIN ANALYZE SELECT * FROM large LEFT JOIN small ON (large.id = small.id) QUERY PLAN ---------------------------------------------------------------------- Hash Left Join (cost=3.25..18179.25 rows=1000000 width=16) (actual time=0.069..550.290 rows=1000000 loops=1) Hash Cond: (large.id = small.id) -> Seq Scan on large (cost=0.00..14425.00 rows=1000000 width=8) (actual time=0.010..174.056 rows=1000000 loops=1) -> Hash (cost=2.00..2.00 rows=100 width=8) (actual time=0.052... Buckets: 1024 Batches: 1 Memory Usage: 12kB -> Seq Scan on small (cost=0.00..2.00 rows=100 width=8) ... Planning Time: 0.291 ms Execution Time: 663.551 ms (8 rows) So great, this estimate is perfect. Now, let's add IS NULL condition on the small table, to find rows without a match (e.g. orders that were not returned): EXPLAIN ANALYZE SELECT * FROM large LEFT JOIN small ON (large.id = small.id) WHERE (small.id IS NULL); QUERY PLAN ---------------------------------------------------------------------- Hash Anti Join (cost=3.25..27052.36 rows=999900 width=16) (actual time=0.071..544.568 rows=999900 loops=1) Hash Cond: (large.id = small.id) -> Seq Scan on large (cost=0.00..14425.00 rows=1000000 width=8) (actual time=0.015..166.019 rows=1000000 loops=1) -> Hash (cost=2.00..2.00 rows=100 width=8) (actual time=0.051... Buckets: 1024 Batches: 1 Memory Usage: 12kB -> Seq Scan on small (cost=0.00..2.00 rows=100 width=8) ... Planning Time: 0.260 ms Execution Time: 658.379 ms (8 rows) Also very accurate, great! Now let's do a condition on the large table instead, filtering some the rows: EXPLAIN ANALYZE SELECT * FROM large LEFT JOIN small ON (large.id = small.id) WHERE (large.a IN (1000, 2000, 3000, 4000, 5000)); QUERY PLAN ---------------------------------------------------------------------- Nested Loop Left Join (cost=0.00..20684.75 rows=5 width=16) (actual time=0.957..127.376 rows=5 loops=1) Join Filter: (large.id = small.id) Rows Removed by Join Filter: 500 -> Seq Scan on large (cost=0.00..20675.00 rows=5 width=8) (actual time=0.878..127.171 rows=5 loops=1) Filter: (a = ANY ('{1000,2000,3000,4000,5000}'::integer[])) Rows Removed by Filter: 999995 -> Materialize (cost=0.00..2.50 rows=100 width=8) ... -> Seq Scan on small (cost=0.00..2.00 rows=100 width=8) ... Planning Time: 0.223 ms Execution Time: 127.407 ms (10 rows) Also great estimate! Surely, if we do both conditions with OR, we'll get a decent estimate too? EXPLAIN ANALYZE SELECT * FROM large LEFT JOIN small ON (large.id = small.id) WHERE (small.id IS NULL) OR (large.a IN (1000, 2000, 3000, 4000, 5000)); QUERY PLAN ---------------------------------------------------------------------- Hash Left Join (cost=3.25..18179.88 rows=5 width=16) (actual time=0.073..580.827 rows=999900 loops=1) Hash Cond: (large.id = small.id) Filter: ((small.id IS NULL) OR (large.a = ANY ('{1000,2000,3000,4000,5000}'::integer[]))) Rows Removed by Filter: 100 -> Seq Scan on large (cost=0.00..14425.00 rows=1000000 width=8) (actual time=0.015..166.809 rows=1000000 loops=1) -> Hash (cost=2.00..2.00 rows=100 width=8) (actual time=0.052... Buckets: 1024 Batches: 1 Memory Usage: 12kB -> Seq Scan on small (cost=0.00..2.00 rows=100 width=8) ... Planning Time: 0.309 ms Execution Time: 694.427 ms (10 rows) Well, bummer! This is pretty surprising, because if we know that clause A produces estimate 1M and clause B estimates as 5, then it's expected that (A OR B) should be estimated as something >= max(1M, 5). For users running this, this has to be really surprising. It's also quite serious, because with underestimates like this the planner is likely to pick nestloops for additional joins, and we all know how that performs for millions of rows ... So, how does this happen? Well, the simple reason is that joins are estimated by applying selectivities for all the clauses on a cartesian product. So we calculate product (100 * 1M), and then apply selectivity for the join condition, and the two single-table clauses. The problem is that the selectivity for "IS NULL" is estimated using the table-level statistics. But the LEFT JOIN entirely breaks the idea that the null_frac has anything to do with NULLs in the join result. Because the join result is not a subset of cartesian product - it's a superset. Even if the small table has no NULLs, the join can have them - that's the whole point of outer joins. When there's only the IS NULL condition, we actually recognize this as a special case and treat the join as antijoin (Hash Anti Join), and that also fixes the estimates - antijoins do handle this fine. But as soon as you change the condition a bit and do the IS NULL check on the other column of the table, it goes wrong too: EXPLAIN ANALYZE SELECT * FROM large LEFT JOIN small ON (large.id = small.id) WHERE (small.b IS NULL); QUERY PLAN ---------------------------------------------------------------------- Hash Left Join (cost=3.25..18179.25 rows=1 width=16) (actual time=0.311..3110.298 rows=999900 loops=1) Hash Cond: (large.id = small.id) Filter: (small.b IS NULL) Rows Removed by Filter: 100 -> Seq Scan on large (cost=0.00..14425.00 rows=1000000 width=8) (actual time=0.014..1032.497 rows=1000000 loops=1) -> Hash (cost=2.00..2.00 rows=100 width=8) (actual time=0.287... Buckets: 1024 Batches: 1 Memory Usage: 12kB -> Seq Scan on small (cost=0.00..2.00 rows=100 width=8) ... Planning Time: 0.105 ms Execution Time: 4083.634 ms (10 rows) I'd bet most users would be rather surprised to learn this subtle change makes a difference. I wonder how to improve this, say by adjusting the IS NULL selectivity when we know to operate on the outer side of the join. We're able to do this for antijoins, so maybe we could do that here, somehow? Unfortunately the important things (join type detection, IS NULL clause estimation) happen pretty far away, but maybe we could pass that info about fraction of NULLs introduced by he join to the nulltestsel, and use it there (essentially by doing null_frac + join_null_frac). And maybe we could do something like that for NULL tests on other columns from the outer side ... The other thing that might be beneficial is calculating boundaries for the estimate. In this case we're capable of estimating the join size for individual conditions, and then we could make ensure the final estimate for the "a OR b" join is >= max of these cardinalities. Of course, that might be expensive if we have to redo some of the join planning/estimates for each individual condition (otherwise might not notice the antijoin transformation and stuff like that). regards -- Tomas Vondra EnterpriseDB: http://www.enterprisedb.com The Enterprise PostgreSQL Company