Contents
Reading EXPLAIN ANALYZE
EXPLAIN shows the execution plan. EXPLAIN ANALYZE — plan + actual timings:
EXPLAIN ANALYZE
SELECT o.id, o.amount, u.email
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = 'pending'
AND o.created_at > '2026-01-01'
ORDER BY o.created_at DESC
LIMIT 20;
Example output:
-> Limit: 20 row(s) (cost=1842 rows=20) (actual time=45.3..45.4 rows=20 loops=1)
-> Sort: o.created_at DESC (cost=1842 rows=8432) (actual time=45.3..45.3 rows=20 loops=1)
-> Nested loop inner join (cost=1842 rows=8432) (actual time=0.15..44.1 rows=8432 loops=1)
-> Filter: (o.status = 'pending' and o.created_at > '2026-01-01')
(cost=852 rows=8432) (actual time=0.12..39.2 rows=8432 loops=1)
-> Table scan on o (cost=852 rows=84321) (actual time=0.10..32.1 rows=84321 loops=1)
What to look at:
| Symptom | What to do |
|---|---|
Table scan — scanning the whole table |
Add an index |
rows= actual >> estimated |
Refresh statistics: ANALYZE TABLE orders; |
Sort before Limit |
Index on (status, created_at) |
actual time >> cost |
Cache, I/O, or lock contention issue |
How MySQL uses indexes
An index is a sorted B-Tree on one or more columns. MySQL can use indexes for:
- WHERE lookups
- ORDER BY sorting
- Covering the whole query (covering index)
-- Show table indexes
SHOW INDEX FROM orders;
-- Cardinality (uniqueness of values)
-- High cardinality = more effective index
SELECT
COLUMN_NAME,
CARDINALITY
FROM information_schema.STATISTICS
WHERE TABLE_NAME = 'orders'
ORDER BY CARDINALITY DESC;
Composite indexes — order matters
Rule: column order in a composite index should match WHERE + ORDER BY order.
-- Query
SELECT * FROM orders
WHERE user_id = 42
AND status = 'completed'
ORDER BY created_at DESC;
-- Bad index — created_at first, but WHERE filters by user_id and status
CREATE INDEX idx_bad ON orders (created_at, user_id, status);
-- Good index — equality first, then sort
CREATE INDEX idx_good ON orders (user_id, status, created_at);
"ESR" rule:
- Equality —
=fields first - Sort — ORDER BY field
- Range —
>,<,BETWEENfields last
-- Query: WHERE category_id = 5 AND price BETWEEN 100 AND 500 ORDER BY name
-- Right order: equality → sort → range? No — range breaks the sort!
-- Right: equality → range, then separately check if sort is needed
CREATE INDEX idx_products ON products (category_id, price, name);
-- MySQL uses (category_id, price) for filtering, name — for sorting where possible
Covering Index — avoid hitting the table
If an index contains all the columns the query needs — MySQL doesn't touch the table:
-- Product-list query for a category
SELECT id, name, price FROM products
WHERE category_id = 5 AND is_active = 1
ORDER BY name;
-- Plain index — MySQL finds rows via the index, then fetches name, price from the table
CREATE INDEX idx_category ON products (category_id, is_active);
-- Covering index — everything needed is in the index, no table lookup
CREATE INDEX idx_cover ON products (category_id, is_active, name, price, id);
-- In EXPLAIN you'll see "Using index" instead of "Using index; Using where"
Don't build covering indexes with 10 columns — the index gets heavy. Only for genuinely hot, heavy queries.
Slow Query Log
# /etc/mysql/mysql.conf.d/mysqld.cnf
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1 # queries longer than 1 second
log_queries_not_using_indexes = 1 # log queries without indexes
min_examined_row_limit = 100 # only if > 100 rows examined
Analyze via mysqldumpslow:
# Top 10 slowest
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
# Top 10 by call count
mysqldumpslow -s c -t 10 /var/log/mysql/slow.log
# Queries on a specific table
mysqldumpslow -s t /var/log/mysql/slow.log | grep orders
Or pt-query-digest from Percona Toolkit — detailed analysis with grouping of similar queries.
Performance Schema — finding bottlenecks
-- Top queries by total time
SELECT
DIGEST_TEXT,
COUNT_STAR as calls,
ROUND(SUM_TIMER_WAIT / 1e12, 3) as total_seconds,
ROUND(AVG_TIMER_WAIT / 1e9, 3) as avg_ms,
SUM_ROWS_EXAMINED as rows_examined
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;
-- Tables with the most full scans
SELECT
OBJECT_SCHEMA,
OBJECT_NAME,
COUNT_READ,
COUNT_FULL_SCAN
FROM performance_schema.table_io_waits_summary_by_table
WHERE COUNT_FULL_SCAN > 0
ORDER BY COUNT_FULL_SCAN DESC;
-- Unused indexes
SELECT
OBJECT_SCHEMA,
OBJECT_NAME,
INDEX_NAME
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE INDEX_NAME IS NOT NULL
AND COUNT_READ = 0
AND OBJECT_SCHEMA NOT IN ('mysql', 'performance_schema', 'information_schema')
ORDER BY OBJECT_SCHEMA, OBJECT_NAME;
Pagination: OFFSET kills performance
The same problem exists at the ORM level — see cursor pagination in EF Core.
-- BAD — at page=1000 MySQL scans 20,000 rows
SELECT id, name, price FROM products
WHERE category_id = 5
ORDER BY id
LIMIT 20 OFFSET 19980;
On a million-row table OFFSET 999980 = scanning almost the entire table.
-- GOOD — cursor pagination, always O(log n) via index
-- First page
SELECT id, name, price FROM products
WHERE category_id = 5
ORDER BY id
LIMIT 20;
-- Next page (pass last_id from previous)
SELECT id, name, price FROM products
WHERE category_id = 5
AND id > :last_id -- :last_id = last id of previous page
ORDER BY id
LIMIT 20;
If you need bidirectional navigation or sorting by a non-unique field:
-- Sort by created_at (not unique) + id (unique tiebreaker)
SELECT id, name, created_at FROM products
WHERE category_id = 5
AND (created_at < :last_created_at
OR (created_at = :last_created_at AND id < :last_id))
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- Index for this query
CREATE INDEX idx_cursor ON products (category_id, created_at DESC, id DESC);
Partitioning for large tables
-- Partitioning by date range (logs, events)
CREATE TABLE event_log (
id BIGINT AUTO_INCREMENT,
event_type VARCHAR(50),
created_at DATETIME NOT NULL,
payload JSON,
PRIMARY KEY (id, created_at) -- created_at required in PK for partitions
)
PARTITION BY RANGE (YEAR(created_at)) (
PARTITION p2024 VALUES LESS THAN (2025),
PARTITION p2025 VALUES LESS THAN (2026),
PARTITION p2026 VALUES LESS THAN (2027),
PARTITION pmax VALUES LESS THAN MAXVALUE
);
-- Queries filtering by created_at will scan only relevant partitions
-- "Partition pruning" — visible in EXPLAIN
EXPLAIN SELECT * FROM event_log WHERE created_at BETWEEN '2026-01-01' AND '2026-03-31';
-- Deleting old data — instant, no locking
ALTER TABLE event_log DROP PARTITION p2024;
-- vs DELETE WHERE year=2024 — slow, generates lots of undo log
Quick diagnostic — checklist
-- 1. Tables without a primary key
SELECT TABLE_SCHEMA, TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA NOT IN ('mysql','information_schema','performance_schema','sys')
AND TABLE_NAME NOT IN (
SELECT TABLE_NAME FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_TYPE = 'PRIMARY KEY'
);
-- 2. Large tables without indexes on foreign keys
SELECT
kcu.TABLE_NAME,
kcu.COLUMN_NAME,
kcu.REFERENCED_TABLE_NAME
FROM information_schema.KEY_COLUMN_USAGE kcu
LEFT JOIN information_schema.STATISTICS s
ON s.TABLE_NAME = kcu.TABLE_NAME
AND s.COLUMN_NAME = kcu.COLUMN_NAME
AND s.TABLE_SCHEMA = kcu.TABLE_SCHEMA
WHERE kcu.REFERENCED_TABLE_NAME IS NOT NULL
AND s.INDEX_NAME IS NULL;
-- 3. Fragmented tables (need OPTIMIZE)
SELECT
TABLE_NAME,
ROUND(DATA_LENGTH/1024/1024, 2) AS data_mb,
ROUND(DATA_FREE/1024/1024, 2) AS free_mb,
ROUND(DATA_FREE/(DATA_LENGTH+INDEX_LENGTH)*100, 1) AS fragmentation_pct
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'myapp'
AND DATA_FREE > 0
ORDER BY DATA_FREE DESC;
💡 Rule of thumb: an index speeds up SELECT but slows down INSERT/UPDATE/DELETE. On write-heavy tables keep only the necessary indexes — each added index is an extra write on every row change. The database should also be secure — see the MySQL 8.4 security guide. Slow-query analysis is automated by the /mysql-explain skill.
Discussion
No comments yet. Be the first.