A MySQL server that spent half its time waiting for locks

By Krzysztof Książek Case study

Anonymised customer engagement. A real audit, written up with the client's name, people, hostnames, IP addresses, account names and personal data removed or generalised. Measurements are quoted as they were collected during the audit. The engagement ended with a written report.

Client
Online platform with user balances, ad campaigns, payments and a forum
Database
MySQL 5.6 Community Server on a single physical server (2 × 6-core Xeon, 64 GB RAM, SSDs in RAID 10 behind a battery-backed write-back cache), no replica
Question
Why the server is slow, and what to change first in the OS, MySQL configuration and schema
Evidence
OS and MySQL summaries, vmstat samples, global status counters, a 52-minute log of all queries (7.68 million statements), and information_schema.innodb_trx and InnoDB status output captured under load
Deliverable
Written audit report with 22 prioritised recommendations

A busy web platform — user accounts and balances, paid advertising campaigns, online payments and a community forum — ran on one MySQL server that was close to saturation. The CPU was nearly fully used and 150 queries were running at once. Indexes were missing in places, but the main finding was different: the database spent almost half of all query time waiting for row locks, and two statements were responsible for holding them.

Table and column names in the examples below are renamed to describe what the tables hold.

The starting point

The server was not short of disk or memory throughput. It was short of CPU, and most of that CPU went to queries that could not make progress.

  • Load average ~24 on 24 hardware threads, with 85–93% user CPU in vmstat.
  • 150 of 175 connections executing a query at the moment of capture.
  • 128× average query concurrency in the 52-minute log.
  • 49% of all query execution time was spent waiting for locks.

The process list was unusual. Instead of a few active queries and many idle connections, nearly every connection was busy: about 40 sending data, 30 creating a sort index, 65 updating and 25 waiting for a table-level lock. Disk I/O was light, swap was not in use and the network showed no errors.

The status counters filled in the rest. The server ran about 4,000 statements per second on average: 1,500 SELECT, 400 UPDATE, 250 INSERT and 30 DELETE. Most row reads came from full table scans (about 3 million rows per second) and index scans (about 1.75 million per second). InnoDB recorded about 25 row-lock waits per second.

Finding 1: two statements locked most of the data

The first ten queries ranked by total time looked strange at first. The top one was an UPDATE of a single row by primary key: median 1 ms, maximum 85 s. It accounted for 24.6% of all execution time, and nearly all of that was lock time — 49% of all lock waiting in the log. A DELETE by primary key, two counter updates on a daily statistics row and a forum view counter showed the same pattern: efficient queries that spent seconds, sometimes nearly a minute, waiting for a lock.

Those queries were the victims. To find what was holding the locks, the audit used the list of open InnoDB transactions captured under load. Almost every transaction that changed data was in LOCK WAIT. Only two data-changing statements were actually running.

A payment confirmation that scanned the whole purchases table

When a payment came back from the payment provider, the application marked the purchase as paid with a statement like this one (renamed and shortened):

UPDATE purchases
   SET status = 'ok', account = '…', details = '…', reference = '…'
 WHERE token = '…';

There was no index on token. MySQL scanned the whole table, about 764,000 rows, and under the default REPEATABLE READ isolation level an UPDATE locks every row it scans, not only the one it changes. One such transaction held locks on 86,000 rows when it was captured. These statements took up to 172 s, and for that long every other write to the purchases table had to wait.

The fix was an index:

ALTER TABLE purchases ADD KEY idx_token (token(30));

An INSERT … SELECT inside a stored procedure

The second statement ran inside a stored procedure called several times a second. It collected the IDs of ad assignments that had expired or should be recycled and wrote them to a work table with INSERT … SELECT over a three-part UNION. The plan estimated about 1.8 million rows in the derived result, and the captured transaction held locks on 822,000 rows in the assignments and campaigns tables.

A plain SELECT reads a snapshot and takes no locks. An INSERT … SELECT under REPEATABLE READ takes shared locks on every row it reads, so a slow query becomes a slow query that also blocks writers. No better index was found for this query. The recommendation was to split it: run the SELECT first, keep the IDs in the application, then INSERT them. The read stays slow but no longer blocks anything. Because the statement lived in a stored procedure, this meant moving part of the logic into the application.

Locks also spread from these tables to others. One multi-table DELETE from the assignments table and its history table had been waiting 11 s for a lock held by the batch.

Finding 2: missing indexes on read queries

Ranked by rows examined, the log showed a second group of problems: read queries that did far more work than their results required.

Scroll horizontally to see all columns when needed.

QueryEvidenceRecommendation
Single campaign details, a six-table join with LIMIT 114.1% of all execution time; average 43 s, maximum 137 s. Three joined tables had no index on the join column; the plan scanned about 800,000 purchase rows through a block nested loop.Indexes on the join columns of the purchases, payment-method and campaign-type tables
Balance history over a date rangeUp to 380 s per run; 88.5 million rows examined to return as few as 281. A similar daily sum examined 9.7 million rows.Composite index on (description, timestamp), which serves both queries
“Recent winners” feed, a UNION ALL of eight tables sorted for the latest 109–66 s per run; 4.6 million rows examined to return 10Test indexes on the filter column of three of the source tables
Latest forum topicsAverage 8 s; about 297,000 rows examined to return 20. Sorting and grouping happened outside a derived table that held everything.Rewrite so aggregation, sorting and LIMIT happen inside the subquery, or drop the subquery
Random active campaign, ORDER BY random LIMIT 1About 117,000 rows examined and a filesort for one row, almost two calls a secondComposite index on (campaign_status, campaign_type_id, random) to filter and sort in one pass
Per-user purchase totalsUp to 764,000 rows examined per call, no index usedComposite indexes on (user_id, status, reference) and on the columns of the date-range variant
Per-cycle earnings summary with four correlated subqueriesUp to 2.6 million rows examined per runComposite index on (type, status, cycle_id, user_id)
Count of active usersAbout 376,000 rows examined per call, more than once a secondAn index on active only if the flag is selective; check the data distribution first

Slow reads matter more on a server with lock problems. Every second a query spends scanning keeps a CPU thread busy that the waiting writers will need when their locks are released.

Finding 3: configuration

Several things were already right and were left alone: a physical server without virtualisation overhead, RAID 10 with a battery-backed write-back cache, O_DIRECT, file-per-table, full durability (innodb_flush_log_at_trx_commit=1, doublewrite, checksums), adequately sized redo logs and table cache, and no accounts without passwords.

Scroll horizontally to see all columns when needed.

AreaObservedRecommendation
Query cacheOn, 256 MB, about 1,000 hits/s. In MySQL 5.6 one thread at a time can read or change it, so at this concurrency it serialises queries.Disable it; cache in the application with Memcached or Redis
Buffer pool45 GB in 2 instances on a 64 GB serverUse 8 instances to reduce contention; grow by 3–4 GB only after reviewing several weeks of memory use
Adaptive hash indexMost semaphore waits were in its code, but it served about 199,000 lookups/s against 38,000 that did not use itKeep it on; if upgrading, consider a server build that partitions it
Background I/Oinnodb_io_capacity 2,000 and innodb_lru_scan_depth 2,000 per buffer-pool instance; checkpoint age only 21 MBLower I/O capacity to 800 and LRU scan depth to 400 with 8 instances, so InnoDB flushes less aggressively
ConnectionsAbout 250 new connections and 8 new threads per second; about 400 SET statements per second, probably from the connectorDouble thread_cache_size; find out which SET statements the application really needs
Slow query logNot permanently enabledEnable with long_query_time = 1
Temporary tablesAbout 45 on-disk temporary tables per second; in-memory limit 16 MBRaise the limit only after checking which queries create them
MyISAMSome key-cache traffic, so MyISAM tables were in useConvert them to InnoDB if they are used in production

The operating system needed small changes: the I/O scheduler from cfq to noop (the RAID controller does its own ordering), swappiness from 60 to 1, and a test of whether controller read-ahead helps on SSDs. Both network ports were 10 GbE cards, but one had negotiated 100 Mb/s and the other 1 Gb/s. The report suggested bringing both to the same speed and bonding them.

Finding 4: no second server

The database ran on a single server with no replica. Any hardware fault, or any maintenance needing a restart or a long schema change on a large table, meant downtime. The report recommended setting up at least one replica, both for failover and to move some read traffic off the primary.

It also noted the cost of keeping business logic in stored procedures. MySQL had little tooling for profiling them, and as the INSERT … SELECT showed, logic inside the database limits how a problem can be fixed. Logic that can live in the application usually should.

The recommendations, in order

  1. Remove the lock bottleneck: index the payment token, and split the INSERT … SELECT into a read and a write, moving that step into the application.
  2. Add the missing join indexes on the campaign-details query.
  3. Rewrite the latest-forum-topics query.
  4. Add the composite indexes for the campaign picker, purchase totals, balance history, winners feed and earnings summary, testing each on a copy first.
  5. Disable the query cache and double the thread cache.
  6. Enable the slow query log at 1 s.
  7. Move to 8 buffer-pool instances and lower background I/O settings at the next planned restart.
  8. Add a replica.
  9. Review connector SET statements, network bonding, the I/O scheduler, swappiness, controller read-ahead and the remaining MyISAM tables.

None of the restart-requiring changes was urgent enough to justify an unplanned restart, and every index was to be tested before production.

What this case shows

The most expensive queries in the log were not the ones to fix. They were fast statements made slow by waiting. Ranking the log by total time pointed at the victims; the list of open transactions showed who was holding the locks. Two statements were responsible: one missing index, and one INSERT … SELECT whose read locked everything it touched.

The audit also says what it could not show. Locking was the bottleneck at the time of the audit, and fixing it was expected to remove most of the waiting. Once it was gone, the next bottleneck would only be visible in a new round of measurements.

More case studies