From a plan to rows
After parsing, the MySQL optimizer turns a query into a tree of iterators, which EXPLAIN FORMAT=TREE prints (and EXPLAIN ANALYZE runs, adding the real row counts and times). Each iterator asks its children for rows and hands rows to its parent. Some pass each row on at once (a table scan, a filter, a nested-loop join, a limit); others must see their whole input first (building a hash table, filling a temporary table, sorting). The animation draws that tree top left, highlights the iterator at work, and counts the rows each one returns.
The data is tiny so every step fits on screen, but the plans are the ones MySQL picks when the tables are large:
CREATE TABLE customers (id INT PRIMARY KEY, name VARCHAR(20), city CHAR(2)); -- 5 rows
CREATE TABLE orders (id INT PRIMARY KEY, cust_id INT, amount INT,
KEY idx_cust_amt (cust_id, amount)); -- 8 rows
In InnoDB every secondary index entry also stores the primary key, so an entry of idx_cust_amt is (cust_id, amount, id). A query that needs only those columns is answered from the index alone: a covering index (EXPLAIN: Using index). Use the idx_cust_amt control to drop the index and see which plans change; use sort / join buffer to make the buffers hold just 3 rows.
JOIN
MySQL joins tables one at a time, in the order the optimizer finds cheapest. Two algorithms are used:
- Nested-loop join. For each row of the outer (first) table, look up the matching rows in the inner table. With an index on the join column (
o.cust_id) each lookup is one B-tree descent (type = ref, oreq_refon a unique key). Cost: outer rows × one index lookup. This is by far the most common MySQL join. - Hash join (8.0.18 and later). When the join is an equality and no index can be used, MySQL reads the smaller input (the build input) into a hash table in the join buffer, then reads the other input (the probe input) once and looks up each row's key. Cost: one pass over each table. It replaced the older block nested loop (EXPLAIN:
Using join buffer (hash join)).
If the build input does not fit in join_buffer_size, the hash join writes rows to chunk files on disk, partitioned by a hash of the join key so that matching rows of both inputs land in the same pair of chunks, and then joins each pair in memory. Run All three tab, Demo: hash join spills to see it with two halves.
MySQL has no sort-merge join. The WHERE condition c.city = 'HN' is checked as early as possible (the Filter iterator above the table scan), so only the matching customers drive the join.
GROUP BY
- Over an ordered index (tight index scan). If an index returns rows already sorted by the group columns, the aggregate only has to watch for the key to change: it keeps one group in memory and returns it the moment the next group starts. No temporary table, no sort.
- Loose index scan (
Using index for group-by). ForMIN()orMAX()of the column after the group columns in an index, MySQL jumps straight to the first or last entry of each group and skips the rest. With many rows per group this reads a tiny fraction of the index. - Temporary table (
Using temporary). Otherwise MySQL creates an internal temporary table with one row per group and a unique hash index on the group key (TempTable engine in memory; converted to an on-disk InnoDB table if it grows pasttmp_table_size). Each input row finds its group and updates the aggregates; at the end the table is scanned.
Since MySQL 8.0, GROUP BY does not sort its result any more (5.7 did, implicitly). If you need an order, write ORDER BY. Watch the output order of Demo: temporary table on the GROUP BY: COUNT, SUM tab: it is the order in which groups were first seen.
ORDER BY
- An index in the right order makes sorting unnecessary: the rows are read in order (
ORDER BY cust_id, amountwithidx_cust_amt). This also letsLIMITstop after the first rows. - Filesort (
Using filesort) otherwise. Despite the name it sorts in memory when it can: rows (the sort key plus the selected columns, packed) go into the sort buffer (sort_buffer_size) and are sorted there. - When the buffer fills, the sorted contents are written to a temporary file as a chunk (run), the buffer is reused, and at the end the chunks are merged. The status counter
Sort_merge_passescounts these merges; if it keeps growing, the sort buffer is too small for your sorts. - With
LIMIT nand small n, filesort keeps a priority queue of the best n rows instead: every other row is compared with the worst of the kept rows and dropped. Memory stays at n rows and nothing spills.
All three in one query
In SELECT c.city, COUNT(*), SUM(o.amount) AS total … GROUP BY c.city ORDER BY total DESC the stages stack: joined rows stream into the temporary table as they are produced, and only after the join has finished can the groups be sorted. Using temporary; Using filesort on the first table in classic EXPLAIN means exactly this.
Reading EXPLAIN
| You see | It means |
|---|---|
type = ref / eq_ref on the inner table | nested-loop join with an index lookup per outer row |
Using join buffer (hash join) | no usable index for the join: a hash join |
Using index | covering index: the table itself is not read |
Using index for group-by | loose index scan: one or two entries per group |
Using temporary | an internal temporary table (GROUP BY, DISTINCT, UNION …) |
Using filesort | a sort, in the sort buffer and maybe temporary files |
What the animation leaves out
- Cost estimates and index statistics: the plans are fixed per query and setting, as the optimizer would choose them for large tables.
- Batched Key Access and Multi-Range Read, index condition pushdown, semi-joins and derived tables, and the hash-join details (MySQL sizes the number of chunk files so that each fits in the join buffer, and also uses the in-memory part of the build input).
- Real B+ tree descents: a lookup is drawn as landing on its first entry.
Practical consequences
- Index the join column of the inner table (usually the foreign key). Without one, a large join becomes a hash join at best.
- A composite index in
(group or order columns, other needed columns)order can remove the temporary table, the filesort, and the table lookups, all at once. ORDER BY … LIMIT nis cheap either way: an index in the right order, or a priority queue.- Raise
sort_buffer_sizeandjoin_buffer_sizeper session for the big reporting query that needs it, not globally: they are allocated per sort or join, per connection.