pingcap / pingcap/tidb

planner: DISTINCT materializes all predicate matches instead of using per-row semijoin/FirstMatch

Open
#69,918 2 comments 0 reactions 0 assignees View on GitHub
contribution severity/moderate sig/planner type/bug
Dominant language
Go
Stars
40.5k
Forks
6.2k
PR merge metrics
PR metrics pending

Description

## Bug Report

Please answer these questions before submitting your issue. Thanks!

### 1. Minimal reproduce step (Required)

[tidb_distinct_correlated_firstmatch_repro.sql](https://github.com/user-attachments/files/30153723/tidb_distinct_correlated_firstmatch_repro.sql)

[tidb_distinct_correlated_firstmatch_repro_result.txt](https://github.com/user-attachments/files/30153724/tidb_distinct_correlated_firstmatch_repro_result.txt)

Run the attached reproduction script:

```bash
mysql -h 127.0.0.1 -P 4000 -u root --comments --table \
< tidb_distinct_correlated_firstmatch_repro.sql \
> tidb_distinct_correlated_firstmatch_repro_result.txt 2>&1
```

The script creates:

- `t0`: 2,000 rows, with an index `i1(c1)`
- `t1`: 20,000 rows
- `t0_no_match`: 100 rows used to verify no-match semantics

`t0.c1` ranges from `10` to `4008`.

Each `t1` row contains a threshold in `t1.c3`. The threshold ranges from `12` to `4010`, with 2,000 distinct threshold values. Therefore, the number of matching `t0` rows varies for different `t1` rows.

The primary query is:

```sql
SELECT DISTINCTROW
t1.c4 AS ref0,
t1.c3 AS ref1,
t1.c2 AS ref2
FROM t1, t0
WHERE t0.c1 <
CAST(GREATEST(t1.c3, '0.8727017201037127') AS SIGNED);
```

All projected columns and all `DISTINCT` keys come from `t1`.

`t0` only determines whether the current `t1` row has at least one matching row. Additional matching `t0` rows only produce duplicate copies of the same projected `t1` values.

The data shape is:

```text
t0 rows = 2,000
t1 rows = 20,000
distinct thresholds = 2,000
matching join pairs = 20,010,000
final DISTINCT rows = 20,000
```

The script compares the following query forms.

#### Baseline

```sql
SELECT DISTINCTROW
t1.c4,
t1.c3,
t1.c2
FROM t1;
```

#### Original query

```sql
SELECT DISTINCTROW
t1.c4,
t1.c3,
t1.c2
FROM t1, t0
WHERE t0.c1 <
CAST(GREATEST(t1.c3, '0.8727017201037127') AS SIGNED);
```

#### Equivalent correlated `EXISTS` rewrite

```sql
SELECT DISTINCTROW
t1.c4,
t1.c3,
t1.c2
FROM t1
WHERE EXISTS (
SELECT 1
FROM t0
WHERE t0.c1 <
CAST(GREATEST(t1.c3, '0.8727017201037127') AS SIGNED)
);
```

#### Correlated `Apply` diagnostic

```sql
SELECT DISTINCTROW
t1.c4,
t1.c3,
t1.c2
FROM t1
WHERE EXISTS (
SELECT /*+ NO_DECORRELATE(), USE_INDEX(t0, i1) */ 1
FROM t0
WHERE t0.c1 <
CAST(GREATEST(t1.c3, '0.8727017201037127') AS SIGNED)
LIMIT 1
);
```

The reproduction script:

- collects table statistics;
- verifies result counts;
- verifies the behavior when no inner row matches;
- runs `EXPLAIN FORMAT = 'verbose'`;
- runs `EXPLAIN ANALYZE` three times for the baseline, original query, correlated `EXISTS` rewrite, and `NO_DECORRELATE` diagnostic;
- and runs the original query once with HashJoin disabled.

The complete SQL script and execution result are attached.

### 2. What did you expect to see? (Required)

All projected columns and all `DISTINCT` keys come from `t1`.

For each `t1` row, `t0` only determines whether at least one row satisfies the correlated predicate:

```sql
t0.c1 <
CAST(GREATEST(t1.c3, '0.8727017201037127') AS SIGNED)
```

After the first matching `t0` row has been found, additional matching rows cannot add a new final `DISTINCT` value. They only create duplicate copies of the same projected `t1` row.

The relevant execution semantics are therefore:

```text
for each t1 row:
check whether at least one matching t0 row exists
stop after the first match
```

I expected the optimizer to consider transforming the duplicate-only inner join into a per-outer-row semijoin or FirstMatch-style plan.

A logically equivalent query is:

```sql
SELECT DISTINCTROW
t1.c4,
t1.c3,
t1.c2
FROM t1
WHERE EXISTS (
SELECT 1
FROM t0
WHERE t0.c1 <
CAST(GREATEST(t1.c3, '0.8727017201037127') AS SIGNED)
);
```

One possible physical implementation would be:

```text
TableScan(t1)
└─per-row semijoin / Apply
└─parameterized lookup on t0
└─stop after the first matching row
```

For example, if the predicate can be converted into a dynamic index range, the inner access could be logically equivalent to:

```text
IndexRangeScan(t0.i1, c1 < current_t1_threshold)
└─Limit 1
```

The optimizer does not need to produce exactly this physical plan. Any equivalent execution strategy that avoids materializing every matching `t1 × t0` pair would be sufficient.

### 3. What did you see instead (Required)

All tested query forms return the same result count:

```text
baseline = 20,000
original = 20,000
exists_rewrite = 20,000
no_decorrelate_apply = 20,000

result_count_equivalence = PASS
```

The no-match test also preserves the expected semantics:

```text
original_rows = 0
exists_rows = 0
apply_rows = 0
```

Statistics were successfully collected:

```text
t0 Row_count = 2,000
t1 Row_count = 20,000
Modify_count = 0
statistics health = 100
```

Therefore, the behavior is not caused by missing or pseudo statistics.

#### Baseline

The baseline only scans and deduplicates `t1`:

```text
HashAgg
└─TableFullScan(t1)
```

Three runs:

```text
6.00 ms
6.66 ms
6.86 ms
```

Median runtime:

```text
6.66 ms
```

#### The original query materializes every predicate match

The original query uses:

```text
HashAgg
└─HashJoin CARTESIAN inner join
├─IndexFullScan(t0.i1)
└─TableFullScan(t1)
```

The correlated inequality is evaluated as a residual join condition:

```text
t0.c1 <
CAST(GREATEST(t1.c3, '0.8727017201037127') AS SIGNED)
```

The optimizer estimates a very large join result:

```text
HashJoin estRows = 40,000,000
```

The actual join output is:

```text
HashJoin actRows = 20,010,000
```

These 20,010,000 matching rows are then reduced by `HashAgg` to only:

```text
20,000 rows
```

The intermediate join output is approximately 1,000.5 times larger than the final result:

```text
20,010,000 / 20,000 = 1,000.5x
```

Three runs:

```text
3.15 s
3.26 s
3.12 s
```

Median runtime:

```text
3.15 s
```

Compared with the baseline:

```text
3.15 s / 6.66 ms = approximately 473x
```

The optimizer already estimates that the join will produce tens of millions of rows, but no duplicate-aware semijoin/FirstMatch alternative is generated for the original `DISTINCT` query.

#### The correlated `EXISTS` rewrite becomes a semi join, but remains expensive

The equivalent correlated `EXISTS` form is recognized as:

```text
HashAgg
└─HashJoin CARTESIAN semi join
├─IndexFullScan(t0.i1)
└─TableFullScan(t1)
```

The semi join emits only 20,000 rows to the upper `HashAgg`, confirming that only existence is required for each `t1` row.

However, it still evaluates the non-equality predicate using a Cartesian Hash Semi Join rather than an efficient per-row FirstMatch access path.

Three runs:

```text
2.04 s
2.27 s
2.18 s
```

Median runtime:

```text
2.18 s
```

The `EXISTS` rewrite is faster than the original inner join because duplicate matches are no longer emitted to `HashAgg`, but it is still approximately:

```text
2.18 s / 6.66 ms = 327x
```

slower than the baseline.

#### `NO_DECORRELATE()` produces `Apply + LIMIT 1`, but still performs repeated index full scans

With:

```sql
/*+ NO_DECORRELATE(), USE_INDEX(t0, i1) */
```

TiDB produces:

```text
HashAgg
└─Apply CARTESIAN semi join
├─TableFullScan(t1)
└─Limit 1
└─Selection
└─IndexReader
└─IndexFullScan(t0.i1)
```

This plan confirms that per-outer-row early termination is semantically valid.

However, the inner access is still:

```text
IndexFullScan(t0.i1)
→ Selection
→ Limit 1
```

rather than a parameterized range access such as:

```text
IndexRangeScan(t0.i1, c1 < current_threshold)
→ Limit 1
```

A representative run reports:

```text
Apply cacheHitRatio = 90%
Limit actRows = 2,000
IndexReader actRows = 2,048,000
cop tasks = 6,000
runtime = approximately 2.8 seconds
```

The 90% Apply cache hit ratio is expected because the 20,000 outer rows contain 2,000 distinct threshold values.

Despite the cache and inner `LIMIT 1`, TiDB repeatedly reads large portions of the inner index because the correlated inequality is not converted into a parameterized range.

The plan also reports that the string-form `GREATEST` expression cannot be pushed down to TiKV. This may contribute to the inner access limitation, but it does not change the main logical optimization opportunity: the original `DISTINCT` inner join can be reduced to a per-row semijoin/FirstMatch.

#### Disabling HashJoin does not expose a FirstMatch plan

When HashJoin is disabled, TiDB uses a MergeJoin-based plan, but still produces:

```text
MergeJoin actRows = 20,010,000
```

The runtime increases to approximately:

```text
12.1 seconds
```

Therefore, this does not appear to be only a cost-model error between HashJoin and an already available efficient plan.

TiDB does not construct the desired transformation:

```text
DISTINCT over t1 columns
+
predicate-connected duplicate-only t0 input
→ per-t1-row semijoin / FirstMatch
```

#### Impact

The query returns 20,000 rows, but TiDB first produces 20,010,000 matching join rows.

The unnecessary work grows with:

```text
number of outer rows
×
number of matching inner rows per outer row
```

For larger inputs, this behavior can cause:

- severe CPU amplification;
- increased query latency;
- unnecessary intermediate-row processing;
- resource contention;
- and query timeouts.

The current workaround is to manually rewrite the query using a correlated `EXISTS`.

However, even the explicit `EXISTS` form currently uses a Cartesian Hash Semi Join, and the `NO_DECORRELATE` form uses repeated index full scans rather than an efficient parameterized FirstMatch path.

#### Suggested fix

Consider adding a logical transformation for `DISTINCT` and equivalent duplicate-insensitive aggregation plans.

When:

- all projected columns and all `DISTINCT` keys come from one join input;
- the other input contributes no observable output values;
- the other input only determines whether the current outer row has at least one predicate match;
- and multiple matching rows from the other input can only create duplicates removed by `DISTINCT`;

the optimizer could transform the inner join into a per-outer-row semijoin or FirstMatch plan.

For this query, the logical transformation is equivalent to:

```sql
SELECT DISTINCTROW
t1.c4,
t1.c3,
t1.c2
FROM t1
WHERE EXISTS (
SELECT 1
FROM t0
WHERE t0.c1 <
CAST(GREATEST(t1.c3, '0.8727017201037127') AS SIGNED)
);
```

The optimizer could then consider a parameterized inner access path, or another equivalent execution strategy that stops after the first match.

If applicable, an index access path could use a dynamic range logically equivalent to:

```text
t0.i1 range: c1 < current_t1_threshold
```

with FirstMatch or `LIMIT 1` semantics.

The transformation must preserve no-match semantics: if a particular `t1` row has no matching `t0` row, that `t1` row must not appear in the final result.

Regression tests could cover:

- correlated equality and inequality predicates;
- different numbers of matching inner rows;
- repeated and unique correlated parameter values;
- nullable projected columns;
- `DISTINCT` and equivalent `GROUP BY` forms;
- and behavior with HashJoin enabled or disabled.

#### Relation to #69915 and #69917

This issue is related to:

- [#69915](https://github.com/pingcap/tidb/issues/69915)
- [#69917](https://github.com/pingcap/tidb/issues/69917)

All three issues involve join inputs that can produce duplicate rows without adding new final `DISTINCT` values.

However, the required transformations are different.

In #69915, the additional inputs are independent and predicate-free, and the reproducer contains `ORDER BY` and `LIMIT`. The optimization needs to combine one-time global existence handling with preservation of the ordered `DISTINCT ... ORDER BY ... LIMIT` early-termination path.

In #69917, the additional inputs are also independent and predicate-free. They can be reduced to one-time global existence checks for the entire query, with no per-outer-row dependency.

In this issue, the matching condition depends on the current `t1` row:

```sql
t0.c1 <
CAST(GREATEST(t1.c3, '0.8727017201037127') AS SIGNED)
```

Therefore, `t0` cannot be replaced by a one-time global existence check. Each `t1` row requires its own correlated existence test.

The missing optimization here is specifically:

```text
predicate-connected duplicate-only input
→ per-outer-row semijoin / FirstMatch
```

rather than the global existence reductions involved in #69915 and #69917.

### 4. What is your TiDB version? (Required)

```text
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
```

Contributor guide

Open the contributing guide

Research direction

Start by running the attached SQL reproduction and comparing the EXPLAIN and EXPLAIN ANALYZE plans for the original join, EXISTS rewrite, and NO_DECORRELATE diagnostic. Trace the planner code that handles DISTINCT, joins, semijoins, and Apply, then add regression coverage for the described duplicate-only join and no-match semantics. Done means an equivalent plan avoids materializing every matching pair without changing results.

Written by the indexing model from the issue text.

Assessment

Tech stack
go, sql
Domain
databases
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.