Bug Report
Please answer these questions before submitting your issue. Thanks!
1. Minimal reproduce step (Required)
tidb_distinct_equality_semijoin_repro.sql
tidb_distinct_equality_semijoin_repro_result.txt
Run the attached reproduction script:
mysql -h 127.0.0.1 -P 4000 -u root --comments --table \
< tidb_distinct_equality_semijoin_repro.sql \
> tidb_distinct_equality_semijoin_repro_result.txt 2>&1
The script creates and analyzes three tables:
t0: 90 rows, 8 distinct c0 values
t1: 203 rows, 32 distinct c0 values
t3: 117 rows, 8 distinct c0 values
The query being tested is:
SELECT DISTINCT
t0.c1 AS ref0,
t3.c1 AS t3_c1,
t0.c0 AS t0_c0
FROM t3
JOIN t0 ON t3.c0 = t0.c0
JOIN t1 ON t1.c0 = t0.c0
ORDER BY t0.c0
LIMIT 8;
t1 does not contribute any projected column, DISTINCT key, or ordering expression. It only determines whether the current t0.c0 value has at least one matching row.
The logically equivalent existence form is:
SELECT DISTINCT
t0.c1 AS ref0,
t3.c1 AS t3_c1,
t0.c0 AS t0_c0
FROM t3
JOIN t0 ON t3.c0 = t0.c0
WHERE EXISTS (
SELECT 1
FROM t1
WHERE t1.c0 = t0.c0
)
ORDER BY t0.c0
LIMIT 8;
The script verifies exact result equivalence:
inner_rows = 7
exists_rows = 7
inner_minus_exists = 0
exists_minus_inner = 0
For the base data, the relevant cardinalities are:
ordinary inner-join rows = 20,223
t0/t3 rows having at least one t1 match = 1,137
final DISTINCT rows = 7
The script also creates t1_amplified by copying every t1 row 32 times:
base t1 rows = 203
amplification = 32
t1_amplified rows = 6,496
distinct c0 values = 32
This amplification changes only the number of duplicate matches. It does not change whether a given t0.c0 value has a match, and it does not change the final DISTINCT result.
For the amplified data:
ordinary inner-join rows = 647,136
final DISTINCT rows = 7
The complete SQL script and execution result are attached.
2. What did you expect to see? (Required)
Because t1 contributes no observable output value, additional t1 rows matching the same t0.c0 value cannot produce a new final DISTINCT row.
For each row produced by t3 JOIN t0, the required semantics with respect to t1 are only:
check whether at least one t1 row has t1.c0 = t0.c0
stop after the first match
I expected the optimizer to consider a duplicate-aware transformation equivalent to:
DISTINCT over columns from t0 and t3
+
inner equality join to duplicate-only t1
→
semijoin / FirstMatch against t1
The explicit EXISTS form demonstrates that TiDB already has a suitable physical plan for this equality predicate:
TopN
└─HashAgg
└─MergeJoin semi join
Therefore, the original inner-join query should be able to use the same or another equivalent duplicate-eliminating strategy, such as:
or:
deduplicate t1.c0
→
join the distinct t1 keys with t0/t3
or an index-based FirstMatch plan that stops after the first matching t1 row.
The optimizer does not need to produce one specific physical plan. Any equivalent strategy that avoids sending every duplicate t1 match to the upper HashAgg would be sufficient.
3. What did you see instead (Required)
Base data
The original inner-join query uses ordinary MergeJoins:
TopN actRows = 7
└─HashAgg actRows = 7
└─Projection actRows = 20,223
└─MergeJoin inner join actRows = 20,223
All 20,223 matching join rows are materialized before HashAgg reduces them to 7 distinct rows.
Three runs of the original query:
Median runtime:
The equivalent EXISTS query is recognized as a semi join:
TopN actRows = 7
└─HashAgg actRows = 7
└─MergeJoin semi join actRows = 1,137
Three runs of the equivalent EXISTS query:
Median runtime:
The original query is approximately:
2.99 ms / 1.60 ms = 1.87x slower
Even on the small base data, the ordinary inner join sends approximately:
more rows to the duplicate-elimination stage than the semi join.
Duplicate amplification
After copying each t1 row 32 times, the original inner-join query produces:
MergeJoin actRows = 647,136
HashAgg actRows = 7
Three runs:
Median runtime:
The equivalent amplified EXISTS query still uses a Merge Semi Join and emits only:
MergeJoin semi join actRows = 1,137
HashAgg actRows = 7
Three runs:
Median runtime:
The original amplified query is approximately:
73.9 ms / 5.73 ms = 12.9x slower
The ordinary join output grows exactly with the duplicate amplification factor:
However, neither the existence condition nor the final DISTINCT result changes.
This shows that the runtime is scaling with duplicate match multiplicity that is not observable in the final result.
The issue is not limited to ORDER BY or LIMIT
The script also executes the same two logical forms without ORDER BY and LIMIT.
Plain DISTINCT with the ordinary inner join:
HashAgg actRows = 7
MergeJoin actRows = 20,223
runtime = 3.35 ms
Plain DISTINCT with the equivalent semi join:
HashAgg actRows = 7
MergeJoin semi join actRows = 1,137
runtime = 1.61 ms
Therefore, this is not only a TopN costing or ordered early-termination issue.
The missing optimization is more generally:
DISTINCT
+
predicate-connected equality-join input that contributes no output columns
+
multiple matches that only create removable duplicates
→
semijoin / FirstMatch
This issue is related at a high level to #69918, because both involve duplicate-only join matches below DISTINCT.
However, this reproducer isolates a different and narrower planner path.
In #69918:
- the join condition is a correlated non-equality predicate;
- the explicit
EXISTS rewrite still uses a Cartesian Hash Semi Join;
- and an additional problem is that TiDB does not generate an efficient parameterized dynamic index range for the correlated predicate.
In this issue:
- every join condition is a simple equality predicate;
- indexes exist on the equality keys;
- the explicit
EXISTS rewrite already produces an efficient MergeJoin semi join;
- there is no dynamic range, residual inequality, or repeated correlated index-range problem;
- and the efficient physical semi-join plan is already available.
This reproducer therefore specifically isolates the failure to derive or consider the available equality semijoin from an ordinary multi-table inner join under DISTINCT.
Impact
The unnecessary intermediate work grows with:
number of t0/t3 rows
×
number of duplicate t1 matches per join key
Increasing only the duplicate multiplicity of t1 from the base data to 32 copies increases the ordinary query runtime from approximately 3 ms to approximately 74 ms, while the final result remains unchanged.
For larger inputs or more heavily duplicated join keys, this can cause:
- unnecessary CPU consumption;
- increased aggregation work;
- larger intermediate result processing;
- higher query latency;
- resource contention;
- and query timeouts.
The current workaround is to manually rewrite the duplicate-only inner join as an EXISTS predicate.
Suggested fix direction
Consider adding a logical transformation for DISTINCT and equivalent duplicate-insensitive aggregation plans.
When:
- an inner-join input contributes no projected column;
- it contributes no
DISTINCT, grouping, ordering, or other observable value;
- its only role is to determine whether a matching row exists;
- and multiple matches can only produce duplicates removed by the upper operator;
the optimizer could replace the ordinary inner join with a semijoin or FirstMatch-equivalent operation.
For equality predicates, TiDB could reuse the same Merge Semi Join candidate that is already generated for the explicit EXISTS form, or deduplicate the inner equality keys before joining.
The transformation must preserve no-match semantics: a t0/t3 row must not appear if no matching t1 row exists.
4. What is your TiDB version? (Required)
Release Version: v8.5.7
Edition: Community
Git Commit Hash: 202b7f47286a1109b5c957401d34c9358d130ae0
Git Branch: HEAD
UTC Build Time: 2026-07-15 02:06:00
GoVersion: go1.25.10
Race Enabled: false
Check Table Before Drop: false
Store: tikv
Bug Report
Please answer these questions before submitting your issue. Thanks!
1. Minimal reproduce step (Required)
tidb_distinct_equality_semijoin_repro.sql
tidb_distinct_equality_semijoin_repro_result.txt
Run the attached reproduction script:
The script creates and analyzes three tables:
The query being tested is:
t1does not contribute any projected column,DISTINCTkey, or ordering expression. It only determines whether the currentt0.c0value has at least one matching row.The logically equivalent existence form is:
The script verifies exact result equivalence:
For the base data, the relevant cardinalities are:
The script also creates
t1_amplifiedby copying everyt1row 32 times:This amplification changes only the number of duplicate matches. It does not change whether a given
t0.c0value has a match, and it does not change the finalDISTINCTresult.For the amplified data:
The complete SQL script and execution result are attached.
2. What did you expect to see? (Required)
Because
t1contributes no observable output value, additionalt1rows matching the samet0.c0value cannot produce a new finalDISTINCTrow.For each row produced by
t3 JOIN t0, the required semantics with respect tot1are only:I expected the optimizer to consider a duplicate-aware transformation equivalent to:
The explicit
EXISTSform demonstrates that TiDB already has a suitable physical plan for this equality predicate:Therefore, the original inner-join query should be able to use the same or another equivalent duplicate-eliminating strategy, such as:
or:
or an index-based FirstMatch plan that stops after the first matching
t1row.The optimizer does not need to produce one specific physical plan. Any equivalent strategy that avoids sending every duplicate
t1match to the upperHashAggwould be sufficient.3. What did you see instead (Required)
Base data
The original inner-join query uses ordinary MergeJoins:
All 20,223 matching join rows are materialized before
HashAggreduces them to 7 distinct rows.Three runs of the original query:
Median runtime:
The equivalent
EXISTSquery is recognized as a semi join:Three runs of the equivalent
EXISTSquery:Median runtime:
The original query is approximately:
Even on the small base data, the ordinary inner join sends approximately:
more rows to the duplicate-elimination stage than the semi join.
Duplicate amplification
After copying each
t1row 32 times, the original inner-join query produces:Three runs:
Median runtime:
The equivalent amplified
EXISTSquery still uses a Merge Semi Join and emits only:Three runs:
Median runtime:
The original amplified query is approximately:
The ordinary join output grows exactly with the duplicate amplification factor:
However, neither the existence condition nor the final
DISTINCTresult changes.This shows that the runtime is scaling with duplicate match multiplicity that is not observable in the final result.
The issue is not limited to ORDER BY or LIMIT
The script also executes the same two logical forms without
ORDER BYandLIMIT.Plain
DISTINCTwith the ordinary inner join:Plain
DISTINCTwith the equivalent semi join:Therefore, this is not only a TopN costing or ordered early-termination issue.
The missing optimization is more generally:
Relation to #69918
This issue is related at a high level to #69918, because both involve duplicate-only join matches below
DISTINCT.However, this reproducer isolates a different and narrower planner path.
In #69918:
EXISTSrewrite still uses a Cartesian Hash Semi Join;In this issue:
EXISTSrewrite already produces an efficientMergeJoin semi join;This reproducer therefore specifically isolates the failure to derive or consider the available equality semijoin from an ordinary multi-table inner join under
DISTINCT.Impact
The unnecessary intermediate work grows with:
Increasing only the duplicate multiplicity of
t1from the base data to 32 copies increases the ordinary query runtime from approximately 3 ms to approximately 74 ms, while the final result remains unchanged.For larger inputs or more heavily duplicated join keys, this can cause:
The current workaround is to manually rewrite the duplicate-only inner join as an
EXISTSpredicate.Suggested fix direction
Consider adding a logical transformation for
DISTINCTand equivalent duplicate-insensitive aggregation plans.When:
DISTINCT, grouping, ordering, or other observable value;the optimizer could replace the ordinary inner join with a semijoin or FirstMatch-equivalent operation.
For equality predicates, TiDB could reuse the same Merge Semi Join candidate that is already generated for the explicit
EXISTSform, or deduplicate the inner equality keys before joining.The transformation must preserve no-match semantics: a
t0/t3row must not appear if no matchingt1row exists.4. What is your TiDB version? (Required)