Skip to content
JZLeetCode
Go back

System Design - How TiDB's Parallel Sort Executor Merges Rows

Table of contents

Open Table of contents

The leaderboard that arrived out of order

Suppose a game records scores in whatever order players finish:

SELECT player, score FROM scores ORDER BY score DESC;

If TiDB chooses a root Sort operator, it must produce a globally ordered stream even when its child sends unsorted chunks (batches of rows). Several workers can sort their own slices in parallel, but that is not enough: [90, 70] from one worker and [95, 80] from another must become [95, 90, 80, 70] for the caller. This article follows that handoff through four source units in pingcap/tidb at commit 0073665, dated October 24, 2026. It describes the root parallel SortExec path, not TiKV-side sorting, every ORDER BY plan, or the separate TopN algorithm. Use EXPLAIN to see which plan your query actually got.

Four pieces on the stage

 +-------------------+       +------------------------+
 | PhysicalSort      |------>| builder.buildSort      |
 | ORDER BY keys     |       | wires child + ByItems  |
 +-------------------+       +-----------+------------+
                                         |
                                         v
  unsorted child chunks -------> +-------+---------+
                                 | SortExec        |
                                 | fetcher/channel |
                                 +---+---------+---+
                                     |         ^
                                     v         | sorted worker streams
                              +------+---------+------+
                              | parallelSortWorker x N |
                              | sort local row batches |
                              +------------+-----------+
                                           |
                                           v
                              +------------+-----------+
                              | multiWayMerger          |
                              | globally ordered rows  |
                              +------------+-----------+
                                           |
                                           v
                                    caller's chunk

PhysicalSort carries the ByItems expressions and directions for the chosen physical plan. executorBuilder.buildSort builds its child, then gives those ByItems to a SortExec. The builder itself is the wiring step, not another sorting algorithm.

SortExec owns the child, channels, workers, comparison functions, result merger, and memory/disk trackers. A parallelSortWorker sorts local rows, while multiWayMerger picks the next row across sorted inputs using a heap. These are Go structs, not separate network services.

From a child’s chunks to sorted worker streams

On Open, SortExec creates ExecutorConcurrency workers and channels. In this pinned revision it explicitly sets IsUnparallel = false on the normal path; the alternative un-parallelized code remains in the file, but is not the path we follow. The first Next initializes key comparators and starts the fetch/sort pipeline. Later Next calls drain the already-started ordered result channel into the caller’s chunk.

The fetcher repeatedly pulls chunks from the child and puts them on a shared channel. fetchChunksParallel launches the workers and a result generator. A worker accumulates rows, sorts a batch with slices.SortFunc, and, if it made several locally sorted batches, merges those batches into one sorted iterator for that worker. Workers share the input channel, so which worker receives a given chunk is not a property of the SQL query.

The comparison is not a string comparison of printed values. SortExec builds type-specific comparison functions from the ORDER BY expressions; its lessRow compares keys in order and reverses a nonzero comparison for DESC. If two rows tie on all ORDER BY keys, do not assume a stable order between them; add a unique tie-breaker such as ORDER BY score DESC, player if your application needs deterministic results.

The final merge, one row at a time

When the worker streams are ready, the result generator initializes the in-memory merger. Its memorySource contributes the first row from each nonempty worker iterator to a heap. Calling multiWayMerger.next takes the heap’s best row and replaces it with the next row from that same iterator; when an iterator is exhausted, the heap shrinks. Only the resulting row stream reaches the caller. Neither sorting each chunk nor concatenating the worker results would satisfy ORDER BY.

worker 1:  90 -> 70     heap front: 95, then 90, then 80, then 70
worker 2:  95 -> 80     output:     95 -> 90 -> 80 -> 70

The example has distinct scores for clarity. The merger holds a front row per input, but the workers’ sorted input rows still consume memory. Global sort must see its input before it can guarantee which row comes first; do not mistake the output Next API for an early-exit algorithm. If the optimizer chooses a TopN for ORDER BY ... LIMIT, that is a different executor with different trade-offs.

Memory and configuration boundaries

Check the version-specific system-variable reference before changing settings; this walkthrough is tied to a development commit, not every TiDB release. For a given query, check its actual EXPLAIN plan before applying root-sort advice.

Takeaway

The plan specifies what ordering is needed. The builder wires the child and sort keys. SortExec hands input chunks to workers, workers sort local streams, and a heap-backed merger produces one globally sorted stream. Following those four pieces explains why local parallelism does not lose global ordering—and why sorting can trade memory, CPU, and temporary-disk I/O.

References

  1. TiDB source: PhysicalSort, pinned commit 0073665.
  2. TiDB source: builder, SortExec.Open, Next, and fetcher.
  3. TiDB source: parallel sort worker.
  4. TiDB source: multiWayMerger and memorySource.
  5. TiDB documentation: system variables.
  6. TiDB documentation: reading EXPLAIN plans.
Share this post on:

Previous Post
LeetCode 1034 Coloring A Border
Next Post
System Design - How Database Group Commit Amortizes fsync