MySQLPerformance Tuning

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
  1. Query optimization — most impact, fix slow queries first using slow log + EXPLAIN

  2. Schema design — correct data types, proper indexes, normalized structure

  3. Configuration tuning — buffer pool, log files, connection settings

  4. 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.

Bash
# 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

SQL
-- 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'
);
Note
A hit ratio below 95% means the working set does not fit in the buffer pool. Consider increasing 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):

SQL
-- 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-pass
SHOW STATUS — Key Metrics

SQL
-- 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

SQL
-- 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.

Bash
// 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:

Bash
# 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:

SQL
-- 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:

SQL
-- 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

Tip
Run 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:

SQL
-- 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:

SQL
-- 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:

SQL
-- 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

Bash
# 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:

SQL
-- 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

SQL
-- 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