| Bug #120809 | Optimizer misses DISTINCT early-out and chooses 40M-row hash join Cartesian product | ||
|---|---|---|---|
| Submitted: | 29 Jun 7:31 | Modified: | 2 Jul 14:07 |
| Reporter: | ZHAOYANG ZHANG | Email Updates: | |
| Status: | Verified | Impact on me: | |
| Category: | MySQL Server: Optimizer | Severity: | S5 (Performance) |
| Version: | 9.7.1 and 8.0.31 | OS: | Ubuntu (22.04.4 LTS) |
| Assigned to: | CPU Architecture: | Any | |
| Tags: | distinct, firstmatch, hash join, JOIN_ORDER, Optimizer, performance, semijoin | ||
[29 Jun 7:31]
ZHAOYANG ZHANG
[29 Jun 7:32]
ZHAOYANG ZHANG
Test case sql file
Attachment: mysql_distinct_earlyout.sql (application/octet-stream, text), 3.69 KiB.
[30 Jun 5:12]
ZHAOYANG ZHANG
I found a smaller and more targeted reproducer for the same issue. I will upload it as: bug120809_distinct_earlyout_unused_table.sql In this reduced case, the SELECT DISTINCT key only contains columns from t1 and t2: SELECT DISTINCTROW t2.c2 AS ref0, t1.c1 AS ref1 FROM t2, t1 STRAIGHT_JOIN t0 ON (NOT (t0.c0)); No column from t0 is used in the SELECT list or the DISTINCT key. Table t0 only checks whether there exists at least one row satisfying NOT(t0.c0). Therefore, multiple matching rows from t0 only generate duplicate DISTINCT keys. The data size is: t0 matching rows where NOT(c0): 200 t1 rows: 600 t2 rows: 600 final DISTINCT rows: 360,000 duplicate-expanded rows before deduplication: 72,000,000 The default plan expands all matching t0 rows and generates 72M rows before duplicate removal: -> Table scan on <temporary> (cost=23.8e+6..24.7e+6 rows=72e+6) (actual time=51187..51283 rows=360000 loops=1) -> Temporary table with deduplication (cost=23.8e+6..23.8e+6 rows=72e+6) (actual time=51187..51187 rows=360000 loops=1) -> Inner hash join (no condition) (cost=7.21e+6 rows=72e+6) (actual time=117..3839 rows=72e+6 loops=1) -> Covering index scan on t2 using i7 (actual time=0.0111..0.402 rows=600 loops=1) -> Hash -> Nested loop inner join (actual time=0.0156..63.6 rows=120000 loops=1) -> Covering index scan on t1 using i4 (actual time=0.00989..0.286 rows=600 loops=1) -> Covering index lookup on t0 using i0 (c0 = 0) (actual time=839e-6..0.0938 rows=200 loops=600) Runtime of the default query: 51.371 sec With a JOIN_ORDER hint, MySQL finds a much faster valid plan. The important difference is that the t0 lookup is executed with "Limit: 1 row(s)", avoiding the duplicate expansion from t0: -> Table scan on <temporary> (actual time=1250..1346 rows=360000 loops=1) -> Temporary table with deduplication (actual time=1250..1250 rows=360000 loops=1) -> Nested loop inner join (actual time=0.327..439 rows=360000 loops=1) -> Inner hash join (no condition) (actual time=0.312..27.3 rows=360000 loops=1) -> Covering index scan on t1 using i4 (actual time=0.00276..0.338 rows=600 loops=1) -> Hash -> Covering index scan on t2 using i7 (actual time=0.00935..0.261 rows=600 loops=1) -> Limit: 1 row(s) (actual time=922e-6..953e-6 rows=1 loops=360000) -> Covering index lookup on t0 using i0 (c0 = 0) (actual time=809e-6..809e-6 rows=1 loops=360000) Runtime of the hinted query: 1.434 sec An equivalent EXISTS rewrite also confirms that t0 is only an existence check: SELECT DISTINCTROW t2.c2 AS ref0, t1.c1 AS ref1 FROM t2, t1 WHERE EXISTS ( SELECT 1 FROM t0 WHERE NOT t0.c0 ); Its plan evaluates the t0 existence check with "Limit: 1 row(s)" only once: -> Temporary table with deduplication (actual time=832..832 rows=360000 loops=1) -> Inner hash join (no condition) (actual time=0.408..23.2 rows=360000 loops=1) -> Covering index scan on t1 using i4 (actual time=0.0024..0.402 rows=600 loops=1) -> Hash -> Inner hash join (FirstMatch) (actual time=0.0265..0.339 rows=600 loops=1) -> Covering index scan on t2 using i7 (actual time=0.00443..0.258 rows=600 loops=1) -> Hash -> Limit: 1 row(s) (actual time=0.0122..0.0123 rows=1 loops=1) -> Covering index lookup on t0 using i0 (c0 = 0) (actual time=0.0115..0.0115 rows=1 loops=1) Runtime of the EXISTS rewrite: 1.018 sec So the default plan is about 35.8x slower than the JOIN_ORDER hinted plan and about 50.5x slower than the equivalent EXISTS rewrite. This seems to be a missed DISTINCT early-out / FirstMatch-style optimization for a joined table whose columns are not used in the DISTINCT key. The default plan materializes all duplicate-producing rows from t0, while the hinted and rewritten plans show that only one matching t0 row is needed.
[30 Jun 5:13]
ZHAOYANG ZHANG
bug120809_distinct_earlyout_unused_table
Attachment: bug120809_distinct_earlyout_unused_table.sql (application/octet-stream, text), 4.06 KiB.
[2 Jul 14:07]
Chaithra Marsur Gopala Reddy
Hi ZHAOYANG ZHANG, Thank you for the test case. Verified as described.
