DevOpsIndex

MySQL / InnoDB Internals

InnoDB Storage Engine Architecture

graph TD
    CLIENT2["Client query"] --> PARSER["SQL Parser + Optimizer"]
    PARSER --> EXEC["Execution Engine"]
    EXEC --> INNODB["InnoDB Storage Engine"]

    subgraph INNODB["InnoDB"]
        BP2["Buffer Pool (innodb_buffer_pool_size)<br/>Data pages + index pages + undo pages<br/>LRU with young/old sublists"]
        REDO["Redo Log (ib_logfile0, ib_logfile1)<br/>WAL for crash recovery<br/>Circular buffer, fixed size"]
        UNDO["Undo Log (ibdata1 / undo tablespace)<br/>Old row versions for MVCC<br/>Transaction rollback"]
        CHANGE["Change Buffer<br/>Buffer secondary index changes<br/>when index page not in buffer pool"]
    end

    INNODB --> DATA2["Data files (.ibd)<br/>Clustered B-tree on primary key<br/>All data in leaf nodes"]

Redo Log (WAL) + Undo Log

sequenceDiagram
    participant TX2 as Transaction
    participant BP3 as Buffer Pool
    participant REDO2 as Redo Log
    participant UNDO2 as Undo Log
    participant DISK2 as Data files

    TX2->>UNDO2: Write original row value (for rollback + MVCC)
    TX2->>BP3: Modify page in buffer pool (dirty page)
    TX2->>REDO2: Write redo record (new value)
    Note over REDO2: fsync on COMMIT (innodb_flush_log_at_trx_commit=1)
    TX2-->>TX2: COMMIT confirmed

    Note over BP3,DISK2: Async checkpoint
    BP3->>DISK2: Flush dirty pages
    Note over REDO2: Redo log before checkpoint can be overwritten

innodb_flush_log_at_trx_commit:

Value Behavior Durability Performance
0 Flush every 1s (OS buffer) 1s data loss on crash Fastest
1 (default) Flush + fsync on every commit Zero loss Slowest
2 Flush to OS buffer on commit, fsync every 1s 1s loss on OS crash Medium

MVCC with Undo Log

graph TD
    CURRENT["Current row version<br/>id=1, name='Bob', trx_id=200"]
    UNDO_PTR["Undo pointer"] --> PREV["Undo log version 1<br/>id=1, name='Alice', trx_id=100"]
    PREV --> PREV2["Undo log version 0<br/>id=1, name='Alex', trx_id=50"]

    TX_OLD["Old transaction (snapshot before 200)<br/>Follows undo pointer to read 'Alice'"]
    TX_NEW["New transaction<br/>Reads current 'Bob'"]

    CURRENT --> TX_OLD
    CURRENT --> TX_NEW

Unlike PostgreSQL (which stores old versions in the heap), MySQL/InnoDB stores them in undo logs.

Undo log purge: Background purge thread removes undo records no longer needed by any transaction. Long-running transactions prevent purge → undo tablespace grows.


InnoDB B-tree (Clustered Index)

PRIMARY KEY is the clustered index.
ALL data stored in leaf nodes of the B-tree.
Secondary indexes store the primary key value as the pointer.

SELECT * FROM users WHERE id=123:
  1. B-tree lookup in clustered index → find leaf → entire row

SELECT * FROM users WHERE email='alice@example.com':
  1. B-tree lookup in secondary index (email) → find leaf → get PK value
  2. B-tree lookup in clustered index (PK) → find leaf → entire row
  (Double lookup = "index dive")

Replication: Binary Log (Binlog)

sequenceDiagram
    participant SRC as Source (Primary)
    participant BINLOG as Binary Log
    participant IO_THREAD as IO Thread (replica)
    participant RELAY as Relay Log (replica)
    participant SQL_THREAD as SQL Thread (replica)
    participant REP_DB as Replica DB

    SRC->>BINLOG: Write binlog event on COMMIT
    IO_THREAD->>BINLOG: Read binlog events (streaming)
    IO_THREAD->>RELAY: Write to relay log
    SQL_THREAD->>RELAY: Read relay log events
    SQL_THREAD->>REP_DB: Execute SQL (or apply row changes)

Binlog formats:

  • STATEMENT: log SQL statements (compact, but non-deterministic queries can diverge)
  • ROW: log actual row changes (verbose, always correct — used by default now)
  • MIXED: statement for safe queries, row for non-deterministic

GTID (Global Transaction ID): Each transaction gets a globally unique ID. Replicas use GTIDs to track position — no need to know binlog filename + offset. Enables automatic failover.


Key Configuration

innodb_buffer_pool_size = 12G      # 70-80% of RAM
innodb_buffer_pool_instances = 8   # reduce contention
innodb_log_file_size = 2G          # larger = faster writes, slower crash recovery
innodb_flush_log_at_trx_commit = 1 # 1 = full durability (use 2 for replicas)
innodb_flush_method = O_DIRECT     # bypass OS cache (avoid double-buffering)
sync_binlog = 1                    # sync binlog to disk per transaction
binlog_format = ROW
gtid_mode = ON
enforce_gtid_consistency = ON

Slow Query Log and Analysis

graph LR
    SQ["Slow Query Log<br/>queries > long_query_time"] --> PT
    PT["pt-query-digest<br/>(Percona Toolkit)"] --> REPORT["Digest report:<br/>top queries by:<br/>total time / count / avg time"]
    REPORT --> FIX["Add index / rewrite query<br/>/ partition table"]
# Enable slow query log
slow_query_log = ON
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1           # log queries > 1 second
log_queries_not_using_indexes = ON  # catch full table scans
min_examined_row_limit = 1000 # skip trivially small queries
# Analyze slow query log
pt-query-digest /var/log/mysql/slow.log | head -100

# Output shows per-query fingerprint:
# Query 1: 23.45s total, 234 calls, 0.10s avg
# SELECT * FROM orders WHERE user_id = ? AND status = ?
# Rows examined: 50000 → index missing on (user_id, status)
-- Find slow queries in real time (Performance Schema)
SELECT DIGEST_TEXT, COUNT_STAR, AVG_TIMER_WAIT/1e12 AS avg_sec,
       SUM_ROWS_EXAMINED/COUNT_STAR AS avg_rows_examined
FROM performance_schema.events_statements_summary_by_digest
WHERE AVG_TIMER_WAIT > 1e12  -- > 1 second
ORDER BY SUM_TIMER_WAIT DESC LIMIT 10;

Deadlock Analysis

sequenceDiagram
    participant TX1 as Transaction 1
    participant TX2 as Transaction 2
    participant ROW_A as Row A
    participant ROW_B as Row B

    TX1->>ROW_A: SELECT ... FOR UPDATE (lock A)
    TX2->>ROW_B: SELECT ... FOR UPDATE (lock B)
    TX1->>ROW_B: SELECT ... FOR UPDATE (waiting for B)
    TX2->>ROW_A: SELECT ... FOR UPDATE (waiting for A)
    Note over TX1,TX2: DEADLOCK — InnoDB detects cycle
    Note over TX2: InnoDB kills TX2 (lowest cost victim)
    TX2-->>TX2: ERROR 1213: Deadlock — retry
    TX1->>ROW_B: Lock acquired, continues
-- View last deadlock
SHOW ENGINE INNODB STATUS\G
-- Look for "LATEST DETECTED DEADLOCK" section
-- Shows: which transactions, which rows were locked, who was killed

-- Enable deadlock logging
innodb_print_all_deadlocks = ON  -- logs every deadlock to error log

-- Prevent deadlocks: always lock rows in the same order
-- Bad: TX1 locks A then B, TX2 locks B then A
-- Good: both always lock in alphabetical/ID order

EXPLAIN and Index Optimization

-- Full EXPLAIN output
EXPLAIN FORMAT=JSON SELECT u.name, COUNT(o.id)
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.created_at > '2024-01-01'
GROUP BY u.id\G

-- Key fields to check:
-- type: ALL (full scan bad), ref/eq_ref (good), range (ok), index (scan index only)
-- key: NULL means no index used
-- rows: estimated rows to examine
-- Extra: "Using filesort" or "Using temporary" = expensive

-- Find missing indexes
EXPLAIN SELECT * FROM orders WHERE user_id = 123 AND status = 'pending'\G
-- If type=ALL and rows=100000 → add composite index

CREATE INDEX idx_orders_user_status ON orders(user_id, status);

-- Covering index: index contains all columns the query needs (no table lookup)
CREATE INDEX idx_orders_covering ON orders(user_id, status, created_at, amount);
-- Query: SELECT amount FROM orders WHERE user_id=1 AND status='paid'
-- Extra: "Using index" → reads index only, never touches table rows

Partitioning

-- Range partitioning by year (for large time-series tables)
CREATE TABLE orders (
    id BIGINT NOT NULL,
    user_id BIGINT,
    amount DECIMAL(10,2),
    created_at DATETIME NOT NULL
)
PARTITION BY RANGE (YEAR(created_at)) (
    PARTITION p2022 VALUES LESS THAN (2023),
    PARTITION p2023 VALUES LESS THAN (2024),
    PARTITION p2024 VALUES LESS THAN (2025),
    PARTITION pmax  VALUES LESS THAN MAXVALUE
);

-- Query uses partition pruning automatically
EXPLAIN SELECT * FROM orders WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31';
-- partitions: p2024  ← only scans 2024 partition, skips 2022-2023

-- Drop old partition instantly (no row-by-row DELETE)
ALTER TABLE orders DROP PARTITION p2022;  -- instant, reclaims disk space

-- List partitions and row counts
SELECT PARTITION_NAME, TABLE_ROWS, DATA_LENGTH/1024/1024 AS data_mb
FROM INFORMATION_SCHEMA.PARTITIONS
WHERE TABLE_NAME = 'orders';

Performance Schema — Real-Time Diagnostics

-- Top wait events (where time is spent)
SELECT EVENT_NAME, COUNT_STAR, SUM_TIMER_WAIT/1e12 AS total_sec
FROM performance_schema.events_waits_summary_global_by_event_name
WHERE COUNT_STAR > 0
ORDER BY SUM_TIMER_WAIT DESC LIMIT 10;

-- I/O by table
SELECT OBJECT_SCHEMA, OBJECT_NAME,
       COUNT_READ, SUM_TIMER_READ/1e12 AS read_sec,
       COUNT_WRITE, SUM_TIMER_WRITE/1e12 AS write_sec
FROM performance_schema.table_io_waits_summary_by_table
ORDER BY (SUM_TIMER_READ + SUM_TIMER_WRITE) DESC LIMIT 10;

-- Current connections with their last query
SELECT PROCESSLIST_ID, PROCESSLIST_USER, PROCESSLIST_HOST,
       PROCESSLIST_DB, PROCESSLIST_COMMAND, PROCESSLIST_TIME,
       LEFT(PROCESSLIST_INFO, 100) AS query
FROM performance_schema.processlist
WHERE PROCESSLIST_COMMAND != 'Sleep'
ORDER BY PROCESSLIST_TIME DESC;

Key Monitoring Queries

-- Buffer pool hit rate (target > 99%)
SELECT (1 - Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests) * 100 AS hit_rate_pct
FROM (
    SELECT variable_value AS Innodb_buffer_pool_reads FROM information_schema.global_status WHERE variable_name = 'Innodb_buffer_pool_reads'
) r, (
    SELECT variable_value AS Innodb_buffer_pool_read_requests FROM information_schema.global_status WHERE variable_name = 'Innodb_buffer_pool_read_requests'
) rr;

-- Replication lag in seconds
SHOW REPLICA STATUS\G
-- Seconds_Behind_Source: 0 = in sync, >30 = alert

-- InnoDB row lock waits (high = contention)
SHOW STATUS LIKE 'Innodb_row_lock%';
-- Innodb_row_lock_waits: cumulative waits
-- Innodb_row_lock_time_avg: average wait ms

-- Table sizes
SELECT table_name,
       round(data_length/1024/1024, 1) AS data_mb,
       round(index_length/1024/1024, 1) AS index_mb,
       table_rows
FROM information_schema.tables
WHERE table_schema = DATABASE()
ORDER BY data_length + index_length DESC LIMIT 20;