MySQL Performance Tuning
MySQL performance tuning is a layered discipline. Before touching configuration knobs, fix your queries and schema — no amount of memory tuning compensates for a missing index on a 50-million-row table. This guide works through each layer systematically, from highest-impact to lowest.
Tuning Hierarchy
Query optimization — most impact, fix slow queries first using slow log + EXPLAIN
Schema design — correct data types, proper indexes, normalized structure
Configuration tuning — buffer pool, log files, connection settings
Hardware — RAM, SSD, CPU — only after software layers are optimized
InnoDB Buffer Pool Sizing
The InnoDB buffer pool is the shared memory area that caches data pages and index pages. It is the single most impactful configuration knob for a read-heavy workload. The goal is to fit the working dataset (the data actively queried) in memory so MySQL avoids disk I/O.
# Rule of thumb: 70-80% of total RAM on a dedicated database server # On a 32 GB server: innodb_buffer_pool_size = 24G innodb_buffer_pool_instances = 8 # 1 per ~1 GB; reduces mutex contention on multi-core
-- Check the buffer pool hit ratio (target: > 99%)
SELECT
ROUND(
(1 - (
(SELECT VARIABLE_VALUE FROM performance_schema.global_status
WHERE VARIABLE_NAME = 'Innodb_buffer_pool_reads') /
NULLIF(
(SELECT VARIABLE_VALUE FROM performance_schema.global_status
WHERE VARIABLE_NAME = 'Innodb_buffer_pool_read_requests'), 0)
)) * 100, 2
) AS buffer_pool_hit_ratio_pct;
-- Also check current pool utilization
SELECT
VARIABLE_NAME,
VARIABLE_VALUE
FROM performance_schema.global_status
WHERE VARIABLE_NAME IN (
'Innodb_buffer_pool_pages_total',
'Innodb_buffer_pool_pages_free',
'Innodb_buffer_pool_pages_data'
);innodb_buffer_pool_size or reducing the dataset through archiving or partitioning.Query Anti-Patterns Checklist
These patterns appear repeatedly in slow query logs and each has a clear fix:
Anti-Pattern | Problem | Fix |
|---|---|---|
SELECT * | Fetches unused columns, prevents covering indexes, increases network overhead | Select only the columns you need |
N+1 loop | One query per row in application code: fetch users, then fetch orders for each user in a loop | Use a single JOIN or batch IDs into IN() query |
Function on indexed column | WHERE YEAR(created_at) = 2024 — prevents index use | Rewrite to range: WHERE created_at >= "2024-01-01" AND created_at < "2025-01-01" |
OFFSET pagination on large tables | SELECT ... LIMIT 10 OFFSET 50000 scans and discards 50,000 rows | Use keyset pagination: WHERE id > last_seen_id LIMIT 10 |
Correlated subquery per row | SELECT (SELECT COUNT(*) FROM orders WHERE user_id = u.id) FROM users — runs once per user row | Rewrite as a JOIN or move to a derived table |
LIKE with leading wildcard | LIKE "%search%" cannot use a B-tree index | Use FULLTEXT index + MATCH() AGAINST() for full-text search |
Implicit type conversion | WHERE user_id = "42" when user_id is INT — forces full scan | Match data types: use integer 42 not string "42" |
Index Audit with sys Schema
Find unused indexes (MySQL 8.0 via sys schema):
-- Indexes with zero usage since the last server restart
SELECT * FROM sys.schema_unused_indexes
WHERE object_schema = DATABASE();
-- Redundant indexes (fully covered by another index)
SELECT * FROM sys.schema_redundant_indexes
WHERE table_schema = DATABASE();
-- Tables doing the most full table scans
SELECT object_schema, object_name,
SUM(count_read) AS full_scan_reads
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE index_name IS NULL
GROUP BY object_schema, object_name
ORDER BY full_scan_reads DESC
LIMIT 20;
-- Verify with Percona Toolkit (most thorough)
-- pt-duplicate-key-checker --host=localhost --user=root --ask-passSHOW STATUS — Key Metrics
-- View runtime status counters SHOW GLOBAL STATUS LIKE 'Questions'; SHOW GLOBAL STATUS LIKE 'Slow_queries'; SHOW GLOBAL STATUS LIKE 'Threads%'; SHOW GLOBAL STATUS LIKE 'Innodb_buffer%'; SHOW GLOBAL STATUS LIKE 'Handler_%'; SHOW GLOBAL STATUS LIKE 'Created_tmp%';
Status Variable | What It Tells You | Action When High |
|---|---|---|
Threads_connected | Current open connections. | If near max_connections, add connection pooling |
Threads_running | Connections actively executing a query. | A spike indicates an overloaded server |
Slow_queries | Cumulative slow query count. Compare against Questions for ratio. | Enable slow log and analyze with pt-query-digest |
Handler_read_rnd_next | Full table scan row reads since startup. | High value = missing indexes on queried tables |
Innodb_buffer_pool_reads | Disk reads because data was not in buffer pool. | Should be < 1% of read requests — increase buffer pool if higher |
Innodb_row_lock_waits | Row lock contention events. | Investigate long transactions with SHOW ENGINE INNODB STATUS |
Created_tmp_disk_tables | Temp tables that spilled to disk. | Increase tmp_table_size and max_heap_table_size |
Select_full_join | JOINs performed without indexes on the joined table. | Add indexes on join columns |
PERFORMANCE_SCHEMA Key Tables
-- Top 10 queries by total execution time (best starting point)
SELECT
DIGEST_TEXT,
COUNT_STAR AS calls,
ROUND(SUM_TIMER_WAIT / 1e12, 2) AS total_sec,
ROUND(AVG_TIMER_WAIT / 1e12, 4) AS avg_sec,
ROUND(MAX_TIMER_WAIT / 1e12, 4) AS max_sec,
SUM_ROWS_EXAMINED AS rows_examined,
SUM_NO_INDEX_USED AS no_index_calls
FROM performance_schema.events_statements_summary_by_digest
WHERE SCHEMA_NAME = DATABASE()
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;
-- Longest currently running queries
SELECT thread_id, event_id,
ROUND(TIMER_WAIT / 1e12, 2) AS seconds,
SQL_TEXT
FROM performance_schema.events_statements_current
WHERE TIMER_WAIT > 1e12
ORDER BY TIMER_WAIT DESC;
-- I/O wait per table (find the most contended tables)
SELECT OBJECT_SCHEMA, OBJECT_NAME,
ROUND((SUM_TIMER_FETCH + SUM_TIMER_INSERT +
SUM_TIMER_UPDATE + SUM_TIMER_DELETE) / 1e12, 2) AS total_wait_sec
FROM performance_schema.table_io_waits_summary_by_table
ORDER BY total_wait_sec DESC
LIMIT 10;Connection Pooling
Creating a MySQL connection is expensive (TCP handshake, authentication, session initialization — ~1–5 ms each). Connection pooling reuses existing connections across requests, eliminating this overhead.
// Node.js — mysql2 connection pool example
const pool = mysql.createPool({
host: 'localhost',
user: 'app',
password: 'secret',
database: 'myapp',
charset: 'utf8mb4',
waitForConnections: true,
connectionLimit: 10, // max pool size (match max_connections / app_servers)
queueLimit: 0 // 0 = unlimited queue
});
// Always use pool.execute() or pool.query() — automatically manages connections
const [rows] = await pool.execute('SELECT * FROM users WHERE id = ?', [userId]);For large-scale deployments with multiple application servers, use ProxySQL as a connection multiplexer. ProxySQL accepts thousands of application connections and multiplexes them onto a smaller number of real MySQL connections:
# ProxySQL key capabilities: # - Connection multiplexing: 10,000 app conns -> 200 real MySQL conns # - Query routing: send writes to primary, reads to replicas # - Query caching: cache frequent read queries # - Query rewriting: transform queries without changing application code # - Failover: automatically route around failed nodes
Optimizer Hints
When the optimizer makes poor choices, guide it with hints embedded in SQL comments:
-- Force a specific index SELECT /*+ INDEX(orders idx_status) */ * FROM orders WHERE status = 'pending'; -- Disable derived table merging (useful for complex CTEs) SELECT /*+ NO_MERGE(subq) */ * FROM ( SELECT user_id, COUNT(*) AS cnt FROM orders GROUP BY user_id ) subq WHERE cnt > 10; -- Force a join order (left table is the outer loop) SELECT /*+ JOIN_ORDER(users, orders) */ u.name, o.total FROM users u JOIN orders o ON o.user_id = u.id; -- STRAIGHT_JOIN: force left-to-right join order (older syntax, still works) SELECT STRAIGHT_JOIN u.name, o.total FROM users u JOIN orders o ON o.user_id = u.id; -- Set max execution time (kills the query if it exceeds the limit) SELECT /*+ MAX_EXECUTION_TIME(5000) */ * FROM large_report_table WHERE year = 2024;
Schema Design Checklist for Performance
Use the smallest data type that fits your data — INT instead of BIGINT if values stay under 2 billion.
Use NOT NULL where possible — NULL requires extra storage and complicates index usage.
Avoid storing frequently queried values in JSON — extract them to proper columns with indexes.
Use ENUM for low-cardinality string columns (status, type) — stored as integers internally, very efficient.
Index every foreign key column — MySQL does NOT create indexes on FK columns in the parent table automatically.
Normalize to at least 3NF for OLTP workloads; selectively denormalize for high-frequency reporting queries.
Consider generated columns (virtual or stored) for computed values you query frequently.
Use DECIMAL for monetary values — FLOAT and DOUBLE are approximate and fail equality comparisons.
Read Replica Strategy
For read-heavy applications, offload expensive reporting and analytics queries to read replicas:
-- On the primary: writes and latency-sensitive reads INSERT INTO orders (user_id, total) VALUES (42, 99.99); SELECT * FROM users WHERE id = 42; -- real-time lookup -- On the replica: analytics and batch reports (slight replication lag acceptable) SELECT DATE(created_at) AS day, SUM(total) AS revenue FROM orders WHERE created_at >= '2024-01-01' GROUP BY DATE(created_at) ORDER BY day;
Performance Tuning Checklist
Area | Action | Tools |
|---|---|---|
Slow queries | Enable slow_query_log, analyze with pt-query-digest, fix top offenders | slow log, pt-query-digest, EXPLAIN |
Buffer pool | Set innodb_buffer_pool_size to 70–80% of RAM; verify hit ratio > 99% | SHOW STATUS LIKE Innodb_buffer% |
Indexes | Remove unused indexes; add missing ones; ensure FK columns have indexes | sys.schema_unused_indexes, EXPLAIN |
Connections | Use connection pooling; keep max_connections reasonable (< 500) | ProxySQL, mysql2 pool, SHOW STATUS |
Schema | Smallest fitting data types; NOT NULL where possible; proper normalization | Code review, information_schema |
Transactions | Keep transactions short; never hold open transactions during user input or network calls | SHOW ENGINE INNODB STATUS |
Configuration | innodb_log_file_size >= 256M; sync_binlog=1; innodb_io_capacity for SSD | mysqltuner.pl |
Monitoring | Regularly check SHOW STATUS, P_S digest tables, and slow log | Grafana + mysqld_exporter |
mysqltuner.pl weekly on production servers. It reads your actual status counters and makes evidence-based recommendations tuned to your specific workload — not generic defaults.Query Rewrite Examples
N+1 pattern — the most common application-level performance killer:
-- BAD: application runs this loop (N+1 queries)
-- SELECT * FROM orders LIMIT 100;
-- for each order: SELECT * FROM users WHERE id = ?
-- GOOD: single query with JOIN
SELECT o.id, o.total, o.created_at,
u.name, u.email
FROM orders o
JOIN users u ON u.id = o.user_id
ORDER BY o.created_at DESC
LIMIT 100;Correlated subquery rewritten as JOIN:
-- BAD: correlated subquery — runs once per user row SELECT u.id, u.name, (SELECT COUNT(*) FROM orders WHERE user_id = u.id) AS order_count FROM users u; -- GOOD: LEFT JOIN + GROUP BY — single pass SELECT u.id, u.name, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id, u.name;
OFFSET pagination rewritten as keyset pagination:
-- BAD: OFFSET causes MySQL to scan and discard previous pages SELECT * FROM products ORDER BY id LIMIT 10 OFFSET 50000; -- Scans 50,010 rows to return 10 -- GOOD: keyset pagination — always O(1) regardless of page -- Pass the last_id seen from the previous page SELECT * FROM products WHERE id > :last_id ORDER BY id LIMIT 10; -- Uses the PK index directly — scans only 10 rows
Monitoring with Grafana + mysqld_exporter
# Install Prometheus mysqld_exporter to scrape MySQL metrics docker run -d --name mysqld_exporter -p 9104:9104 -e DATA_SOURCE_NAME="exporter:password@tcp(mysql:3306)/" prom/mysqld-exporter # Key metrics exposed at /metrics: # mysql_global_status_slow_queries # mysql_global_status_innodb_buffer_pool_read_requests # mysql_global_status_innodb_buffer_pool_reads # mysql_global_status_threads_connected # mysql_global_status_threads_running # Grafana dashboard ID 7362 (MySQL Overview) provides # a ready-made dashboard for these metrics
sys Schema Views for Quick Wins
The sys schema (MySQL 5.7+) wraps Performance Schema data in human-friendly views:
-- Top 10 queries by total latency with formatted output
SELECT * FROM sys.statements_with_runtimes_in_95th_percentile LIMIT 10;
-- Queries doing full table scans
SELECT * FROM sys.statements_with_full_table_scans LIMIT 10;
-- Queries doing filesort
SELECT * FROM sys.statements_with_sorting LIMIT 10;
-- Unused indexes in the current database
SELECT * FROM sys.schema_unused_indexes
WHERE object_schema = DATABASE();
-- Tables with no primary key (fragile for replication and partitioning)
SELECT TABLE_SCHEMA, TABLE_NAME
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_TYPE = 'BASE TABLE'
AND TABLE_NAME NOT IN (
SELECT TABLE_NAME FROM information_schema.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND CONSTRAINT_TYPE = 'PRIMARY KEY'
);Diagnosing Live Performance Issues
-- Find currently running queries taking more than 5 seconds SELECT id, user, host, db, command, time, state, info FROM information_schema.PROCESSLIST WHERE command != 'Sleep' AND time > 5 ORDER BY time DESC; -- Kill a long-running query (use the id from PROCESSLIST) KILL QUERY 1234; -- kill just the query, keep connection KILL 1234; -- kill the entire connection -- Check for lock contention (InnoDB row locks) SELECT r.trx_id AS waiting_trx, r.trx_query AS waiting_query, b.trx_id AS blocking_trx, b.trx_query AS blocking_query, TIMESTAMPDIFF(SECOND, r.trx_wait_started, NOW()) AS wait_seconds FROM information_schema.innodb_lock_waits w JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id; -- Full InnoDB status (includes lock info, buffer pool stats, transaction list) SHOW ENGINE INNODB STATUSG
Summary: Performance Impact by Category
Optimization | Typical Impact | Effort |
|---|---|---|
Fix a missing index on a large table | 10x–1000x query speedup | Low — one CREATE INDEX |
Rewrite N+1 as a single JOIN | 10x–100x application-level speedup | Medium — application code change |
Increase innodb_buffer_pool_size to fit working set | 2x–10x on read-heavy workloads | Low — config change + restart |
Add connection pooling | 2x–5x throughput under high concurrency | Medium — infrastructure change |
Replace correlated subquery with derived table | 2x–20x query speedup | Low — SQL rewrite |
Add a read replica for reporting | 2x+ primary server capacity | High — infrastructure |
Upgrade spinning disk to SSD | 5x–50x I/O throughput | High — hardware |