MySQL Query Optimization

Index design is only the first step. Even with perfect indexes, a poorly written SQL query can still cripple your database. This chapter starts with the query execution pipeline internals, then dissects JOIN algorithms, subquery transformations, pagination bottlenecks, ORDER BY/GROUP BY optimization, bulk DML strategies, and concludes with three real-world case studies and a 20-item anti-pattern checklist. Every topic includes EXPLAIN output and quantifiable performance comparisons.

Applicable versions: This chapter is based on MySQL 8.0 / 8.4 LTS (InnoDB engine). Features specific to 8.0.18+ (Hash Join) and 8.0.20+ (anti-join, semi-join enhancements) are explicitly noted. MySQL 5.7 differences are mentioned in relevant sections.

1. Query Execution Pipeline

1.1 The Full Path from SQL Text to Result Set

When a SQL statement arrives at the MySQL Server, it passes through the following stages:

Client | v +-----------------+ | Connector | -- Connection mgmt, auth, thread assignment +-----------------+ | v +-----------------+ | Parser | -- Lexical analysis (Lexer) + Syntax analysis (YACC) | sql_yacc.yy | -- Generates Abstract Syntax Tree (AST / Parse Tree) +-----------------+ | v +-----------------+ | Preprocessor | -- Semantic checks: table/column existence, permissions | sql_resolver.cc| -- Type checking, view expansion, wildcard * expansion +-----------------+ | v +-----------------+ | Optimizer | -- Logical optimization โ†’ Physical optimization | sql_optimizer | -- Cost model estimation, execution plan generation | sql_planner.cc | -- JOIN ordering, index selection, subquery transforms +-----------------+ | v +-----------------+ | Executor | -- Volcano/Iterator Model (8.0+) | sql_executor.cc| -- Row-by-row pull (next() call chain) +-----------------+ | v Result Set โ†’ Client

1.2 Parser Stage

MySQL uses Bison (a YACC variant) to define SQL grammar rules (sql/sql_yacc.yy, over 17,000 lines). The Parser converts SQL text into a Parse Tree (internal structures SELECT_LEX / Query_block).

This stage only checks whether syntax is valid, not whether tables or columns exist. For example, SELECT * FORM users fails at the Parser stage (FORM is not a keyword), but SELECT * FROM nonexistent_table passes through.

1.3 Preprocessor Stage

The Preprocessor (also called Resolver) performs semantic analysis:

  • Name resolution: Verifying table, column, and function existence; resolving alias references
  • Permission checks: Whether the current user has SELECT/UPDATE privileges
  • Type inference: Determining expression return types; inserting implicit type conversions
  • Wildcard expansion: Replacing SELECT * with the actual column list
  • View expansion: Merging view definitions into the query tree (MERGE algorithm) or creating derived tables (TEMPTABLE algorithm)

1.4 Optimizer Stage

The optimizer is MySQL's most complex module, divided into two sub-stages:

Logical Optimization (RBO Rules)

  • Constant folding: WHERE 1=1 AND id > 5 โ†’ WHERE id > 5
  • Predicate pushdown: Applying WHERE conditions as early as possible to the innermost table
  • Subquery transformation: IN subquery โ†’ semi-join; NOT IN โ†’ anti-join
  • Outer join elimination: Automatically converting LEFT JOIN to INNER JOIN when WHERE conditions make them equivalent
  • Redundant sort elimination: Skipping filesort when ORDER BY matches index order
  • Condition simplification: WHERE a > 5 AND a > 3 โ†’ WHERE a > 5

Physical Optimization (CBO Cost Model)

MySQL 8.0 uses a cost model to evaluate different execution plan costs. The cost consists of two components:

Cost FactorDefaultSourceDescription
io_block_read_cost1.0mysql.server_costCost of reading a data page from disk
memory_block_read_cost0.25mysql.server_costCost of reading a data page from Buffer Pool
row_evaluate_cost0.1mysql.server_costCPU cost of evaluating one row
key_compare_cost0.05mysql.server_costCost of one index key comparison

The optimizer's core decisions include:

  1. Access path selection: Full table scan vs index scan vs index range scan vs ref access
  2. JOIN ordering: For N tables, there are N! possible permutations. When N ≤ optimizer_search_depth (default 62, practically exhaustive for 7-8 tables), it does exhaustive search; otherwise uses greedy heuristics
  3. JOIN algorithm: NLJ / BNL / Hash Join / BKA+MRR
  4. Subquery strategy: Materialization, FirstMatch, LooseScan, etc.

1.5 Executor Stage (Iterator Model)

MySQL 8.0 refactored the traditional handler-call approach into the Iterator Model:

LimitIterator (LIMIT 10) | SortingIterator (ORDER BY create_time) | FilterIterator (WHERE status = 'active') | NestedLoopJoinIterator / \ TableScanIterator IndexLookupIterator (orders) (users, PK)

Each Iterator implements Init() and Read() methods. The upper Iterator calls the lower one's Read() to pull one row, processes it, and returns it upward โ€” this is the classic "Volcano Model." EXPLAIN ANALYZE outputs execution statistics for this iterator tree.

Source code entry: The sql/iterators/ directory contains all Iterator implementations. Key files are composite_iterators.cc (nested loop, sort, aggregate) and hash_join_iterator.cc.

2. JOIN Algorithms Deep Dive

2.1 Nested Loop Join (NLJ)

The Nested Loop Join is MySQL's most fundamental JOIN algorithm and the preferred algorithm when indexes are available:

-- Pseudocode: Nested Loop Join
for each row r1 in outer_table:          -- Driving table (outer loop)
    for each row r2 in inner_table:      -- Driven table (inner loop)
        if r1.join_key == r2.join_key:   -- Via index lookup
            output (r1, r2)

Key points:

  • The driving table is scanned fully (or via index range scan). For each row, an index lookup finds matching rows in the driven table
  • If the driven table has an index on the JOIN column, each lookup is O(log n), total cost is O(M * log N)
  • The optimizer chooses the table with the smaller result set as the driving table ("small table drives big table" principle)
  • When the driven table has a suitable index, NLJ is highly efficient
Nested Loop Join โ€” With Index Driving table (orders, 1000 rows) Driven table (users, 1M rows) +--------+----------+ +----+-----------+ | id | user_id | | id | name | โ† B+Tree PK Index +--------+----------+ +----+-----------+ | 1 | 42 | ------------> | 42 | Alice | Index Lookup: O(log N) | 2 | 17 | ------------> | 17 | Bob | Index Lookup: O(log N) | 3 | 42 | ------------> | 42 | Alice | Buffer Pool hit (cached) | ... | ... | | .. | ... | +--------+----------+ +----+-----------+ Total cost: 1000 index lookups โ‰ˆ 1000 * 3 I/Os = 3000 random reads With warm Buffer Pool, actual I/O is much less

2.2 Block Nested Loop (BNL)

When the driven table has no index on the JOIN column, each inner loop iteration requires a full table scan of the driven table. With M rows in the driving table and N rows in the driven table, this means scanning the driven table M times โ€” catastrophic I/O.

BNL optimizes this by loading driving table rows in batches into a JOIN Buffer (memory), then scanning the driven table once per batch:

-- Pseudocode: Block Nested Loop
while join_buffer is not full:
    read rows from outer_table into join_buffer

    for each row r2 in inner_table:       -- Full scan of driven table
        for each row r1 in join_buffer:   -- Compare in memory
            if r1.join_key == r2.join_key:
                output (r1, r2)

    clear join_buffer
Block Nested Loop โ€” Without Index Driving table (1000 rows) Driven table (100K rows) +----------+ +-----------+ | row 1 |--+ | | | row 2 | | Load into Join Buffer | 1st full | | ... | +-> [Buffer 256KB] | table | | row 100 |--+ 100 rows/batch | scan | +----------+ | +-----------+ | row 101 |--+ | ^ | ... | +-> [Buffer 256KB] | | row 200 |--+ 100 rows/batch --------> 2nd full table scan +----------+ ^ | ... | | +----------+ 10 total full scans (1000 rows / 100 per batch = 10) join_buffer_size = 256KB (default) ~2.5KB per row โ†’ ~100 rows per batch Without BNL: scan driven table 1000 times โ†’ With BNL: only 10 times
MySQL 8.0.20+ change: BNL has been completely replaced by Hash Join. Even if EXPLAIN still shows Block Nested Loop, the actual execution uses Hash Join. The block_nested_loop optimizer switch also controls Hash Join.

2.3 Hash Join (MySQL 8.0.18+)

Hash Join was a revolutionary feature introduced in MySQL 8.0.18, specifically designed to solve performance issues with non-indexed equi-JOINs:

-- Pseudocode: Hash Join
-- 1. Build Phase
hash_table = {}
for each row r1 in smaller_table:       -- Choose the smaller table
    hash_table[hash(r1.join_key)] = r1  -- Build hash table

-- 2. Probe Phase
for each row r2 in larger_table:        -- Scan the larger table
    if hash(r2.join_key) in hash_table: -- O(1) lookup
        for each r1 in hash_table[hash(r2.join_key)]:
            output (r1, r2)
Hash Join Two-Phase Execution Phase 1: Build (hash table) Phase 2: Probe Smaller table (departments, 50 rows) Larger table (employees, 100K rows) +----+--------+ +----+--------+---------+ | id | name | | id | dept_id| name | +----+--------+ +----+--------+---------+ | 1 | Eng | โ†’ hash(1)=0x3A | 1 | 3 | Alice | | 2 | Sales | โ†’ hash(2)=0x7F | 2 | 1 | Bob | | 3 | HR | โ†’ hash(3)=0x12 | .. | .. | ... | +----+--------+ +----+--------+---------+ | | v v +--Hash Table (in memory)--+ Scan row by row, hash(dept_id) | bucket 0x12 โ†’ [HR] | O(1) lookup in hash table | bucket 0x3A โ†’ [Eng] | โ†’ output if match | bucket 0x7F โ†’ [Sales] | +-------------------------+ Total cost: Build O(M) + Probe O(N) = O(M+N) Compare: NLJ O(M*log N), BNL O(M*N/buffer)

Hash Join memory management:

  • Hash table size is limited by join_buffer_size
  • When the hash table exceeds memory, MySQL spills to disk (temporary files)
  • Spill strategy: Grace Hash Join โ€” partition both build and probe inputs by hash, write to disk, then process partition by partition
  • MySQL 8.0.18 initially only supported in-memory Hash Join; disk spill support was added in 8.0.20

Hash Join trigger conditions (MySQL 8.0.18+):

  1. Equi-join condition (a.id = b.id) โ€” 8.0.20+ also supports non-equi conditions and Cartesian products
  2. No usable index on the driven table's JOIN column (otherwise optimizer prefers NLJ)
  3. block_nested_loop=on in optimizer_switch (default on)

2.4 Batched Key Access (BKA) + Multi-Range Read (MRR)

BKA enhances NLJ by solving the random I/O problem. Standard NLJ performs random index lookups on the driven table. BKA batches and sorts the keys to convert random reads into sequential reads:

BKA + MRR Workflow Driving table JOIN Buffer Sorted Keys MRR Sequential Read +-------+ +-----------+ +-----------+ +----------+ | uid=42| --> | uid=42 | | uid=5 | | Page 1 | | uid=17| --> | uid=17 | sort | uid=17 | | Page 2 | | uid=5 | --> | uid=5 | -----> | uid=42 | | Page 5 | | uid=99| --> | uid=99 | | uid=99 | | Page 8 | +-------+ +-----------+ +-----------+ +----------+ Sequential I/O! (vs random jumps)
-- BKA is off by default; enable manually
SET optimizer_switch = 'mrr=on,mrr_cost_based=off,batched_key_access=on';

-- Or use hints for a single query:
SELECT /*+ BKA(t2) MRR(t2) */ *
FROM orders t1 JOIN users t2 ON t1.user_id = t2.id;

2.5 JOIN Algorithm Performance Comparison

AlgorithmTime ComplexityNeeds IndexMemoryI/O PatternMySQL VersionUse Case
NLJO(M * log N)Driven table yesVery lowRandom readsAllIndexed equi/range JOIN
BNLO(M * N / B)Nojoin_buffer_sizeMultiple full scans< 8.0.20Non-indexed JOIN (replaced by Hash Join)
Hash JoinO(M + N)Nojoin_buffer_sizeTwo full scans8.0.18+Non-indexed equi-JOIN; analytics
BKA + MRRO(M * log N)Driven table yesjoin_buffer_sizeSequential reads5.6+Indexed but data scattered on disk
Benchmark reference (orders 1M rows JOIN users 100K rows, cold Buffer Pool):
NLJ (indexed): 0.8s | Hash Join (no index): 2.1s | BNL (no index, 5.7): 47s | NLJ no index (theoretical): >300s

3. Subquery Optimization

3.1 Correlated vs Non-Correlated Subqueries

TypeDefinitionExecutionPerformance
Non-correlatedSubquery does not reference outer table columnsExecuted once, result cachedUsually fine
CorrelatedSubquery references outer table columnsRe-executed for each outer rowPotentially slow
-- Non-correlated: executed once
SELECT * FROM orders
WHERE user_id IN (SELECT id FROM users WHERE country = 'US');

-- Correlated: executed once per outer row
SELECT *, (SELECT name FROM users u WHERE u.id = o.user_id) AS user_name
FROM orders o;

3.2 Subquery Materialization

The optimizer can materialize a subquery's result set into a temporary table, then use that temporary table for the JOIN:

-- Original query
SELECT * FROM orders WHERE user_id IN (SELECT id FROM users WHERE vip = 1);

-- Optimizer may internally execute as:
-- 1. Execute subquery, store results in temp table (with dedup + hash index)
-- 2. SELECT * FROM orders WHERE user_id IN (SELECT id FROM materialized_temp)
-- EXPLAIN shows: MATERIALIZED

3.3 Semi-Join Transformations

Semi-join (MySQL 5.6+) is a critical optimization. When IN or EXISTS subqueries qualify, the optimizer converts them to semi-joins, enabling more efficient JOIN algorithms. Semi-join semantics: for each outer row, return it if at least one inner row matches.

MySQL implements four semi-join strategies:

1 FirstMatch

Returns the outer row as soon as the first match is found, skipping remaining matches. Similar to EXISTS short-circuit evaluation.

SELECT * FROM employees e
WHERE e.dept_id IN (SELECT d.id FROM departments d WHERE d.budget > 1000000);
-- EXPLAIN: FirstMatch(e)

Best for: Inner table has index and few matching rows

2 LooseScan

When subquery results have many duplicates, uses index skip-scan to avoid redundant matching.

SELECT * FROM departments d
WHERE d.id IN (SELECT dept_id FROM employees WHERE salary > 100000);
-- With idx(dept_id) on employees, LooseScan jumps between distinct values

Best for: Subquery column has index with high duplication

3 Materialize + Lookup

Materializes subquery results into a temp table (with dedup + hash index), then the outer table does lookups against it.

Best for: Small subquery result set, large outer table

4 DuplicateWeedout

Executes a regular JOIN (which may produce duplicates), then removes duplicates using a temp table keyed on the outer table's primary key.

-- EXPLAIN: Start temporary / End temporary

Best for: Fallback when other strategies don't apply

3.4 EXISTS vs IN vs JOIN Comparison

-- Approach 1: IN subquery
SELECT * FROM orders o
WHERE o.user_id IN (SELECT u.id FROM users u WHERE u.country = 'US');

-- Approach 2: EXISTS
SELECT * FROM orders o
WHERE EXISTS (SELECT 1 FROM users u WHERE u.id = o.user_id AND u.country = 'US');

-- Approach 3: JOIN
SELECT o.* FROM orders o
INNER JOIN users u ON o.user_id = u.id WHERE u.country = 'US';

-- MySQL 8.0 optimizer typically transforms all three into the same plan (semi-join)
-- In MySQL 5.7 and earlier, IN subquery may not be optimized
JOIN caveat: If users-to-orders is 1:N, INNER JOIN returns duplicate orders rows. Add DISTINCT or use EXISTS/IN for semi-join semantics.

3.5 Anti-Join Patterns (NOT EXISTS / NOT IN / LEFT JOIN WHERE NULL)

-- Goal: "Find users who have never placed an order"

-- Approach 1: NOT EXISTS (recommended)
SELECT * FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);

-- Approach 2: NOT IN (dangerous if orders.user_id has NULLs!)
SELECT * FROM users u WHERE u.id NOT IN (SELECT user_id FROM orders);

-- Approach 3: LEFT JOIN + WHERE NULL
SELECT u.* FROM users u
LEFT JOIN orders o ON u.id = o.user_id WHERE o.id IS NULL;
ApproachNULL-safeMySQL 8.0 OptimizationRecommendation
NOT EXISTSSafeanti-join (8.0.17+)Preferred
LEFT JOIN WHERE NULLSafeanti-join (8.0.17+)Equivalent to NOT EXISTS
NOT INWrong result with NULLsCan optimize if column is NOT NULLAvoid
NOT IN NULL trap: If orders.user_id contains even one NULL row, NOT IN returns an empty result set. This is because SQL's three-valued logic: x NOT IN (1, 2, NULL) equals x!=1 AND x!=2 AND x!=NULL, and x!=NULL evaluates to UNKNOWN, making the entire AND expression UNKNOWN.

3.6 Subquery Rewriting Examples

1 Correlated Subquery โ†’ JOIN

-- BEFORE: One subquery per row (100K rows = 100K subqueries)
SELECT o.id, o.amount,
    (SELECT u.name FROM users u WHERE u.id = o.user_id) AS user_name
FROM orders o;

-- AFTER: Single JOIN
SELECT o.id, o.amount, u.name AS user_name
FROM orders o LEFT JOIN users u ON u.id = o.user_id;
-- Improvement: 800ms โ†’ 45ms

2 Scalar Subquery โ†’ Window Function

-- BEFORE
SELECT e.*, e.salary / (SELECT SUM(salary) FROM employees e2
    WHERE e2.dept_id = e.dept_id) AS pct
FROM employees e;

-- AFTER (MySQL 8.0+)
SELECT e.*, e.salary / SUM(e.salary) OVER (PARTITION BY e.dept_id) AS pct
FROM employees e;
-- From N subqueries โ†’ single scan, 10-50x improvement

3 Multiple COUNT Subqueries โ†’ Conditional Aggregation

-- BEFORE: 3 full table scans
SELECT
    (SELECT COUNT(*) FROM orders WHERE status = 'pending') AS pending,
    (SELECT COUNT(*) FROM orders WHERE status = 'shipped') AS shipped,
    (SELECT COUNT(*) FROM orders WHERE status = 'delivered') AS delivered;

-- AFTER: Single scan
SELECT
    COUNT(CASE WHEN status = 'pending' THEN 1 END) AS pending,
    COUNT(CASE WHEN status = 'shipped' THEN 1 END) AS shipped,
    COUNT(CASE WHEN status = 'delivered' THEN 1 END) AS delivered
FROM orders;

4 Derived Table โ†’ CTE (MySQL 8.0+)

-- BEFORE: Nested derived tables, hard to read
SELECT * FROM (
    SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id
) AS t1 JOIN (
    SELECT user_id, COUNT(*) AS cnt FROM returns GROUP BY user_id
) AS t2 ON t1.user_id = t2.user_id;

-- AFTER: CTE, clearer and reusable
WITH order_totals AS (
    SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id
),
return_counts AS (
    SELECT user_id, COUNT(*) AS cnt FROM returns GROUP BY user_id
)
SELECT * FROM order_totals JOIN return_counts
ON order_totals.user_id = return_counts.user_id;

5 Correlated Subquery โ†’ Window Function (Ranking)

-- BEFORE
SELECT e.*, (SELECT COUNT(*) FROM employees e2
    WHERE e2.dept_id = e.dept_id AND e2.salary > e.salary) + 1 AS salary_rank
FROM employees e;

-- AFTER
SELECT e.*, RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS salary_rank
FROM employees e;

4. Pagination Optimization

4.1 The LIMIT Large Offset Problem

Pagination queries are among the most common in web applications. But the traditional LIMIT offset, count becomes a severe performance bottleneck at large offsets:

-- Page 1: fast
SELECT * FROM articles ORDER BY created_at DESC LIMIT 0, 20;
-- Scans 20 rows, returns 20 rows

-- Page 5000: extremely slow!
SELECT * FROM articles ORDER BY created_at DESC LIMIT 100000, 20;
-- MySQL must scan (and sort) 100,020 rows, discard the first 100,000, return only 20
-- Those 100,000 rows of scanning work are completely wasted
LIMIT 100000, 20 Execution Index on created_at (or full table scan + filesort) Row 1 โ” Row 2 โ”‚ Row 3 โ”‚ ... โ”‚ Scan and discard these 100,000 rows Row 99999 โ”‚ (wasted I/O and CPU) Row 100000 โ”˜ Row 100001 โ” Row 100002 โ”‚ Actually return these 20 rows ... โ”‚ Row 100020 โ”˜

4.2 Solution 1: Deferred JOIN

Core idea: First retrieve only primary key IDs via a covering index, then JOIN back for full row data.

-- Original query (slow)
SELECT * FROM articles ORDER BY created_at DESC LIMIT 100000, 20;

-- Deferred JOIN (fast)
SELECT a.* FROM articles a
INNER JOIN (
    SELECT id FROM articles ORDER BY created_at DESC LIMIT 100000, 20
) AS tmp ON a.id = tmp.id;

-- Why faster?
-- The subquery scans only index pages (much smaller than data pages)
-- Scanning 100K index entries is much faster than 100K full rows
-- Finally only 20 IDs need to go back to the table for full data
Benchmark (articles table 5M rows, LIMIT 100000, 20):
Original: 1.8s | Deferred JOIN: 0.12s | ~15x improvement

4.3 Solution 2: Cursor-based Pagination (Keyset Pagination)

Cursor-based pagination completely avoids OFFSET by remembering the position of the last record on the previous page:

-- Page 1
SELECT * FROM articles ORDER BY created_at DESC, id DESC LIMIT 20;
-- Last row: created_at = '2024-03-15 10:30:00', id = 98765

-- Page 2 (using last row's values as cursor)
SELECT * FROM articles
WHERE (created_at, id) < ('2024-03-15 10:30:00', 98765)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Advantages: Constant performance regardless of page depth; not affected by concurrent inserts/deletes; ideal for infinite scroll.

Disadvantages: Cannot jump directly to page N; frontend must pass cursor parameters; sort fields must be indexed and unique with ID.

4.4 Solution 3: Covering Index for Sort + Filter

-- If you only need a few columns
SELECT id, title, created_at FROM articles
ORDER BY created_at DESC LIMIT 100000, 20;

-- Create covering index
ALTER TABLE articles ADD INDEX idx_cover (created_at DESC, id, title);
-- Entire query completed within the index, no table access
-- EXPLAIN: Using index

4.5 Pagination Performance Comparison

ApproachOFFSET=100OFFSET=100KOFFSET=1MJump to page?Use Case
Plain LIMIT1ms1.8s15s+YesSmall datasets only
Deferred JOIN1ms120ms1.2sYesMedium datasets with paging
Cursor-based1ms1ms1msNoInfinite scroll, APIs
Covering index1ms80ms800msYesFew columns needed

5. ORDER BY and GROUP BY Optimization

5.1 Filesort Algorithms

When MySQL cannot obtain sorted results directly from an index, it performs filesort. Despite the name, filesort primarily occurs in memory, spilling to disk only when data exceeds sort_buffer_size.

AlgorithmAliasHow It WorksWhen Used
Two-pass sortoriginalFirst pass: extract sort key + row pointer, sort in memory. Second pass: re-read full rows in sorted orderWide rows (> max_length_for_sort_data)
Single-pass sortmodifiedExtract sort key + all needed columns in one pass, sort and output directlyNormal width rows (default)
Two Filesort Algorithms Compared [Single-Pass] [Two-Pass] Table data Table data +--+------+---------+-----+ +--+------+---------+-----+ |id| name | address |date | |id| name | address |date | +--+------+---------+-----+ +--+------+---------+-----+ | | v Read sort_key + all columns v Read sort_key + rowid +---------+------+----+---------+ +---------+------+ |sort_key | name | id | address | |sort_key | rowid| +---------+------+----+---------+ +---------+------+ | | v Sort v Sort +---------+------+----+---------+ +---------+------+ | sorted | ... | .. | ... | | sorted | rowid| +---------+------+----+---------+ +---------+------+ | | v Output directly v Read full rows by rowid Result set Result set Single-pass: avoids second read Two-pass: uses less sort_buffer Downside: uses more sort_buffer Downside: extra random reads

5.2 sort_buffer_size Tuning

  • Default: 256KB (MySQL 8.0)
  • Spill behavior: When sort data exceeds sort_buffer_size, MySQL uses merge sort โ€” splits data into sorted runs on disk, then merges
  • Detecting spills: Check SHOW STATUS LIKE 'Sort_merge_passes'; growing values indicate buffer is too small
  • Recommendation: Don't set too large globally (per-thread allocation). 1-8MB is reasonable. Use session-level settings for specific large sorts

5.3 Using Indexes to Avoid Filesort

The best sort optimization is to avoid sorting entirely. If ORDER BY columns match index order, MySQL reads from the index in order:

-- Index: INDEX idx_created (created_at)

-- Can use index for sorting (no filesort)
SELECT * FROM articles WHERE status = 'published'
ORDER BY created_at DESC LIMIT 20;

-- Mixed ASC/DESC with composite index
-- INDEX idx_ab (a, b)
-- ORDER BY a, b         โ†’ uses index โœ“
-- ORDER BY a DESC, b DESC โ†’ uses index โœ“ (backward scan)
-- ORDER BY a ASC, b DESC  โ†’ 5.7: no, 8.0: yes (requires DESC index)
-- WHERE a = 1 ORDER BY b  โ†’ uses index โœ“ (a is equality, b is sort)

5.4 Loose Index Scan for GROUP BY

-- INDEX idx_dept_salary (dept_id, salary)

-- Find max salary per department
SELECT dept_id, MAX(salary) FROM employees GROUP BY dept_id;

-- Loose Index Scan:
-- 1. Jump to first record of dept_id=1
-- 2. Since index is sorted by (dept_id, salary), last entry = max
-- 3. Jump to dept_id=2 boundary, take last entry
-- Only visits boundary rows, skips all middle rows per group
-- EXPLAIN: Using index for group-by

5.5 ORDER BY + LIMIT Optimization

When ORDER BY appears with LIMIT, MySQL uses a priority queue (heap sort) instead of a full sort:

-- Only need top 10
SELECT * FROM articles ORDER BY score DESC LIMIT 10;

-- Optimizer uses priority queue (min/max heap):
-- Maintains a heap of size 10
-- During scan, only keeps current top 10
-- Time: O(N * log K) where K=10
-- Memory: only stores K rows
-- Much better than full sort O(N * log N)

6. COUNT(*) Optimization

6.1 Why InnoDB's COUNT(*) Is Slow

Unlike MyISAM, InnoDB does not cache exact row counts because of MVCC (Multi-Version Concurrency Control):

  • Different transactions may see different row counts (depending on isolation level and row versions)
  • Rows marked for deletion but not yet purged are visible to some transactions but not others
  • Therefore, maintaining a global exact row counter is impossible

Executing SELECT COUNT(*) FROM big_table, InnoDB must scan a complete index (choosing the smallest secondary index) to count rows. For 100M rows, this may take 30-60 seconds.

6.2 COUNT(*) vs COUNT(1) vs COUNT(col)

SyntaxSemanticsPerformanceCounts NULLs?
COUNT(*)Count all rowsOptimal (optimizer special-cases it)Yes
COUNT(1)Same as COUNT(*)Identical to COUNT(*)Yes
COUNT(col)Count non-NULL valuesMay need to read column valuesNo
COUNT(DISTINCT col)Count distinct non-NULL valuesRequires dedup operationNo
Conclusion: COUNT(*) and COUNT(1) are identical in MySQL. The optimizer internally converts COUNT(1) to COUNT(*). This is a common interview question misconception.

6.3 Optimization Approaches

Approach 1: Approximate Count

-- From information_schema (error margin up to 40-50%)
SELECT TABLE_ROWS FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'mydb' AND TABLE_NAME = 'orders';

-- Using EXPLAIN (faster, error typically 10-30%)
EXPLAIN SELECT COUNT(*) FROM orders;
-- The "rows" column is the optimizer's estimate

Approach 2: Cached Count

-- Method A: Redis counter (INCR on INSERT, DECR on DELETE)
-- Method B: Database counter table with triggers
CREATE TABLE table_counts (
    table_name VARCHAR(64) PRIMARY KEY,
    row_count BIGINT UNSIGNED NOT NULL DEFAULT 0
);

-- Maintain with triggers
-- Query: O(1)
SELECT row_count FROM table_counts WHERE table_name = 'orders';

Approach 3: Summary Tables

-- Pre-aggregated daily stats
CREATE TABLE daily_order_stats (
    stat_date DATE PRIMARY KEY,
    total_count INT UNSIGNED NOT NULL DEFAULT 0,
    pending_count INT UNSIGNED NOT NULL DEFAULT 0
);
-- Refreshed periodically via cron job

Approach 4: Covering Index for Filtered COUNT

SELECT COUNT(*) FROM orders WHERE status = 'pending' AND created_at > '2024-01-01';
-- With covering index idx(status, created_at), only scans index
ALTER TABLE orders ADD INDEX idx_status_date (status, created_at);

7. Large UPDATE/DELETE Optimization

7.1 Why Bulk DML Is Problematic

A simple DELETE FROM orders WHERE created_at < '2020-01-01' affecting 5M rows causes:

  • Long transaction: Undo log explosion, catastrophic rollback cost
  • Lock contention: InnoDB row locks (actually next-key locks) held too long, blocking other DML
  • Replication lag: A 30-minute transaction on the primary takes 30 minutes to replay on replicas
  • Buffer Pool pollution: Many modified pages may evict hot data

7.2 Batch Processing

-- Batch delete (recommended approach)
-- Application-layer loop:
-- while True:
--     cursor.execute("""
--         DELETE FROM orders WHERE created_at < '2020-01-01'
--         ORDER BY id LIMIT 5000
--     """)
--     if cursor.rowcount == 0: break
--     connection.commit()
--     time.sleep(0.5)  # Let replicas catch up

Key points:

  • ORDER BY id LIMIT N ensures deterministic row sets, avoids rescanning
  • COMMIT after each batch releases locks and undo log
  • Batch size 1,000-10,000 rows โ€” too small = network overhead, too large = long transactions
  • Sleep between batches gives replicas time to catch up

7.3 Partition Exchange for Bulk Delete

-- If table is partitioned by date
ALTER TABLE orders DROP PARTITION p202301;  -- Instant!

-- Or use EXCHANGE to preserve partition definition:
CREATE TABLE orders_archive LIKE orders;
ALTER TABLE orders_archive REMOVE PARTITIONING;
ALTER TABLE orders EXCHANGE PARTITION p202301 WITH TABLE orders_archive;
DROP TABLE orders_archive;  -- Or archive to cold storage

7.4 pt-archiver for Incremental Archival

# Percona Toolkit's pt-archiver
pt-archiver \
    --source h=localhost,D=mydb,t=orders \
    --where "created_at < '2020-01-01'" \
    --limit 5000 --commit-each \
    --check-slave-lag h=replica-host --max-lag 5 \
    --purge

8. Query Rewriting Patterns

Here are 15+ common query rewriting patterns with before/after comparisons:

1 OR โ†’ UNION ALL

-- BEFORE: OR across different columns may cause full scan
SELECT * FROM users WHERE email = '[email protected]' OR phone = '13800138000';

-- AFTER: Split into two queries, each using its own index
SELECT * FROM users WHERE email = '[email protected]'
UNION ALL
SELECT * FROM users WHERE phone = '13800138000' AND email != '[email protected]';

2 Implicit Type Conversion Fix

-- BEFORE: phone is VARCHAR but numeric value passed
SELECT * FROM users WHERE phone = 13800138000;  -- Index unusable!

-- AFTER: Use matching type
SELECT * FROM users WHERE phone = '13800138000';  -- Uses index

3 Function on Column โ†’ Range Condition

-- BEFORE: Function on indexed column
SELECT * FROM orders WHERE DATE(created_at) = '2024-03-15';

-- AFTER: Range condition
SELECT * FROM orders
WHERE created_at >= '2024-03-15' AND created_at < '2024-03-16';

-- Or: functional index (MySQL 8.0.13+)
ALTER TABLE orders ADD INDEX idx_date ((DATE(created_at)));

4 LIKE '%keyword%' โ†’ Full-text Index

-- BEFORE: Leading wildcard, index unusable
SELECT * FROM articles WHERE title LIKE '%optimization%';

-- AFTER: Full-text index
ALTER TABLE articles ADD FULLTEXT INDEX ft_title (title);
SELECT * FROM articles WHERE MATCH(title) AGAINST('optimization' IN BOOLEAN MODE);

5 NOT IN โ†’ NOT EXISTS

-- BEFORE: NULL trap + possible poor performance
SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM blacklist);

-- AFTER: NULL-safe and optimized
SELECT * FROM users u
WHERE NOT EXISTS (SELECT 1 FROM blacklist b WHERE b.user_id = u.id);

6 SELECT * โ†’ Specific Columns

-- BEFORE: Returns unnecessary columns, can't use covering index
SELECT * FROM orders WHERE user_id = 42;

-- AFTER: May hit covering index
SELECT id, amount, status FROM orders WHERE user_id = 42;
-- With idx(user_id, amount, status), uses covering index

7 UNION โ†’ UNION ALL

-- BEFORE: UNION auto-deduplicates (requires sort or hash)
SELECT user_id FROM orders WHERE created_at > '2024-01-01'
UNION
SELECT user_id FROM returns WHERE created_at > '2024-01-01';

-- AFTER: Skip dedup if not needed
SELECT user_id FROM orders WHERE created_at > '2024-01-01'
UNION ALL
SELECT user_id FROM returns WHERE created_at > '2024-01-01';

8 Charset Mismatch Fix

-- BEFORE: Different charsets cause implicit conversion, index unusable
-- users.name is utf8mb4, old_logs.user_name is utf8
SELECT * FROM users u JOIN old_logs l ON u.name = l.user_name;

-- AFTER: Unify charsets
ALTER TABLE old_logs MODIFY user_name VARCHAR(100) CHARACTER SET utf8mb4;

9 Multiple Queries โ†’ Single Query with CASE

-- BEFORE: 3 separate queries
-- q1: SELECT AVG(salary) FROM emp WHERE dept='Eng';
-- q2: SELECT AVG(salary) FROM emp WHERE dept='Sales';
-- q3: SELECT AVG(salary) FROM emp WHERE dept='HR';

-- AFTER: Single query
SELECT
    AVG(CASE WHEN dept='Eng' THEN salary END) AS eng_avg,
    AVG(CASE WHEN dept='Sales' THEN salary END) AS sales_avg,
    AVG(CASE WHEN dept='HR' THEN salary END) AS hr_avg
FROM emp;

10 INSERT ... ON DUPLICATE KEY vs REPLACE

-- REPLACE = DELETE + INSERT (triggers delete trigger, reassigns AUTO_INCREMENT)
-- INSERT ... ON DUPLICATE KEY UPDATE = true UPDATE (preserves original row ID)

-- Prefer:
INSERT INTO daily_stats (stat_date, cnt) VALUES ('2024-03-15', 100)
ON DUPLICATE KEY UPDATE cnt = VALUES(cnt);
-- Not: REPLACE INTO daily_stats ...

11 OFFSET Pagination โ†’ Cursor Pagination

-- BEFORE
SELECT * FROM articles ORDER BY id DESC LIMIT 100000, 20;

-- AFTER
SELECT * FROM articles WHERE id < last_seen_id ORDER BY id DESC LIMIT 20;

12 Multi-table DELETE โ†’ Single-table Batches

-- BEFORE: Cross-table delete, heavy locking
DELETE o, oi FROM orders o JOIN order_items oi ON o.id = oi.order_id
WHERE o.created_at < '2020-01-01';

-- AFTER: Child table first, then parent, in batches
DELETE FROM order_items WHERE order_id IN (
    SELECT id FROM orders WHERE created_at < '2020-01-01'
) LIMIT 5000;
DELETE FROM orders WHERE created_at < '2020-01-01' LIMIT 5000;

13 Index Arithmetic โ†’ Pre-computed Condition

-- BEFORE: Arithmetic on indexed column, index unusable
SELECT * FROM orders WHERE id + 1 = 10;

-- AFTER
SELECT * FROM orders WHERE id = 9;

14 HAVING โ†’ WHERE (when possible)

-- BEFORE: HAVING filters after grouping
SELECT dept_id, COUNT(*) AS cnt FROM employees
GROUP BY dept_id HAVING dept_id > 5;

-- AFTER: WHERE filters before grouping (fewer rows to aggregate)
SELECT dept_id, COUNT(*) AS cnt FROM employees
WHERE dept_id > 5 GROUP BY dept_id;

15 N+1 Query โ†’ Single JOIN or Batch IN

-- BEFORE: N+1 problem
-- users = query("SELECT * FROM users LIMIT 100")
-- for user in users:
--     orders = query("SELECT * FROM orders WHERE user_id = ?", user.id)

-- AFTER: Single JOIN
SELECT u.*, o.* FROM users u
LEFT JOIN orders o ON u.id = o.user_id
LIMIT 100;

-- Or batch IN:
SELECT * FROM orders WHERE user_id IN (1, 2, 3, ..., 100);

9. Query Profiling

9.1 SHOW PROFILE (Deprecated but Useful)

SET profiling = 1;

SELECT * FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 10;

SHOW PROFILES;
SHOW PROFILE FOR QUERY 1;
+--------------------------------+-----------+
| Status                         | Duration  |
+--------------------------------+-----------+
| starting                       | 0.000012  |
| checking permissions           | 0.000005  |
| Opening tables                 | 0.000018  |
| optimizing                     | 0.000015  |
| statistics                     | 0.000042  |
| executing                      | 0.001850  | โ† Main cost in execution
| cleaning up                    | 0.000003  |
+--------------------------------+-----------+

SHOW PROFILE CPU, BLOCK IO FOR QUERY 1;

9.2 Performance Schema

-- Top 10 slowest queries by total execution time
SELECT
    DIGEST_TEXT,
    COUNT_STAR AS exec_count,
    ROUND(SUM_TIMER_WAIT / 1e12, 3) AS total_sec,
    ROUND(AVG_TIMER_WAIT / 1e12, 3) AS avg_sec,
    SUM_ROWS_EXAMINED AS rows_examined,
    SUM_ROWS_SENT AS rows_sent,
    ROUND(SUM_ROWS_EXAMINED / NULLIF(SUM_ROWS_SENT, 0), 1) AS examine_per_sent
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC LIMIT 10;

9.3 sys Schema

-- Most resource-intensive statements
SELECT * FROM sys.statement_analysis ORDER BY avg_latency DESC LIMIT 10;

-- Statements with full table scans
SELECT * FROM sys.statements_with_full_table_scans LIMIT 10;

-- Unused indexes (candidates for removal)
SELECT * FROM sys.schema_unused_indexes;

-- Redundant indexes
SELECT * FROM sys.schema_redundant_indexes;

9.4 Optimizer Trace

SET optimizer_trace = 'enabled=on';

SELECT * FROM orders o JOIN users u ON o.user_id = u.id
WHERE o.status = 'pending' ORDER BY o.created_at DESC LIMIT 10;

SELECT * FROM information_schema.OPTIMIZER_TRACE\G

-- Key JSON sections:
-- "join_optimization" โ†’ "rows_estimation": row count estimates per table/index
-- "considered_execution_plans": evaluated JOIN orders and their costs
-- "chosen_plan": the selected plan
-- "reconsidering_access_paths_for_index_ordering": index choice changes due to ORDER BY

SET optimizer_trace = 'enabled=off';

9.5 EXPLAIN ANALYZE Workflow

EXPLAIN ANALYZE
SELECT o.*, u.name FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = 'pending' ORDER BY o.created_at DESC LIMIT 10\G

-- Output example:
-- -> Limit: 10 row(s)  (cost=45.2 rows=10) (actual time=0.85..0.92 rows=10 loops=1)
--     -> Sort: o.created_at DESC, limit input to 10 row(s)
--         -> Nested loop join  (cost=45.2 rows=100)
--            (actual time=0.12..0.78 rows=100 loops=1)
--             -> Index lookup on o using idx_status (status='pending')
--                (cost=22.1 rows=100) (actual time=0.08..0.35 rows=100 loops=1)
--             -> Single-row index lookup on u using PRIMARY (id=o.user_id)
--                (cost=0.25 rows=1) (actual time=0.003..0.003 rows=1 loops=100)

-- Key comparisons:
-- cost vs actual time: estimate vs reality
-- rows (estimated) vs rows (actual): large gap = stale statistics
-- loops: how many times the operation executed
EXPLAIN ANALYZE tips:
1. actual time=X..Y: X = time to first row, Y = time to all rows
2. If estimated vs actual rows differ by >10x, run ANALYZE TABLE
3. Watch loops โ€” a fast operation with loops=100000 has high total cost
4. EXPLAIN ANALYZE actually executes the query โ€” be careful with DML

10. Query Hints

10.1 Traditional Index Hints

-- USE INDEX: Suggest index (optimizer may still ignore)
SELECT * FROM orders USE INDEX (idx_user_date)
WHERE user_id = 42 AND created_at > '2024-01-01';

-- FORCE INDEX: Force index usage (stronger than USE INDEX)
SELECT * FROM orders FORCE INDEX (idx_user_date)
WHERE user_id = 42 AND created_at > '2024-01-01';

-- IGNORE INDEX: Exclude specific index
SELECT * FROM orders IGNORE INDEX (idx_status)
WHERE status = 'pending' AND user_id = 42;

10.2 MySQL 8.0 Optimizer Hints

-- JOIN order control
SELECT /*+ JOIN_ORDER(u, o) */ * FROM orders o JOIN users u ON o.user_id = u.id;

-- JOIN algorithm control
SELECT /*+ HASH_JOIN(o) */ * FROM orders o JOIN users u ON o.user_id = u.id;

-- Disable ICP
SELECT /*+ NO_ICP(orders) */ * FROM orders WHERE user_id = 42 AND amount > 100;

-- Derived table merge control
SELECT /*+ MERGE(derived_t) */ * FROM (SELECT ...) AS derived_t;
SELECT /*+ NO_MERGE(derived_t) */ * FROM (SELECT ...) AS derived_t;

-- Semi-join strategy
SELECT /*+ SEMIJOIN(@subq1 FIRSTMATCH) */ *
FROM orders WHERE user_id IN (SELECT /*+ QB_NAME(subq1) */ id FROM users);

-- SET_VAR: Temporarily modify system variable during query
SELECT /*+ SET_VAR(sort_buffer_size = 8388608) */ *
FROM articles ORDER BY score DESC LIMIT 100;

10.3 When to Use Hints

  • Appropriate: Stale statistics causing wrong index choice; known data distribution the optimizer can't infer; special pagination queries
  • Code smell: Heavy hint usage indicates deeper issues โ€” poor index design, stale statistics, or query structure needing refactoring
  • Maintenance burden: Hints move plan decisions from optimizer to application code. Schema or data changes may make hints harmful
  • Recommendation: First try ANALYZE TABLE, then index optimization, then hints as temporary workaround

10.4 optimizer_switch Flags

SELECT @@optimizer_switch\G

-- Key switches:
-- index_condition_pushdown=on     -- ICP
-- mrr=on                          -- Multi-Range Read
-- batched_key_access=off          -- BKA (off by default)
-- block_nested_loop=on            -- BNL / Hash Join
-- semijoin=on                     -- Semi-join transforms
-- firstmatch=on                   -- FirstMatch strategy
-- loosescan=on                    -- LooseScan strategy
-- materialization=on              -- Subquery materialization
-- hash_join=on                    -- Hash Join (8.0.18+)
-- skip_scan=on                    -- Skip Scan (8.0.13+)
-- subquery_to_derived=off         -- Subquery โ†’ derived (8.0.21+)
-- prefer_ordering_index=on        -- Prefer sorting index (8.0.21+)
-- hypergraph_optimizer=off        -- Hypergraph optimizer (8.0.31+, experimental)

-- Modify single switch
SET optimizer_switch = 'batched_key_access=on';

11. Real-World Case Studies

1 E-commerce Search: LIKE + Multiple Filters + Pagination

Scenario: Product search page with keyword search + multi-dimensional filters + sorting + pagination. Products table: 8M rows.

-- Original query (3.5s response time)
SELECT * FROM products
WHERE title LIKE '%bluetooth earbuds%'
  AND category_id = 42 AND price BETWEEN 10 AND 50 AND rating >= 4.0
ORDER BY sales_count DESC LIMIT 100000, 20;

-- Problems:
-- 1. LIKE '%...%' can't use B+Tree index
-- 2. Multiple filters make index selection difficult
-- 3. ORDER BY sales_count conflicts with filter indexes โ†’ filesort
-- 4. LIMIT 100000, 20 large offset

-- Optimization:
-- Step 1: Full-text index for keyword search
ALTER TABLE products ADD FULLTEXT INDEX ft_title (title) WITH PARSER ngram;

-- Step 2: Composite index for filters + sort
ALTER TABLE products ADD INDEX idx_cat_rating_sales (category_id, rating, sales_count);

-- Step 3: Deferred JOIN + cursor pagination
-- Result: 3.5s โ†’ 45ms (deferred JOIN) โ†’ 8ms (cursor pagination)

2 Analytics Dashboard: Date Range Aggregation

Scenario: Operations dashboard showing daily order count, total amount, and refund count for past 30 days. Orders table: 20M rows.

-- Original query (12s)
SELECT DATE(created_at) AS day, COUNT(*) AS cnt, SUM(amount) AS total,
    SUM(CASE WHEN status='refunded' THEN 1 ELSE 0 END) AS refunds
FROM orders
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY DATE(created_at) ORDER BY day;

-- Optimization A: Covering index + range rewrite โ†’ 12s โ†’ 1.2s
ALTER TABLE orders ADD INDEX idx_created_status_amount (created_at, status, amount);

-- Optimization B: Summary table โ†’ 1.2s โ†’ 0.5ms
CREATE TABLE daily_order_summary (
    summary_date DATE PRIMARY KEY,
    order_count INT UNSIGNED NOT NULL,
    total_amount DECIMAL(15,2) NOT NULL,
    refund_count INT UNSIGNED NOT NULL
);
-- Refresh hourly via cron job

3 Social Feed: Complex JOIN with Follower Graph

Scenario: "Following feed" in a social app. User follows N people, need to show their recent posts sorted by time. follows: 5M rows, posts: 50M rows.

-- Original query (8s)
SELECT p.* FROM posts p
JOIN follows f ON p.user_id = f.following_id
WHERE f.follower_id = 12345
ORDER BY p.created_at DESC LIMIT 20;

-- Problem: User follows 500 people โ†’ JOIN matches 500K posts โ†’ sort all โ†’ take 20

-- Solution: Fan-out on Write (Push Model)
CREATE TABLE feed_inbox (
    user_id BIGINT NOT NULL,
    post_id BIGINT NOT NULL,
    created_at DATETIME NOT NULL,
    PRIMARY KEY (user_id, created_at, post_id),
    INDEX idx_user_time (user_id, created_at DESC)
);

-- When a post is published, async write to all followers' inboxes
-- Query: 8s โ†’ 3ms (covering index + sequential scan)
SELECT fi.post_id, p.content FROM feed_inbox fi
JOIN posts p ON fi.post_id = p.id
WHERE fi.user_id = 12345
ORDER BY fi.created_at DESC LIMIT 20;

-- Trade-off: Write amplification (1 post โ†’ N inbox writes)
-- For high-follower accounts, use pull model or hybrid approach

12. Anti-Patterns Checklist

#Anti-PatternProblemCorrect Approach
1SELECT *Returns unnecessary columns, prevents covering index, wastes bandwidthList specific columns needed
2WHERE DATE(col) = '...'Function on indexed column, index unusableWHERE col >= '...' AND col < '...'
3WHERE col != 'value'Inequality cannot efficiently use indexInvert logic, or use covering index
4LIMIT 100000, 20Large offset scans wasted rowsCursor pagination or deferred JOIN
5OR across columnsMay cause full table scanSplit into UNION ALL
6Implicit type conversionVARCHAR column with INT value, index unusableUse matching types
7NOT IN (subquery)Wrong results with NULLs, poor performanceNOT EXISTS or LEFT JOIN WHERE NULL
8Scalar subquery in SELECTExecutes once per outer rowRewrite as JOIN or window function
9Row-by-row INSERT in loopN network round-trips + N transaction commitsBatch INSERT (1000 rows at once)
10Single large transaction DELETELong txn, lock contention, replication lagBatch delete + COMMIT
11ORDER BY RAND()Full scan + random number per row + filesortGet COUNT, then random OFFSET
12UNION without ALLImplicit dedup requires sort/temp tableUse UNION ALL when dedup not needed
13Charset mismatch JOINImplicit charset conversion, index unusableUnify charset and collation
14LIKE '%prefix'Leading wildcard, index unusableFull-text index or Elasticsearch
15Arithmetic on index columnWHERE id + 1 = 10 index unusableWHERE id = 9
16Redundant indexesINDEX(a) + INDEX(a,b), former is redundantDrop the subsumed index
17GROUP BY without indexRequires temp table + filesortCreate index on GROUP BY columns
18N+1 query problemQuery list, then loop for related dataSingle JOIN or batch IN query
19HAVING instead of WHEREHAVING filters after grouping, WHERE beforeMove applicable conditions to WHERE
20UPDATE/DELETE without LIMITAccidental full-table modificationAdd LIMIT, use sql_safe_updates=1

13. FAQ

Q1: How does MySQL's Hash Join compare to PostgreSQL's?

PostgreSQL has supported Hash Join, Merge Join, and Nested Loop Join since early versions, with the optimizer freely choosing between them. MySQL's Hash Join was only introduced in 8.0.18 and is currently used only for non-indexed equi-JOINs. PostgreSQL supports Parallel Hash Join (parallel hash table building), which MySQL does not. PostgreSQL's cost model for Hash Join is also more mature.

Q2: Why doesn't the optimizer use my index?

Common reasons: (1) Stale statistics โ€” run ANALYZE TABLE t; (2) Optimizer thinks full scan is cheaper (usually when returning a large percentage of rows); (3) Low index selectivity (e.g., gender column with only M/F); (4) Query conditions don't match leftmost prefix; (5) Implicit type conversion makes index unusable. Use optimizer_trace to see the optimizer's decision process.

Q3: Should the smaller or larger table be the driving table in JOINs?

The principle is smaller result set drives larger table (note: "result set" not "table size"). The driving table (outer loop) needs full/range scan; the driven table (inner loop) uses index lookups. With M driving rows and index lookup cost log(N), total cost is M * log(N). Smaller M is better. MySQL's optimizer usually chooses correctly, but for complex queries you can force order with STRAIGHT_JOIN or /*+ JOIN_ORDER */.

Q4: What if EXPLAIN's rows column is inaccurate?

The rows column is the optimizer's estimate based on index statistics. If severely inaccurate: (1) Run ANALYZE TABLE; (2) Increase innodb_stats_persistent_sample_pages (default 20, set to 100) for better sampling; (3) Create histograms ANALYZE TABLE t UPDATE HISTOGRAM ON col (MySQL 8.0+). Use EXPLAIN ANALYZE to see actual values.

Q5: Are CTEs and derived tables equivalent in performance?

In MySQL 8.0, non-recursive CTEs are by default "merged" into the outer query (similar to view merging), performing identically to equivalent derived tables. However, if a CTE is referenced multiple times, the optimizer may materialize it (compute once). Use /*+ MERGE(cte_name) */ or /*+ NO_MERGE(cte_name) */ to control this behavior. Recursive CTEs are always materialized.

Q6: Are subqueries always slower than JOINs?

This is an outdated view. In MySQL 5.5 and earlier, subquery optimization was indeed weak. But MySQL 5.6 introduced semi-join transforms, 5.7 improved materialization, and 8.0 added anti-join and more transforms. In MySQL 8.0, IN subqueries, EXISTS, and equivalent JOINs are typically transformed into the same execution plan. Only some complex nested correlated subqueries still need manual rewriting.

Q7: How do I determine if a query needs optimization?

Focus on three core metrics: (1) Response time โ€” for online services, >100ms deserves attention, >1s needs urgent action; (2) Rows examined vs rows sent ratio โ€” ideal is close to 1, >1000 indicates severe waste; (3) Filesort or temporary table usage โ€” watch for these in EXPLAIN Extra. Performance Schema's events_statements_summary_by_digest helps identify the queries most in need of optimization.

Q8: What is MySQL 8.0's Hypergraph Optimizer?

The Hypergraph Optimizer (introduced in 8.0.31, experimental) is MySQL's next-generation query optimizer. The traditional optimizer uses left-deep trees for JOIN plans (each JOIN has exactly two inputs). The Hypergraph Optimizer uses hypergraphs, enabling exploration of richer plan spaces (e.g., bushy trees). Enable with SET optimizer_switch='hypergraph_optimizer=on', but currently recommended for testing environments only.

Q9: How to safely modify slow queries in production?

Recommended workflow: (1) Analyze the slow query with EXPLAIN ANALYZE on a replica or test environment; (2) Verify the rewritten query produces correct results and improves performance; (3) If adding indexes, use Online DDL or pt-online-schema-change; (4) Canary deploy the new query (e.g., 10% of traffic); (5) Monitor slow_log and Performance Schema; (6) Full rollout after confirmation.

Q10: COUNT(*) is slow but business requires exact counts โ€” what to do?

Several strategies: (1) For COUNT(*) without WHERE, use counter table + triggers; (2) For filtered COUNT, create covering indexes to reduce I/O; (3) If a few seconds delay is acceptable, cache count in Redis and update asynchronously; (4) For pagination, show "approximately N results" (approximate value); (5) For reporting, use summary tables with pre-computed aggregates by dimension.