pingcap / pingcap/tidb

planner: ORDER BY misses pre-join sort with order-preserving IndexJoins, causing ~7.2x slowdown

Open
#71,319 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

### 1. Minimal reproduce step (Required)

[tidb_setup.sql](https://github.com/user-attachments/files/32330349/tidb_setup.sql)

[prejoin_sort_indexjoin_repro.sql](https://github.com/user-attachments/files/32330354/prejoin_sort_indexjoin_repro.sql)

[prejoin_sort_indexjoin_repro.txt](https://github.com/user-attachments/files/32330365/prejoin_sort_indexjoin_repro.txt)

I found a case where TiDB performs `ORDER BY` after materializing the full join result, although an equivalent plan can sort the small outer input first and preserve that ordering through IndexJoin operators.

Please run the attached `setup.sql` first, then use the following session settings:

```sql
USE tidb_ordering_repro;

SET SESSION tidb_cost_model_version = 2;
SET SESSION tidb_opt_advanced_join_hint = ON;
```

The original query is:

```sql
SELECT t1.c1 AS ref0
FROM t3
JOIN t0 ON t3.c1 = t0.c0
JOIN t1 ON t3.c0 = t1.c0
ORDER BY t1.c1 DESC;
```

Its default plan is approximately:

```text
Sort
└─ HashJoin
├─ t0
└─ HashJoin
├─ t1
└─ t3
```

`EXPLAIN ANALYZE` shows that the top-level Sort processes 6,303,964 rows.

For comparison, the following equivalent rewrite exposes a much cheaper physical plan:

```sql
SELECT /*+ INL_JOIN(t3, t0) */ q.c1 AS ref0
FROM (
SELECT c0, c1
FROM t1
ORDER BY c1 DESC
LIMIT 18446744073709551615
) AS q
JOIN t3 ON t3.c0 = q.c0
JOIN t0 ON t0.c0 = t3.c1
ORDER BY q.c1 DESC;
```

`LIMIT 18446744073709551615` does not remove any row. It is only used to retain the ordering of the derived-table input.

This rewrite produces approximately:

```text
IndexJoin
├─ IndexJoin
│ ├─ TopN / Sort
│ │ └─ t1
│ └─ IndexLookUp(t3)
└─ IndexRangeScan(t0)
```

There is no final Sort above the joins. The required ordering is created on the small `t1` input and preserved through the IndexJoin operators.

I measured each query three times with `EXPLAIN ANALYZE`.

Default query:

```text
3.31 s
3.14 s
3.12 s
median: 3.14 s
```

Pre-sort / IndexJoin alternative:

```text
421.2 ms
437.0 ms
472.3 ms
median: 437.0 ms
```

The alternative is about:

```text
3.14 s / 0.437 s = 7.19x faster
```

The cost traces also show a very large difference:

```text
Default plan cost:
5,997,900,763.72

Pre-sort + IndexJoin plan cost:
75,256,003.05
```

So the alternative has about 79.7x lower estimated cost once it is exposed to the optimizer.

As a control, removing `ORDER BY` from the original query makes the default HashJoin plan finish in about 363 ms:

```sql
SELECT t1.c1 AS ref0
FROM t3
JOIN t0 ON t3.c1 = t0.c0
JOIN t1 ON t3.c0 = t1.c0;
```

This suggests that most of the slowdown comes from sorting the multi-million-row join result.

All three tables were analyzed with:

```sql
ANALYZE TABLE t0 ALL COLUMNS;
ANALYZE TABLE t1 ALL COLUMNS;
ANALYZE TABLE t3 ALL COLUMNS;
```

The default plan estimates about 5.37M output rows and actually produces about 6.30M rows, so this does not appear to be caused by a severe join-cardinality estimation error.

The full setup and query scripts are attached as `setup.sql` and `query.sql`.

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

I expected the optimizer to consider a physical alternative that creates the required ordering on the small `t1` input before the joins and then preserves that ordering through IndexJoin operators.

Conceptually:

```text
Sort / TopN(t1.c1 DESC)
|
IndexJoin(t3)
|
IndexJoin(t0)
```

This avoids sorting the entire multi-million-row join result.

The equivalent rewrite demonstrates that TiDB is already capable of executing this plan shape, and TiDB's own cost model estimates it to be much cheaper than the default plan.

Therefore, I would expect the original query optimizer to enumerate this alternative and compare its physical cost with the final-Sort plan.

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

For the original query, TiDB only chooses a plan that performs the joins first and then sorts the full result:

```text
Sort: actual rows = 6,303,964
└─ HashJoin
└─ HashJoin
```

The median runtime is about 3.14 s and the Sort uses about 153 MB of memory.

However, an equivalent rewrite exposes a plan that sorts only the small `t1` input and preserves the ordering through two IndexJoin operators:

```text
TopN / Sort: actual rows = 502
→ IndexJoin
→ IndexJoin
```

The median runtime of this plan is about 437 ms, approximately 7.19x faster.

In addition, the optimizer's own cost model estimates the exposed plan at about 75.26M versus about 5.998B for the default plan, so the faster alternative is also estimated to be dramatically cheaper.

This appears to be a plan-enumeration / physical-property issue: the cost model can recognize the alternative as cheaper once it is exposed, but the optimizer does not generate a pre-join Sort enforcer plus order-preserving IndexJoin alternative for the original query.

Related to #65208, but this case is different in that it does not depend on an existing ordered index or `LIMIT` early termination. The useful ordering must first be created by sorting the small outer input, and then preserved through the joins.

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

```text
Release Version: v8.5.0
Edition: Community
Git Commit Hash: d13e52ed6e22cc5789bed7c64c861578cd2ed55b
Git Branch: HEAD
UTC Build Time: 2024-12-18 02:26:06
GoVersion: go1.23.3
Race Enabled: false
Check Table Before Drop: false
Store: tikv
```

Additional session configuration:

```text
tidb_cost_model_version = 2
tidb_opt_advanced_join_hint = ON
```

Contributor guide

Open the contributing guide

Research direction

Run tidb_setup.sql and prejoin_sort_indexjoin_repro.sql with the listed session settings, then compare EXPLAIN ANALYZE for the original and rewritten queries. Start in the planner's physical-plan handling for ORDER BY and order-preserving IndexJoin; done means the original query can consider the pre-sort IndexJoin shape and avoid sorting the full join result.

Written by the indexing model from the issue text.

Assessment

Tech stack
go, sql
Domain
databases
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.