Everything for MySQL in one place — pick a section below. 16 reviewed items across 4 content types.
Quick Answer
MySQL's query cache stored the exact result set of a SELECT keyed on its literal SQL text, but it used a single global mutex, so every single write to any table invalidated every cached query touching that table and briefly serialized all query cache access cluster-wide — under real write-heavy concurrency, that mutex contention cost more than the cache ever saved, which is why it was deprecated in 5.7 and removed outright in 8.0. Modern MySQL relies instead on the InnoDB buffer pool caching underlying data and indexes, and application-level or proxy-level caching (Redis, ProxySQL query result caching) for repeated identical query results.
Detailed Answer
Imagine a single shared whiteboard in an office where anyone can write down the answer to a common question so others can read it instead of recalculating it themselves. This works fine if the office rarely changes any of the underlying facts. But if even one person updating any related fact anywhere in the building requires erasing the entire whiteboard and someone standing in line to physically access it one at a time to either read or erase it, then in a busy, constantly-changing office, people spend more time queued up at the whiteboard than they would have spent just recalculating the answer themselves.
MySQL's query cache stored the complete result set of a SELECT statement, keyed by the literal text of the query, so that an identical subsequent query could skip execution entirely and return the cached rows. This looked attractive for read-heavy, rarely-changing tables, and early MySQL deployments in the 2000s, often serving mostly-static content, saw real benefit. The design's fatal flaw was invalidation granularity: the query cache tracked dependencies at the table level, not the row or even the specific rows a query actually touched, so a single INSERT, UPDATE, or DELETE on a table invalidated every cached query that referenced that table anywhere in its query text, regardless of whether the write actually affected the rows those cached queries cared about.
Internally, both reading from and invalidating the query cache required acquiring a single global mutex (a lock that only one thread can hold at a time) protecting the entire cache structure, because the cache was a single shared, global resource rather than something sharded by table or connection. On a busy server with many concurrent connections doing a mix of reads and writes, every query — cached or not — had to briefly contend for this one global lock just to check or update cache state, meaning the query cache added serialization overhead to every single query on the server, not just the ones benefiting from a cache hit.
In production, this played out as query cache being genuinely useful on read-only or read-mostly workloads with low connection concurrency, and actively harmful — sometimes reducing throughput by more than half — on typical modern OLTP workloads with frequent writes and high concurrency, which describes most production web applications. MySQL's own documentation eventually recommended disabling it (query_cache_type=0) for exactly this reason well before it was formally removed, and the team ultimately removed it entirely in 8.0 rather than trying to fix the architecture, because a single global mutex is fundamentally incompatible with high-concurrency scaling regardless of how the invalidation logic is tuned.
The modern replacement isn't one single feature but a layered approach: the InnoDB buffer pool already caches actual data pages and indexes in memory (which is a much more granular, engine-level cache that doesn't suffer the same table-wide invalidation problem), and for caching complete query results, teams use an external cache like Redis or Memcached with explicit application-level cache keys and TTLs, or a proxy layer like ProxySQL that can cache specific query result sets with fine-grained rules. The gotcha for teams migrating off an old MySQL version that still relied on the query cache: removing it doesn't just require flipping a config flag, because any application that was silently depending on the query cache masking a genuinely slow, unindexed query will suddenly see that query's real latency for the first time in years, and teams have been caught off guard discovering years-old unindexed queries that 'always ran fine' purely because the query cache had been quietly absorbing the cost.
Code Example
# Confirm query cache is disabled (it no longer exists as of MySQL 8.0, but check on 5.7 and earlier) SHOW VARIABLES LIKE 'query_cache_type'; # On MySQL 5.7, explicitly disable it in preparation for an 8.0 upgrade SET GLOBAL query_cache_type = 0; SET GLOBAL query_cache_size = 0; # After disabling, immediately profile queries that may have relied on it going unindexed EXPLAIN SELECT * FROM checkout_sessions WHERE customer_id = 88213 ORDER BY created_at DESC; # Use ProxySQL for application-level query result caching instead, keyed by query digest -- INSERT INTO mysql_query_rules (rule_id, match_digest, cache_ttl) VALUES (1, '^SELECT .* FROM checkout_sessions', 5000);
Interview Tip
A junior engineer typically doesn't know the query cache was removed, or assumes it was removed simply because 'it was old.' For a senior or architect role, the interviewer wants the actual architectural reason: a single global mutex protecting table-level (not row-level) invalidation meant every write on a table blew away every cached query for that table and briefly serialized all query cache access, which scales inversely with the concurrency modern applications actually need. The senior-level insight is knowing what replaced it functionally — the InnoDB buffer pool for engine-level data caching, and external caches like Redis or ProxySQL for result-level caching — and being aware that disabling or removing the query cache on an older system can suddenly expose long-hidden unindexed queries that had been silently masked by cache hits for years.
◈ Architecture Diagram
Query cache (removed in 8.0) Modern approach ┌──────────────────────┐ ┌───────────┐ ┌──────────┐ │ 1 global mutex │ │ InnoDB │ │ Redis / │ │ table-level invalidate│ ✗ serial │ buffer pool│ │ ProxySQL │ ✓ granular └──────────────────────┘ └───────────┘ └──────────┘
💬 Comments
Quick Answer
Normal CPU with climbing Threads_running almost always means threads are waiting, not computing, so check SHOW PROCESSLIST and performance_schema for threads stuck in a 'Waiting for' state, cross-reference against SHOW ENGINE INNODB STATUS for lock waits, and check max_connections headroom — a single blocking query holding a lock can stall dozens of otherwise-fast queries behind it without ever spiking CPU, since waiting threads consume almost no CPU at all.
Detailed Answer
Picture a highway with a single stalled car in one lane during rush hour. From a satellite view measuring total fuel burned (CPU usage), everything looks almost normal — most cars are idling, not accelerating. But every driver on the road is experiencing enormous delay, because they're all queued up behind one blockage, not because the road network itself is overloaded. Looking at aggregate 'engine usage' across all the idling cars would completely mislead you about where the actual problem is.
MySQL's Threads_running counts connections actively executing a statement, as opposed to Threads_connected which counts all open connections whether idle or busy. When Threads_running climbs while CPU stays flat, it means threads are executing but spending their time blocked — waiting on a row lock, a table lock, an internal mutex, or I/O — rather than burning CPU cycles computing something. This is a critical distinction because a CPU-bound problem and a lock-contention problem look completely different in every other regard but can both present as 'the database is slow,' and applying a CPU-bound fix (like adding more compute) to a lock-contention problem does nothing.
The diagnostic sequence starts with SHOW FULL PROCESSLIST or querying performance_schema.threads and performance_schema.data_lock_waits, looking specifically at the State column for values like 'Waiting for table metadata lock,' 'Waiting for row lock,' or 'statistics' — different wait states point to entirely different root causes. If many threads show 'Waiting for row lock' or similar, SHOW ENGINE INNODB STATUS's TRANSACTIONS section reveals exactly which transaction is holding the lock everyone else is waiting on, often a single long-running or forgotten-open transaction (an application connection that started a transaction and never committed or rolled back, perhaps due to a bug in error handling) blocking dozens of otherwise fast queries.
In production, engineers correlate Threads_running against Threads_connected and max_connections, because a separate but easily confused failure mode is connection exhaustion — the application's connection pool holding onto more connections than the database allows, causing new connection attempts to queue or fail outright, which superficially also presents as 'everything is slow' but requires a completely different fix (connection pool sizing or max_connections tuning) than lock contention does. Distinguishing the two quickly during an incident saves critical minutes, since Threads_running high with Threads_connected near max_connections points at exhaustion, while Threads_running high with Threads_connected comfortably below max_connections but many threads in a lock-wait state points at contention.
The gotcha experienced engineers watch for: the actual root-cause transaction holding a lock is often not itself slow or expensive — it might be a completely ordinary UPDATE that ran fine, except the application connection that ran it never called COMMIT or ROLLBACK afterward due to a bug, leaving the transaction open (and its locks held) indefinitely while the connection sits idle. SHOW ENGINE INNODB STATUS and performance_schema will show this as a transaction with a long Trx_started time but essentially zero recent activity — it looks idle, not busy, which is exactly why teams scanning for 'the slow query' during an incident often miss it, since the actual blocking transaction isn't running anything slow at all, it's just never finishing.
Code Example
# Step 1: check if threads are running (executing) or just connected (idle) SHOW GLOBAL STATUS LIKE 'Threads_running'; SHOW GLOBAL STATUS LIKE 'Threads_connected'; SHOW GLOBAL STATUS LIKE 'max_connections'; -- Step 2: find exactly which queries/threads are waiting and on what SELECT id, user, host, db, command, time, state, info FROM information_schema.processlist WHERE command != 'Sleep' ORDER BY time DESC; -- Step 3: identify the specific transaction holding a lock everyone else is waiting on SHOW ENGINE INNODB STATUS\G -- Look at the TRANSACTIONS section for a long-running, idle-looking transaction -- Step 4: kill the blocking transaction once confirmed safe to do so KILL 48213;
Interview Tip
A junior engineer typically checks CPU and memory graphs first and gets stuck when they look normal. For a senior or architect role, the interviewer wants you to immediately separate 'threads executing and burning CPU' from 'threads waiting and burning almost nothing,' since Threads_running climbing with flat CPU is the specific signature of lock contention or connection exhaustion, not compute pressure. The strongest answers describe finding a long-open, forgotten transaction via SHOW ENGINE INNODB STATUS that looks idle rather than slow — the classic real-world root cause — and explicitly distinguish lock contention from connection-pool exhaustion, since they require entirely different fixes and are easy to conflate under incident pressure.
◈ Architecture Diagram
Threads_running ↑ CPU flat ──▶ threads WAITING, not computing Txn A: UPDATE orders SET ... (never COMMITTED) ── holds lock Txn B,C,D,...: waiting for row lock ── queue builds, CPU stays low
💬 Comments
Quick Answer
Leading indicators are InnoDB buffer pool hit ratio dropping, history list length (undo log backlog) growing, and Threads_running trending up while Threads_connected stays flat, because these reveal internal pressure building before users notice anything. CPU usage is a poor primary signal for MySQL because a database can be in serious trouble — lock-starved, replication-lagged, or thrashing its buffer pool — while CPU sits comfortably low, since waiting and I/O-bound states don't register as CPU load at all.
Detailed Answer
A hospital emergency room's true stress signal isn't how many doctors are physically moving around at any moment — it's how long the waiting room has been filling up and how many patients are queued for a bed. A room could have doctors calmly standing by (low CPU-equivalent) while the actual bottleneck is beds not turning over fast enough (locks, I/O, connections), and by the time doctors themselves look overwhelmed, the real crisis started much earlier upstream.
MySQL's most commonly-watched infrastructure metric, CPU usage, measures compute time spent, which correlates well with query-plan-driven load like heavy sorting or hashing, but says nothing about time spent waiting — locks, disk I/O, network round trips to a replica, or semaphore waits inside InnoDB. A database experiencing serious internal pressure from any of these sources can show flat or even low CPU precisely because threads aren't computing, they're blocked, and blocked threads don't consume CPU cycles. This makes CPU usage a lagging or even entirely absent signal for the failure modes that most commonly cause real MySQL incidents in production.
InnoDB buffer pool hit ratio (the percentage of data page reads served from memory versus requiring a disk read) is a genuine leading indicator: as the working set grows beyond available buffer pool size, hit ratio degrades gradually before query latency visibly spikes, giving an early warning that either the buffer pool needs to grow or the working set needs to shrink (via archiving, sharding, or better indexes). History list length — the size of InnoDB's undo log, which grows when long-running transactions prevent old row versions from being purged under MVCC (MySQL's mechanism for letting readers see a consistent snapshot without blocking writers) — is another strong leading indicator: a growing history list means something is holding a long transaction open, and if left unchecked it directly degrades every subsequent query's performance because InnoDB has more old row versions to scan through.
In production, mature monitoring dashboards for MySQL prioritize buffer pool hit ratio, history list length, Threads_running relative to Threads_connected, and replication lag ahead of CPU and even ahead of raw query latency, specifically because these internal signals predict the latency spike before it happens, giving on-call engineers a window to intervene (killing a long-running transaction, scaling the buffer pool, adding a replica) before customers notice anything. Teams that alert primarily on CPU and query latency consistently get paged only once the incident is already fully user-visible, because those are lagging confirmations rather than early warnings.
The gotcha even experienced teams miss: history list length can grow silently from an application bug that opens a transaction and never closes it (not necessarily a large or complex transaction — a completely trivial SELECT run inside an ORM's auto-transaction wrapper that never commits can hold back purge indefinitely), and this metric climbing has no direct correlation with any specific slow query in the slow query log, since the 'culprit' transaction may not itself be running anything slow — it's just sitting open. This is why history list length has to be monitored as its own first-class signal rather than assumed to be implied by slow-query logging, which will show nothing wrong at all while the real problem quietly compounds in the background.
Code Example
# Check buffer pool hit ratio (leading indicator of working-set pressure) SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%'; -- hit ratio approx = 1 - (Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests) -- Check InnoDB history list length (undo log backlog from long-open transactions) SHOW ENGINE INNODB STATUS\G -- Look for 'History list length' near the top of the TRANSACTIONS section -- Find the specific long-running transaction driving history list growth SELECT trx_id, trx_started, trx_state, trx_query FROM information_schema.innodb_trx ORDER BY trx_started ASC LIMIT 5; -- Compare active vs total connections as a leading indicator distinct from CPU SHOW GLOBAL STATUS LIKE 'Threads_running';
Interview Tip
A junior engineer typically lists CPU usage and query latency as the top MySQL metrics to watch. For a senior or architect role, the interviewer wants you to explain specifically why CPU is a poor primary signal for a database — because lock waits, I/O waits, and MVCC-driven overhead don't register as CPU load — and to name buffer pool hit ratio and history list length as the real leading indicators that predict trouble before customers notice. The senior-level detail that stands out is knowing history list length can grow from a trivial, non-slow transaction that simply never commits, meaning the slow query log shows nothing useful while the real problem compounds silently — which is exactly the kind of incident that burns hours of investigation time when a team is only watching CPU and latency dashboards.
◈ Architecture Diagram
Leading (predictive) Lagging (confirms impact) ┌────────────────────────┐ ┌────────────────────────┐ │Buffer pool hit ratio ↓ │ │Query latency p99 spike │ │History list length ↑ │────────▶ │CPU usage climbing │ │Threads_running ↑ (flat │ │User-visible timeouts │ │ Threads_connected) │ └────────────────────────┘ └────────────────────────┘
💬 Comments
Quick Answer
Automated failover must confirm the old primary is actually unreachable from multiple independent vantage points (not just one monitoring node's view, which could itself be a network partition), select the replica with the most advanced GTID position as the new primary, and fence off the old primary (actively prevent it from accepting writes again) before redirecting traffic — skipping the fencing step is what turns a clean failover into split-brain, where two nodes both believe they're primary and accept conflicting writes.
Detailed Answer
Think of a company where the CEO suddenly stops answering calls, and the leadership team needs to decide whether to promote a deputy. If only one assistant's phone call attempt failing is used as proof the CEO is unreachable, that assistant might just have a broken phone line, while the CEO is actually fine and everyone else can still reach them — promoting a deputy in that case creates two people simultaneously issuing directives to the company. The safe process requires several independent people confirming the CEO is truly unreachable, and once a deputy is promoted, actively revoking the original CEO's signing authority so they can't issue a conflicting order if they resurface unexpectedly.
MySQL replication automation tools like Orchestrator or MHA (Master High Availability) exist specifically to solve the failover decision safely, because a naive failover script that simply promotes 'a' replica the instant a health check fails is dangerous in exactly the way the analogy describes: a monitoring node's own network partition can make a perfectly healthy primary look unreachable, and if the automation acts on that single vantage point, it can promote a replica while the original primary is still alive and accepting writes from clients that haven't yet noticed the failover — this is split-brain, where two nodes both believe they're the authoritative primary.
Correctly designed failover automation requires multiple independent observers to agree the primary is actually down (cross-checking from several hosts in different network zones, not trusting a single health check), and uses GTID (Global Transaction Identifier, a unique ID assigned to every transaction that lets MySQL compare exactly how caught-up two servers are) to determine which replica has replicated the most complete set of transactions and is therefore the safest promotion candidate — promoting a replica that's behind means silently losing the delta of transactions the old primary had that never made it to the new primary. Before completing the promotion, the automation must fence the old primary: setting it read-only, revoking its ability to serve as a replication source, and ideally blocking it at the network or proxy layer, specifically so that if it comes back online after a transient issue (not an actual crash, just a network blip), it cannot accept writes that would silently diverge from the new primary's state.
In production, the fencing step is frequently the one teams skip when building a first version of failover automation, because it's the part with no positive-path benefit during normal operation — it only matters in the exact failure scenario it's designed for, so it's easy to under-test. Engineers validate failover automation with actual game-day exercises that simulate a network partition (not just killing the MySQL process, which is a cleaner and easier-to-handle failure than a network split), specifically because a hard process crash and a network partition produce very different symptoms to the monitoring system, and automation tuned only against the easy case (process crash) can behave dangerously against the harder, more realistic case (partition).
The gotcha that catches teams who built solid automation and still hit an incident: GTID-based promotion correctly picks the most caught-up replica, but if application-side connection routing (via a proxy, DNS, or service discovery) isn't updated atomically with the promotion, some application instances can keep writing to the old primary for a window after the database-level failover completed, because they cached a connection or resolved an old endpoint before the failover began — meaning the database layer did everything correctly, but the failure now happens one layer up, in connection routing, which many teams don't test as part of their failover drill because they consider the database automation itself to be 'the failover.'
Code Example
# Check GTID position to determine the most caught-up replica before any promotion SHOW REPLICA STATUS\G -- Compare Retrieved_Gtid_Set and Executed_Gtid_Set across all replica candidates -- Fence the old primary immediately upon confirmed failure — make it read-only first SET GLOBAL read_only = ON; SET GLOBAL super_read_only = ON; -- Promote the chosen replica (Orchestrator/MHA automate this, shown conceptually) STOP REPLICA; RESET REPLICA ALL; SET GLOBAL read_only = OFF; -- Update the proxy/service-discovery layer atomically with the database promotion # proxysql-admin --write-hostgroup=10 --add-host=payments-db-replica-2:3306 --promote
Interview Tip
A junior engineer typically describes failover automation as 'detect the primary is down, promote a replica.' For a senior or architect role, the interviewer wants you to identify the two failure modes that naive automation misses entirely: acting on a single monitoring node's view (which can itself be partitioned from a perfectly healthy primary) and skipping the fencing step, both of which lead directly to split-brain. They're listening for GTID-based candidate selection to avoid promoting a replica that's behind, explicit fencing of the old primary (read-only, super_read_only, network-level block) before redirecting traffic, and the often-missed detail that connection routing has to update atomically with database promotion, since application instances holding stale connections can keep writing to the old primary even after a technically correct database-level failover completes.
◈ Architecture Diagram
Primary (network partition, still alive) ✗ falsely marked down
│ │
│ if fencing SKIPPED │ if fenced properly
↓ ↓
Both accept writes = SPLIT BRAIN Old primary → read_only
New primary → sole writer ✓💬 Comments
Quick Answer
Check Slave_SQL_Running_State and the replica's IO thread versus SQL thread progress separately — if the IO thread (receiving data from the source) is caught up but the SQL thread (applying it) isn't, the bottleneck is apply speed, which multi-threaded replication fixes by applying independent transactions from different schemas or table groups in parallel instead of one row at a time. If both threads are behind, the bottleneck is upstream — network bandwidth or the source's binlog generation rate.
Detailed Answer
Imagine a single cashier at a busy store re-ringing up every receipt from a much larger store's entire day of sales, one line item at a time, in the exact order they originally happened. Even if the register tape (network transfer) arrives instantly, the cashier can only process as fast as one person typing — replaying history one transaction at a time is fundamentally slower than the original store, which had dozens of registers running simultaneously.
MySQL replication historically applied every change from the binary log (binlog, the source's record of every data-changing statement or row event) using a single SQL thread on the replica, strictly in commit order, to guarantee the replica ends up in exactly the same state as the source. This was the safe default because naively parallelizing could apply conflicting changes out of order and corrupt data — but it meant a replica's apply capacity was capped at whatever a single CPU core and one thread of I/O could sustain, no matter how many cores or disks the replica actually had available.
Replication in MySQL is split into two independent threads: the IO thread, which streams binlog events from the source over the network and writes them to the replica's local relay log, and the SQL thread, which reads the relay log and actually applies the changes to the replica's data. Seconds_Behind_Source measures the SQL thread's lag relative to the source's current time, so diagnosing the bottleneck means checking whether the IO thread is caught up (relay log receipt is current) while the SQL thread lags — that isolates the problem to apply speed. Multi-threaded replication (enabled via replica_parallel_workers) fixes exactly this by allowing the SQL thread's job to be split across multiple worker threads, applying transactions from different logical partitions (by default, schema/database) concurrently as long as MySQL can prove they don't touch overlapping data.
In production, engineers watch Seconds_Behind_Source as the headline metric but drill into Slave_SQL_Running_State and performance_schema replication tables to see whether workers are actually parallelizing effectively or serializing anyway — a common trap is that if every write goes to a single schema or a small number of hot tables, replica_parallel_workers has almost nothing to parallelize across, because the default parallelization mode groups work by schema, and one busy schema is still effectively one lane. WRITESET-based parallelization (available in newer MySQL versions) parallelizes based on which actual rows a transaction touches rather than which schema, which unlocks real concurrency even for single-schema workloads, at the cost of needing row-based binlog format.
The gotcha that catches teams enabling multi-threaded replication for the first time: increasing replica_parallel_workers doesn't help, and can even make ordering-sensitive lag worse, if the workload is dominated by large single transactions (a big batch update or a long-running migration) — a multi-gigabyte transaction still applies as one atomic unit on one worker thread regardless of how many other workers are configured, so the real fix for large-transaction-driven lag is breaking up application-side batch jobs into smaller transactions, not tuning replication thread count, and no amount of replica-side configuration can parallelize a single indivisible transaction.
Code Example
# Check replication lag and thread state on the replica SHOW REPLICA STATUS\G -- Compare Seconds_Behind_Source, Slave_IO_Running, Slave_SQL_Running -- Enable multi-threaded replication with 4 worker threads, grouped by writeset for real parallelism SET GLOBAL replica_parallel_workers = 4; SET GLOBAL replica_parallel_type = 'LOGICAL_CLOCK'; SET GLOBAL binlog_transaction_dependency_tracking = 'WRITESET'; -- Inspect per-worker apply progress to confirm parallelism is actually happening SELECT * FROM performance_schema.replication_applier_status_by_worker;
Interview Tip
A junior engineer typically answers 'enable multi-threaded replication' as a blanket fix for any replication lag. For a senior or architect role, the interviewer wants you to first separate IO-thread lag from SQL-thread lag to prove the bottleneck is apply speed rather than network or source-side binlog generation, and to explain that default schema-based parallelization does nothing for a workload concentrated on one schema or table — WRITESET-based dependency tracking is what actually unlocks parallelism there. The deepest signal is recognizing that a single oversized transaction cannot be parallelized no matter how many worker threads are configured, which means some lag problems require an application-side fix (smaller batch sizes), not a replication-tuning one.
◈ Architecture Diagram
Source ──binlog──▶ IO thread (relay log) ──▶ SQL thread
caught up ✓ but behind ✗
= apply bottleneck, not network
Single-threaded apply: [tx1][tx2][tx3][tx4] sequential
Multi-threaded (WRITESET): [tx1][tx3] || [tx2][tx4] parallel, no row overlap💬 Comments
Quick Answer
InnoDB builds a wait-for graph between transactions holding and requesting row locks, and the instant that graph forms a cycle, it kills the transaction with the smaller estimated rollback cost (roughly, the one that has modified fewer rows) rather than the one that started the wait. Consistent lock ordering prevents deadlocks caused by explicit application logic, but gap locks and auto-generated locks from secondary index scans can create deadlocks between transactions that never explicitly disagree on ordering from the application's point of view.
Detailed Answer
Picture two delivery drivers on a narrow one-lane bridge, each entering from opposite ends at the same moment, each blocking the other's path forward and unable to reverse without help. Neither driver did anything individually wrong — they just happened to start crossing at the same instant from opposite directions. Someone has to back one truck out to break the standoff, and the sensible choice is to back out whichever truck has the lighter, easier-to-reverse load, not whichever truck happened to enter the bridge first.
InnoDB (MySQL's default storage engine since 5.5) uses row-level locking to allow high concurrency, but that means two transactions can each hold a lock the other one needs — transaction A holds a lock on row 1 and is waiting for row 2, while transaction B holds row 2 and waits for row 1. InnoDB maintains an internal wait-for graph representing which transactions are blocked on which locks held by which other transactions, and it checks this graph for cycles proactively rather than waiting for a timeout, which is why deadlocks are detected in milliseconds rather than after lock_wait_timeout expires (that timeout is for genuine long waits, not deadlocks).
When a cycle is detected, InnoDB picks a victim transaction to roll back based on an internal heuristic approximating rollback cost — generally the transaction that has done less work (fewer modified rows) is killed, since undoing it is cheaper than undoing the other transaction's larger set of changes. This is a deliberate engineering tradeoff: InnoDB isn't trying to be 'fair' about which transaction started first, it's trying to minimize the total wasted work of resolving the deadlock, which is the right objective for overall system throughput even though it can feel arbitrary to the specific application that gets its transaction killed.
In production, engineers reduce deadlock frequency by keeping transactions short (holding locks for the shortest possible window), always accessing tables and rows in a consistent order across all code paths that touch multiple tables, ensuring proper indexes exist so UPDATE and DELETE statements don't lock far more rows than necessary via a full table scan, and considering READ COMMITTED isolation instead of the default REPEATABLE READ when gap locking behavior is causing unnecessary contention. Monitoring innodb_deadlocks and enabling innodb_print_all_deadlocks surfaces the exact competing statements so a team can identify a recurring pattern rather than treating each deadlock as a one-off.
The gotcha that defeats teams who did enforce consistent application-level lock ordering: InnoDB's default REPEATABLE READ isolation level uses gap locks and next-key locks (locks that cover the empty space between index entries, not just the index entries themselves) to prevent phantom reads, and these locks are taken based on how a query's WHERE clause maps onto the index it uses — not based on any explicit ordering the application controls. Two transactions can update completely different specific rows, in what looks like non-overlapping order from the application code, and still deadlock because their range-scan queries acquired overlapping gap locks on the same index. This is why deadlocks can appear between transactions whose application-level logic never seems to conflict, and the actual fix often requires either a covering index that avoids the gap-locking range scan, or explicitly switching that transaction to READ COMMITTED, not touching the application's lock ordering at all.
Code Example
# View the most recent deadlock's full details, including both competing transactions SHOW ENGINE INNODB STATUS\G -- Look at the LATEST DETECTED DEADLOCK section -- Log every deadlock automatically to the error log for trend analysis SET GLOBAL innodb_print_all_deadlocks = ON; -- Reduce gap-lock-driven deadlocks for a specific hot transaction by using READ COMMITTED SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- Check deadlock counter to confirm frequency trend after a mitigation SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks';
Interview Tip
A junior engineer typically explains deadlocks with the textbook two-transactions-locking-two-rows example and stops there. For a senior or architect role, the interviewer wants you to explain InnoDB's actual victim-selection heuristic (smaller rollback cost, not arrival order) and, more importantly, to know that REPEATABLE READ's gap locks and next-key locks can cause deadlocks between transactions that never explicitly disagree on row access order from the application's perspective. Mentioning that the fix for gap-lock-driven deadlocks is often a covering index or switching to READ COMMITTED — rather than reordering application code that was never actually the problem — is what separates someone who has debugged a real production deadlock storm from someone reciting the concept.
◈ Architecture Diagram
Txn A: locks row1 ──waits for──▶ row2 (held by B)
Txn B: locks row2 ──waits for──▶ row1 (held by A)
└──────── cycle detected ────────┘
InnoDB kills smaller-cost txn💬 Comments
Quick Answer
MyISAM tables survive in production mostly through inherited legacy schemas, tables created explicitly with ENGINE=MyISAM by an old migration script, or MySQL system tables in very old versions — and the failure mode is that MyISAM has no crash recovery and no transaction support, so a MyISAM table can silently end up corrupted or partially written after an unclean shutdown in a way InnoDB's redo log would have prevented outright.
Detailed Answer
Think of InnoDB like a bank that keeps a detailed transaction journal of every step of every transfer, so that if the power goes out mid-transfer, the bank can replay the journal on restart and know exactly which transfers were fully completed, which were only half-done and must be reversed, and which never started. MyISAM is like a bank that just directly edits the ledger in place with no journal — if the power goes out mid-edit, there's no record of what state the edit was in, and the ledger page itself might be left half-written or corrupted with no way to automatically detect or repair it.
InnoDB became the default engine in MySQL 5.5 specifically because it provides ACID transactions (Atomicity, Consistency, Isolation, Durability — the guarantees that a transaction either fully happens or not at all, and survives a crash), row-level locking for concurrency, and crash recovery via a redo log and doublewrite buffer that let it detect and repair incomplete writes on restart. MyISAM predates all of this: it uses table-level locking (an entire table is locked for any write, killing concurrency under write-heavy load), has no transaction support at all, and has no crash recovery mechanism — a MyISAM table that was mid-write during a crash can come back corrupted, requiring a manual REPAIR TABLE that itself can lose data.
MyISAM tables persist in real environments for a few specific, recurring reasons: schemas migrated from very old MySQL installations that were never explicitly converted; ORM or migration tooling that hardcoded ENGINE=MyISAM years ago and nobody revisited; and historically, MyISAM's fulltext search support being a reason teams chose it before InnoDB gained fulltext indexing in 5.6. The danger is that a table's engine is easy to overlook in day-to-day work — SELECT and INSERT statements behave identically regardless of engine until something goes wrong, so a MyISAM table can sit unnoticed in a schema for years until the exact moment it matters most: during a crash, a failover, or a concurrency spike.
In production, the actual incident pattern looks like this: a server experiences an unclean shutdown (OOM kill, power loss, forced restart), and on restart, InnoDB tables recover automatically and transparently via redo log replay — completely invisible to the team, no action needed. MyISAM tables in the same crash come back marked as crashed or, worse, silently return incorrect results without being marked as crashed at all, and the team only discovers it when a report's numbers look wrong or a query errors out days later. Engineers audit engine usage across a schema periodically with information_schema.TABLES specifically because engine choice is invisible in normal query behavior and only surfaces at the worst possible moment.
The gotcha that catches teams doing an ENGINE=InnoDB conversion under time pressure: ALTER TABLE ... ENGINE=InnoDB on a large MyISAM table is not a quick metadata change — it's a full table rebuild that copies every row into a new InnoDB table under the hood, which can take hours on a large table, hold significant locks depending on MySQL version and settings, and requires enough free disk space to hold both the old and new copies of the table simultaneously mid-conversion. Teams that discover a critical MyISAM table during an incident and try to 'just convert it real quick' as part of the fix often turn a data-integrity incident into a second, self-inflicted availability incident because the conversion itself locks or slows the table for far longer than expected.
Code Example
# Audit every non-InnoDB table across all schemas — the first step before any incident, not during one
SELECT TABLE_SCHEMA, TABLE_NAME, ENGINE
FROM information_schema.TABLES
WHERE ENGINE != 'InnoDB' AND TABLE_SCHEMA NOT IN ('mysql','information_schema','performance_schema');
# Convert a legacy MyISAM table to InnoDB (plan for a maintenance window on large tables)
ALTER TABLE legacy_orders ENGINE = InnoDB;
# Check free disk space before converting — the operation needs room for both copies mid-rebuild
SHOW TABLE STATUS LIKE 'legacy_orders';
# For a zero-downtime conversion on a large table, use gh-ost instead of a direct ALTER
gh-ost --host=db.prod --database=shop --table=legacy_orders --alter="ENGINE=InnoDB" --executeInterview Tip
A junior engineer typically states 'InnoDB is the default, always use it' and moves on. For a senior or architect role, the interviewer wants to hear how MyISAM actually survives in real schemas — legacy migrations, old ORM defaults, forgotten fulltext-search tables — and precisely what fails: no crash recovery means a MyISAM table can come back corrupted or silently wrong after an unclean shutdown, while InnoDB tables in the same crash recover transparently via redo log replay. The senior-level insight is knowing that converting a large MyISAM table under incident pressure is itself risky, since ALTER TABLE ENGINE=InnoDB is a full table rebuild, not a metadata flip, and should be done proactively with gh-ost or during a planned window, not reactively mid-incident.
◈ Architecture Diagram
MyISAM: table-level lock, no journal InnoDB: row-level lock, redo log ┌──────────────┐ crash → ✗ corrupt ┌──────────────┐ crash → ✓ auto-recovered │ Whole table │ needs REPAIR TABLE │ Redo log │ replayed on restart │ locked on write│ │ + doublewrite │ └──────────────┘ └──────────────┘
💬 Comments
Quick Answer
gh-ost creates a shadow copy of the table, backfills existing rows in throttled batches, and captures ongoing production changes by tailing the binary log (rather than using triggers like older tools), then does a brief atomic rename to swap the shadow table in — all without holding a long-lived lock on the original table. If the migration's binlog-tailing falls behind the rate of production writes, the cutover keeps getting deferred indefinitely, and in the worst case the shadow table grows more stale relative to production the longer the migration runs, risking a cutover that briefly applies changes out of order if not handled carefully.
Detailed Answer
Imagine renovating a busy retail store's checkout counter without ever closing the store. Instead of shutting the doors, you build an entire new counter in the back room, copy over the existing till and inventory records at your own pace, and meanwhile keep a running log of every single sale still happening at the old counter out front. Only at the very last moment — once the back-room counter has caught up completely — do you do a fast, five-second swap of the signage so customers now walk to the new counter, which by then already reflects every sale that happened while you were building it.
A direct ALTER TABLE in MySQL, for most schema changes, requires rebuilding the table and until relatively recent versions and instant-DDL-eligible changes, holds a metadata lock that blocks reads and writes for the duration — on a multi-billion-row table this can mean hours of full unavailability, which is unacceptable for a production system. gh-ost (and its predecessor pt-online-schema-change) were built specifically to avoid this by never touching the original table's structure directly: they create a new table with the desired schema, copy rows across in small batches to avoid overloading the source, and separately capture every write happening to the original table during the copy so nothing is lost.
gh-ost's key internal difference from pt-online-schema-change is how it captures ongoing changes: pt-osc uses triggers on the original table (extra code that fires on every INSERT/UPDATE/DELETE to also apply the change to the shadow table), which adds write overhead and risk directly on the production table's hot path. gh-ost instead connects to the source as if it were a replica and tails the binary log asynchronously, parsing row-based binlog events and applying them to the shadow table — this means zero triggers and zero added overhead on the actual production write path, since the binlog stream would be produced regardless of whether gh-ost is watching it. Once the shadow table's backfill is complete and its binlog-derived state is caught up to nearly real-time, gh-ost performs the cutover: a brief table rename swap, typically locking for well under a second.
In production, the critical operational parameter is throttling: gh-ost continuously monitors the source's replication lag and load, and deliberately slows or pauses its own batch copying if it detects the source struggling, specifically to avoid the migration itself becoming the cause of a production incident. Engineers monitor the migration's own progress percentage, its current copy throughput, and the replica lag it's inducing, and set explicit throttle flags (max load thresholds on metrics like Threads_running) so the tool self-regulates rather than needing constant manual babysitting during a migration that might run for many hours on a huge table.
The actual failure mode when a migration falls behind production write volume isn't data loss — gh-ost's binlog-tailing approach means it will eventually catch up as long as the binlog retention window is long enough — but if production write volume sustained during the migration exceeds gh-ost's replay throughput indefinitely, the cutover point simply never arrives, and the migration can run for days without completing, all while consuming binlog disk space on the source and leaving the schema change in permanent limbo. The gotcha experienced engineers watch for is exactly this scenario during a traffic spike or a concurrent bulk-import job: a migration that was on track to finish in 3 hours can effectively stall indefinitely if a separate high-volume batch job starts writing to the same table mid-migration, and the correct response is to pause the competing batch job or the migration itself, not to disable gh-ost's throttling to force it through, which would risk exactly the production impact the whole tool exists to avoid.
Code Example
# Run gh-ost with throttling and a dry run first to validate the migration plan gh-ost \ --host=db-primary.prod.internal \ --database=payments \ --table=orders \ --alter="ADD COLUMN shipped_at DATETIME NULL, ADD INDEX idx_shipped_at (shipped_at)" \ --max-load=Threads_running=25 \ --critical-load=Threads_running=50 \ --chunk-size=1000 \ --dry-run # Execute for real once the dry run looks clean gh-ost \ --host=db-primary.prod.internal \ --database=payments \ --table=orders \ --alter="ADD COLUMN shipped_at DATETIME NULL, ADD INDEX idx_shipped_at (shipped_at)" \ --max-load=Threads_running=25 \ --execute # Monitor migration progress and induced replication lag in real time # gh-ost prints progress percentage and ETA to stdout / the specified log file
Interview Tip
A junior engineer typically says 'gh-ost avoids locking the table, so it's safe.' For an architect role, the interviewer wants you to explain the actual mechanism — binlog tailing instead of triggers, throttled batch copying, and a brief final rename — and to reason about the failure mode when production write volume outpaces the migration's replay throughput: it doesn't lose data, but the cutover can be indefinitely deferred, and running migrations can pile up next to concurrent bulk jobs and create resource contention that requires an operational decision (pause the batch job, not force the cutover) rather than a purely technical one. Comparing gh-ost's binlog approach against pt-osc's trigger-based approach, and explaining why triggers add overhead directly to the production write path, is what shows real hands-on migration experience.
◈ Architecture Diagram
Original table ──writes──▶ binlog ──tailed by gh-ost──▶ Shadow table
│ │
│ batched backfill of existing rows ───────▶│
│ │
└──── brief atomic RENAME swap (<1s) once caught up ─────┘💬 Comments
Context
A marketplace whose largest tables (orders, order_items — low billions of rows) had made schema evolution effectively frozen: the last direct ALTER had locked writes long enough to become a Sev-1, so teams designed around missing columns with JSON blobs and shadow tables.
Problem
Schema fear was warping the data model (JSON-blob workarounds defeating indexes), each team hand-ran its own migration tooling with inconsistent safety habits, and the DBA pair was the bottleneck approving every change by folklore.
Solution
Built a migration service around gh-ost: developers submit migrations as PRs (SQL + intent metadata); CI validates (dry-run against a production-restored replica, duration estimate, disk headroom check — gh-ost needs table-size × ~1.2 free); execution runs through a scheduler that serializes migrations per cluster, runs them off-peak with throttling wired to replication lag and threads_running, posts progress to the team channel, and holds the cut-over for a human ack on tables above a size threshold. Postponed cut-over (--postpone-cut-over-flag-file) makes the final atomic swap a deliberate, low-traffic-window action.
Commands
gh-ost --alter='ADD COLUMN fulfillment_tier TINYINT NOT NULL DEFAULT 0' --database=shop --table=orders --max-lag-millis=1500 --critical-load=Threads_running=200 --postpone-cut-over-flag-file=/tmp/ghost.postpone --execute
CI: restore-replica dry-run -> estimated 14h copy, 190GB headroom ✓
cutover: rm /tmp/ghost.postpone # during the agreed window
Outcome
34 large-table migrations in the first year with zero write-lock incidents; median developer wait went from 'quarterly negotiation with DBAs' to a self-service pipeline with a scheduled slot; two JSON-blob workarounds were unwound into real columns. The replica dry-run caught one migration that would have violated a foreign-key assumption before it touched production.
Lessons Learned
The social change outran the tool: making migrations boring restored honest data modeling. Disk headroom is the operational gotcha teams forget — the shadow table is a full copy, and two concurrent large migrations on one cluster is how you discover that at 90% disk.
💬 Comments
Context
A content platform on a single MySQL primary whose read traffic grew 10x in a year (new discovery features); the primary sat at 85% CPU serving 92% reads, and the roadmap said another 3x was coming.
Problem
Sharding was the reflexive proposal but a poor fit (queries span the content graph); the app had no read/write routing (everything hit the primary); and naive replica reads risked stale-read bugs in flows that read-after-write (a user edits, then immediately views).
Solution
Introduced ProxySQL between the app and MySQL: query rules route SELECTs to a replica pool (three replicas, weighted, health-checked with automatic ejection on lag beyond threshold) while writes and transactions pin to the primary; read-after-write correctness handled by session-consistency rules — flows flagged as consistency-critical (post-write reads) route to the primary via query annotations, and GTID-based causal reads were adopted where the driver supported them. Replica lag monitoring gates the pool: a lagging replica drains instead of serving stale content.
Commands
proxysql: INSERT INTO mysql_query_rules (rule_id, match_pattern, destination_hostgroup) VALUES (10, '^SELECT.*', 2); -- reads -> replica HG
annotation rule: /* consistency:primary */ SELECT ... -> hostgroup 1
monitor: max_replication_lag=5 per replica in mysql_servers — auto-eject on breach
Outcome
Primary CPU fell from 85% to 30% (writes plus consistency-critical reads only); read capacity now scales horizontally by adding replicas (validated to the projected 3x in a load test); stale-read bug count from the migration: one (a flow missed the annotation audit, caught in canary). Failover complexity improved too — ProxySQL redirects on topology change faster than the app's old DNS-based config.
Lessons Learned
The read-after-write audit was the real migration work — routing is easy, consistency semantics are the project. Auto-ejecting lagged replicas turned replication lag from a correctness bug into a capacity blip.
💬 Comments
Symptom
Application returns 502s. MySQL shows max_connections reached.
Error Message
ERROR 1040: Too many connections
Root Cause
Slow query from missing index held connections 30+ seconds. Pool filled up, new requests refused.
Diagnosis Steps
Solution
Killed long queries, added missing index, increased max_connections, configured pool timeouts.
Commands
SHOW FULL PROCESSLIST;
KILL <id>;
Prevention
Add indexes. Set pool max-wait timeout. Monitor connections. Alert at 80%.
💬 Comments
Symptom
Instance-wide query latency degrades over an afternoon — every query, every table, gradually slower; history list length grows unbounded; disk usage climbs in the system tablespace. No deploy, no traffic change. Eventually even primary-key lookups take hundreds of milliseconds.
Error Message
No error. SHOW ENGINE INNODB STATUS: 'History list length 47,882,190' and climbing; a session in SHOW PROCESSLIST idle in transaction for 4+ hours — an analyst's screen session running a data-fix script that opened a transaction, hit a prompt, and waited for input nobody was giving.
Root Cause
The abandoned open transaction pinned the purge horizon: InnoDB's MVCC must retain every undo record newer than the oldest active read view, so hours of the instance's write history accumulated as undo pages. Every consistent read then had to walk increasingly-long version chains (hence the universal slowdown), purge fell hopelessly behind, and undo growth inflated the system tablespace — which does not shrink back without a rebuild on older configurations.
Diagnosis Steps
Solution
Killed the offending session (the script was restartable); purge began draining the history list over the following hours and latency normalized. Follow-ups: connection-class timeouts so interactive/script sessions can't hold transactions indefinitely (wait_timeout tightened per user class, plus an event killing transactions idle beyond 15 minutes with allowlisted exceptions), history-list-length alerting, and undo tablespaces configured as separate + truncatable so a future event doesn't permanently inflate storage.
Commands
SELECT trx_id, trx_started, trx_mysql_thread_id FROM information_schema.innodb_trx ORDER BY trx_started LIMIT 5;
SHOW ENGINE INNODB STATUS\G # History list length
alert: innodb_history_list_length > 1e6 (warn) / 1e7 (page); oldest_trx_age > 900s
Prevention
Long-running transactions are instance-wide hazards, not session-local ones: alert on history list length and oldest-transaction age; enforce idle-transaction timeouts by user class; and teach the pattern — the danger isn't the script's queries, it's the transaction it forgot to close. Batch data-fixes commit in chunks, always.
💬 Comments
Symptom
The replica's SQL thread stops: replication halted with a duplicate-entry error on an INSERT the primary executed hours earlier. Read traffic silently degrades to stale (the app's routing keeps serving the frozen replica); the lag alert fires only when a downstream consistency check trips — the replica reports 'lag' as null while broken, not as a growing number.
Error Message
Last_SQL_Error: Error 'Duplicate entry '88231' for key 'orders.PRIMARY'' on query. Worker 3 failed executing transaction 'aaaa-bbbb:882931' — Slave_SQL_Running: No.
Root Cause
Weeks earlier, an engineer had 'fixed' a data discrepancy by inserting a missing row directly on the replica (read_only was set, but their account had SUPER, which bypasses it). The primary eventually inserted the same logical row through the normal path; when that event replicated, the row already existed and the SQL thread stopped. Two failures compounded: writable-in-practice replicas (SUPER bypass), and lag monitoring that treated a broken SQL thread as 'no data' instead of 'critical'.
Diagnosis Steps
Solution
Compared the conflicting row against the primary's version (identical content — the manual fix had anticipated the real insert), skipped the transaction with the GTID-aware method (inject empty transaction for that GTID), and confirmed downstream consistency with a table checksum. Hardening: super_read_only=ON on all replicas (closes the SUPER bypass), replication monitoring rewritten to page on Slave_SQL_Running=No / SQL-thread errors explicitly (not just lag thresholds), and a standing rule that data fixes go through the primary, always, with the incident as the training example.
Commands
SHOW REPLICA STATUS\G # Last_SQL_Error, Retrieved vs Executed GTID sets
SET GTID_NEXT='aaaa-bbbb:882931'; BEGIN; COMMIT; SET GTID_NEXT='AUTOMATIC'; # skip via empty trx
SET GLOBAL super_read_only = ON; # every replica, permanently
Prevention
super_read_only on every replica, no exceptions — read_only alone is a suggestion to privileged accounts. Monitor replication state (threads running, error fields), not only lag numbers: a stopped SQL thread often reports no lag at all. Periodic checksum verification (pt-table-checksum-style) catches silent drift before it becomes a 2am stop.
💬 Comments