Postgres picks suboptimal index after building of an extended statistics
Hi,
Ivan Frolkov reported a problem with choosing a non-optimal index during
a query optimization. This problem appeared after building of an
extended statistics.
I prepared the test case (see t.sql in attachment).
For reproduction of this case we need to have a composite primary key
index and one another index.
Before creation of extended statistics, SELECT from the table choose PK
index and returns only one row. But after, this SELECT picks alternative
index, fetches and filters many tuples.
The problem is related to a corner case in btree cost estimation procedure:
if postgres detects unique one-row index scan, it sets
numIndexTuples to 1.0.
But the selectivity is calculated as usual, by the
clauselist_selectivity() routine and can have a value, much more than
corresponding to single tuple. This selectivity value is used later in
the code to calculate a number of fetched tuples and can lead to
choosing of an suboptimal index.
The attached patch is my suggestion to fix this problem.
--
regards,
Andrey Lepikhov
Postgres Professional
Attachments:
0001-In-the-case-of-an-unique-one-row-btree-index-scan-on.patchtext/plain; charset=UTF-8; name=0001-In-the-case-of-an-unique-one-row-btree-index-scan-on.patch; x-mac-creator=0; x-mac-type=0Download
From 810eb4691acec26a07d8fa67bedb7a7381c31824 Mon Sep 17 00:00:00 2001
From: Andrey Lepikhov <a.lepikhov@postgrespro.ru>
Date: Wed, 23 Jun 2021 12:05:24 +0500
Subject: [PATCH] In the case of an unique one row btree index scan only one
row can be returned. In the genericcostestimate() routine we must arrange the
index selectivity value in accordance with this knowledge.
---
src/backend/utils/adt/selfuncs.c | 106 ++++++++++++++++++-------------
1 file changed, 62 insertions(+), 44 deletions(-)
diff --git a/src/backend/utils/adt/selfuncs.c b/src/backend/utils/adt/selfuncs.c
index 0c8c05f6c2..91160960aa 100644
--- a/src/backend/utils/adt/selfuncs.c
+++ b/src/backend/utils/adt/selfuncs.c
@@ -6369,65 +6369,82 @@ genericcostestimate(PlannerInfo *root,
double num_scans;
double qual_op_cost;
double qual_arg_cost;
- List *selectivityQuals;
- ListCell *l;
-
- /*
- * If the index is partial, AND the index predicate with the explicitly
- * given indexquals to produce a more accurate idea of the index
- * selectivity.
- */
- selectivityQuals = add_predicate_to_index_quals(index, indexQuals);
- /*
- * Check for ScalarArrayOpExpr index quals, and estimate the number of
- * index scans that will be performed.
- */
+ numIndexTuples = costs->numIndexTuples;
num_sa_scans = 1;
- foreach(l, indexQuals)
+
+ if (numIndexTuples >= 0.0)
{
- RestrictInfo *rinfo = (RestrictInfo *) lfirst(l);
+ List *selectivityQuals;
+ ListCell *l;
- if (IsA(rinfo->clause, ScalarArrayOpExpr))
+ /*
+ * If the index is partial, AND the index predicate with the explicitly
+ * given indexquals to produce a more accurate idea of the index
+ * selectivity.
+ */
+ selectivityQuals = add_predicate_to_index_quals(index, indexQuals);
+
+ /*
+ * Check for ScalarArrayOpExpr index quals, and estimate the number of
+ * index scans that will be performed.
+ */
+ foreach(l, indexQuals)
{
- ScalarArrayOpExpr *saop = (ScalarArrayOpExpr *) rinfo->clause;
- int alength = estimate_array_length(lsecond(saop->args));
+ RestrictInfo *rinfo = (RestrictInfo *) lfirst(l);
- if (alength > 1)
- num_sa_scans *= alength;
+ if (IsA(rinfo->clause, ScalarArrayOpExpr))
+ {
+ ScalarArrayOpExpr *saop = (ScalarArrayOpExpr *) rinfo->clause;
+ int alength = estimate_array_length(lsecond(saop->args));
+
+ if (alength > 1)
+ num_sa_scans *= alength;
+ }
}
- }
- /* Estimate the fraction of main-table tuples that will be visited */
- indexSelectivity = clauselist_selectivity(root, selectivityQuals,
- index->rel->relid,
- JOIN_INNER,
- NULL);
+ /* Estimate the fraction of main-table tuples that will be visited */
+ indexSelectivity = clauselist_selectivity(root, selectivityQuals,
+ index->rel->relid,
+ JOIN_INNER,
+ NULL);
- /*
- * If caller didn't give us an estimate, estimate the number of index
- * tuples that will be visited. We do it in this rather peculiar-looking
- * way in order to get the right answer for partial indexes.
- */
- numIndexTuples = costs->numIndexTuples;
- if (numIndexTuples <= 0.0)
+ /*
+ * If caller didn't give us an estimate, estimate the number of index
+ * tuples that will be visited. We do it in this rather peculiar-looking
+ * way in order to get the right answer for partial indexes.
+ */
+ if (numIndexTuples == 0.0)
+ {
+ numIndexTuples = indexSelectivity * index->rel->tuples;
+
+ /*
+ * The above calculation counts all the tuples visited across all
+ * scans induced by ScalarArrayOpExpr nodes. We want to consider the
+ * average per-indexscan number, so adjust. This is a handy place to
+ * round to integer, too. (If caller supplied tuple estimate, it's
+ * responsible for handling these considerations.)
+ */
+ numIndexTuples = rint(numIndexTuples / num_sa_scans);
+ }
+ }
+ else
{
- numIndexTuples = indexSelectivity * index->rel->tuples;
+ Assert(numIndexTuples == -1.0);
/*
- * The above calculation counts all the tuples visited across all
- * scans induced by ScalarArrayOpExpr nodes. We want to consider the
- * average per-indexscan number, so adjust. This is a handy place to
- * round to integer, too. (If caller supplied tuple estimate, it's
- * responsible for handling these considerations.)
+ * Unique one row scan can select no more than one row. It needs to
+ * manually set the selectivity of the index. The value of numIndexTuples
+ * will be corrected later.
*/
- numIndexTuples = rint(numIndexTuples / num_sa_scans);
+ indexSelectivity = 1.0 / index->rel->tuples;
}
/*
* We can bound the number of tuples by the index size in any case. Also,
* always estimate at least one tuple is touched, even when
* indexSelectivity estimate is tiny.
+ * Also, one row single scan case need to fix the numIndexTuples value here.
*/
if (numIndexTuples > index->tuples)
numIndexTuples = index->tuples;
@@ -6710,16 +6727,17 @@ btcostestimate(PlannerInfo *root, IndexPath *path, double loop_count,
/*
* If index is unique and we found an '=' clause for each column, we can
- * just assume numIndexTuples = 1 and skip the expensive
- * clauselist_selectivity calculations. However, a ScalarArrayOp or
- * NullTest invalidates that theory, even though it sets eqQualHere.
+ * skip the expensive clauselist_selectivity calculations and assume
+ * numIndexTuples = -1.0 in order to tell the generic cost estimator to skip
+ * such expensive calculations too. However, a ScalarArrayOp or NullTest
+ * invalidates that theory, even though it sets eqQualHere.
*/
if (index->unique &&
indexcol == index->nkeycolumns - 1 &&
eqQualHere &&
!found_saop &&
!found_is_null_op)
- numIndexTuples = 1.0;
+ numIndexTuples = -1.0;
else
{
List *selectivityQuals;
--
2.31.1
On Wed, Jun 23, 2021 at 7:19 AM Andrey V. Lepikhov
<a.lepikhov@postgrespro.ru> wrote:
Ivan Frolkov reported a problem with choosing a non-optimal index during
a query optimization. This problem appeared after building of an
extended statistics.
Any thoughts on this, Tomas?
--
Peter Geoghegan
On 8/11/21 2:48 AM, Peter Geoghegan wrote:
On Wed, Jun 23, 2021 at 7:19 AM Andrey V. Lepikhov
<a.lepikhov@postgrespro.ru> wrote:Ivan Frolkov reported a problem with choosing a non-optimal index during
a query optimization. This problem appeared after building of an
extended statistics.Any thoughts on this, Tomas?
Thanks for reminding me, I missed / forgot about this thread.
I agree the current behavior is unfortunate, but I'm not convinced the
proposed patch is fixing the right place - doesn't this mean the index
costing won't match the row estimates displayed by EXPLAIN?
I wonder if we should teach clauselist_selectivity about UNIQUE indexes,
and improve the cardinality estimates directly, not just costing for
index scans.
Also, is it correct that the patch calculates num_sa_scans only when
(numIndexTuples >= 0.0)?
regards
--
Tomas Vondra
EnterpriseDB: http://www.enterprisedb.com
The Enterprise PostgreSQL Company
On 8/12/21 4:26 AM, Tomas Vondra wrote:
On 8/11/21 2:48 AM, Peter Geoghegan wrote:
On Wed, Jun 23, 2021 at 7:19 AM Andrey V. Lepikhov
<a.lepikhov@postgrespro.ru> wrote:Ivan Frolkov reported a problem with choosing a non-optimal index during
a query optimization. This problem appeared after building of an
extended statistics.Any thoughts on this, Tomas?
Thanks for reminding me, I missed / forgot about this thread.
I agree the current behavior is unfortunate, but I'm not convinced the
proposed patch is fixing the right place - doesn't this mean the index
costing won't match the row estimates displayed by EXPLAIN?
I think, it is not a problem. In EXPLAIN you will see only 1 row
with/without this patch.
I wonder if we should teach clauselist_selectivity about UNIQUE indexes,
and improve the cardinality estimates directly, not just costing for
index scans.
This idea looks better. I will try to implement it.
Also, is it correct that the patch calculates num_sa_scans only when
(numIndexTuples >= 0.0)?
Thanks, fixed.
--
regards,
Andrey Lepikhov
Postgres Professional
Attachments:
v2-0001-In-the-case-of-an-unique-one-row-btree-index-scan-on.patchtext/x-patch; charset=UTF-8; name=v2-0001-In-the-case-of-an-unique-one-row-btree-index-scan-on.patchDownload
From 8a4ad08d61a5d14a45ef5e182f002e918f0eaccc Mon Sep 17 00:00:00 2001
From: Andrey Lepikhov <a.lepikhov@postgrespro.ru>
Date: Wed, 23 Jun 2021 12:05:24 +0500
Subject: [PATCH] In the case of an unique one row btree index scan only one
row can be returned. In the genericcostestimate() routine we must arrange the
index selectivity value in accordance with this knowledge.
---
src/backend/utils/adt/selfuncs.c | 78 ++++++++++++++++++++------------
1 file changed, 48 insertions(+), 30 deletions(-)
diff --git a/src/backend/utils/adt/selfuncs.c b/src/backend/utils/adt/selfuncs.c
index 0c8c05f6c2..9538c4a5b4 100644
--- a/src/backend/utils/adt/selfuncs.c
+++ b/src/backend/utils/adt/selfuncs.c
@@ -6369,15 +6369,9 @@ genericcostestimate(PlannerInfo *root,
double num_scans;
double qual_op_cost;
double qual_arg_cost;
- List *selectivityQuals;
ListCell *l;
- /*
- * If the index is partial, AND the index predicate with the explicitly
- * given indexquals to produce a more accurate idea of the index
- * selectivity.
- */
- selectivityQuals = add_predicate_to_index_quals(index, indexQuals);
+ numIndexTuples = costs->numIndexTuples;
/*
* Check for ScalarArrayOpExpr index quals, and estimate the number of
@@ -6398,36 +6392,59 @@ genericcostestimate(PlannerInfo *root,
}
}
- /* Estimate the fraction of main-table tuples that will be visited */
- indexSelectivity = clauselist_selectivity(root, selectivityQuals,
- index->rel->relid,
- JOIN_INNER,
- NULL);
+ if (numIndexTuples >= 0.0)
+ {
+ List *selectivityQuals;
- /*
- * If caller didn't give us an estimate, estimate the number of index
- * tuples that will be visited. We do it in this rather peculiar-looking
- * way in order to get the right answer for partial indexes.
- */
- numIndexTuples = costs->numIndexTuples;
- if (numIndexTuples <= 0.0)
+ /*
+ * If the index is partial, AND the index predicate with the explicitly
+ * given indexquals to produce a more accurate idea of the index
+ * selectivity.
+ */
+ selectivityQuals = add_predicate_to_index_quals(index, indexQuals);
+
+ /* Estimate the fraction of main-table tuples that will be visited */
+ indexSelectivity = clauselist_selectivity(root, selectivityQuals,
+ index->rel->relid,
+ JOIN_INNER,
+ NULL);
+
+ /*
+ * If caller didn't give us an estimate, estimate the number of index
+ * tuples that will be visited. We do it in this rather peculiar-looking
+ * way in order to get the right answer for partial indexes.
+ */
+ if (numIndexTuples == 0.0)
+ {
+ numIndexTuples = indexSelectivity * index->rel->tuples;
+
+ /*
+ * The above calculation counts all the tuples visited across all
+ * scans induced by ScalarArrayOpExpr nodes. We want to consider the
+ * average per-indexscan number, so adjust. This is a handy place to
+ * round to integer, too. (If caller supplied tuple estimate, it's
+ * responsible for handling these considerations.)
+ */
+ numIndexTuples = rint(numIndexTuples / num_sa_scans);
+ }
+ }
+ else
{
- numIndexTuples = indexSelectivity * index->rel->tuples;
+ Assert(numIndexTuples == -1.0);
/*
- * The above calculation counts all the tuples visited across all
- * scans induced by ScalarArrayOpExpr nodes. We want to consider the
- * average per-indexscan number, so adjust. This is a handy place to
- * round to integer, too. (If caller supplied tuple estimate, it's
- * responsible for handling these considerations.)
+ * Unique one row scan can select no more than one row. It needs to
+ * manually set the selectivity of the index. The value of numIndexTuples
+ * will be corrected later.
*/
- numIndexTuples = rint(numIndexTuples / num_sa_scans);
+ indexSelectivity = 1.0 / index->rel->tuples;
}
/*
* We can bound the number of tuples by the index size in any case. Also,
* always estimate at least one tuple is touched, even when
* indexSelectivity estimate is tiny.
+ * Also, one row single scan case need to fix the numIndexTuples value here.
*/
if (numIndexTuples > index->tuples)
numIndexTuples = index->tuples;
@@ -6710,16 +6727,17 @@ btcostestimate(PlannerInfo *root, IndexPath *path, double loop_count,
/*
* If index is unique and we found an '=' clause for each column, we can
- * just assume numIndexTuples = 1 and skip the expensive
- * clauselist_selectivity calculations. However, a ScalarArrayOp or
- * NullTest invalidates that theory, even though it sets eqQualHere.
+ * skip the expensive clauselist_selectivity calculations and assume
+ * numIndexTuples = -1.0 in order to tell the generic cost estimator to skip
+ * such expensive calculations too. However, a ScalarArrayOp or NullTest
+ * invalidates that theory, even though it sets eqQualHere.
*/
if (index->unique &&
indexcol == index->nkeycolumns - 1 &&
eqQualHere &&
!found_saop &&
!found_is_null_op)
- numIndexTuples = 1.0;
+ numIndexTuples = -1.0;
else
{
List *selectivityQuals;
--
2.25.1
On 12/8/21 04:26, Tomas Vondra wrote:
On 8/11/21 2:48 AM, Peter Geoghegan wrote:
On Wed, Jun 23, 2021 at 7:19 AM Andrey V. Lepikhov
I agree the current behavior is unfortunate, but I'm not convinced the
proposed patch is fixing the right place - doesn't this mean the index
costing won't match the row estimates displayed by EXPLAIN?
I rewrote the patch. It's now simpler and shorter. May be more convenient.
Also, it includes a regression test to detect the problem in future.
I wonder if we should teach clauselist_selectivity about UNIQUE indexes,
and improve the cardinality estimates directly, not just costing for
index scans.
I tried to implement this in different ways. But it causes additional
overhead and code complexity - analyzing a list of indexes and match
clauses of each index with input clauses in each selectivity estimation.
I don't like that way and propose a new patch in attachment.
--
regards,
Andrey Lepikhov
Postgres Professional
Attachments:
0001-Estimating-number-of-fetched-rows-in-a-btree-index-w.patchtext/plain; charset=UTF-8; name=0001-Estimating-number-of-fetched-rows-in-a-btree-index-w.patch; x-mac-creator=0; x-mac-type=0Download
From 41ce6007cd552afd1a73983f0b9c9cac0e125d58 Mon Sep 17 00:00:00 2001
From: "Andrey V. Lepikhov" <a.lepikhov@postgrespro.ru>
Date: Mon, 30 Aug 2021 11:21:57 +0500
Subject: [PATCH] Estimating number of fetched rows in a btree index we save
selectivity estimation in the costs structure. It will be used by the
genericcostestimate routine as a top bound for estimation of total tuples,
visited in the main table.
This code fix the problem with unique index, when we know for sure
that no more than one tuple can be fetched, but clauselist_selectivity
gives us much less accurate estimation because of many possible reasons.
A regression test is added as a demonstration of the problem.
---
src/backend/utils/adt/selfuncs.c | 18 ++++++++++--
src/test/regress/expected/stats_ext.out | 38 +++++++++++++++++++++++++
src/test/regress/sql/stats_ext.sql | 34 ++++++++++++++++++++++
3 files changed, 88 insertions(+), 2 deletions(-)
diff --git a/src/backend/utils/adt/selfuncs.c b/src/backend/utils/adt/selfuncs.c
index c2aeb4b947..dd1cadad61 100644
--- a/src/backend/utils/adt/selfuncs.c
+++ b/src/backend/utils/adt/selfuncs.c
@@ -6074,6 +6074,14 @@ genericcostestimate(PlannerInfo *root,
*/
numIndexTuples = rint(numIndexTuples / num_sa_scans);
}
+ else if (costs->indexSelectivity > 0. &&
+ indexSelectivity > costs->indexSelectivity)
+ /*
+ * If caller give us an estimation of amount of fetched index tuples,
+ * it could give the selectivity estimation. In this case amount of
+ * returned tuples can't be more than amount of fetched tuples.
+ */
+ indexSelectivity = costs->indexSelectivity;
/*
* We can bound the number of tuples by the index size in any case. Also,
@@ -6258,6 +6266,7 @@ btcostestimate(PlannerInfo *root, IndexPath *path, double loop_count,
bool found_is_null_op;
double num_sa_scans;
ListCell *lc;
+ Selectivity btreeSelectivity;
/*
* For a btree scan, only leading '=' quals plus inequality quals for the
@@ -6362,19 +6371,23 @@ btcostestimate(PlannerInfo *root, IndexPath *path, double loop_count,
/*
* If index is unique and we found an '=' clause for each column, we can
* just assume numIndexTuples = 1 and skip the expensive
- * clauselist_selectivity calculations. However, a ScalarArrayOp or
+ * clauselist_selectivity calculations. However, a ScalarArrayOp or
* NullTest invalidates that theory, even though it sets eqQualHere.
+ * Value of btreeSelectivity is used as a top bound for selectivity
+ * estimation of returned tuples in the genericcostestimate routine.
*/
if (index->unique &&
indexcol == index->nkeycolumns - 1 &&
eqQualHere &&
!found_saop &&
!found_is_null_op)
+ {
numIndexTuples = 1.0;
+ btreeSelectivity = 1. / index->rel->tuples;
+ }
else
{
List *selectivityQuals;
- Selectivity btreeSelectivity;
/*
* If the index is partial, AND the index predicate with the
@@ -6402,6 +6415,7 @@ btcostestimate(PlannerInfo *root, IndexPath *path, double loop_count,
*/
MemSet(&costs, 0, sizeof(costs));
costs.numIndexTuples = numIndexTuples;
+ costs.indexSelectivity = btreeSelectivity;
genericcostestimate(root, path, loop_count, &costs);
diff --git a/src/test/regress/expected/stats_ext.out b/src/test/regress/expected/stats_ext.out
index 7524e65142..b90463821f 100644
--- a/src/test/regress/expected/stats_ext.out
+++ b/src/test/regress/expected/stats_ext.out
@@ -1602,3 +1602,41 @@ NOTICE: drop cascades to 2 other objects
DETAIL: drop cascades to table tststats.priv_test_tbl
drop cascades to view tststats.priv_test_view
DROP USER regress_stats_user1;
+-- Reproduction of the problem with picking of suboptimal index.
+SET enable_bitmapscan = 'off';
+-- Table with specific distribution of values
+CREATE TABLE tbl42 AS (
+ SELECT
+ gs % 10 AS x,
+ (gs % 10 + (gs/10::int4) % 10) % 10 AS y,
+ (gs / 100)::int4 AS z
+ FROM generate_series(1,1000) AS gs
+);
+INSERT INTO tbl42 (
+ SELECT gs,gs,gs FROM generate_series(1000,2000) AS gs
+);
+CREATE UNIQUE INDEX good ON tbl42 (x,y,z);
+CREATE INDEX bad ON tbl42(x);
+ANALYZE tbl42;
+-- Optimizer picks optimal, a primary key unique index
+EXPLAIN (COSTS OFF)
+SELECT * FROM tbl42 WHERE x=1 AND y=1 AND z=1;
+ QUERY PLAN
+-------------------------------------------------
+ Index Only Scan using good on tbl42
+ Index Cond: ((x = 1) AND (y = 1) AND (z = 1))
+(2 rows)
+
+CREATE STATISTICS aestat(dependencies,ndistinct) ON x,y,z FROM tbl42;
+ANALYZE tbl42;
+-- Optimizer picks suboptimal index
+EXPLAIN (COSTS OFF)
+SELECT * FROM tbl42 WHERE x=1 AND y=1 AND z=1;
+ QUERY PLAN
+-------------------------------------------------
+ Index Only Scan using good on tbl42
+ Index Cond: ((x = 1) AND (y = 1) AND (z = 1))
+(2 rows)
+
+-- Clean up
+DROP TABLE tbl42 CASCADE;
diff --git a/src/test/regress/sql/stats_ext.sql b/src/test/regress/sql/stats_ext.sql
index 906503bd0b..f274f53996 100644
--- a/src/test/regress/sql/stats_ext.sql
+++ b/src/test/regress/sql/stats_ext.sql
@@ -877,3 +877,37 @@ DROP FUNCTION op_leak(int, int);
RESET SESSION AUTHORIZATION;
DROP SCHEMA tststats CASCADE;
DROP USER regress_stats_user1;
+
+
+-- Reproduction of the problem with picking of suboptimal index.
+SET enable_bitmapscan = 'off';
+
+-- Table with specific distribution of values
+CREATE TABLE tbl42 AS (
+ SELECT
+ gs % 10 AS x,
+ (gs % 10 + (gs/10::int4) % 10) % 10 AS y,
+ (gs / 100)::int4 AS z
+ FROM generate_series(1,1000) AS gs
+);
+INSERT INTO tbl42 (
+ SELECT gs,gs,gs FROM generate_series(1000,2000) AS gs
+);
+
+CREATE UNIQUE INDEX good ON tbl42 (x,y,z);
+CREATE INDEX bad ON tbl42(x);
+ANALYZE tbl42;
+
+-- Optimizer picks optimal, a primary key unique index
+EXPLAIN (COSTS OFF)
+SELECT * FROM tbl42 WHERE x=1 AND y=1 AND z=1;
+
+CREATE STATISTICS aestat(dependencies,ndistinct) ON x,y,z FROM tbl42;
+ANALYZE tbl42;
+
+-- Optimizer picks suboptimal index
+EXPLAIN (COSTS OFF)
+SELECT * FROM tbl42 WHERE x=1 AND y=1 AND z=1;
+
+-- Clean up
+DROP TABLE tbl42 CASCADE;
--
2.33.0
Andrey Lepikhov <a.lepikhov@postgrespro.ru> writes:
On 12/8/21 04:26, Tomas Vondra wrote:
I wonder if we should teach clauselist_selectivity about UNIQUE indexes,
and improve the cardinality estimates directly, not just costing for
index scans.
I tried to implement this in different ways. But it causes additional
overhead and code complexity - analyzing a list of indexes and match
clauses of each index with input clauses in each selectivity estimation.
I don't like that way and propose a new patch in attachment.
I looked at this briefly. I do not think that messing with
btcostestimate/genericcostestimate is the right response at all.
The problem can be demonstrated with no index whatever, as in the
attached shortened version of the original example. I get
QUERY PLAN
---------------------------------------------------
Seq Scan on a (cost=0.00..46.02 rows=1 width=12)
Filter: ((x = 1) AND (y = 1) AND (z = 1))
(2 rows)
before adding the extended stats, and
QUERY PLAN
----------------------------------------------------
Seq Scan on a (cost=0.00..46.02 rows=28 width=12)
Filter: ((x = 1) AND (y = 1) AND (z = 1))
(2 rows)
afterwards. So the extended stats have made the rowcount
estimate significantly worse, which seems like an indicator of a
bug somewhere in extended stats. The more so because I can crank
default_statistics_target all the way to 10000 without these
estimates changing. If we can't get a dead-on estimate for a
2001-row table at that stats level, we're doing something wrong,
surely?
Also, I found that if I ask only for ndistinct stats,
I still get rows=1. The fishiness seems to be directly
a problem with dependencies stats.
regards, tom lane
Attachments:
On 7/8/22 03:07, Tom Lane wrote:
Andrey Lepikhov <a.lepikhov@postgrespro.ru> writes:
On 12/8/21 04:26, Tomas Vondra wrote:
I wonder if we should teach clauselist_selectivity about UNIQUE indexes,
and improve the cardinality estimates directly, not just costing for
index scans.I tried to implement this in different ways. But it causes additional
overhead and code complexity - analyzing a list of indexes and match
clauses of each index with input clauses in each selectivity estimation.
I don't like that way and propose a new patch in attachment.I looked at this briefly. I do not think that messing with
btcostestimate/genericcostestimate is the right response at all.
The problem can be demonstrated with no index whatever, as in the
attached shortened version of the original example. I get
I partly agree with you. Yes, I see the problem too. But also we have a
problem that I described above: optimizer don't choose a path with
minimal selectivity from a set selectivities which shows cardinality
less than 1 (see badestimate2.sql).
New patch (see in attachment), fixes this problem.
--
Regards
Andrey Lepikhov
Postgres Professional
Attachments:
0001-Use-Index-path-with-the-best-selectivity-estimation.patchtext/x-patch; charset=UTF-8; name=0001-Use-Index-path-with-the-best-selectivity-estimation.patchDownload
From 463c085852be921199b7a6d0987378d55145e41c Mon Sep 17 00:00:00 2001
From: Andrey Lepikhov <a.lepikhov@postgrespro.ru>
Date: Mon, 11 Jul 2022 12:25:43 +0500
Subject: [PATCH] Use Index path with the best selectivity estimation
---
src/backend/optimizer/util/pathnode.c | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index 483c4f4137..a8d7f22343 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -207,6 +207,20 @@ compare_path_costs_fuzzily(Path *path1, Path *path2, double fuzz_factor)
/* ... but path2 fuzzily worse on startup, so path1 wins */
return COSTS_BETTER1;
}
+ if (IsA(path1, IndexPath) && IsA(path2, IndexPath))
+ {
+ /*
+ * Couldn't differ value of the paths. Last chance - if these paths
+ * are index paths - use the path with the lower selectivity value.
+ */
+ if (((IndexPath *) path1)->indexselectivity <
+ ((IndexPath *) path2)->indexselectivity)
+ return COSTS_BETTER1;
+
+ if (((IndexPath *) path1)->indexselectivity >
+ ((IndexPath *) path2)->indexselectivity)
+ return COSTS_BETTER2;
+ }
/* fuzzily the same on both costs */
return COSTS_EQUAL;
--
2.34.1
Hi,
On 2022-07-11 12:57:36 +0500, Andrey Lepikhov wrote:
On 7/8/22 03:07, Tom Lane wrote:
Andrey Lepikhov <a.lepikhov@postgrespro.ru> writes:
On 12/8/21 04:26, Tomas Vondra wrote:
I wonder if we should teach clauselist_selectivity about UNIQUE indexes,
and improve the cardinality estimates directly, not just costing for
index scans.I tried to implement this in different ways. But it causes additional
overhead and code complexity - analyzing a list of indexes and match
clauses of each index with input clauses in each selectivity estimation.
I don't like that way and propose a new patch in attachment.I looked at this briefly. I do not think that messing with
btcostestimate/genericcostestimate is the right response at all.
The problem can be demonstrated with no index whatever, as in the
attached shortened version of the original example. I getI partly agree with you. Yes, I see the problem too. But also we have a
problem that I described above: optimizer don't choose a path with minimal
selectivity from a set selectivities which shows cardinality less than 1
(see badestimate2.sql).
New patch (see in attachment), fixes this problem.
This causes the mains regression tests to fail due to a planner change:
https://cirrus-ci.com/build/6680222884429824
diff -U3 /tmp/cirrus-ci-build/src/test/regress/expected/join.out /tmp/cirrus-ci-build/build/testrun/regress/regress/results/join.out
--- /tmp/cirrus-ci-build/src/test/regress/expected/join.out 2022-11-22 12:27:18.852087140 +0000
+++ /tmp/cirrus-ci-build/build/testrun/regress/regress/results/join.out 2022-11-22 12:28:47.934938882 +0000
@@ -6671,10 +6671,9 @@
Merge Cond: (j1.id1 = j2.id1)
Join Filter: (j2.id2 = j1.id2)
-> Index Scan using j1_id1_idx on j1
- -> Index Only Scan using j2_pkey on j2
+ -> Index Scan using j2_id1_idx on j2
Index Cond: (id1 >= ANY ('{1,5}'::integer[]))
- Filter: ((id1 % 1000) = 1)
-(7 rows)
+(6 rows)
select * from j1
inner join j2 on j1.id1 = j2.id1 and j1.id2 = j2.id2
Greetings,
Andres Freund
On 12/8/2021 06:26, Tomas Vondra wrote:
On 8/11/21 2:48 AM, Peter Geoghegan wrote:
On Wed, Jun 23, 2021 at 7:19 AM Andrey V. Lepikhov
<a.lepikhov@postgrespro.ru> wrote:Ivan Frolkov reported a problem with choosing a non-optimal index during
a query optimization. This problem appeared after building of an
extended statistics.Any thoughts on this, Tomas?
Thanks for reminding me, I missed / forgot about this thread.
I agree the current behavior is unfortunate, but I'm not convinced the
proposed patch is fixing the right place - doesn't this mean the index
costing won't match the row estimates displayed by EXPLAIN?I wonder if we should teach clauselist_selectivity about UNIQUE indexes,
and improve the cardinality estimates directly, not just costing for
index scans.Also, is it correct that the patch calculates num_sa_scans only when
(numIndexTuples >= 0.0)?
I can't stop thinking about this issue. It is bizarre when Postgres
chooses a non-unique index if a unique index gives us proof of minimum scan.
I don't see a reason to teach the clauselist_selectivity() routine to
estimate UNIQUE indexes. We add some cycles, but it will work with btree
indexes only.
Maybe to change compare_path_costs_fuzzily() and add some heuristic, for
example:
"If selectivity of both paths gives us no more than 1 row, prefer to use
a unique index or an index with least selectivity."
--
regards,
Andrey Lepikhov
Postgres Professional
On 9/25/23 06:30, Andrey Lepikhov wrote:
...
I can't stop thinking about this issue. It is bizarre when Postgres
chooses a non-unique index if a unique index gives us proof of minimum
scan.
That's true, but no one implemented this heuristics. So the "proof of
minimum scan" is merely hypothetical - the optimizer is unaware of it.
I don't see a reason to teach the clauselist_selectivity() routine to
estimate UNIQUE indexes. We add some cycles, but it will work with btree
indexes only.
I'm not sure I understand what this is meant to say. Can you elaborate?
We only allow UNIQUE for btree indexes anyway, so what exactly is the
problem here?
Maybe to change compare_path_costs_fuzzily() and add some heuristic, for
example:
"If selectivity of both paths gives us no more than 1 row, prefer to use
a unique index or an index with least selectivity."
I don't understand how this would work. What do yo mean by "selectivity
of a path"? AFAICS the problem here is that we estimate a path to return
more rows (while we know there can only be 1, thanks to UNIQUE index).
So how would you know the path does not give us more than 1 row? Surely
you're not proposing compare_path_costs_fuzzily() to do something
expensive like analyzing the paths, or something.
Also, how would it work in upper levels? If you just change which path
we keep, but leave the inaccurate row estimate in place, that may fix
that level, but it's certainly going to confuse later planning steps.
IMHO the problem here is that we produce wrong estimate, so we better
fix that, instead of adding band-aids to other places.
This happens because functional dependencies are very simple type of
statistics - it has some expectations about the input data and also the
queries executed on it. For example it assumes the data is reasonably
homogeneous, so that we can calculate a global "degree".
But the data in the example directly violates that - it has 1000 rows
that are very random (so we'd find no dependencies), and 1000 rows with
perfect dependencies. Hence we end with degree=0.5, which approximates
the dependencies to all data. Not great, true, but that's the price for
simplicity of this statistics kind.
So the simplest solution is to disable dependencies on such data sets.
It's a bit inconvenient/unfortunate we build dependencies by default,
and people may not be aware of there assumptions.
Perhaps we could/should make dependency_degree() more pessimistic when
we find some "violations" of the rule (we intentionally are not strict
about it, because data are imperfect). I don't have a good idea how to
change the formulas, but I'm open to the idea in principle.
The other thing we could do is checking for unique indexes in
clauselist_selectivity, and if we find a match we can just skip the
extended statistics altogether. Not sure how expensive this would be,
but for typical cases (with modest number of indexes) perhaps OK. It
wouldn't work without a unique index, but I don't have a better idea.
regards
--
Tomas Vondra
EnterpriseDB: http://www.enterprisedb.com
The Enterprise PostgreSQL Company
Thanks for detaied answer,
On 3/11/2023 00:37, Tomas Vondra wrote:
On 9/25/23 06:30, Andrey Lepikhov wrote:
...
I can't stop thinking about this issue. It is bizarre when Postgres
chooses a non-unique index if a unique index gives us proof of minimum
scan.That's true, but no one implemented this heuristics. So the "proof of
minimum scan" is merely hypothetical - the optimizer is unaware of it.
See the simple patch in the attachment. There, I have attempted to
resolve situations of uncertainty to avoid making decisions based solely
on the order of indexes in the list.
I don't see a reason to teach the clauselist_selectivity() routine to
estimate UNIQUE indexes. We add some cycles, but it will work with btree
indexes only.I'm not sure I understand what this is meant to say. Can you elaborate?
We only allow UNIQUE for btree indexes anyway, so what exactly is the
problem here?
Partly, you already answered yourself below: we have unique index
estimation in a few estimation calls, but go through the list of indexes
each time.
Also, for this sake, we would add some input parameters, usually NULL,
because many estimations don't involve indexes at all.
Maybe to change compare_path_costs_fuzzily() and add some heuristic, for
example:
"If selectivity of both paths gives us no more than 1 row, prefer to use
a unique index or an index with least selectivity."I don't understand how this would work. What do yo mean by "selectivity
of a path"? AFAICS the problem here is that we estimate a path to return
more rows (while we know there can only be 1, thanks to UNIQUE index).
Oops, I meant cardinality. See the patch in the attachment.
So how would you know the path does not give us more than 1 row? Surely
you're not proposing compare_path_costs_fuzzily() to do something
expensive like analyzing the paths, or something.
I solely propose to make optimizer more consecutive in its decisions: if
we have one row for both path candidates, use uniqueness of the index or
value of selectivity as one more parameter.
Also, how would it work in upper levels? If you just change which path
we keep, but leave the inaccurate row estimate in place, that may fix
that level, but it's certainly going to confuse later planning steps.
It is designed for the only scan level.
IMHO the problem here is that we produce wrong estimate, so we better
fix that, instead of adding band-aids to other places.
Agree. I am looking for a solution to help users somehow resolve such
problems. As an alternative solution, I can propose a selectivity hook
or (maybe even better) - use the pathlist approach and add indexes into
the index list with some predefined order - at first positions, place
unique indexes with more columns, etc.
This happens because functional dependencies are very simple type of
statistics - it has some expectations about the input data and also the
queries executed on it. For example it assumes the data is reasonably
homogeneous, so that we can calculate a global "degree".But the data in the example directly violates that - it has 1000 rows
that are very random (so we'd find no dependencies), and 1000 rows with
perfect dependencies. Hence we end with degree=0.5, which approximates
the dependencies to all data. Not great, true, but that's the price for
simplicity of this statistics kind.So the simplest solution is to disable dependencies on such data sets.
It's a bit inconvenient/unfortunate we build dependencies by default,
and people may not be aware of there assumptions.Perhaps we could/should make dependency_degree() more pessimistic when
we find some "violations" of the rule (we intentionally are not strict
about it, because data are imperfect). I don't have a good idea how to
change the formulas, but I'm open to the idea in principle.
Thanks for the explanation!
The other thing we could do is checking for unique indexes in
clauselist_selectivity, and if we find a match we can just skip the
extended statistics altogether. Not sure how expensive this would be,
but for typical cases (with modest number of indexes) perhaps OK. It
wouldn't work without a unique index, but I don't have a better idea.
It looks a bit expensive for me. But I am ready to try, if current
solution doesn't look applicable.
--
regards,
Andrei Lepikhov
Postgres Professional
Attachments:
0001-Use-an-index-path-with-the-best-selectivity-estimati.patchtext/plain; charset=UTF-8; name=0001-Use-an-index-path-with-the-best-selectivity-estimati.patchDownload
From 21677861e101eed22c829e0b14290a56786a12c2 Mon Sep 17 00:00:00 2001
From: "Andrey V. Lepikhov" <a.lepikhov@postgrespro.ru>
Date: Wed, 22 Nov 2023 12:01:39 +0700
Subject: [PATCH] Use an index path with the best selectivity estimation
---
src/backend/optimizer/util/pathnode.c | 40 +++++++++++++++++
.../expected/drop-index-concurrently-1.out | 16 ++++---
src/test/regress/expected/functional_deps.out | 43 +++++++++++++++++++
src/test/regress/expected/join.out | 40 +++++++++--------
src/test/regress/sql/functional_deps.sql | 36 ++++++++++++++++
5 files changed, 149 insertions(+), 26 deletions(-)
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index 0b1d17b9d3..32aca83d67 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -454,6 +454,46 @@ add_path(RelOptInfo *parent_rel, Path *new_path)
costcmp = compare_path_costs_fuzzily(new_path, old_path,
STD_FUZZ_FACTOR);
+ /*
+ * Apply some heuristics on index paths.
+ */
+ if (IsA(new_path, IndexPath) && IsA(old_path, IndexPath))
+ {
+ IndexPath *inp = (IndexPath *) new_path;
+ IndexPath *iop = (IndexPath *) old_path;
+
+ if (new_path->rows <= 1.0 && old_path->rows <= 1.0)
+ {
+ if (inp->indexinfo->unique && !iop->indexinfo->unique)
+ /*
+ * When both paths are predicted to produce only one tuple,
+ * the optimiser should prefer choosing a unique index scan
+ * in all cases.
+ */
+ costcmp = COSTS_BETTER1;
+ else if (costcmp != COSTS_DIFFERENT)
+ /*
+ * If the optimiser doesn't have an obviously stable choice
+ * of unique index, increase the chance of avoiding mistakes
+ * by choosing an index with smaller selectivity.
+ * This option makes decision more conservative and looks
+ * debatable.
+ */
+ costcmp = (inp->indexselectivity < iop->indexselectivity) ?
+ COSTS_BETTER1 : COSTS_BETTER2;
+ }
+ else if (costcmp == COSTS_EQUAL)
+ /*
+ * The optimizer can't differ the value of two index paths.
+ * To avoid making a decision that is based on only an index
+ * order in the list, use some rational strategy based on
+ * selectivity: prefer touching fewer tuples on the disk to
+ * filtering them after.
+ */
+ costcmp = (inp->indexselectivity < iop->indexselectivity) ?
+ COSTS_BETTER1 : COSTS_BETTER2;
+ }
+
/*
* If the two paths compare differently for startup and total cost,
* then we want to keep both, and we can skip comparing pathkeys and
diff --git a/src/test/isolation/expected/drop-index-concurrently-1.out b/src/test/isolation/expected/drop-index-concurrently-1.out
index 1cb2250891..2392cdb033 100644
--- a/src/test/isolation/expected/drop-index-concurrently-1.out
+++ b/src/test/isolation/expected/drop-index-concurrently-1.out
@@ -12,13 +12,15 @@ step preps: PREPARE getrow_seqscan AS SELECT * FROM test_dc WHERE data = 34 ORDE
step begin: BEGIN;
step disableseq: SET enable_seqscan = false;
step explaini: EXPLAIN (COSTS OFF) EXECUTE getrow_idxscan;
-QUERY PLAN
-----------------------------------------------
-Sort
- Sort Key: id
- -> Index Scan using test_dc_data on test_dc
- Index Cond: (data = 34)
-(4 rows)
+QUERY PLAN
+---------------------------------------------
+Sort
+ Sort Key: id
+ -> Bitmap Heap Scan on test_dc
+ Recheck Cond: (data = 34)
+ -> Bitmap Index Scan on test_dc_data
+ Index Cond: (data = 34)
+(6 rows)
step enableseq: SET enable_seqscan = true;
step explains: EXPLAIN (COSTS OFF) EXECUTE getrow_seqscan;
diff --git a/src/test/regress/expected/functional_deps.out b/src/test/regress/expected/functional_deps.out
index 32381b8ae7..61af99a041 100644
--- a/src/test/regress/expected/functional_deps.out
+++ b/src/test/regress/expected/functional_deps.out
@@ -230,3 +230,46 @@ EXECUTE foo;
ALTER TABLE articles DROP CONSTRAINT articles_pkey RESTRICT;
EXECUTE foo; -- fail
ERROR: column "articles.keywords" must appear in the GROUP BY clause or be used in an aggregate function
+/*
+ * Corner cases of PostgreSQL optimizer:
+ *
+ * Correlations between columns aren't found by DBMS.
+ * Selectivities multiplication of many columns increases total selectivity
+ * error. If such non-true selectivity is so small, that rows estimation
+ * give us absolute minimum (1) then the optimizer can't choose between different
+ * indexes and uses first from the index list (last created).
+ */
+\set scale 100000
+CREATE TABLE t AS (
+ SELECT c1 AS c1, -- selectivity(c1)*selectivity(c2)*nrows <= 1
+ c1 AS c2,
+ -- Create two columns with different selectivity.
+ (c1 % 2) AS c3, -- missing from a good index.
+ (c1 % 4) AS c4 -- missing from a bad index.
+ FROM generate_series(1,:scale) AS c1
+);
+UPDATE t SET c1=1,c2=1,c3=1,c4=2 WHERE c1<:scale/1000;
+CREATE INDEX bad ON t (c1,c2,c3);
+CREATE INDEX good ON t (c1,c2,c4);
+ANALYZE t; -- update stat on the indexes.
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE c1=1 AND c2=1 AND c3=1 AND c4=1;
+ QUERY PLAN
+----------------------------------------------------
+ Index Scan using good on t
+ Index Cond: ((c1 = 1) AND (c2 = 1) AND (c4 = 1))
+ Filter: (c3 = 1)
+(3 rows)
+
+-- Set the bad index to the first position in the index list.
+DROP INDEX bad;
+CREATE INDEX bad ON t (c1,c2,c3);
+ANALYZE t;
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE c1=1 AND c2=1 AND c3=1 AND c4=1;
+ QUERY PLAN
+----------------------------------------------------
+ Index Scan using good on t
+ Index Cond: ((c1 = 1) AND (c2 = 1) AND (c4 = 1))
+ Filter: (c3 = 1)
+(3 rows)
+
+DROP TABLE t CASCADE;
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index 2c73270143..32b33fabd3 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -8629,14 +8629,15 @@ analyze j2;
explain (costs off) select * from j1
inner join j2 on j1.id1 = j2.id1 and j1.id2 = j2.id2
where j1.id1 % 1000 = 1 and j2.id1 % 1000 = 1;
- QUERY PLAN
------------------------------------------
+ QUERY PLAN
+---------------------------------------------------------
Merge Join
- Merge Cond: (j1.id1 = j2.id1)
- Join Filter: (j2.id2 = j1.id2)
- -> Index Scan using j1_id1_idx on j1
- -> Index Scan using j2_id1_idx on j2
-(5 rows)
+ Merge Cond: ((j1.id1 = j2.id1) AND (j1.id2 = j2.id2))
+ -> Index Only Scan using j1_pkey on j1
+ Filter: ((id1 % 1000) = 1)
+ -> Index Only Scan using j2_pkey on j2
+ Filter: ((id1 % 1000) = 1)
+(6 rows)
select * from j1
inner join j2 on j1.id1 = j2.id1 and j1.id2 = j2.id2
@@ -8651,15 +8652,16 @@ where j1.id1 % 1000 = 1 and j2.id1 % 1000 = 1;
explain (costs off) select * from j1
inner join j2 on j1.id1 = j2.id1 and j1.id2 = j2.id2
where j1.id1 % 1000 = 1 and j2.id1 % 1000 = 1 and j2.id1 = any (array[1]);
- QUERY PLAN
-----------------------------------------------------
+ QUERY PLAN
+---------------------------------------------------------
Merge Join
- Merge Cond: (j1.id1 = j2.id1)
- Join Filter: (j2.id2 = j1.id2)
- -> Index Scan using j1_id1_idx on j1
- -> Index Scan using j2_id1_idx on j2
+ Merge Cond: ((j1.id1 = j2.id1) AND (j1.id2 = j2.id2))
+ -> Index Only Scan using j1_pkey on j1
+ Filter: ((id1 % 1000) = 1)
+ -> Index Only Scan using j2_pkey on j2
Index Cond: (id1 = ANY ('{1}'::integer[]))
-(6 rows)
+ Filter: ((id1 % 1000) = 1)
+(7 rows)
select * from j1
inner join j2 on j1.id1 = j2.id1 and j1.id2 = j2.id2
@@ -8674,12 +8676,12 @@ where j1.id1 % 1000 = 1 and j2.id1 % 1000 = 1 and j2.id1 = any (array[1]);
explain (costs off) select * from j1
inner join j2 on j1.id1 = j2.id1 and j1.id2 = j2.id2
where j1.id1 % 1000 = 1 and j2.id1 % 1000 = 1 and j2.id1 >= any (array[1,5]);
- QUERY PLAN
--------------------------------------------------------
+ QUERY PLAN
+---------------------------------------------------------
Merge Join
- Merge Cond: (j1.id1 = j2.id1)
- Join Filter: (j2.id2 = j1.id2)
- -> Index Scan using j1_id1_idx on j1
+ Merge Cond: ((j1.id1 = j2.id1) AND (j1.id2 = j2.id2))
+ -> Index Only Scan using j1_pkey on j1
+ Filter: ((id1 % 1000) = 1)
-> Index Only Scan using j2_pkey on j2
Index Cond: (id1 >= ANY ('{1,5}'::integer[]))
Filter: ((id1 % 1000) = 1)
diff --git a/src/test/regress/sql/functional_deps.sql b/src/test/regress/sql/functional_deps.sql
index 406490b995..f617ee9269 100644
--- a/src/test/regress/sql/functional_deps.sql
+++ b/src/test/regress/sql/functional_deps.sql
@@ -208,3 +208,39 @@ EXECUTE foo;
ALTER TABLE articles DROP CONSTRAINT articles_pkey RESTRICT;
EXECUTE foo; -- fail
+
+/*
+ * Corner cases of PostgreSQL optimizer:
+ *
+ * Correlations between columns aren't found by DBMS.
+ * Selectivities multiplication of many columns increases total selectivity
+ * error. If such non-true selectivity is so small, that rows estimation
+ * give us absolute minimum (1) then the optimizer can't choose between different
+ * indexes and uses first from the index list (last created).
+ */
+\set scale 100000
+
+CREATE TABLE t AS (
+ SELECT c1 AS c1, -- selectivity(c1)*selectivity(c2)*nrows <= 1
+ c1 AS c2,
+ -- Create two columns with different selectivity.
+ (c1 % 2) AS c3, -- missing from a good index.
+ (c1 % 4) AS c4 -- missing from a bad index.
+ FROM generate_series(1,:scale) AS c1
+);
+UPDATE t SET c1=1,c2=1,c3=1,c4=2 WHERE c1<:scale/1000;
+
+CREATE INDEX bad ON t (c1,c2,c3);
+CREATE INDEX good ON t (c1,c2,c4);
+ANALYZE t; -- update stat on the indexes.
+
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE c1=1 AND c2=1 AND c3=1 AND c4=1;
+
+-- Set the bad index to the first position in the index list.
+DROP INDEX bad;
+CREATE INDEX bad ON t (c1,c2,c3);
+ANALYZE t;
+
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE c1=1 AND c2=1 AND c3=1 AND c4=1;
+
+DROP TABLE t CASCADE;
--
2.43.0
Second version of the patch - resolve non-symmetrical decision, thanks
to Teodor Sigaev's review.
--
regards,
Andrei Lepikhov
Postgres Professional
Attachments:
v2-0001-Choose-an-index-path-with-the-best-selectivity-estim.patchtext/plain; charset=UTF-8; name=v2-0001-Choose-an-index-path-with-the-best-selectivity-estim.patchDownload
From 604899b6afe70eccbbdbf69ce254f37808c598db Mon Sep 17 00:00:00 2001
From: "Andrey V. Lepikhov" <a.lepikhov@postgrespro.ru>
Date: Mon, 27 Nov 2023 11:23:48 +0700
Subject: [PATCH] Choose an index path with the best selectivity estimation.
In the case when optimizer predicts only one row prefer choosing UNIQUE indexes
In other cases, if optimizer treats indexes as equal, make a last attempt
selecting the index with less selectivity - this decision takes away dependency
on the order of indexes in an index list (good for reproduction of some issues)
and proposes one more objective argument to choose specific index.
---
src/backend/optimizer/util/pathnode.c | 42 ++++++++++++++++++
.../expected/drop-index-concurrently-1.out | 16 ++++---
src/test/regress/expected/functional_deps.out | 43 +++++++++++++++++++
src/test/regress/expected/join.out | 40 +++++++++--------
src/test/regress/sql/functional_deps.sql | 36 ++++++++++++++++
5 files changed, 151 insertions(+), 26 deletions(-)
diff --git a/src/backend/optimizer/util/pathnode.c b/src/backend/optimizer/util/pathnode.c
index 0b1d17b9d3..4b5aedd579 100644
--- a/src/backend/optimizer/util/pathnode.c
+++ b/src/backend/optimizer/util/pathnode.c
@@ -454,6 +454,48 @@ add_path(RelOptInfo *parent_rel, Path *new_path)
costcmp = compare_path_costs_fuzzily(new_path, old_path,
STD_FUZZ_FACTOR);
+ /*
+ * Apply some heuristics on index paths.
+ */
+ if (IsA(new_path, IndexPath) && IsA(old_path, IndexPath))
+ {
+ IndexPath *inp = (IndexPath *) new_path;
+ IndexPath *iop = (IndexPath *) old_path;
+
+ if (new_path->rows <= 1.0 && old_path->rows <= 1.0)
+ {
+ /*
+ * When both paths are predicted to produce only one tuple,
+ * the optimiser should prefer choosing a unique index scan
+ * in all cases.
+ */
+ if (inp->indexinfo->unique && !iop->indexinfo->unique)
+ costcmp = COSTS_BETTER1;
+ else if (!inp->indexinfo->unique && iop->indexinfo->unique)
+ costcmp = COSTS_BETTER2;
+ else if (costcmp != COSTS_DIFFERENT)
+ /*
+ * If the optimiser doesn't have an obviously stable choice
+ * of unique index, increase the chance of avoiding mistakes
+ * by choosing an index with smaller selectivity.
+ * This option makes decision more conservative and looks
+ * debatable.
+ */
+ costcmp = (inp->indexselectivity < iop->indexselectivity) ?
+ COSTS_BETTER1 : COSTS_BETTER2;
+ }
+ else if (costcmp == COSTS_EQUAL)
+ /*
+ * The optimizer can't differ the value of two index paths.
+ * To avoid making a decision that is based on only an index
+ * order in the list, use some rational strategy based on
+ * selectivity: prefer touching fewer tuples on the disk to
+ * filtering them after.
+ */
+ costcmp = (inp->indexselectivity < iop->indexselectivity) ?
+ COSTS_BETTER1 : COSTS_BETTER2;
+ }
+
/*
* If the two paths compare differently for startup and total cost,
* then we want to keep both, and we can skip comparing pathkeys and
diff --git a/src/test/isolation/expected/drop-index-concurrently-1.out b/src/test/isolation/expected/drop-index-concurrently-1.out
index 1cb2250891..2392cdb033 100644
--- a/src/test/isolation/expected/drop-index-concurrently-1.out
+++ b/src/test/isolation/expected/drop-index-concurrently-1.out
@@ -12,13 +12,15 @@ step preps: PREPARE getrow_seqscan AS SELECT * FROM test_dc WHERE data = 34 ORDE
step begin: BEGIN;
step disableseq: SET enable_seqscan = false;
step explaini: EXPLAIN (COSTS OFF) EXECUTE getrow_idxscan;
-QUERY PLAN
-----------------------------------------------
-Sort
- Sort Key: id
- -> Index Scan using test_dc_data on test_dc
- Index Cond: (data = 34)
-(4 rows)
+QUERY PLAN
+---------------------------------------------
+Sort
+ Sort Key: id
+ -> Bitmap Heap Scan on test_dc
+ Recheck Cond: (data = 34)
+ -> Bitmap Index Scan on test_dc_data
+ Index Cond: (data = 34)
+(6 rows)
step enableseq: SET enable_seqscan = true;
step explains: EXPLAIN (COSTS OFF) EXECUTE getrow_seqscan;
diff --git a/src/test/regress/expected/functional_deps.out b/src/test/regress/expected/functional_deps.out
index 32381b8ae7..61af99a041 100644
--- a/src/test/regress/expected/functional_deps.out
+++ b/src/test/regress/expected/functional_deps.out
@@ -230,3 +230,46 @@ EXECUTE foo;
ALTER TABLE articles DROP CONSTRAINT articles_pkey RESTRICT;
EXECUTE foo; -- fail
ERROR: column "articles.keywords" must appear in the GROUP BY clause or be used in an aggregate function
+/*
+ * Corner cases of PostgreSQL optimizer:
+ *
+ * Correlations between columns aren't found by DBMS.
+ * Selectivities multiplication of many columns increases total selectivity
+ * error. If such non-true selectivity is so small, that rows estimation
+ * give us absolute minimum (1) then the optimizer can't choose between different
+ * indexes and uses first from the index list (last created).
+ */
+\set scale 100000
+CREATE TABLE t AS (
+ SELECT c1 AS c1, -- selectivity(c1)*selectivity(c2)*nrows <= 1
+ c1 AS c2,
+ -- Create two columns with different selectivity.
+ (c1 % 2) AS c3, -- missing from a good index.
+ (c1 % 4) AS c4 -- missing from a bad index.
+ FROM generate_series(1,:scale) AS c1
+);
+UPDATE t SET c1=1,c2=1,c3=1,c4=2 WHERE c1<:scale/1000;
+CREATE INDEX bad ON t (c1,c2,c3);
+CREATE INDEX good ON t (c1,c2,c4);
+ANALYZE t; -- update stat on the indexes.
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE c1=1 AND c2=1 AND c3=1 AND c4=1;
+ QUERY PLAN
+----------------------------------------------------
+ Index Scan using good on t
+ Index Cond: ((c1 = 1) AND (c2 = 1) AND (c4 = 1))
+ Filter: (c3 = 1)
+(3 rows)
+
+-- Set the bad index to the first position in the index list.
+DROP INDEX bad;
+CREATE INDEX bad ON t (c1,c2,c3);
+ANALYZE t;
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE c1=1 AND c2=1 AND c3=1 AND c4=1;
+ QUERY PLAN
+----------------------------------------------------
+ Index Scan using good on t
+ Index Cond: ((c1 = 1) AND (c2 = 1) AND (c4 = 1))
+ Filter: (c3 = 1)
+(3 rows)
+
+DROP TABLE t CASCADE;
diff --git a/src/test/regress/expected/join.out b/src/test/regress/expected/join.out
index 2c73270143..32b33fabd3 100644
--- a/src/test/regress/expected/join.out
+++ b/src/test/regress/expected/join.out
@@ -8629,14 +8629,15 @@ analyze j2;
explain (costs off) select * from j1
inner join j2 on j1.id1 = j2.id1 and j1.id2 = j2.id2
where j1.id1 % 1000 = 1 and j2.id1 % 1000 = 1;
- QUERY PLAN
------------------------------------------
+ QUERY PLAN
+---------------------------------------------------------
Merge Join
- Merge Cond: (j1.id1 = j2.id1)
- Join Filter: (j2.id2 = j1.id2)
- -> Index Scan using j1_id1_idx on j1
- -> Index Scan using j2_id1_idx on j2
-(5 rows)
+ Merge Cond: ((j1.id1 = j2.id1) AND (j1.id2 = j2.id2))
+ -> Index Only Scan using j1_pkey on j1
+ Filter: ((id1 % 1000) = 1)
+ -> Index Only Scan using j2_pkey on j2
+ Filter: ((id1 % 1000) = 1)
+(6 rows)
select * from j1
inner join j2 on j1.id1 = j2.id1 and j1.id2 = j2.id2
@@ -8651,15 +8652,16 @@ where j1.id1 % 1000 = 1 and j2.id1 % 1000 = 1;
explain (costs off) select * from j1
inner join j2 on j1.id1 = j2.id1 and j1.id2 = j2.id2
where j1.id1 % 1000 = 1 and j2.id1 % 1000 = 1 and j2.id1 = any (array[1]);
- QUERY PLAN
-----------------------------------------------------
+ QUERY PLAN
+---------------------------------------------------------
Merge Join
- Merge Cond: (j1.id1 = j2.id1)
- Join Filter: (j2.id2 = j1.id2)
- -> Index Scan using j1_id1_idx on j1
- -> Index Scan using j2_id1_idx on j2
+ Merge Cond: ((j1.id1 = j2.id1) AND (j1.id2 = j2.id2))
+ -> Index Only Scan using j1_pkey on j1
+ Filter: ((id1 % 1000) = 1)
+ -> Index Only Scan using j2_pkey on j2
Index Cond: (id1 = ANY ('{1}'::integer[]))
-(6 rows)
+ Filter: ((id1 % 1000) = 1)
+(7 rows)
select * from j1
inner join j2 on j1.id1 = j2.id1 and j1.id2 = j2.id2
@@ -8674,12 +8676,12 @@ where j1.id1 % 1000 = 1 and j2.id1 % 1000 = 1 and j2.id1 = any (array[1]);
explain (costs off) select * from j1
inner join j2 on j1.id1 = j2.id1 and j1.id2 = j2.id2
where j1.id1 % 1000 = 1 and j2.id1 % 1000 = 1 and j2.id1 >= any (array[1,5]);
- QUERY PLAN
--------------------------------------------------------
+ QUERY PLAN
+---------------------------------------------------------
Merge Join
- Merge Cond: (j1.id1 = j2.id1)
- Join Filter: (j2.id2 = j1.id2)
- -> Index Scan using j1_id1_idx on j1
+ Merge Cond: ((j1.id1 = j2.id1) AND (j1.id2 = j2.id2))
+ -> Index Only Scan using j1_pkey on j1
+ Filter: ((id1 % 1000) = 1)
-> Index Only Scan using j2_pkey on j2
Index Cond: (id1 >= ANY ('{1,5}'::integer[]))
Filter: ((id1 % 1000) = 1)
diff --git a/src/test/regress/sql/functional_deps.sql b/src/test/regress/sql/functional_deps.sql
index 406490b995..f617ee9269 100644
--- a/src/test/regress/sql/functional_deps.sql
+++ b/src/test/regress/sql/functional_deps.sql
@@ -208,3 +208,39 @@ EXECUTE foo;
ALTER TABLE articles DROP CONSTRAINT articles_pkey RESTRICT;
EXECUTE foo; -- fail
+
+/*
+ * Corner cases of PostgreSQL optimizer:
+ *
+ * Correlations between columns aren't found by DBMS.
+ * Selectivities multiplication of many columns increases total selectivity
+ * error. If such non-true selectivity is so small, that rows estimation
+ * give us absolute minimum (1) then the optimizer can't choose between different
+ * indexes and uses first from the index list (last created).
+ */
+\set scale 100000
+
+CREATE TABLE t AS (
+ SELECT c1 AS c1, -- selectivity(c1)*selectivity(c2)*nrows <= 1
+ c1 AS c2,
+ -- Create two columns with different selectivity.
+ (c1 % 2) AS c3, -- missing from a good index.
+ (c1 % 4) AS c4 -- missing from a bad index.
+ FROM generate_series(1,:scale) AS c1
+);
+UPDATE t SET c1=1,c2=1,c3=1,c4=2 WHERE c1<:scale/1000;
+
+CREATE INDEX bad ON t (c1,c2,c3);
+CREATE INDEX good ON t (c1,c2,c4);
+ANALYZE t; -- update stat on the indexes.
+
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE c1=1 AND c2=1 AND c3=1 AND c4=1;
+
+-- Set the bad index to the first position in the index list.
+DROP INDEX bad;
+CREATE INDEX bad ON t (c1,c2,c3);
+ANALYZE t;
+
+EXPLAIN (COSTS OFF) SELECT * FROM t WHERE c1=1 AND c2=1 AND c3=1 AND c4=1;
+
+DROP TABLE t CASCADE;
--
2.43.0
Hi!
I'd like to get this subject off the ground. The problem originally
described in [1] obviously comes from wrong selectivity estimation.
"Dependencies" extended statistics lead to significant selectivity miss
24/1000 instead of 1/1000. When the estimation is correct, the PostgreSQL
optimizer is capable of choosing the appropriate unique index for the query.
Tom pointed out in [2] that this might be a problem of "Dependencies"
extended statistics. I've rechecked this. The dataset consists of two
parts. The first part defined in the CREATE TABLE statement contains
independent values. The second part defined in the INSERT statement
contains dependent values. "Dependencies" extended statistics estimate
dependency degree as a fraction of rows containing dependent values.
According to this definition, it correctly estimates the dependency degree
as about 0.5 for all the combinations. So, the error in the estimate comes
from the synergy of two factors: MCV estimation of the frequency of
individual values, and spreading of average dependency degree for those
values, which is not relevant for them. I don't think there is a way to
fix "dependencies" extended statistics because it works exactly as
designed. I have to note that instead of fixing "dependencies" extended
statistics you can just add multi-column MCV statistics, which would fix
the problem.
CREATE STATISTICS aestat(dependencies,ndistinct,mcv) ON x,y,z FROM a;
Independently on how well our statistics work, it looks pitiful that we
couldn't fix that using the knowledge of unique constraints on the table.
Despite statistics, which give us just more or less accurate estimates, the
constraint is something we really enforce and thus can rely on. The
patches provided by Andrei in [1], [3], and [4] fix this issue at the index
scan path level. As Thomas pointed out in [5], that could lead to
inconsistency between the number of rows used for unique index scan
estimation and the value displayed in EXPLAIN (and used for other paths).
Even though this was debated in [6], I can confirm this inconsistency.
Thus, with the patch published in [4], I can see the 28-row estimation with
a unique index scan.
` QUERY PLAN
-----------------------------------------------------------------------
Index Only Scan using a_pkey on a (cost=0.28..8.30 rows=28 width=12)
Index Cond: ((x = 1) AND (y = 1) AND (z = 1))
(2 rows)
Also, there is a set of patches [7], [8], and [9], which makes the
optimizer consider path selectivity as long as path costs during the path
selection. I've rechecked that none of these patches could resolve the
original problem described in [1]. Also, I think they are quite tricky.
The model of our optimizer assumes that paths in the list should be the
different ways of getting the same result. If we choose the paths by their
selectivity, that breaks this model. I don't say there is no way for
this. But if we do this, that would require significant rethinking of our
optimizer model and possible revision of a significant part of it. Anyway,
I think if there is still interest in this, that should be moved into a
separate thread to keep this thread focused on the problem described in [1].
Finally, I'd like to note that the issue described in [1] is mostly the
selectivity estimation problem. It could be solved by adding the
multi-column MCV statistics. The patches published so far look more like
hacks for particular use cases rather than appropriate solutions. It still
looks promising to me to use the knowledge of unique constraints during
selectivity estimation [10]. Even though it's hard to implement and
possibly implies some overhead, it fits the current model. I also think
unique contracts could probably be used in some way to improve estimates
even when there is no full match.
Links.
1.
/messages/by-id/0ca4553c-1f34-12ba-9122-44199d1ced41@postgrespro.ru
2. /messages/by-id/3119052.1657231656@sss.pgh.pa.us
3.
/messages/by-id/90a1d6ef-c777-b95d-9f77-0065ad4522df@postgrespro.ru
4.
/messages/by-id/a5a18d86-c0e5-0ceb-9a18-be1beb2d2944@postgrespro.ru
5.
/messages/by-id/f8044836-5d61-a4e0-af82-5821a2a1f0a7@enterprisedb.com
6.
/messages/by-id/90a1d6ef-c777-b95d-9f77-0065ad4522df@postgrespro.ru
7.
/messages/by-id/2df148b5-0bb8-f80b-ac03-251682fab585@postgrespro.ru
8.
/messages/by-id/6fb43191-2df3-4791-b307-be754e648276@postgrespro.ru
9.
/messages/by-id/154f786a-06a0-4fb1-b8a4-16c66149731b@postgrespro.ru
10.
/messages/by-id/f8044836-5d61-a4e0-af82-5821a2a1f0a7@enterprisedb.com
------
Regards,
Alexander Korotkov
On 18/12/2023 15:29, Alexander Korotkov wrote:
Also, there is a set of patches [7], [8], and [9], which makes the
optimizer consider path selectivity as long as path costs during the
path selection. I've rechecked that none of these patches could resolve
the original problem described in [1].
It is true. We accidentally mixed two different problems in one thread.
Also, I think they are quite
tricky. The model of our optimizer assumes that paths in the list
should be the different ways of getting the same result. If we choose
the paths by their selectivity, that breaks this model. I don't say
there is no way for this. But if we do this, that would require
significant rethinking of our optimizer model and possible revision of a
significant part of it.
I can't understand that. In [9] we just elaborate the COSTS_EQUAL case
and establish final decision on more stable basis than a casual order of
indexes in the list.
Anyway, I think if there is still interest in
this, that should be moved into a separate thread to keep this thread
focused on the problem described in [1].
Agree. IMO, the problem of optimizer dependency on an order of indexes
in the relation index list is more urgent for now.
Finally, I'd like to note that the issue described in [1] is mostly the
selectivity estimation problem. It could be solved by adding the
multi-column MCV statistics. The patches published so far look more
like hacks for particular use cases rather than appropriate solutions.
It still looks promising to me to use the knowledge of unique
constraints during selectivity estimation [10]. Even though it's hard
to implement and possibly implies some overhead, it fits the current
model. I also think unique contracts could probably be used in some way
to improve estimates even when there is no full match.
I have tried to use the knowledge about unique indexes in the
selectivity estimation routine. But it looks invasive and adds a lot of
overhead.
--
regards,
Andrei Lepikhov
Postgres Professional
On Thu, Dec 21, 2023 at 10:41 AM Andrei Lepikhov
<a.lepikhov@postgrespro.ru> wrote:
On 18/12/2023 15:29, Alexander Korotkov wrote:
Also, there is a set of patches [7], [8], and [9], which makes the
optimizer consider path selectivity as long as path costs during the
path selection. I've rechecked that none of these patches could resolve
the original problem described in [1].It is true. We accidentally mixed two different problems in one thread.
Also, I think they are quite
tricky. The model of our optimizer assumes that paths in the list
should be the different ways of getting the same result. If we choose
the paths by their selectivity, that breaks this model. I don't say
there is no way for this. But if we do this, that would require
significant rethinking of our optimizer model and possible revision of a
significant part of it.I can't understand that. In [9] we just elaborate the COSTS_EQUAL case
and establish final decision on more stable basis than a casual order of
indexes in the list.
I took a closer look at the patch in [9]. I should drop my argument
about breaking the model, because add_path() already considers other
aspects than just costs. But I have two more note about that patch:
1) It seems that you're determining the fact that the index path
should return strictly one row by checking path->rows <= 1.0 and
indexinfo->unique. Is it really guaranteed that in this case quals
are matching unique constraint? path->rows <= 1.0 could be just an
estimation error. Or one row could be correctly estimated, but it's
going to be selected by some quals matching unique constraint and
other quals in recheck. So, it seems there is a risk to select
suboptimal index due to this condition.
2) Even for non-unique indexes this patch is putting new logic on top
of the subsequent code. How we can prove it's going to be a win?
That could lead, for instance, to dropping parallel-safe paths in
cases we didn't do so before.
Anyway, please start a separate thread if you're willing to put more
work into this.
Anyway, I think if there is still interest in
this, that should be moved into a separate thread to keep this thread
focused on the problem described in [1].Agree. IMO, the problem of optimizer dependency on an order of indexes
in the relation index list is more urgent for now.Finally, I'd like to note that the issue described in [1] is mostly the
selectivity estimation problem. It could be solved by adding the
multi-column MCV statistics. The patches published so far look more
like hacks for particular use cases rather than appropriate solutions.
It still looks promising to me to use the knowledge of unique
constraints during selectivity estimation [10]. Even though it's hard
to implement and possibly implies some overhead, it fits the current
model. I also think unique contracts could probably be used in some way
to improve estimates even when there is no full match.I have tried to use the knowledge about unique indexes in the
selectivity estimation routine. But it looks invasive and adds a lot of
overhead.
I got it. But it doesn't look enough to decide this is no way. Could
you, please, share some of your results? It might happen that we just
need to rework some of data structures to make this information more
easily accessible at selectivity estimation stage.
------
Regards,
Alexander Korotkov