Database NotesRohit’s interview study guide
Chapter 10

Transactions and Concurrency

What ACID really guarantees in InnoDB, how isolation levels, MVCC snapshots and row, gap and next-key locks work, and how to handle deadlocks, job queues and lost updates — every example run in two live sessions.

11 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Transactions and ACID

Added

A transaction is a group of statements that the database treats as one unit: either all of their changes happen, or none do. The classic example is a bank transfer — subtract 200 from Rohit, add 200 to Maya. If the server crashes or the second statement fails between the two, you must never end up with the money gone from one account and not arrived in the other.

ACID names the four guarantees, and in InnoDB each one is backed by a concrete mechanism:

LetterGuaranteeHow InnoDB provides it
AtomicityAll of the transaction's changes or noneThe undo log: every change records how to reverse it; ROLLBACK (or crash recovery) applies those records
ConsistencyA transaction moves the data from one valid state to anotherConstraints the database checks (PRIMARY KEY, UNIQUE, FOREIGN KEY, CHECK, NOT NULL) plus the rules your code keeps inside transactions
IsolationConcurrent transactions don't see each other's half-finished workMVCC snapshots for plain reads and locks for writes and locking reads, tuned by the isolation level
DurabilityOnce COMMIT returns, the change survives a crashThe redo log is flushed to disk at commit (innodb_flush_log_at_trx_commit = 1), and the doublewrite buffer protects against torn pages

The mental model: a transaction is a shopping basket at a till. You can fill it, change your mind and put things back; nothing leaves the shop until you pay (COMMIT), and if you walk away (ROLLBACK, disconnect, crash) everything goes back on the shelves.

A transfer that works, and one that breaks a rule halfway through:

SQL
START TRANSACTION;
UPDATE accounts SET balance = balance - 200 WHERE id = 1;
UPDATE accounts SET balance = balance + 200 WHERE id = 2;
COMMIT;
SELECT * FROM accounts;
START TRANSACTION;
UPDATE accounts SET balance = balance + 1000 WHERE id = 3;
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
SELECT * FROM accounts;
ROLLBACK;
SELECT * FROM accounts;
Result
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.000 sec)
mysql> UPDATE accounts SET balance = balance - 200 WHERE id = 1;
Query OK, 1 row affected (0.000 sec)
Rows matched: 1  Changed: 1  Warnings: 0
mysql> UPDATE accounts SET balance = balance + 200 WHERE id = 2;
Query OK, 1 row affected (0.000 sec)
Rows matched: 1  Changed: 1  Warnings: 0
mysql> COMMIT;
Query OK, 0 rows affected (0.004 sec)
mysql> SELECT * FROM accounts;
+----+-------+---------+
| id | owner | balance |
+----+-------+---------+
|  1 | Rohit |  300.00 |
|  2 | Maya  |  500.00 |
|  3 | Liam  |  100.00 |
+----+-------+---------+
3 rows in set (0.000 sec)
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.000 sec)
mysql> UPDATE accounts SET balance = balance + 1000 WHERE id = 3;
Query OK, 1 row affected (0.001 sec)
Rows matched: 1  Changed: 1  Warnings: 0
mysql> UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
ERROR 3819 (HY000): Check constraint 'balance_not_negative' is violated.
mysql> SELECT * FROM accounts;
+----+-------+---------+
| id | owner | balance |
+----+-------+---------+
|  1 | Rohit |  300.00 |
|  2 | Maya  |  500.00 |
|  3 | Liam  | 1100.00 |
+----+-------+---------+
3 rows in set (0.000 sec)
mysql> ROLLBACK;
Query OK, 0 rows affected (0.003 sec)
mysql> SELECT * FROM accounts;
+----+-------+---------+
| id | owner | balance |
+----+-------+---------+
|  1 | Rohit |  300.00 |
|  2 | Maya  |  500.00 |
|  3 | Liam  |  100.00 |
+----+-------+---------+
3 rows in set (0.000 sec)
What’s happening
  1. START TRANSACTION opens a transaction (BEGIN is a synonym). The first UPDATE takes 200 from Rohit, the second gives it to Maya; neither change is visible to other sessions yet.
  2. COMMIT makes both changes permanent and visible at the same moment: 300 and 500. That's atomicity and durability in the happy path — and the total money (900) is unchanged, the invariant the application cares about.
  3. The second transaction gives Liam 1000 first, then tries to take 1000 from Rohit, who only has 300. The CHECK (balance >= 0) constraint rejects that statement: ERROR 3819 (HY000): Check constraint 'balance_not_negative' is violated.
  4. The important part: the SELECT straight after the error shows Liam at 1100. In MySQL an error rolls back only the failing statement, not the transaction. The transaction is still open, holding Liam's half of the transfer.
  5. ROLLBACK undoes everything since START TRANSACTION using the undo log, and the table is back to 300 / 500 / 100. Application code must do this itself: catch the error, roll back, and only then report failure.
AdvancedPitfall: committing after a failed statement
AdvancedWhat durability depends on

Durability is a setting, not a law of nature. These are the values on the sandbox, which are MySQL's defaults:

SQL
SELECT @@innodb_flush_log_at_trx_commit AS flush_log_at_commit, @@sync_binlog AS sync_binlog,
       @@innodb_doublewrite AS doublewrite, @@log_bin AS binlog;
Result
+---------------------+-------------+-------------+--------+
| flush_log_at_commit | sync_binlog | doublewrite | binlog |
+---------------------+-------------+-------------+--------+
|                   1 |           1 | ON          |      1 |
+---------------------+-------------+-------------+--------+
What’s happening
  1. innodb_flush_log_at_trx_commit = 1: every COMMIT writes and fsyncs the redo log before returning. With 2 the log is written at commit but fsynced about once a second, and with 0 both happen about once a second — faster, but a crash (or, with 2, an operating-system crash) can lose up to about a second of committed transactions.
  2. sync_binlog = 1: the binary log (used for replication and point-in-time recovery) is also fsynced at every commit, so the binlog and InnoDB agree after a crash. Together, 1 and 1 are the "fully durable" setting.
  3. innodb_doublewrite = ON: pages are first written to a doublewrite area, so a crash in the middle of writing a 16 KB page can't leave a half-old, half-new page that the redo log can't repair.
  4. The fsync at commit is the main cost of a write transaction. That's why one transaction with 1,000 inserts is far faster than 1,000 autocommitted inserts — later in this chapter 20,000 one-row autocommit updates took about 20 seconds.

#COMMIT, ROLLBACK and SAVEPOINT

Added

COMMIT ends a transaction and makes its changes permanent and visible. ROLLBACK ends it and undoes every change. A savepoint is a named marker inside a transaction: ROLLBACK TO SAVEPOINT name undoes only the work done after the marker and leaves the transaction open, so you can carry on and still commit the earlier part.

Mental model: a savepoint is a save game before the boss fight. If the fight goes badly you reload the save, not the whole game. ORMs use exactly this to implement "nested transactions": an inner transaction() call inside an outer one becomes a SAVEPOINT, and an inner failure becomes ROLLBACK TO SAVEPOINT.

An order for Zara (customer 6) with an optional extra item that we decide not to keep:

SQL
START TRANSACTION;
INSERT INTO orders (id, customer_id, status, ordered_at) VALUES (9, 6, 'pending', '2025-04-10 10:00:00');
INSERT INTO order_items VALUES (9, 3, 1, 799.00);
SAVEPOINT before_extras;
INSERT INTO order_items VALUES (9, 7, 2, 29.00);
UPDATE products SET stock = stock - 2 WHERE id = 7;
SELECT order_id, product_id, quantity FROM order_items WHERE order_id = 9;
ROLLBACK TO SAVEPOINT before_extras;
SELECT order_id, product_id, quantity FROM order_items WHERE order_id = 9;
SELECT stock FROM products WHERE id = 7;
COMMIT;
ROLLBACK TO SAVEPOINT before_extras;
SELECT o.id, o.status, i.product_id, i.quantity FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.id = 9;
Result
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.000 sec)
mysql> INSERT INTO orders (id, customer_id, status, ordered_at) VALUES (9, 6, 'pending', '2025-04-10 10:00:00');
Query OK, 1 row affected (0.001 sec)
mysql> INSERT INTO order_items VALUES (9, 3, 1, 799.00);
Query OK, 1 row affected (0.000 sec)
mysql> SAVEPOINT before_extras;
Query OK, 0 rows affected (0.000 sec)
mysql> INSERT INTO order_items VALUES (9, 7, 2, 29.00);
Query OK, 1 row affected (0.001 sec)
mysql> UPDATE products SET stock = stock - 2 WHERE id = 7;
Query OK, 1 row affected (0.001 sec)
Rows matched: 1  Changed: 1  Warnings: 0
mysql> SELECT order_id, product_id, quantity FROM order_items WHERE order_id = 9;
+----------+------------+----------+
| order_id | product_id | quantity |
+----------+------------+----------+
|        9 |          3 |        1 |
|        9 |          7 |        2 |
+----------+------------+----------+
2 rows in set (0.000 sec)
mysql> ROLLBACK TO SAVEPOINT before_extras;
Query OK, 0 rows affected (0.000 sec)
mysql> SELECT order_id, product_id, quantity FROM order_items WHERE order_id = 9;
+----------+------------+----------+
| order_id | product_id | quantity |
+----------+------------+----------+
|        9 |          3 |        1 |
+----------+------------+----------+
1 row in set (0.000 sec)
mysql> SELECT stock FROM products WHERE id = 7;
+-------+
| stock |
+-------+
|   100 |
+-------+
1 row in set (0.000 sec)
mysql> COMMIT;
Query OK, 0 rows affected (0.008 sec)
mysql> ROLLBACK TO SAVEPOINT before_extras;
ERROR 1305 (42000): SAVEPOINT before_extras does not exist
mysql> SELECT o.id, o.status, i.product_id, i.quantity FROM orders o JOIN order_items i ON i.order_id = o.id WHERE o.id = 9;
+----+---------+------------+----------+
| id | status  | product_id | quantity |
+----+---------+------------+----------+
|  9 | pending |          3 |        1 |
+----+---------+------------+----------+
1 row in set (0.000 sec)
What’s happening
  1. The transaction inserts order 9 and its first item (a Pixel 10), then sets the savepoint before_extras.
  2. After the savepoint it adds two USB-C chargers and decrements their stock. Inside the transaction both items are visible.
  3. ROLLBACK TO SAVEPOINT before_extras undoes only the work after the marker: the charger row disappears and the stock of product 7 is back at 100. The order and the Pixel line survive, and the transaction is still open.
  4. COMMIT keeps order 9 with one item. It also deletes every savepoint of the transaction, which is why the second ROLLBACK TO SAVEPOINT before_extras fails: ERROR 1305 (42000): SAVEPOINT before_extras does not exist.
  5. A plain ROLLBACK (without TO) would have discarded the whole order. RELEASE SAVEPOINT name removes a savepoint without rolling anything back; setting a savepoint with an existing name moves it.
AdvancedRolling back to a savepoint keeps the locks

The manual says InnoDB undoes the changes made after a savepoint but does not release the row locks taken after it. performance_schema.data_locks shows that directly:

SQL
START TRANSACTION;
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
SAVEPOINT s1;
UPDATE products SET stock = stock - 1 WHERE id = 7;
SELECT * FROM products WHERE id = 8 FOR UPDATE;
SELECT object_name, index_name, lock_type, lock_mode, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09';
ROLLBACK TO SAVEPOINT s1;
SELECT object_name, index_name, lock_type, lock_mode, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09';
ROLLBACK;
Result
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.000 sec)
mysql> SELECT stock FROM products WHERE id = 1 FOR UPDATE;
+-------+
| stock |
+-------+
|    12 |
+-------+
1 row in set (0.001 sec)
mysql> SAVEPOINT s1;
Query OK, 0 rows affected (0.000 sec)
mysql> UPDATE products SET stock = stock - 1 WHERE id = 7;
Query OK, 1 row affected (0.001 sec)
Rows matched: 1  Changed: 1  Warnings: 0
mysql> SELECT * FROM products WHERE id = 8 FOR UPDATE;
+----+-----------------------------+-------------+--------+-------+
| id | name                        | category_id | price  | stock |
+----+-----------------------------+-------------+--------+-------+
|  8 | Noise-cancelling Headphones |           5 | 249.00 |     8 |
+----+-----------------------------+-------------+--------+-------+
1 row in set (0.001 sec)
mysql> SELECT object_name, index_name, lock_type, lock_mode, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09';
+-------------+------------+-----------+---------------+-----------+
| object_name | index_name | lock_type | lock_mode     | lock_data |
+-------------+------------+-----------+---------------+-----------+
| products    | NULL       | TABLE     | IX            | NULL      |
| products    | PRIMARY    | RECORD    | X,REC_NOT_GAP | 1         |
| products    | PRIMARY    | RECORD    | X,REC_NOT_GAP | 7         |
| products    | PRIMARY    | RECORD    | X,REC_NOT_GAP | 8         |
+-------------+------------+-----------+---------------+-----------+
4 rows in set (0.007 sec)
mysql> ROLLBACK TO SAVEPOINT s1;
Query OK, 0 rows affected (0.000 sec)
mysql> SELECT object_name, index_name, lock_type, lock_mode, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09';
+-------------+------------+-----------+---------------+-----------+
| object_name | index_name | lock_type | lock_mode     | lock_data |
+-------------+------------+-----------+---------------+-----------+
| products    | NULL       | TABLE     | IX            | NULL      |
| products    | PRIMARY    | RECORD    | X,REC_NOT_GAP | 1         |
| products    | PRIMARY    | RECORD    | X,REC_NOT_GAP | 7         |
| products    | PRIMARY    | RECORD    | X,REC_NOT_GAP | 8         |
+-------------+------------+-----------+---------------+-----------+
4 rows in set (0.000 sec)
mysql> ROLLBACK;
Query OK, 0 rows affected (0.002 sec)
What’s happening
  1. Before the savepoint the transaction locks product 1 (FOR UPDATE); after it, it updates product 7 and locks product 8. data_locks shows three record locks (X,REC_NOT_GAP = exclusive lock on just that row) plus the table-level intention lock IX.
  2. After ROLLBACK TO SAVEPOINT s1, the update to product 7 is undone — but the locks on 7 and 8 are still held. Another session that wants product 7 still has to wait until this transaction ends.
  3. So a savepoint is not a way to "give back" rows early. Keep transactions short instead.
AdvancedEdge case: savepoint in an empty transaction

One edge case seen while testing: when the savepoint is set before the transaction has touched anything, rolling back to it released the lock:

SQL
START TRANSACTION;
SAVEPOINT s1;
UPDATE products SET stock = stock - 1 WHERE id = 7;
SELECT index_name, lock_type, lock_mode, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09';
ROLLBACK TO SAVEPOINT s1;
SELECT index_name, lock_type, lock_mode, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09';
SELECT * FROM products WHERE id = 8 FOR UPDATE;
SAVEPOINT s2;
UPDATE products SET stock = stock - 1 WHERE id = 7;
ROLLBACK TO SAVEPOINT s2;
SELECT index_name, lock_type, lock_mode, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09';
ROLLBACK;
Result
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.000 sec)
mysql> SAVEPOINT s1;
Query OK, 0 rows affected (0.000 sec)
mysql> UPDATE products SET stock = stock - 1 WHERE id = 7;
Query OK, 1 row affected (0.001 sec)
Rows matched: 1  Changed: 1  Warnings: 0
mysql> SELECT index_name, lock_type, lock_mode, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09';
+------------+-----------+---------------+-----------+
| index_name | lock_type | lock_mode     | lock_data |
+------------+-----------+---------------+-----------+
| NULL       | TABLE     | IX            | NULL      |
| PRIMARY    | RECORD    | X,REC_NOT_GAP | 7         |
+------------+-----------+---------------+-----------+
2 rows in set (0.001 sec)
mysql> ROLLBACK TO SAVEPOINT s1;
Query OK, 0 rows affected (0.002 sec)
mysql> SELECT index_name, lock_type, lock_mode, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09';
Empty set (0.001 sec)
mysql> SELECT * FROM products WHERE id = 8 FOR UPDATE;
+----+-----------------------------+-------------+--------+-------+
| id | name                        | category_id | price  | stock |
+----+-----------------------------+-------------+--------+-------+
|  8 | Noise-cancelling Headphones |           5 | 249.00 |     8 |
+----+-----------------------------+-------------+--------+-------+
1 row in set (0.000 sec)
mysql> SAVEPOINT s2;
Query OK, 0 rows affected (0.000 sec)
mysql> UPDATE products SET stock = stock - 1 WHERE id = 7;
Query OK, 1 row affected (0.001 sec)
Rows matched: 1  Changed: 1  Warnings: 0
mysql> ROLLBACK TO SAVEPOINT s2;
Query OK, 0 rows affected (0.000 sec)
mysql> SELECT index_name, lock_type, lock_mode, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09';
+------------+-----------+---------------+-----------+
| index_name | lock_type | lock_mode     | lock_data |
+------------+-----------+---------------+-----------+
| NULL       | TABLE     | IX            | NULL      |
| PRIMARY    | RECORD    | X,REC_NOT_GAP | 7         |
| PRIMARY    | RECORD    | X,REC_NOT_GAP | 8         |
+------------+-----------+---------------+-----------+
3 rows in set (0.001 sec)
mysql> ROLLBACK;
Query OK, 0 rows affected (0.002 sec)
What’s happening
  1. s1 is set while the transaction is still empty. After the update of product 7 and ROLLBACK TO SAVEPOINT s1, data_locks is empty — the lock on 7 is gone.
  2. The transaction then locks product 8, sets s2, updates 7 again and rolls back to s2. This time both locks — on 8 (taken before s2) and on 7 (taken after) — stay, as documented.
  3. Don't build on the edge case: the documented behaviour is "locks taken after the savepoint are kept until the transaction ends".
What does the table contain?
SQL
START TRANSACTION;
INSERT INTO accounts VALUES (4, 'Sofia', 50.00);
SAVEPOINT a;
INSERT INTO accounts VALUES (5, 'Noah', 70.00);
SAVEPOINT b;
INSERT INTO accounts VALUES (6, 'Zara', 90.00);
ROLLBACK TO SAVEPOINT a;
INSERT INTO accounts VALUES (7, 'Asha', 30.00);
COMMIT;

Which of the rows 4–7 exist afterwards?

Show answer

Rows 4 and 7.

What’s happening
  1. Row 4 is inserted before savepoint a, so no rollback in this transaction can touch it except a full ROLLBACK.
  2. Rows 5 and 6 were inserted after a; ROLLBACK TO SAVEPOINT a undoes both. It also deletes savepoint b, because b was set after a.
  3. The transaction is still open, so row 7 is inserted normally, and COMMIT keeps 4 and 7.

#Autocommit and implicit commits

Added

With autocommit on — the default (@@autocommit = 1) — every statement that isn't inside an explicit transaction is its own transaction: MySQL commits it as soon as it succeeds. START TRANSACTION (or BEGIN) switches autocommit off until the next COMMIT or ROLLBACK. SET autocommit = 0 switches it off for the whole session: from then on you are always inside a transaction, and nothing is saved until you commit.

On top of that, some statements cause an implicit commit: MySQL commits the current transaction before running them (and usually after). The ones you meet: all DDL (CREATE, ALTER, DROP, RENAME, TRUNCATE TABLE, CREATE INDEX), account statements (CREATE USER, GRANT), LOCK TABLES, and START TRANSACTION / BEGIN itself. Mental model: implicit-commit statements are the till closing for the night — whatever was in your basket gets rung up whether you meant to pay or not.

SQL
SELECT @@autocommit;
START TRANSACTION;
INSERT INTO accounts VALUES (4, 'Sofia', 50.00);
CREATE TABLE audit_log (id INT PRIMARY KEY, note VARCHAR(100));
ROLLBACK;
SELECT * FROM accounts WHERE id = 4;
SHOW TABLES LIKE 'audit_log';
Result
mysql> SELECT @@autocommit;
+--------------+
| @@autocommit |
+--------------+
|            1 |
+--------------+
1 row in set (0.000 sec)
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.000 sec)
mysql> INSERT INTO accounts VALUES (4, 'Sofia', 50.00);
Query OK, 1 row affected (0.001 sec)
mysql> CREATE TABLE audit_log (id INT PRIMARY KEY, note VARCHAR(100));
Query OK, 0 rows affected (0.016 sec)
mysql> ROLLBACK;
Query OK, 0 rows affected (0.000 sec)
mysql> SELECT * FROM accounts WHERE id = 4;
+----+-------+---------+
| id | owner | balance |
+----+-------+---------+
|  4 | Sofia |   50.00 |
+----+-------+---------+
1 row in set (0.001 sec)
mysql> SHOW TABLES LIKE 'audit_log';
+----------------------------+
| Tables_in_ch09 (audit_log) |
+----------------------------+
| audit_log                  |
+----------------------------+
1 row in set (0.003 sec)
What’s happening
  1. Autocommit is on, so START TRANSACTION opens an explicit transaction and the INSERT of Sofia waits inside it, uncommitted.
  2. CREATE TABLE audit_log is DDL, so MySQL commits the open transaction first — Sofia's row is now permanent — and then creates the table.
  3. ROLLBACK has nothing to roll back: there is no transaction any more. It still says Query OK; MySQL doesn't warn you.
  4. Both the row and the table exist. Code that mixes DDL into a transaction (migrations, "create a temp table and fill it") silently loses atomicity. Since MySQL 8.0 each DDL statement is itself atomic and crash-safe, but it still commits whatever came before it. (CREATE TEMPORARY TABLE is the exception: it doesn't cause an implicit commit.)
AdvancedTransactional DDL in PostgreSQL

PostgreSQL treats DDL like any other statement inside a transaction, so a migration can create tables, change columns and fill data, and roll it all back. The same idea in PostgreSQL 18:

SQL
BEGIN;
CREATE TABLE audit_log (id int PRIMARY KEY, note text);
INSERT INTO audit_log VALUES (1, 'created inside a transaction');
ROLLBACK;
SELECT * FROM audit_log;
Result
BEGIN
CREATE TABLE
INSERT 0 1
ROLLBACK
ERROR:  relation "audit_log" does not exist
LINE 1: SELECT * FROM audit_log;
                      ^
What’s happening
  1. The CREATE TABLE and the INSERT both happened inside the transaction.
  2. ROLLBACK undid the table itself, so the SELECT fails: the table never existed as far as anyone else is concerned.
  3. That's why migration tools can wrap each PostgreSQL migration in a transaction, while on MySQL a failed migration can leave half its DDL applied and needs to be written to be re-runnable.
AdvancedThe forgotten COMMIT and the nested START TRANSACTION

Two classic bugs. Session A turns autocommit off, updates a row and then sits idle; later, A runs START TRANSACTION twice:

SQL
-- Session A
SET autocommit = 0;
UPDATE accounts SET balance = balance - 50 WHERE id = 1;
-- Session B
SET SESSION innodb_lock_wait_timeout = 2;
UPDATE accounts SET balance = balance + 10 WHERE id = 1;
SELECT t.trx_mysql_thread_id AS thread, t.trx_state, t.trx_started, t.trx_rows_locked, t.trx_rows_modified, t.trx_query, p.command, p.time FROM information_schema.innodb_trx t JOIN performance_schema.processlist p ON p.id = t.trx_mysql_thread_id WHERE p.db = 'ch09';
-- Session A
COMMIT;
SET autocommit = 1;
START TRANSACTION;
UPDATE accounts SET balance = balance + 1 WHERE id = 3;
START TRANSACTION;
ROLLBACK;
SELECT * FROM accounts WHERE id = 3;
Result
A | mysql> SET autocommit = 0;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> UPDATE accounts SET balance = balance - 50 WHERE id = 1;
A | Query OK, 1 row affected (0.000 sec)
A | Rows matched: 1  Changed: 1  Warnings: 0
B | mysql> SET SESSION innodb_lock_wait_timeout = 2;
B | Query OK, 0 rows affected (0.000 sec)
B | mysql> UPDATE accounts SET balance = balance + 10 WHERE id = 1;
B | ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
B | mysql> SELECT t.trx_mysql_thread_id AS thread, t.trx_state, t.trx_started, t.trx_rows_locked, t.trx_rows_modified, t.trx_query, p.command, p.time FROM information_schema.innodb_trx t JOIN performance_schema.processlist p ON p.id = t.trx_mysql_thread_id WHERE p.db = 'ch09';
B | +--------+-----------+---------------------+-----------------+-------------------+-----------+---------+------+
B | | thread | trx_state | trx_started         | trx_rows_locked | trx_rows_modified | trx_query | command | time |
B | +--------+-----------+---------------------+-----------------+-------------------+-----------+---------+------+
B | |   1113 | RUNNING   | 2026-10-16 16:05:50 |               1 |                 1 | NULL      | Sleep   |    4 |
B | +--------+-----------+---------------------+-----------------+-------------------+-----------+---------+------+
B | 1 row in set (0.001 sec)
A | mysql> COMMIT;
A | Query OK, 0 rows affected (0.003 sec)
A | mysql> SET autocommit = 1;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> START TRANSACTION;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> UPDATE accounts SET balance = balance + 1 WHERE id = 3;
A | Query OK, 1 row affected (0.001 sec)
A | Rows matched: 1  Changed: 1  Warnings: 0
A | mysql> START TRANSACTION;
A | Query OK, 0 rows affected (0.007 sec)
A | mysql> ROLLBACK;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SELECT * FROM accounts WHERE id = 3;
A | +----+-------+---------+
A | | id | owner | balance |
A | +----+-------+---------+
A | |  3 | Liam  |  101.00 |
A | +----+-------+---------+
A | 1 row in set (0.001 sec)
What’s happening
  1. With autocommit = 0, A's UPDATE silently started a transaction that holds an exclusive lock on account 1 until A commits.
  2. B, with its lock wait timeout lowered to 2 s for the demo (the default is 50 s), waits for that lock and gives up: ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction.
  3. information_schema.INNODB_TRX joined with the process list shows the culprit: a RUNNING transaction with 1 row locked and 1 modified, trx_query NULL and command Sleep — an idle connection holding a transaction open. This is the first thing to look for when "everything is waiting" in production; the usual cause is a connection returned to a pool mid-transaction.
  4. Back with autocommit on, A starts a transaction, adds 1 to Liam, and runs START TRANSACTION again. MySQL has no nested transactions: the second START TRANSACTION implicitly commits the first (note its 0.007 s — the fsync of a commit). The ROLLBACK then rolls back an empty transaction, and Liam keeps 101.
  5. Nested transactions in application code need savepoints, which is what ORMs generate for you.

#Isolation levels

Added

When transactions run at the same time, the isolation level decides how much of each other's work they can see. The SQL standard defines four levels by which anomalies they allow (the anomalies are demonstrated in the next topic):

LevelDirty readNon-repeatable readPhantomHow InnoDB does it
READ UNCOMMITTEDpossiblepossiblepossibleReads the newest version of each row, committed or not
READ COMMITTED—possiblepossibleA fresh snapshot for every statement
REPEATABLE READ (MySQL default)——— for plain readsOne snapshot for the whole transaction; locking reads use next-key locks
SERIALIZABLE———Like REPEATABLE READ, but plain SELECTs inside a transaction become FOR SHARE locking reads

MySQL's default is REPEATABLE READ; PostgreSQL, Oracle and SQL Server default to READ COMMITTED. The mental model for the two levels you'll actually use: READ COMMITTED takes a new photo before every statement; REPEATABLE READ takes one photo at the first read and keeps looking at it until the transaction ends.

SQL
-- Session A
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;
-- Session B
UPDATE accounts SET balance = 450 WHERE id = 1;
-- Session A
SELECT balance FROM accounts WHERE id = 1;
COMMIT;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;
-- Session B
UPDATE accounts SET balance = 400 WHERE id = 1;
-- Session A
SELECT balance FROM accounts WHERE id = 1;
COMMIT;
SELECT balance FROM accounts WHERE id = 1;
Result
A | mysql> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> START TRANSACTION;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SELECT balance FROM accounts WHERE id = 1;
A | +---------+
A | | balance |
A | +---------+
A | |  500.00 |
A | +---------+
A | 1 row in set (0.000 sec)
B | mysql> UPDATE accounts SET balance = 450 WHERE id = 1;
B | Query OK, 1 row affected (0.004 sec)
B | Rows matched: 1  Changed: 1  Warnings: 0
A | mysql> SELECT balance FROM accounts WHERE id = 1;
A | +---------+
A | | balance |
A | +---------+
A | |  450.00 |
A | +---------+
A | 1 row in set (0.000 sec)
A | mysql> COMMIT;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> START TRANSACTION;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SELECT balance FROM accounts WHERE id = 1;
A | +---------+
A | | balance |
A | +---------+
A | |  450.00 |
A | +---------+
A | 1 row in set (0.001 sec)
B | mysql> UPDATE accounts SET balance = 400 WHERE id = 1;
B | Query OK, 1 row affected (0.003 sec)
B | Rows matched: 1  Changed: 1  Warnings: 0
A | mysql> SELECT balance FROM accounts WHERE id = 1;
A | +---------+
A | | balance |
A | +---------+
A | |  450.00 |
A | +---------+
A | 1 row in set (0.000 sec)
A | mysql> COMMIT;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SELECT balance FROM accounts WHERE id = 1;
A | +---------+
A | | balance |
A | +---------+
A | |  400.00 |
A | +---------+
A | 1 row in set (0.000 sec)
What’s happening
  1. A switches its session to READ COMMITTED, starts a transaction and reads Rohit's balance: 500.
  2. B (autocommit) changes it to 450 and commits immediately.
  3. A reads again in the same transaction and gets 450. Each statement in READ COMMITTED gets a fresh snapshot, so it sees everything committed before the statement began. Two identical reads, two different answers — a non-repeatable read.
  4. A switches to REPEATABLE READ and starts a new transaction. Its first read (450) creates the snapshot. B changes the balance to 400 and commits; A's second read still says 450 — the transaction keeps reading from its snapshot.
  5. Only after COMMIT does A's next read — a new autocommit transaction with a new snapshot — see 400. Neither level blocked anyone: both readers and the writer ran without waiting, which is what MVCC buys (see MVCC).
AdvancedSetting the level: next transaction, session or global

SET TRANSACTION without a scope applies to the next transaction only; SET SESSION TRANSACTION to the rest of the session; SET GLOBAL TRANSACTION to sessions that connect afterwards. You can't change it in the middle of a transaction:

SQL
SELECT @@transaction_isolation;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;
SELECT trx_isolation_level FROM information_schema.innodb_trx WHERE trx_mysql_thread_id = CONNECTION_ID();
COMMIT;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;
SELECT trx_isolation_level FROM information_schema.innodb_trx WHERE trx_mysql_thread_id = CONNECTION_ID();
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
COMMIT;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT @@transaction_isolation;
Result
mysql> SELECT @@transaction_isolation;
+-------------------------+
| @@transaction_isolation |
+-------------------------+
| REPEATABLE-READ         |
+-------------------------+
1 row in set (0.000 sec)
mysql> SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
Query OK, 0 rows affected (0.000 sec)
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.000 sec)
mysql> SELECT balance FROM accounts WHERE id = 1;
+---------+
| balance |
+---------+
|  500.00 |
+---------+
1 row in set (0.000 sec)
mysql> SELECT trx_isolation_level FROM information_schema.innodb_trx WHERE trx_mysql_thread_id = CONNECTION_ID();
+---------------------+
| trx_isolation_level |
+---------------------+
| READ COMMITTED      |
+---------------------+
1 row in set (0.001 sec)
mysql> COMMIT;
Query OK, 0 rows affected (0.001 sec)
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.001 sec)
mysql> SELECT balance FROM accounts WHERE id = 1;
+---------+
| balance |
+---------+
|  500.00 |
+---------+
1 row in set (0.000 sec)
mysql> SELECT trx_isolation_level FROM information_schema.innodb_trx WHERE trx_mysql_thread_id = CONNECTION_ID();
+---------------------+
| trx_isolation_level |
+---------------------+
| REPEATABLE READ     |
+---------------------+
1 row in set (0.000 sec)
mysql> SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
ERROR 1568 (25001): Transaction characteristics can't be changed while a transaction is in progress
mysql> COMMIT;
Query OK, 0 rows affected (0.000 sec)
mysql> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
Query OK, 0 rows affected (0.000 sec)
mysql> SELECT @@transaction_isolation;
+-------------------------+
| @@transaction_isolation |
+-------------------------+
| READ-COMMITTED          |
+-------------------------+
1 row in set (0.000 sec)
What’s happening
  1. SET TRANSACTION ISOLATION LEVEL READ COMMITTED applied to the next transaction: INNODB_TRX reports READ COMMITTED for it. (A transaction only appears in INNODB_TRX once it has read or written something.)
  2. The following transaction is back at the session's REPEATABLE READ — the one-shot setting was used up.
  3. Changing the level inside a transaction fails with ERROR 1568 (25001): Transaction characteristics can't be changed while a transaction is in progress.
  4. SET SESSION … changes @@transaction_isolation for the rest of the connection. In a pooled application, beware: the next borrower of that connection inherits it. Set it in the server config (transaction_isolation = READ-COMMITTED) or per transaction instead.
AdvancedRead-only transactions

You can also declare a transaction read-only, which lets InnoDB skip some bookkeeping and makes writes fail early. Seen in the same session:

Output
mysql> START TRANSACTION READ ONLY;
Query OK, 0 rows affected (0.001 sec)
mysql> UPDATE accounts SET balance = 0 WHERE id = 1;
ERROR 1792 (25006): Cannot execute statement in a READ ONLY transaction.
AdvancedTip: READ COMMITTED in practice

#Read anomalies: dirty reads, non-repeatable reads and phantoms

Added

Each isolation level is defined by which of these it rules out, so it pays to know them by name and by example:

  • A dirty read is reading another transaction's uncommitted change. If that transaction rolls back, you acted on data that never existed.
  • A non-repeatable read is reading the same row twice in one transaction and getting different values, because another transaction committed an update in between (shown in the previous topic).
  • A phantom is running the same query twice and getting a different set of rows, because another transaction inserted or deleted rows that match the condition.

Two more anomalies aren't in the standard's table but come up in every serious interview:

  • A lost update: two transactions read the same value, both compute a new one, and the second write silently overwrites the first.
  • Write skew: two transactions read the same data, each makes a change that is fine on its own, and together they break a rule — neither wrote the row the other wrote, so row locks never conflict.
SQL
-- Session B
START TRANSACTION;
UPDATE accounts SET balance = balance - 400 WHERE id = 1;
-- Session A
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT balance FROM accounts WHERE id = 1;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT balance FROM accounts WHERE id = 1;
-- Session B
ROLLBACK;
-- Session A
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT balance FROM accounts WHERE id = 1;
Result
B | mysql> START TRANSACTION;
B | Query OK, 0 rows affected (0.000 sec)
B | mysql> UPDATE accounts SET balance = balance - 400 WHERE id = 1;
B | Query OK, 1 row affected (0.001 sec)
B | Rows matched: 1  Changed: 1  Warnings: 0
A | mysql> SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SELECT balance FROM accounts WHERE id = 1;
A | +---------+
A | | balance |
A | +---------+
A | |  100.00 |
A | +---------+
A | 1 row in set (0.001 sec)
A | mysql> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SELECT balance FROM accounts WHERE id = 1;
A | +---------+
A | | balance |
A | +---------+
A | |  500.00 |
A | +---------+
A | 1 row in set (0.000 sec)
B | mysql> ROLLBACK;
B | Query OK, 0 rows affected (0.006 sec)
A | mysql> SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SELECT balance FROM accounts WHERE id = 1;
A | +---------+
A | | balance |
A | +---------+
A | |  500.00 |
A | +---------+
A | 1 row in set (0.001 sec)
What’s happening
  1. B starts a transaction and takes 400 from Rohit (500 → 100) but doesn't commit.
  2. A in READ UNCOMMITTED reads 100: the uncommitted value. That's a dirty read. If A were a fraud check or a report, it would act on a balance that, a moment later, never existed.
  3. The same A in READ COMMITTED reads 500, the last committed value, while B's change is still pending. InnoDB doesn't wait for B; it rebuilds the committed version from the undo log.
  4. B rolls back. Now even READ UNCOMMITTED sees 500. The 100 that A saw earlier was never real.
  5. There's almost never a good reason to use READ UNCOMMITTED in InnoDB: READ COMMITTED is not slower in any way that matters, because readers never block on writers.
AdvancedPhantoms: READ COMMITTED vs REPEATABLE READ

The same two reads around an insert, at both levels:

SQL
-- Session A
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT COUNT(*), SUM(balance) FROM accounts WHERE balance >= 100;
-- Session B
INSERT INTO accounts VALUES (4, 'Sofia', 250.00);
-- Session A
SELECT COUNT(*), SUM(balance) FROM accounts WHERE balance >= 100;
COMMIT;
-- Session B
DELETE FROM accounts WHERE id = 4;
-- Session A
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT COUNT(*), SUM(balance) FROM accounts WHERE balance >= 100;
-- Session B
INSERT INTO accounts VALUES (4, 'Sofia', 250.00);
-- Session A
SELECT COUNT(*), SUM(balance) FROM accounts WHERE balance >= 100;
COMMIT;
Result
A | mysql> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> START TRANSACTION;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SELECT COUNT(*), SUM(balance) FROM accounts WHERE balance >= 100;
A | +----------+--------------+
A | | COUNT(*) | SUM(balance) |
A | +----------+--------------+
A | |        3 |       900.00 |
A | +----------+--------------+
A | 1 row in set (0.001 sec)
B | mysql> INSERT INTO accounts VALUES (4, 'Sofia', 250.00);
B | Query OK, 1 row affected (0.007 sec)
A | mysql> SELECT COUNT(*), SUM(balance) FROM accounts WHERE balance >= 100;
A | +----------+--------------+
A | | COUNT(*) | SUM(balance) |
A | +----------+--------------+
A | |        4 |      1150.00 |
A | +----------+--------------+
A | 1 row in set (0.000 sec)
A | mysql> COMMIT;
A | Query OK, 0 rows affected (0.000 sec)
B | mysql> DELETE FROM accounts WHERE id = 4;
B | Query OK, 1 row affected (0.003 sec)
A | mysql> SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> START TRANSACTION;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SELECT COUNT(*), SUM(balance) FROM accounts WHERE balance >= 100;
A | +----------+--------------+
A | | COUNT(*) | SUM(balance) |
A | +----------+--------------+
A | |        3 |       900.00 |
A | +----------+--------------+
A | 1 row in set (0.000 sec)
B | mysql> INSERT INTO accounts VALUES (4, 'Sofia', 250.00);
B | Query OK, 1 row affected (0.004 sec)
A | mysql> SELECT COUNT(*), SUM(balance) FROM accounts WHERE balance >= 100;
A | +----------+--------------+
A | | COUNT(*) | SUM(balance) |
A | +----------+--------------+
A | |        3 |       900.00 |
A | +----------+--------------+
A | 1 row in set (0.000 sec)
A | mysql> COMMIT;
A | Query OK, 0 rows affected (0.000 sec)
What’s happening
  1. Under READ COMMITTED, A counts accounts with at least 100: 3 rows, 900 in total.
  2. B inserts Sofia with 250 and commits. A runs the identical query again and gets 4 rows, 1150 — a row appeared out of nowhere: a phantom.
  3. Under REPEATABLE READ, the same sequence gives 3 / 900 both times. The snapshot was taken at A's first read, and Sofia's row was inserted by a transaction that committed after it, so it's invisible to A.
  4. So InnoDB's REPEATABLE READ prevents phantoms for plain reads, which the SQL standard doesn't require of this level. For locking reads it prevents them a different way, with next-key locks.
AdvancedA REPEATABLE READ surprise: UPDATE sees what SELECT doesn't

InnoDB's snapshot applies to plain SELECTs. UPDATE, DELETE and locking reads always work on the latest committed rows. Mixing the two inside one transaction gives a result that surprises most people:

SQL
-- Session A
START TRANSACTION;
SELECT * FROM accounts;
-- Session B
INSERT INTO accounts VALUES (4, 'Sofia', 250.00);
-- Session A
SELECT * FROM accounts;
UPDATE accounts SET balance = balance + 10;
SELECT * FROM accounts;
ROLLBACK;
Result
A | mysql> START TRANSACTION;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SELECT * FROM accounts;
A | +----+-------+---------+
A | | id | owner | balance |
A | +----+-------+---------+
A | |  1 | Rohit |  500.00 |
A | |  2 | Maya  |  300.00 |
A | |  3 | Liam  |  100.00 |
A | +----+-------+---------+
A | 3 rows in set (0.000 sec)
B | mysql> INSERT INTO accounts VALUES (4, 'Sofia', 250.00);
B | Query OK, 1 row affected (0.003 sec)
A | mysql> SELECT * FROM accounts;
A | +----+-------+---------+
A | | id | owner | balance |
A | +----+-------+---------+
A | |  1 | Rohit |  500.00 |
A | |  2 | Maya  |  300.00 |
A | |  3 | Liam  |  100.00 |
A | +----+-------+---------+
A | 3 rows in set (0.000 sec)
A | mysql> UPDATE accounts SET balance = balance + 10;
A | Query OK, 4 rows affected (0.000 sec)
A | Rows matched: 4  Changed: 4  Warnings: 0
A | mysql> SELECT * FROM accounts;
A | +----+-------+---------+
A | | id | owner | balance |
A | +----+-------+---------+
A | |  1 | Rohit |  510.00 |
A | |  2 | Maya  |  310.00 |
A | |  3 | Liam  |  110.00 |
A | |  4 | Sofia |  260.00 |
A | +----+-------+---------+
A | 4 rows in set (0.000 sec)
A | mysql> ROLLBACK;
A | Query OK, 0 rows affected (0.002 sec)
What’s happening
  1. A's first SELECT creates the snapshot: three accounts.
  2. B inserts Sofia and commits. A's second SELECT still shows three rows — consistent with the snapshot.
  3. A's UPDATE accounts SET balance = balance + 10 reports 4 rows matched: writes operate on the current data, which includes Sofia.
  4. Now A's SELECT shows four rows, Sofia included with 260. A row that A modified itself becomes visible in A's own snapshot. The "repeatable" read just changed inside one transaction, because A's own write pulled a phantom in.
  5. This is why "read in a snapshot, then decide what to write" is unsafe for invariants under REPEATABLE READ. When the decision depends on the read, use a locking read (FOR UPDATE) so you read the current rows and lock them.
AdvancedLost update

Two clerks process a withdrawal of 100 and a deposit of 50 on Rohit's account at the same time, each computing the new balance in application code:

SQL
-- Session A
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;
-- Session B
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;
-- Session A
UPDATE accounts SET balance = 400.00 WHERE id = 1;
COMMIT;
-- Session B
UPDATE accounts SET balance = 550.00 WHERE id = 1;
COMMIT;
SELECT balance FROM accounts WHERE id = 1;
Result
A | mysql> START TRANSACTION;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SELECT balance FROM accounts WHERE id = 1;
A | +---------+
A | | balance |
A | +---------+
A | |  500.00 |
A | +---------+
A | 1 row in set (0.000 sec)
B | mysql> START TRANSACTION;
B | Query OK, 0 rows affected (0.000 sec)
B | mysql> SELECT balance FROM accounts WHERE id = 1;
B | +---------+
B | | balance |
B | +---------+
B | |  500.00 |
B | +---------+
B | 1 row in set (0.000 sec)
A | mysql> UPDATE accounts SET balance = 400.00 WHERE id = 1;
A | Query OK, 1 row affected (0.000 sec)
A | Rows matched: 1  Changed: 1  Warnings: 0
A | mysql> COMMIT;
A | Query OK, 0 rows affected (0.003 sec)
B | mysql> UPDATE accounts SET balance = 550.00 WHERE id = 1;
B | Query OK, 1 row affected (0.000 sec)
B | Rows matched: 1  Changed: 1  Warnings: 0
B | mysql> COMMIT;
B | Query OK, 0 rows affected (0.003 sec)
B | mysql> SELECT balance FROM accounts WHERE id = 1;
B | +---------+
B | | balance |
B | +---------+
B | |  550.00 |
B | +---------+
B | 1 row in set (0.000 sec)
What’s happening
  1. A and B both read 500.
  2. A writes 400 (500 − 100) and commits.
  3. B writes 550 (500 + 50) — computed from the stale 500 — and commits. B wasn't blocked, because A had already committed and released its lock.
  4. The final balance is 550; the correct answer is 450. A's withdrawal was silently lost, under the default REPEATABLE READ level.
  5. Fixes, all covered later: write balance = balance - 100 so the database does the arithmetic on the current value; lock the row first with SELECT … FOR UPDATE; or use a version column and reject stale writes (optimistic locking).
AdvancedWrite skew under REPEATABLE READ

The rule: at least one doctor must be on call. Asha and Ben are both on call, and both ask to go off at the same moment. Each transaction checks the rule, then updates its own row:

SQL
-- Setup, run once before the sessions start
CREATE TABLE on_call (doctor VARCHAR(20) PRIMARY KEY, on_duty BOOLEAN NOT NULL);
INSERT INTO on_call VALUES ('Asha', TRUE), ('Ben', TRUE);
-- Session A
START TRANSACTION;
SELECT COUNT(*) FROM on_call WHERE on_duty;
-- Session B
START TRANSACTION;
SELECT COUNT(*) FROM on_call WHERE on_duty;
-- Session A
UPDATE on_call SET on_duty = FALSE WHERE doctor = 'Asha';
-- Session B
UPDATE on_call SET on_duty = FALSE WHERE doctor = 'Ben';
-- Session A
COMMIT;
-- Session B
COMMIT;
-- Session A
SELECT * FROM on_call;
Result
A | mysql> START TRANSACTION;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SELECT COUNT(*) FROM on_call WHERE on_duty;
A | +----------+
A | | COUNT(*) |
A | +----------+
A | |        2 |
A | +----------+
A | 1 row in set (0.000 sec)
B | mysql> START TRANSACTION;
B | Query OK, 0 rows affected (0.000 sec)
B | mysql> SELECT COUNT(*) FROM on_call WHERE on_duty;
B | +----------+
B | | COUNT(*) |
B | +----------+
B | |        2 |
B | +----------+
B | 1 row in set (0.000 sec)
A | mysql> UPDATE on_call SET on_duty = FALSE WHERE doctor = 'Asha';
A | Query OK, 1 row affected (0.000 sec)
A | Rows matched: 1  Changed: 1  Warnings: 0
B | mysql> UPDATE on_call SET on_duty = FALSE WHERE doctor = 'Ben';
B | Query OK, 1 row affected (0.000 sec)
B | Rows matched: 1  Changed: 1  Warnings: 0
A | mysql> COMMIT;
A | Query OK, 0 rows affected (0.005 sec)
B | mysql> COMMIT;
B | Query OK, 0 rows affected (0.005 sec)
A | mysql> SELECT * FROM on_call;
A | +--------+---------+
A | | doctor | on_duty |
A | +--------+---------+
A | | Asha   |       0 |
A | | Ben    |       0 |
A | +--------+---------+
A | 2 rows in set (0.001 sec)
What’s happening
  1. Both transactions count 2 doctors on duty, so each concludes it's safe for one to leave.
  2. A updates Asha's row, B updates Ben's row. They touch different rows, so there's no lock conflict at all, and both commit.
  3. Result: nobody is on call. Each transaction was correct against its snapshot; together they broke the rule. REPEATABLE READ doesn't prevent write skew.
AdvancedWrite skew under SERIALIZABLE

The same sequence under SERIALIZABLE, starting again from two doctors on duty:

SQL
-- Session A
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- Session B
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- Session A
START TRANSACTION;
SELECT COUNT(*) FROM on_call WHERE on_duty;
-- Session B
START TRANSACTION;
SELECT COUNT(*) FROM on_call WHERE on_duty;
-- Session A
UPDATE on_call SET on_duty = FALSE WHERE doctor = 'Asha';
-- Session B
UPDATE on_call SET on_duty = FALSE WHERE doctor = 'Ben';
-- Session A
COMMIT;
SELECT * FROM on_call;
Result
A | mysql> SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
A | Query OK, 0 rows affected (0.000 sec)
B | mysql> SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
B | Query OK, 0 rows affected (0.000 sec)
A | mysql> START TRANSACTION;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SELECT COUNT(*) FROM on_call WHERE on_duty;
A | +----------+
A | | COUNT(*) |
A | +----------+
A | |        2 |
A | +----------+
A | 1 row in set (0.000 sec)
B | mysql> START TRANSACTION;
B | Query OK, 0 rows affected (0.000 sec)
B | mysql> SELECT COUNT(*) FROM on_call WHERE on_duty;
B | +----------+
B | | COUNT(*) |
B | +----------+
B | |        2 |
B | +----------+
B | 1 row in set (0.000 sec)
A | mysql> UPDATE on_call SET on_duty = FALSE WHERE doctor = 'Asha';
B | mysql> UPDATE on_call SET on_duty = FALSE WHERE doctor = 'Ben';
A | Query OK, 1 row affected (1.072 sec)
A | Rows matched: 1  Changed: 1  Warnings: 0
B | ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
A | mysql> COMMIT;
A | Query OK, 0 rows affected (0.004 sec)
A | mysql> SELECT * FROM on_call;
A | +--------+---------+
A | | doctor | on_duty |
A | +--------+---------+
A | | Asha   |       0 |
A | | Ben    |       1 |
A | +--------+---------+
A | 2 rows in set (0.001 sec)
What’s happening
  1. Under SERIALIZABLE, InnoDB turns each plain SELECT inside a transaction into a SELECT … FOR SHARE, so both counts take shared locks on both rows. Shared locks are compatible, so both reads succeed.
  2. A's UPDATE of Asha needs an exclusive lock, but B holds a shared lock on Asha: A waits.
  3. B's UPDATE of Ben needs an exclusive lock, but A holds a shared lock on Ben: each waits for the other — a deadlock. InnoDB detects it at once and rolls back B: ERROR 1213 (40001).
  4. A's update then goes through (it had waited about a second, while the script paused before B's step) and commits. Ben is still on call: the rule holds. B's application would retry, re-count 1 doctor and refuse.
  5. Without switching the whole session to SERIALIZABLE, the targeted fix is to lock what you read: SELECT COUNT(*) FROM on_call WHERE on_duty FOR UPDATE. PostgreSQL's SERIALIZABLE works differently (serializable snapshot isolation, with no read locks), but also aborts one of the two with a serialization failure.
Predict the count

Under InnoDB's default REPEATABLE READ:

SQL
-- Session A
START TRANSACTION;
SELECT COUNT(*) FROM accounts;            -- returns 3
-- Session B (autocommit)
INSERT INTO accounts VALUES (4, 'Sofia', 250.00);
-- Session A
SELECT COUNT(*) FROM accounts;            -- (1) ?
SELECT COUNT(*) FROM accounts FOR SHARE;  -- (2) ?
SELECT COUNT(*) FROM accounts;            -- (3) ?
Show answer

(1) 3, (2) 4, (3) 3.

What’s happening
  1. (1) is a plain consistent read from A's snapshot, taken at A's first SELECT, before Sofia existed.
  2. (2) is a locking read. Locking reads ignore the snapshot and read the latest committed rows (and lock them), so they see Sofia.
  3. (3) is a plain read again, back to the snapshot: 3. The same transaction can see two different "truths" depending on how it reads — the same effect the MVCC example below shows with FOR SHARE on a single row.

#MVCC: how InnoDB gives each transaction a snapshot

Added

MVCC — multi-version concurrency control — is how InnoDB lets readers and writers work at the same time without blocking each other. When a row is updated, InnoDB doesn't overwrite the old version and forget it. It writes the new version in place and puts the old version into the undo log, linked from the new one. Every row carries two hidden fields: DB_TRX_ID, the id of the transaction that last changed it, and DB_ROLL_PTR, a pointer to the previous version in the undo log. So each row is the head of a version chain.

A plain SELECT (a consistent read) uses a read view: a note of which transactions had committed when the snapshot was taken. For each row it walks the version chain back until it finds a version written by a transaction the read view says was committed. Locking reads, UPDATE and DELETE don't use the read view — they read the latest committed version and lock it (a current read).

The mental model is a Wikipedia page history: the page always shows the latest edit, but every older revision is kept, and a reader who arrived at 10:00 is shown the revision that was current at 10:00, no matter how many edits happen while they read. Old revisions are deleted (InnoDB calls it purge) only when no reader could still need them.

clustered index (current) undo log (older versions) id=1 balance=400 DB_TRX_ID = T3 (committed) DB_ROLL_PTR → balance=450 written by T2 roll ptr → balance=500 written by T1 end of chain reader whose snapshot predates T3 skips T3's version → reads balance=450 locking read / UPDATE (current read) always takes the head → balance=400
One row, three versions — the same values as the example below. A consistent read walks back to the newest version its read view allows; a current read always uses the head.
SQL
-- Session A
START TRANSACTION;
-- Session B
UPDATE accounts SET balance = 450 WHERE id = 1;
-- Session A
SELECT balance FROM accounts WHERE id = 1;
-- Session B
UPDATE accounts SET balance = 400 WHERE id = 1;
-- Session A
SELECT balance FROM accounts WHERE id = 1;
SELECT balance FROM accounts WHERE id = 1 FOR SHARE;
SELECT balance FROM accounts WHERE id = 1;
COMMIT;
START TRANSACTION WITH CONSISTENT SNAPSHOT;
-- Session B
UPDATE accounts SET balance = 350 WHERE id = 1;
-- Session A
SELECT balance FROM accounts WHERE id = 1;
COMMIT;
SELECT balance FROM accounts WHERE id = 1;
Result
A | mysql> START TRANSACTION;
A | Query OK, 0 rows affected (0.000 sec)
B | mysql> UPDATE accounts SET balance = 450 WHERE id = 1;
B | Query OK, 1 row affected (0.003 sec)
B | Rows matched: 1  Changed: 1  Warnings: 0
A | mysql> SELECT balance FROM accounts WHERE id = 1;
A | +---------+
A | | balance |
A | +---------+
A | |  450.00 |
A | +---------+
A | 1 row in set (0.000 sec)
B | mysql> UPDATE accounts SET balance = 400 WHERE id = 1;
B | Query OK, 1 row affected (0.003 sec)
B | Rows matched: 1  Changed: 1  Warnings: 0
A | mysql> SELECT balance FROM accounts WHERE id = 1;
A | +---------+
A | | balance |
A | +---------+
A | |  450.00 |
A | +---------+
A | 1 row in set (0.002 sec)
A | mysql> SELECT balance FROM accounts WHERE id = 1 FOR SHARE;
A | +---------+
A | | balance |
A | +---------+
A | |  400.00 |
A | +---------+
A | 1 row in set (0.000 sec)
A | mysql> SELECT balance FROM accounts WHERE id = 1;
A | +---------+
A | | balance |
A | +---------+
A | |  450.00 |
A | +---------+
A | 1 row in set (0.000 sec)
A | mysql> COMMIT;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> START TRANSACTION WITH CONSISTENT SNAPSHOT;
A | Query OK, 0 rows affected (0.000 sec)
B | mysql> UPDATE accounts SET balance = 350 WHERE id = 1;
B | Query OK, 1 row affected (0.003 sec)
B | Rows matched: 1  Changed: 1  Warnings: 0
A | mysql> SELECT balance FROM accounts WHERE id = 1;
A | +---------+
A | | balance |
A | +---------+
A | |  400.00 |
A | +---------+
A | 1 row in set (0.000 sec)
A | mysql> COMMIT;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SELECT balance FROM accounts WHERE id = 1;
A | +---------+
A | | balance |
A | +---------+
A | |  350.00 |
A | +---------+
A | 1 row in set (0.001 sec)
What’s happening
  1. A runs START TRANSACTION but reads nothing yet. B sets the balance to 450 and commits. A's first SELECT sees 450: in REPEATABLE READ the snapshot is created at the transaction's first consistent read, not at START TRANSACTION, so B's commit came "before" A's snapshot.
  2. B sets the balance to 400 and commits. A's plain SELECT still sees 450: InnoDB found the 400 version at the head of the chain, saw it was written by a transaction outside A's read view, and followed the roll pointer back to 450.
  3. SELECT … FOR SHARE is a current read: it ignores the snapshot, reads the latest committed version — 400 — and locks it.
  4. The plain SELECT straight after is back to 450. One transaction, two different answers for one row, depending on the kind of read. That's not a bug; it's the definition of the two read types.
  5. START TRANSACTION WITH CONSISTENT SNAPSHOT creates the read view immediately: B's later change to 350 is invisible to A, which reads 400 — the value at the moment the transaction started. After COMMIT, a new read sees 350.
AdvancedLong transactions make the undo log grow

Purge can only delete old versions that no open read view still needs. One forgotten open transaction therefore keeps every version created since it started. The metric trx_rseg_history_len (the "History list length" in SHOW ENGINE INNODB STATUS) counts transactions whose undo hasn't been purged yet. bump_balance(n) is a small procedure that runs n one-row autocommit updates of Liam's balance:

SQL
-- Setup, run once
DELIMITER //
CREATE PROCEDURE bump_balance(n INT)
BEGIN
  DECLARE i INT DEFAULT 0;
  WHILE i < n DO
    UPDATE accounts SET balance = balance + 0.01 WHERE id = 3;
    SET i = i + 1;
  END WHILE;
END //
DELIMITER ;

-- Session A
SELECT count FROM information_schema.innodb_metrics WHERE name = 'trx_rseg_history_len';
START TRANSACTION WITH CONSISTENT SNAPSHOT;
-- Session B
CALL bump_balance(20000);
SELECT count FROM information_schema.innodb_metrics WHERE name = 'trx_rseg_history_len';
-- Session A
SELECT balance FROM accounts WHERE id = 3;
COMMIT;
-- Session B
SELECT count FROM information_schema.innodb_metrics WHERE name = 'trx_rseg_history_len';
SELECT balance FROM accounts WHERE id = 3;
Result
A | mysql> SELECT count FROM information_schema.innodb_metrics WHERE name = 'trx_rseg_history_len';
A | +-------+
A | | count |
A | +-------+
A | |    34 |
A | +-------+
A | 1 row in set (0.001 sec)
A | mysql> START TRANSACTION WITH CONSISTENT SNAPSHOT;
A | Query OK, 0 rows affected (0.000 sec)
B | mysql> CALL bump_balance(20000);
B | Query OK, 1 row affected (21.864 sec)
B | mysql> SELECT count FROM information_schema.innodb_metrics WHERE name = 'trx_rseg_history_len';
B | +-------+
B | | count |
B | +-------+
B | | 20007 |
B | +-------+
B | 1 row in set (0.001 sec)
A | mysql> SELECT balance FROM accounts WHERE id = 3;
A | +---------+
A | | balance |
A | +---------+
A | |  100.00 |
A | +---------+
A | 1 row in set (0.009 sec)
A | mysql> COMMIT;
A | Query OK, 0 rows affected (0.000 sec)
B | mysql> SELECT count FROM information_schema.innodb_metrics WHERE name = 'trx_rseg_history_len';
B | +-------+
B | | count |
B | +-------+
B | |     0 |
B | +-------+
B | 1 row in set (0.001 sec)
B | mysql> SELECT balance FROM accounts WHERE id = 3;
B | +---------+
B | | balance |
B | +---------+
B | |  300.00 |
B | +---------+
B | 1 row in set (0.000 sec)
What’s happening
  1. Before: a history list of a few dozen (other sessions on the server were working too).
  2. A opens a snapshot and does nothing. B runs 20,000 small committed transactions (about 22 s — each commit is a redo-log fsync).
  3. The history list is now about 20,000: purge can't remove any of those old versions, because A's snapshot might still need them.
  4. A's read of Liam's balance still says 100 — and took 9 ms instead of well under 1 ms, because it walked back through the version chain to find the version its snapshot allows.
  5. As soon as A commits, purge catches up and the history list drops to 0. In production the same thing happens with a forgotten transaction, a long report, or a mysqldump --single-transaction of a big database (the whole dump reads from one snapshot): undo space grows, and every read of hot rows gets slower. Monitor history list length and the age of the oldest transaction in INNODB_TRX.
AdvancedNote: MVCC in PostgreSQL

#Locking reads: FOR UPDATE and FOR SHARE

Added

A plain SELECT takes no locks; it reads a snapshot. When you read a row in order to change it — check stock and then decrement it, check a balance and then debit it — you want to read the current value and stop anyone else changing it until you're done. That's a locking read:

  • SELECT … FOR UPDATE takes an exclusive (X) lock on every row it reads: other transactions can't update, delete or lock those rows until you commit or roll back.
  • SELECT … FOR SHARE takes a shared (S) lock: others can also read with FOR SHARE, but nobody can modify the rows. It's for "make sure this row stays as it is while I work" — for example, checking that a parent row exists before inserting a child. (LOCK IN SHARE MODE is the pre-8.0 spelling and still works.)
Held \ RequestedS (FOR SHARE)X (FOR UPDATE, UPDATE, DELETE)
Scompatiblewaits
Xwaitswaits

Locks are held until the end of the transaction. Mental model: a locking read is putting your hand on the last item on the shelf: others can see it (plain reads still work), but nobody else can take it until you've paid or put it back. Outside a transaction (with autocommit) a locking read commits — and releases its locks — as soon as it finishes, which makes it useless.

SQL
-- Session A
START TRANSACTION;
SELECT name, stock FROM products WHERE id = 2 FOR UPDATE;
-- Session B
START TRANSACTION;
SELECT name, stock FROM products WHERE id = 2 FOR UPDATE;
-- Session C
SELECT engine_transaction_id AS trx, object_name, index_name, lock_mode, lock_status, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09' AND lock_type = 'RECORD';
SELECT waiting_query, blocking_query, wait_age FROM sys.innodb_lock_waits WHERE locked_table LIKE '%ch09%';
-- Session A
UPDATE products SET stock = stock - 1 WHERE id = 2;
COMMIT;
-- Session B
UPDATE products SET stock = stock - 1 WHERE id = 2;
COMMIT;
SELECT name, stock FROM products WHERE id = 2;
Result
A | mysql> START TRANSACTION;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SELECT name, stock FROM products WHERE id = 2 FOR UPDATE;
A | +-------------+-------+
A | | name        | stock |
A | +-------------+-------+
A | | MacBook Air |     5 |
A | +-------------+-------+
A | 1 row in set (0.000 sec)
B | mysql> START TRANSACTION;
B | Query OK, 0 rows affected (0.000 sec)
B | mysql> SELECT name, stock FROM products WHERE id = 2 FOR UPDATE;
C | mysql> SELECT engine_transaction_id AS trx, object_name, index_name, lock_mode, lock_status, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09' AND lock_type = 'RECORD';
C | +--------+-------------+------------+---------------+-------------+-----------+
C | | trx    | object_name | index_name | lock_mode     | lock_status | lock_data |
C | +--------+-------------+------------+---------------+-------------+-----------+
C | | 109813 | products    | PRIMARY    | X,REC_NOT_GAP | GRANTED     | 2         |
C | | 109814 | products    | PRIMARY    | X,REC_NOT_GAP | WAITING     | 2         |
C | +--------+-------------+------------+---------------+-------------+-----------+
C | 2 rows in set (0.001 sec)
C | mysql> SELECT waiting_query, blocking_query, wait_age FROM sys.innodb_lock_waits WHERE locked_table LIKE '%ch09%';
C | +----------------------------------------------------------+----------------+----------+
C | | waiting_query                                            | blocking_query | wait_age |
C | +----------------------------------------------------------+----------------+----------+
C | | SELECT name, stock FROM products WHERE id = 2 FOR UPDATE | NULL           | 00:00:01 |
C | +----------------------------------------------------------+----------------+----------+
C | 1 row in set (0.005 sec)
A | mysql> UPDATE products SET stock = stock - 1 WHERE id = 2;
A | Query OK, 1 row affected (0.001 sec)
A | Rows matched: 1  Changed: 1  Warnings: 0
A | mysql> COMMIT;
A | Query OK, 0 rows affected (0.004 sec)
B | +-------------+-------+
B | | name        | stock |
B | +-------------+-------+
B | | MacBook Air |     4 |
B | +-------------+-------+
B | 1 row in set (1.400 sec)
B | mysql> UPDATE products SET stock = stock - 1 WHERE id = 2;
B | Query OK, 1 row affected (0.001 sec)
B | Rows matched: 1  Changed: 1  Warnings: 0
B | mysql> COMMIT;
B | Query OK, 0 rows affected (0.004 sec)
B | mysql> SELECT name, stock FROM products WHERE id = 2;
B | +-------------+-------+
B | | name        | stock |
B | +-------------+-------+
B | | MacBook Air |     3 |
B | +-------------+-------+
B | 1 row in set (0.000 sec)
What’s happening
  1. A locks the MacBook Air row with FOR UPDATE and sees stock 5.
  2. B issues the same locking read and blocks — no output appears under its statement.
  3. Session C looks at the locks. performance_schema.data_locks shows two record locks on products row 2 of the PRIMARY index: A's X,REC_NOT_GAP is GRANTED, B's is WAITING. (REC_NOT_GAP = the row only, no gap; the lookup was an equality on a unique key.) sys.innodb_lock_waits shows the waiting query and how long it has waited; blocking_query is NULL because A isn't running a statement right now — it's idle inside its transaction.
  4. A decrements the stock and commits. Only then does B's SELECT return — with stock 4, the value A committed, after waiting 1.4 s. A locking read always returns the latest committed data, never B's old snapshot.
  5. B decrements to 3 and commits. The two "check then update" sequences ran one after the other, so no update was lost — compare the lost update without locks.
AdvancedFOR SHARE: many readers, no writers

Shared locks let several transactions hold the same row, but they block writers:

SQL
-- Session A
START TRANSACTION;
SELECT stock FROM products WHERE id = 3 FOR SHARE;
-- Session B
START TRANSACTION;
SELECT stock FROM products WHERE id = 3 FOR SHARE;
-- Session C
SELECT engine_transaction_id AS trx, lock_mode, lock_status, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09' AND lock_type = 'RECORD';
-- Session B
SET SESSION innodb_lock_wait_timeout = 2;
UPDATE products SET stock = stock - 1 WHERE id = 3;
-- Session A
COMMIT;
-- Session B
ROLLBACK;
Result
A | mysql> START TRANSACTION;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SELECT stock FROM products WHERE id = 3 FOR SHARE;
A | +-------+
A | | stock |
A | +-------+
A | |    20 |
A | +-------+
A | 1 row in set (0.000 sec)
B | mysql> START TRANSACTION;
B | Query OK, 0 rows affected (0.000 sec)
B | mysql> SELECT stock FROM products WHERE id = 3 FOR SHARE;
B | +-------+
B | | stock |
B | +-------+
B | |    20 |
B | +-------+
B | 1 row in set (0.001 sec)
C | mysql> SELECT engine_transaction_id AS trx, lock_mode, lock_status, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09' AND lock_type = 'RECORD';
C | +-----------------+---------------+-------------+-----------+
C | | trx             | lock_mode     | lock_status | lock_data |
C | +-----------------+---------------+-------------+-----------+
C | | 562948421906432 | S,REC_NOT_GAP | GRANTED     | 3         |
C | | 562948421905624 | S,REC_NOT_GAP | GRANTED     | 3         |
C | +-----------------+---------------+-------------+-----------+
C | 2 rows in set (0.007 sec)
B | mysql> SET SESSION innodb_lock_wait_timeout = 2;
B | Query OK, 0 rows affected (0.000 sec)
B | mysql> UPDATE products SET stock = stock - 1 WHERE id = 3;
B | ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
A | mysql> COMMIT;
A | Query OK, 0 rows affected (0.000 sec)
B | mysql> ROLLBACK;
B | Query OK, 0 rows affected (0.000 sec)
What’s happening
  1. A and B both take FOR SHARE on the Pixel 10 row; shared locks are compatible, so both succeed and C sees two S,REC_NOT_GAP locks, both GRANTED.
  2. The long transaction ids (562948421906432…) are how InnoDB labels transactions that haven't written anything: read-only transactions don't get a real transaction id.
  3. B then tries to UPDATE the row. That needs an X lock, which conflicts with A's S lock, so B waits until its 2-second lock wait timeout: ERROR 1205.
  4. Note that B's own S lock doesn't help it: the conflict is with A's S lock. Two transactions that both hold S on a row and both try to upgrade to X deadlock each other — a common pattern behind "deadlock on a single row".
AdvancedWithout an index, FOR UPDATE locks the whole table

InnoDB locks index records it scans, not just the rows that match. products.name has no index, so finding 'Pixel 10' means scanning the primary key from start to end — and locking every row on the way:

SQL
-- Session A
START TRANSACTION;
SELECT id, name, stock FROM products WHERE name = 'Pixel 10' FOR UPDATE;
SELECT index_name, lock_mode, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09' AND object_name = 'products' AND lock_type = 'RECORD';
-- Session B
SET SESSION innodb_lock_wait_timeout = 2;
UPDATE products SET stock = stock + 1 WHERE id = 7;
-- Session A
ROLLBACK;
Result
A | mysql> START TRANSACTION;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SELECT id, name, stock FROM products WHERE name = 'Pixel 10' FOR UPDATE;
A | +----+----------+-------+
A | | id | name     | stock |
A | +----+----------+-------+
A | |  3 | Pixel 10 |    20 |
A | +----+----------+-------+
A | 1 row in set (0.000 sec)
A | mysql> SELECT index_name, lock_mode, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09' AND object_name = 'products' AND lock_type = 'RECORD';
A | +------------+-----------+------------------------+
A | | index_name | lock_mode | lock_data              |
A | +------------+-----------+------------------------+
A | | PRIMARY    | X         | supremum pseudo-record |
A | | PRIMARY    | X         | 1                      |
A | | PRIMARY    | X         | 2                      |
A | | PRIMARY    | X         | 3                      |
A | | PRIMARY    | X         | 4                      |
A | | PRIMARY    | X         | 5                      |
A | | PRIMARY    | X         | 6                      |
A | | PRIMARY    | X         | 7                      |
A | | PRIMARY    | X         | 8                      |
A | +------------+-----------+------------------------+
A | 9 rows in set (0.001 sec)
B | mysql> SET SESSION innodb_lock_wait_timeout = 2;
B | Query OK, 0 rows affected (0.000 sec)
B | mysql> UPDATE products SET stock = stock + 1 WHERE id = 7;
B | ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
A | mysql> ROLLBACK;
A | Query OK, 0 rows affected (0.000 sec)
What’s happening
  1. The query returns one row, but data_locks lists nine X locks: every product row (1–8) plus the supremum pseudo-record (the "end of index" marker, which locks the gap after the last row). Plain X means a next-key lock — the row and the gap before it.
  2. B tries to update product 7, the USB-C charger, which has nothing to do with A's query, and times out.
  3. In practice: make sure every UPDATE, DELETE and FOR UPDATE filters through an index. A missing index turns row locking into table locking.
  4. Under READ COMMITTED, InnoDB releases the locks on scanned rows that didn't match the WHERE once it has evaluated them, which softens this — one reason that level has fewer lock waits.
AdvancedLock wait timeouts

innodb_lock_wait_timeout (50 s by default) limits how long a statement waits for a row lock before failing with ERROR 1205 (HY000). Two details matter for application code:

SQL
SELECT @@innodb_lock_wait_timeout AS lock_wait_timeout, @@innodb_deadlock_detect AS deadlock_detect,
       @@innodb_print_all_deadlocks AS print_all_deadlocks, @@innodb_rollback_on_timeout AS rollback_on_timeout;
Result
+-------------------+-----------------+---------------------+---------------------+
| lock_wait_timeout | deadlock_detect | print_all_deadlocks | rollback_on_timeout |
+-------------------+-----------------+---------------------+---------------------+
|                50 |               1 |                   0 |                   0 |
+-------------------+-----------------+---------------------+---------------------+
What’s happening
  1. With innodb_rollback_on_timeout = 0 (the default), a lock wait timeout rolls back only the statement that timed out. The transaction stays open with its earlier changes and locks — exactly like the CHECK-constraint error in the first topic. Your code must roll back (or retry the statement) explicitly.
  2. A deadlock is different: InnoDB rolls back the whole victim transaction.
  3. The timeout can be set per session (SET SESSION innodb_lock_wait_timeout = 5), which is how the examples in this chapter keep their waits to 2–3 seconds. For a statement that must never wait at all, see NOWAIT and SKIP LOCKED.

#Row, gap and next-key locks

Added

InnoDB doesn't lock rows; it locks index records, and in REPEATABLE READ it also locks the gaps between them. There are four kinds you see in data_locks:

  • A record lock (X,REC_NOT_GAP / S,REC_NOT_GAP) locks one index record.
  • A gap lock (X,GAP) locks the open interval before a record, so nobody can insert into it. It doesn't lock the record itself. Gap locks never conflict with each other — two transactions can hold the same gap — they only block inserts.
  • A next-key lock (plain X or S) is a record lock plus the gap before it: the half-open interval (previous record, this record]. It's InnoDB's default lock for locking reads and searches in REPEATABLE READ.
  • An insert intention lock (X,GAP,INSERT_INTENTION) is what an INSERT takes on the gap it inserts into; it waits if someone holds a gap or next-key lock covering that gap.

Why gaps? A locking read with a range — WHERE id BETWEEN 15 AND 25 FOR UPDATE — promises that running it again in the same transaction returns the same rows. Locking the existing rows isn't enough: someone could insert id = 17. So InnoDB also locks the gaps the scan covered. Mental model: you're not just holding the books you took off the shelf, you're also standing in the gaps between them so nobody can slide a new book in.

10 20 30 next-key lock "X" on 20 gap (10, 20) + record 20 gap lock "X,GAP" on 30 gap (20, 30), record 30 not locked insert 5 ✓ insert 35 ✓ insert 17 waits insert 27 waits
What the range lock below really covered, read from performance_schema.data_locks.
SQL
-- Setup, run once before the sessions start
CREATE TABLE gap_demo (id INT PRIMARY KEY, note VARCHAR(20));
INSERT INTO gap_demo VALUES (10,'ten'),(20,'twenty'),(30,'thirty');
-- Session A
START TRANSACTION;
SELECT * FROM gap_demo WHERE id BETWEEN 15 AND 25 FOR UPDATE;
SELECT index_name, lock_type, lock_mode, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09' AND object_name = 'gap_demo';
-- Session B
SET SESSION innodb_lock_wait_timeout = 2;
INSERT INTO gap_demo VALUES (17, 'seventeen');
INSERT INTO gap_demo VALUES (27, 'twenty-seven');
INSERT INTO gap_demo VALUES (5, 'five');
INSERT INTO gap_demo VALUES (35, 'thirty-five');
UPDATE gap_demo SET note = 'TEN' WHERE id = 10;
-- Session A
ROLLBACK;
-- Session B
DELETE FROM gap_demo WHERE id IN (5, 35);
UPDATE gap_demo SET note = 'ten' WHERE id = 10;
Result
A | mysql> START TRANSACTION;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SELECT * FROM gap_demo WHERE id BETWEEN 15 AND 25 FOR UPDATE;
A | +----+--------+
A | | id | note   |
A | +----+--------+
A | | 20 | twenty |
A | +----+--------+
A | 1 row in set (0.000 sec)
A | mysql> SELECT index_name, lock_type, lock_mode, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09' AND object_name = 'gap_demo';
A | +------------+-----------+-----------+-----------+
A | | index_name | lock_type | lock_mode | lock_data |
A | +------------+-----------+-----------+-----------+
A | | NULL       | TABLE     | IX        | NULL      |
A | | PRIMARY    | RECORD    | X,GAP     | 30        |
A | | PRIMARY    | RECORD    | X         | 20        |
A | +------------+-----------+-----------+-----------+
A | 3 rows in set (0.000 sec)
B | mysql> SET SESSION innodb_lock_wait_timeout = 2;
B | Query OK, 0 rows affected (0.000 sec)
B | mysql> INSERT INTO gap_demo VALUES (17, 'seventeen');
B | ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
B | mysql> INSERT INTO gap_demo VALUES (27, 'twenty-seven');
B | ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
B | mysql> INSERT INTO gap_demo VALUES (5, 'five');
B | Query OK, 1 row affected (0.009 sec)
B | mysql> INSERT INTO gap_demo VALUES (35, 'thirty-five');
B | Query OK, 1 row affected (0.002 sec)
B | mysql> UPDATE gap_demo SET note = 'TEN' WHERE id = 10;
B | Query OK, 1 row affected (0.003 sec)
B | Rows matched: 1  Changed: 1  Warnings: 0
A | mysql> ROLLBACK;
A | Query OK, 0 rows affected (0.000 sec)
B | mysql> DELETE FROM gap_demo WHERE id IN (5, 35); 
B | Query OK, 2 rows affected (0.004 sec)
B | mysql> UPDATE gap_demo SET note = 'ten' WHERE id = 10;
B | Query OK, 1 row affected (0.004 sec)
B | Rows matched: 1  Changed: 1  Warnings: 0
What’s happening
  1. A's range 15 … 25 contains only the row 20. data_locks shows what that cost: a next-key lock X on 20 (the gap (10, 20) and row 20) and a gap lock X,GAP on 30 (the gap (20, 30), not row 30). Together they cover (10, 30) — every place a row with id 15–25 could ever be inserted, rounded out to the neighbouring records.
  2. B's INSERT of 17 lands in the gap (10, 20) and waits until its 2-second timeout.
  3. 27 is outside A's range, but it lands in the locked gap (20, 30), so it waits too. Gap locks cover whole gaps between existing records, not just your WHERE range — the cost of phantom protection.
  4. 5 and 35 are outside both gaps and insert immediately. Updating row 10 also works: 10 is the boundary of the locked gap, but its record isn't locked.
  5. In the earlier examples an equality on the primary key took only X,REC_NOT_GAP: for a unique index and an existing row, InnoDB knows no other row can match, so it skips the gap.
AdvancedLocking a row that doesn't exist, and READ COMMITTED

Two more cases: an equality on a key that doesn't exist, and the same range query at READ COMMITTED:

SQL
-- Session A
START TRANSACTION;
SELECT * FROM gap_demo WHERE id = 25 FOR UPDATE;
-- Session B
SET SESSION innodb_lock_wait_timeout = 3;
INSERT INTO gap_demo VALUES (22, 'twenty-two');
-- Session C
SELECT engine_transaction_id AS trx, lock_mode, lock_status, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09' AND object_name = 'gap_demo' AND lock_type = 'RECORD';
-- Session A
ROLLBACK;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT * FROM gap_demo WHERE id BETWEEN 15 AND 25 FOR UPDATE;
SELECT lock_mode, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09' AND object_name = 'gap_demo' AND lock_type = 'RECORD';
-- Session B
INSERT INTO gap_demo VALUES (17, 'seventeen');
-- Session A
SELECT * FROM gap_demo WHERE id BETWEEN 15 AND 25 FOR UPDATE;
ROLLBACK;
-- Session B
DELETE FROM gap_demo WHERE id = 17;
Result
A | mysql> START TRANSACTION;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SELECT * FROM gap_demo WHERE id = 25 FOR UPDATE;
A | Empty set (0.000 sec)
B | mysql> SET SESSION innodb_lock_wait_timeout = 3;
B | Query OK, 0 rows affected (0.000 sec)
B | mysql> INSERT INTO gap_demo VALUES (22, 'twenty-two');
C | mysql> SELECT engine_transaction_id AS trx, lock_mode, lock_status, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09' AND object_name = 'gap_demo' AND lock_type = 'RECORD';
C | +--------+------------------------+-------------+-----------+
C | | trx    | lock_mode              | lock_status | lock_data |
C | +--------+------------------------+-------------+-----------+
C | | 110166 | X,GAP                  | GRANTED     | 30        |
C | | 110167 | X,GAP,INSERT_INTENTION | WAITING     | 30        |
C | +--------+------------------------+-------------+-----------+
C | 2 rows in set (0.001 sec)
B | ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
A | mysql> ROLLBACK;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> START TRANSACTION;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SELECT * FROM gap_demo WHERE id BETWEEN 15 AND 25 FOR UPDATE;
A | +----+--------+
A | | id | note   |
A | +----+--------+
A | | 20 | twenty |
A | +----+--------+
A | 1 row in set (0.000 sec)
A | mysql> SELECT lock_mode, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09' AND object_name = 'gap_demo' AND lock_type = 'RECORD';
A | +---------------+-----------+
A | | lock_mode     | lock_data |
A | +---------------+-----------+
A | | X,REC_NOT_GAP | 20        |
A | +---------------+-----------+
A | 1 row in set (0.001 sec)
B | mysql> INSERT INTO gap_demo VALUES (17, 'seventeen');
B | Query OK, 1 row affected (0.008 sec)
A | mysql> SELECT * FROM gap_demo WHERE id BETWEEN 15 AND 25 FOR UPDATE;
A | +----+-----------+
A | | id | note      |
A | +----+-----------+
A | | 17 | seventeen |
A | | 20 | twenty    |
A | +----+-----------+
A | 2 rows in set (0.000 sec)
A | mysql> ROLLBACK;
A | Query OK, 0 rows affected (0.000 sec)
B | mysql> DELETE FROM gap_demo WHERE id = 17;
B | Query OK, 1 row affected (0.002 sec)
What’s happening
  1. WHERE id = 25 FOR UPDATE finds nothing, but still locks: an X,GAP lock on 30, i.e. the gap (20, 30) where 25 would go. This is how InnoDB stops anyone creating id 25 while A "owns" its absence.
  2. B's insert of 22 shows up in C's query as X,GAP,INSERT_INTENTION with status WAITING on record 30 — the insert intention lock queued behind A's gap lock — until B times out.
  3. A switches to READ COMMITTED and repeats the range query: now data_locks shows only X,REC_NOT_GAP on 20. No gap locks.
  4. So B's insert of 17 succeeds at once, and A's identical query returns two rows — a phantom, which READ COMMITTED allows. That's the trade: fewer locks and waits, weaker guarantees.
  5. One case keeps gap-style locking even in READ COMMITTED: duplicate-key checks for unique indexes and foreign keys.
Which statements wait?

gap_demo holds ids 10, 20 and 30 (primary key). Session A, in REPEATABLE READ, runs START TRANSACTION; SELECT * FROM gap_demo WHERE id BETWEEN 15 AND 25 FOR UPDATE; and stays open. Which of these, run by session B, have to wait?

  1. INSERT INTO gap_demo VALUES (17, 'x');
  2. INSERT INTO gap_demo VALUES (27, 'x');
  3. INSERT INTO gap_demo VALUES (35, 'x');
  4. UPDATE gap_demo SET note = 'TEN' WHERE id = 10;
Show answer

Only 1 and 2 wait; 3 and 4 run immediately — exactly what the example above showed.

What’s happening
  1. A's scan locked the next-key (10, 20] on record 20 and the gap (20, 30) before record 30: everything strictly between 10 and 30.
  2. 17 falls in (10, 20) and 27 in (20, 30), so both inserts need an insert-intention lock in a locked gap and wait. 27 is outside A's WHERE, but gaps are locked whole.
  3. 35 falls in the gap after 30, which nobody locked, so it inserts at once.
  4. Row 10 is the left boundary of a locked gap, but its own record isn't locked, so the update goes through.

#Deadlocks

Added

A deadlock is a cycle of waits: transaction A holds a lock B needs, while B holds a lock A needs. Neither can ever proceed. InnoDB checks for cycles every time a transaction has to wait (innodb_deadlock_detect, on by default), and when it finds one it rolls back one transaction — the "victim", normally the one that has modified the fewest rows — so the other can continue. The victim gets ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction.

Deadlocks aren't a bug in the database; they're a normal consequence of row locking under concurrency, and InnoDB resolves them in milliseconds. The application's job is to retry the whole transaction when it gets 1213, and to make deadlocks rare. The mental model is two cars meeting on a one-lane bridge: each is waiting for the other to reverse; the traffic warden (InnoDB) picks one and sends it back to the start.

The classic case: two transfers in opposite directions, each locking its own source account first.

SQL
-- Session A
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- Session B
START TRANSACTION;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
-- Session A
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- Session B
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
-- Session A
COMMIT;
SELECT * FROM accounts;
Result
A | mysql> START TRANSACTION;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> UPDATE accounts SET balance = balance - 100 WHERE id = 1;
A | Query OK, 1 row affected (0.001 sec)
A | Rows matched: 1  Changed: 1  Warnings: 0
B | mysql> START TRANSACTION;
B | Query OK, 0 rows affected (0.000 sec)
B | mysql> UPDATE accounts SET balance = balance - 50 WHERE id = 2;
B | Query OK, 1 row affected (0.001 sec)
B | Rows matched: 1  Changed: 1  Warnings: 0
A | mysql> UPDATE accounts SET balance = balance + 100 WHERE id = 2;
B | mysql> UPDATE accounts SET balance = balance + 50 WHERE id = 1;
A | Query OK, 1 row affected (1.079 sec)
A | Rows matched: 1  Changed: 1  Warnings: 0
B | ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
A | mysql> COMMIT;
A | Query OK, 0 rows affected (0.003 sec)
A | mysql> SELECT * FROM accounts;
A | +----+-------+---------+
A | | id | owner | balance |
A | +----+-------+---------+
A | |  1 | Rohit |  400.00 |
A | |  2 | Maya  |  400.00 |
A | |  3 | Liam  |  100.00 |
A | +----+-------+---------+
A | 3 rows in set (0.000 sec)
What’s happening
  1. A locks account 1 (debit Rohit), B locks account 2 (debit Maya). No conflict yet.
  2. A wants account 2 to credit Maya — held by B — so A waits. There's no output under A's statement yet.
  3. B wants account 1 to credit Rohit — held by A. Now A waits for B and B waits for A: a cycle. InnoDB detects it immediately and rolls back B: ERROR 1213 (40001).
  4. With B's locks released, A's waiting update completes (its 1.08 s is the time it spent waiting while the script moved on to B's step). A commits: Rohit 400, Maya 400.
  5. B's transaction was rolled back entirely — its debit of Maya too — so the data is consistent; B's transfer simply didn't happen. Its application should now retry it from START TRANSACTION.
AdvancedReading LATEST DETECTED DEADLOCK

SHOW ENGINE INNODB STATUS keeps the most recent deadlock. Right after the example above:

SQL
SHOW ENGINE INNODB STATUS\G
Result
LATEST DETECTED DEADLOCK
------------------------
2026-10-16 16:06:50 281472820768448
*** (1) TRANSACTION:
TRANSACTION 110198, ACTIVE 1 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
MySQL thread id 1162, OS thread handle 281471505174208, query id 534844 localhost root updating
UPDATE accounts SET balance = balance + 100 WHERE id = 2

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 970 page no 4 n bits 72 index PRIMARY of table `ch09`.`accounts` trx id 110198 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
 0: len 4; hex 80000001; asc     ;;
 1: len 6; hex 00000001ae76; asc      v;;
 2: len 7; hex 020000008f0802; asc        ;;
 3: len 5; hex 526f686974; asc Rohit;;
 4: len 5; hex 8000019000; asc      ;;

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 970 page no 4 n bits 72 index PRIMARY of table `ch09`.`accounts` trx id 110198 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
 0: len 4; hex 80000002; asc     ;;
 1: len 6; hex 00000001ae77; asc      w;;
 2: len 7; hex 01000001702d1a; asc     p- ;;
 3: len 4; hex 4d617961; asc Maya;;
 4: len 5; hex 800000fa00; asc      ;;

*** (2) TRANSACTION:
TRANSACTION 110199, ACTIVE 1 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
MySQL thread id 1161, OS thread handle 281471506292416, query id 534845 localhost root updating
UPDATE accounts SET balance = balance + 50 WHERE id = 1

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 970 page no 4 n bits 72 index PRIMARY of table `ch09`.`accounts` trx id 110199 lock_mode X locks rec but not gap
Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
 0: len 4; hex 80000002; asc     ;;
 1: len 6; hex 00000001ae77; asc      w;;
 2: len 7; hex 01000001702d1a; asc     p- ;;
 3: len 4; hex 4d617961; asc Maya;;
 4: len 5; hex 800000fa00; asc      ;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 970 page no 4 n bits 72 index PRIMARY of table `ch09`.`accounts` trx id 110199 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
 0: len 4; hex 80000001; asc     ;;
 1: len 6; hex 00000001ae76; asc      v;;
 2: len 7; hex 020000008f0802; asc        ;;
 3: len 5; hex 526f686974; asc Rohit;;
 4: len 5; hex 8000019000; asc      ;;

*** WE ROLL BACK TRANSACTION (2)
What’s happening
  1. Transaction (1) is A: its current statement is UPDATE accounts … WHERE id = 2. HOLDS THE LOCK(S) shows lock_mode X locks rec but not gap on the record whose first field is hex 80000001 — the INT primary key 1 (InnoDB stores signed integers with the sign bit flipped, so 80000001 is 1). The other fields are the hidden transaction id, the roll pointer, Rohit and the balance.
  2. WAITING FOR THIS LOCK for (1) is the record 80000002 — account 2.
  3. Transaction (2) is B: holds record 2, waits for record 1, running UPDATE accounts … WHERE id = 1. That's the cycle, spelled out.
  4. *** WE ROLL BACK TRANSACTION (2) names the victim. Both transactions had modified one row, so InnoDB picked the one that arrived second.
  5. Only the latest deadlock is kept. Set innodb_print_all_deadlocks = ON to have every deadlock written to the error log, which is what you want in production to see patterns.
AdvancedA deadlock from gap locks: insert-if-missing

Two sessions each check whether a key exists with FOR UPDATE, and insert it if not — a very common pattern for "get or create":

SQL
-- Session A
START TRANSACTION;
SELECT * FROM gap_demo WHERE id = 25 FOR UPDATE;
-- Session B
START TRANSACTION;
SELECT * FROM gap_demo WHERE id = 26 FOR UPDATE;
-- Session C
SELECT engine_transaction_id AS trx, lock_mode, lock_status, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09' AND object_name = 'gap_demo' AND lock_type = 'RECORD';
-- Session A
INSERT INTO gap_demo VALUES (25, 'twenty-five');
-- Session B
INSERT INTO gap_demo VALUES (26, 'twenty-six');
-- Session A
COMMIT;
SELECT * FROM gap_demo;
DELETE FROM gap_demo WHERE id = 25;
Result
A | mysql> START TRANSACTION;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SELECT * FROM gap_demo WHERE id = 25 FOR UPDATE;
A | Empty set (0.000 sec)
B | mysql> START TRANSACTION;
B | Query OK, 0 rows affected (0.000 sec)
B | mysql> SELECT * FROM gap_demo WHERE id = 26 FOR UPDATE;
B | Empty set (0.000 sec)
C | mysql> SELECT engine_transaction_id AS trx, lock_mode, lock_status, lock_data FROM performance_schema.data_locks WHERE object_schema = 'ch09' AND object_name = 'gap_demo' AND lock_type = 'RECORD';
C | +--------+-----------+-------------+-----------+
C | | trx    | lock_mode | lock_status | lock_data |
C | +--------+-----------+-------------+-----------+
C | | 110227 | X,GAP     | GRANTED     | 30        |
C | | 110226 | X,GAP     | GRANTED     | 30        |
C | +--------+-----------+-------------+-----------+
C | 2 rows in set (0.001 sec)
A | mysql> INSERT INTO gap_demo VALUES (25, 'twenty-five');
B | mysql> INSERT INTO gap_demo VALUES (26, 'twenty-six');
B | ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
A | Query OK, 1 row affected (1.078 sec)
A | mysql> COMMIT;
A | Query OK, 0 rows affected (0.003 sec)
A | mysql> SELECT * FROM gap_demo;
A | +----+-------------+
A | | id | note        |
A | +----+-------------+
A | | 10 | ten         |
A | | 20 | twenty      |
A | | 25 | twenty-five |
A | | 30 | thirty      |
A | +----+-------------+
A | 4 rows in set (0.000 sec)
A | mysql> DELETE FROM gap_demo WHERE id = 25;
A | Query OK, 1 row affected (0.003 sec)
What’s happening
  1. Neither 25 nor 26 exists, so both locking reads take a gap lock on the gap (20, 30) — C shows two X,GAP locks on 30, both GRANTED. Gap locks don't conflict with each other.
  2. A's INSERT of 25 needs an insert-intention lock in that gap, which conflicts with B's gap lock: A waits.
  3. B's INSERT of 26 conflicts with A's gap lock: B waits for A while A waits for B — deadlock. B is rolled back, A's insert completes.
  4. The deadlock report below confirms it.
AdvancedThe gap-lock deadlock report

The LATEST DETECTED DEADLOCK section straight afterwards (the hex field dumps are left out here; they only show the record 30):

SQL
SHOW ENGINE INNODB STATUS\G
Result
LATEST DETECTED DEADLOCK
------------------------
2026-10-16 16:06:54 281472820768448
*** (1) TRANSACTION:
TRANSACTION 110226, ACTIVE 1 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s)
MySQL thread id 1165, OS thread handle 281472764452544, query id 534871 localhost root update
INSERT INTO gap_demo VALUES (25, 'twenty-five')

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 971 page no 4 n bits 72 index PRIMARY of table `ch09`.`gap_demo` trx id 110226 lock_mode X locks gap before rec
Record lock, heap no 4 PHYSICAL RECORD: n_fields 4; compact format; info bits 0

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 971 page no 4 n bits 72 index PRIMARY of table `ch09`.`gap_demo` trx id 110226 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 4 PHYSICAL RECORD: n_fields 4; compact format; info bits 0

*** (2) TRANSACTION:
TRANSACTION 110227, ACTIVE 1 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s)
MySQL thread id 1167, OS thread handle 281472362917568, query id 534872 localhost root update
INSERT INTO gap_demo VALUES (26, 'twenty-six')

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 971 page no 4 n bits 72 index PRIMARY of table `ch09`.`gap_demo` trx id 110227 lock_mode X locks gap before rec
Record lock, heap no 4 PHYSICAL RECORD: n_fields 4; compact format; info bits 0

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 971 page no 4 n bits 72 index PRIMARY of table `ch09`.`gap_demo` trx id 110227 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 4 PHYSICAL RECORD: n_fields 4; compact format; info bits 0

*** WE ROLL BACK TRANSACTION (2)
What’s happening
  1. Both transactions hold lock_mode X locks gap before rec — the gap lock before record 30 — and both are waiting for lock_mode X locks gap before rec insert intention, the insert-intention lock in that same gap.
  2. Each insert-intention request conflicts with the other transaction's gap lock, which is the cycle. InnoDB rolled back transaction (2), B.
  3. Fixes for this pattern: use INSERT … ON DUPLICATE KEY UPDATE (or INSERT IGNORE) and let the unique index arbitrate (see Upserts: ON DUPLICATE KEY UPDATE); run at READ COMMITTED, where the empty FOR UPDATE takes no gap lock; or simply catch 1213 and retry.
AdvancedRetrying deadlocked transactions from Node.js

The victim must re-run the whole transaction, not just the failed statement, because everything it did was rolled back. A small retry wrapper with mysql2 — the two transfers are forced to deadlock by pausing between their two updates:

retry-deadlock.mjs
import mysql from "mysql2/promise";
import { setTimeout as sleep } from "node:timers/promises";

const config = { host: "127.0.0.1", port: 3307, user: "root", password: "study", database: "ch09" };
const RETRYABLE = new Set(["ER_LOCK_DEADLOCK", "ER_LOCK_WAIT_TIMEOUT"]);

// One transfer = one transaction. pauseMs widens the window so the demo deadlocks every time.
async function transfer(conn, from, to, amount, pauseMs) {
  await conn.beginTransaction();
  try {
    await conn.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", [amount, from]);
    await sleep(pauseMs);
    await conn.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", [amount, to]);
    await conn.commit();
  } catch (err) {
    await conn.rollback(); // InnoDB already rolled back the victim; this also resets the connection's state
    throw err;
  }
}

// Re-run the WHOLE transaction when InnoDB picks it as a deadlock victim.
async function withRetry(label, fn, attempts = 3) {
  for (let attempt = 1; ; attempt++) {
    try {
      const result = await fn();
      console.log(`${label}: committed on attempt ${attempt}`);
      return result;
    } catch (err) {
      if (!RETRYABLE.has(err.code) || attempt === attempts) throw err;
      console.log(`${label}: attempt ${attempt} failed: ${err.code} (${err.errno}, SQLSTATE ${err.sqlState}) ${err.message}`);
      await sleep(50 * attempt); // back off a little before trying again
    }
  }
}

const a = await mysql.createConnection(config);
const b = await mysql.createConnection(config);
await a.query("UPDATE accounts SET balance = CASE id WHEN 1 THEN 500 WHEN 2 THEN 300 ELSE balance END");

await Promise.all([
  withRetry("A: Rohit -> Maya 100", () => transfer(a, 1, 2, 100, 200)),
  withRetry("B: Maya -> Rohit  50", () => transfer(b, 2, 1, 50, 200)),
]);

const [rows] = await a.query("SELECT id, owner, balance FROM accounts WHERE id IN (1, 2)");
console.table(rows);
await a.end();
await b.end();
Result
A: Rohit -> Maya 100: attempt 1 failed: ER_LOCK_DEADLOCK (1213, SQLSTATE 40001) Deadlock found when trying to get lock; try restarting transaction
B: Maya -> Rohit  50: committed on attempt 1
A: Rohit -> Maya 100: committed on attempt 2
┌─────────┬────┬─────────┬──────────┐
│ (index) │ id │ owner   │ balance  │
├─────────┼────┼─────────┼──────────┤
│ 0       │ 1  │ 'Rohit' │ '450.00' │
│ 1       │ 2  │ 'Maya'  │ '350.00' │
└─────────┴────┴─────────┴──────────┘
What’s happening
  1. Both transfers start together on separate connections; each debits its source account and then sleeps 200 ms, so each holds one row lock when it asks for the other's: the same cycle as the SQL example.
  2. InnoDB rolled back transfer A in this run (in earlier runs it picked B — which one loses depends on timing). mysql2 surfaces it as an error with code: 'ER_LOCK_DEADLOCK', errno: 1213, sqlState: '40001'.
  3. The other transfer commits; withRetry waits 50 ms and re-runs the whole victim from beginTransaction(), which now succeeds on attempt 2.
  4. Final balances are right: Rohit 500 − 100 + 50 = 450, Maya 300 + 100 − 50 = 350. (DECIMAL columns arrive as strings in mysql2 so no precision is lost.)
  5. Retry only errors that mean "try again" (1213 deadlock, and 1205 lock wait timeout if your transaction is safe to repeat), cap the attempts, and keep side effects such as sending emails outside the retried function.
AdvancedTip: making deadlocks rare

#NOWAIT and SKIP LOCKED

Added

Since MySQL 8.0, a locking read can say what to do when a row is already locked, instead of waiting:

  • NOWAIT — fail immediately with an error if any row it needs is locked. For "if someone else is editing this, tell the user now" rather than hanging for 50 seconds.
  • SKIP LOCKED — silently leave the locked rows out of the result. For work queues: many workers each grab "the next job nobody else has", without blocking each other.

Mental model: at a deli counter, NOWAIT is walking away as soon as you see a queue; SKIP LOCKED is the next free assistant serving the next customer who isn't already being served.

SQL
-- Setup, run once before the sessions start
CREATE TABLE jobs (
  id     INT AUTO_INCREMENT PRIMARY KEY,
  task   VARCHAR(40) NOT NULL,
  status ENUM('queued','running','done') NOT NULL DEFAULT 'queued',
  worker VARCHAR(10) NULL,
  INDEX idx_status_id (status, id)
);
INSERT INTO jobs (task) VALUES ('send receipt #1'), ('send receipt #2'), ('resize image #7'), ('rebuild search index');
-- Session A
START TRANSACTION;
SELECT id, task FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;
-- Session B
START TRANSACTION;
SELECT id, task FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;
SELECT id, task FROM jobs WHERE id = 1 FOR UPDATE NOWAIT;
SELECT id, task FROM jobs WHERE status = 'queued' ORDER BY id FOR UPDATE NOWAIT;
-- Session A
UPDATE jobs SET status = 'running', worker = 'A' WHERE id = 1;
COMMIT;
-- Session B
UPDATE jobs SET status = 'running', worker = 'B' WHERE id = 2;
COMMIT;
-- Session A
SELECT * FROM jobs;
Result
A | mysql> START TRANSACTION;
A | Query OK, 0 rows affected (0.000 sec)
A | mysql> SELECT id, task FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;
A | +----+-----------------+
A | | id | task            |
A | +----+-----------------+
A | |  1 | send receipt #1 |
A | +----+-----------------+
A | 1 row in set (0.001 sec)
B | mysql> START TRANSACTION;
B | Query OK, 0 rows affected (0.000 sec)
B | mysql> SELECT id, task FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;
B | +----+-----------------+
B | | id | task            |
B | +----+-----------------+
B | |  2 | send receipt #2 |
B | +----+-----------------+
B | 1 row in set (0.001 sec)
B | mysql> SELECT id, task FROM jobs WHERE id = 1 FOR UPDATE NOWAIT;
B | ERROR 3572 (HY000): Statement aborted because lock(s) could not be acquired immediately and NOWAIT is set.
B | mysql> SELECT id, task FROM jobs WHERE status = 'queued' ORDER BY id FOR UPDATE NOWAIT;
B | ERROR 3572 (HY000): Statement aborted because lock(s) could not be acquired immediately and NOWAIT is set.
A | mysql> UPDATE jobs SET status = 'running', worker = 'A' WHERE id = 1;
A | Query OK, 1 row affected (0.000 sec)
A | Rows matched: 1  Changed: 1  Warnings: 0
A | mysql> COMMIT;
A | Query OK, 0 rows affected (0.003 sec)
B | mysql> UPDATE jobs SET status = 'running', worker = 'B' WHERE id = 2;
B | Query OK, 1 row affected (0.000 sec)
B | Rows matched: 1  Changed: 1  Warnings: 0
B | mysql> COMMIT;
B | Query OK, 0 rows affected (0.007 sec)
A | mysql> SELECT * FROM jobs;
A | +----+----------------------+---------+--------+
A | | id | task                 | status  | worker |
A | +----+----------------------+---------+--------+
A | |  1 | send receipt #1      | running | A      |
A | |  2 | send receipt #2      | running | B      |
A | |  3 | resize image #7      | queued  | NULL   |
A | |  4 | rebuild search index | queued  | NULL   |
A | +----+----------------------+---------+--------+
A | 4 rows in set (0.000 sec)
What’s happening
  1. A asks for the first queued job with FOR UPDATE SKIP LOCKED and gets job 1, now locked by A.
  2. B runs the identical query. Job 1 is locked, so it's skipped, and B gets job 2 — no waiting, no duplicate work.
  3. B tries job 1 directly with NOWAIT: instead of waiting, it fails at once with ERROR 3572 (HY000): Statement aborted because lock(s) could not be acquired immediately and NOWAIT is set. A whole-queue NOWAIT read fails the same way because one of its rows (job 1) is locked.
  4. Each worker marks its job running and commits, which releases the lock quickly; the actual work happens afterwards, outside the transaction. Jobs 3 and 4 are still queued for the next workers.
  5. The index on (status, id) matters: it lets each worker find "the first queued job" without scanning (and locking) the whole table.
AdvancedThree Node.js workers sharing one queue

The same claim query from three concurrent workers, using mysql2:

workers.mjs
import mysql from "mysql2/promise";
import { setTimeout as sleep } from "node:timers/promises";

const pool = mysql.createPool({ host: "127.0.0.1", port: 3307, user: "root", password: "study", database: "ch09", connectionLimit: 5 });

await pool.query("TRUNCATE jobs");
await pool.query("INSERT INTO jobs (task) VALUES " + Array.from({ length: 9 }, (_, i) => `('job ${i + 1}')`).join(", "));

// Claim one job: lock the first queued row nobody else holds, mark it running, commit (so the lock is short).
async function claim(conn, worker) {
  await conn.beginTransaction();
  const [rows] = await conn.query(
    "SELECT id, task FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED",
  );
  if (rows.length === 0) { await conn.commit(); return null; }
  await conn.query("UPDATE jobs SET status = 'running', worker = ? WHERE id = ?", [worker, rows[0].id]);
  await conn.commit();
  return rows[0];
}

async function worker(name) {
  const conn = await pool.getConnection();
  const done = [];
  try {
    for (let job; (job = await claim(conn, name)); ) {
      await sleep(20 + (job.id % 3) * 15); // pretend to do the work
      await conn.query("UPDATE jobs SET status = 'done' WHERE id = ?", [job.id]);
      done.push(job.id);
    }
  } finally {
    conn.release();
  }
  console.log(`${name} processed jobs ${done.join(", ")}`);
}

await Promise.all(["W1", "W2", "W3"].map(worker));
const [summary] = await pool.query("SELECT status, COUNT(*) AS jobs, COUNT(DISTINCT worker) AS workers FROM jobs GROUP BY status");
console.table(summary);
await pool.end();
Result
W3 processed jobs 3, 4, 7
W1 processed jobs 1, 5, 9
W2 processed jobs 2, 6, 8
┌─────────┬────────┬──────┬─────────┐
│ (index) │ status │ jobs │ workers │
├─────────┼────────┼──────┼─────────┤
│ 0       │ 'done' │ 9    │ 3       │
└─────────┴────────┴──────┴─────────┘
What’s happening
  1. Three workers start at once, each with its own pooled connection, and each claims jobs in a loop until the claim query returns nothing.
  2. On the first round they get jobs 1, 2 and 3 — each one's SKIP LOCKED passed over the jobs already locked by the others.
  3. Work takes 20–50 ms depending on the job, so the workers come back at different times and the remaining jobs get spread unevenly but each exactly once: the summary shows all 9 done, by 3 workers, with no job in two lists. (Which worker gets which job varies between runs.)
  4. A crash between "claim" and "done" leaves a job running forever, so real queues also store a claim timestamp and re-queue stale jobs. Dedicated brokers (SQS, RabbitMQ, Redis Streams) handle that for you; SKIP LOCKED makes the database a perfectly good small queue.
AdvancedPitfall: SKIP LOCKED isn't a consistent read

#Optimistic vs pessimistic locking

Added

There are two ways to stop concurrent users overwriting each other's changes:

  • Pessimistic locking assumes conflicts are likely and locks first: SELECT … FOR UPDATE, decide, write, commit. Others wait. Simple and correct, but it holds locks for the whole read-decide-write cycle, so it only works when that cycle is short and inside one database transaction.
  • Optimistic locking assumes conflicts are rare and checks at write time: every row has a version number; you read the row and its version, and you write with … WHERE id = ? AND version = ?, incrementing the version. If someone else wrote in between, your UPDATE matches 0 rows and you know you lost the race — reload and retry, or tell the user. No locks are held between the read and the write, so it works across HTTP requests and minutes of user think-time.

Mental model: pessimistic is taking the only key to the meeting room; optimistic is writing your edit on a numbered draft — if the number on the master copy has changed since you picked up your draft, someone got there first.

SQL
-- Setup, run once
ALTER TABLE products ADD COLUMN version INT NOT NULL DEFAULT 1;
-- Session A
SELECT id, name, price, version FROM products WHERE id = 3;
-- Session B
SELECT id, name, price, version FROM products WHERE id = 3;
-- Session A
UPDATE products SET price = 749.00, version = version + 1 WHERE id = 3 AND version = 1;
-- Session B
UPDATE products SET price = 699.00, version = version + 1 WHERE id = 3 AND version = 1;
SELECT id, name, price, version FROM products WHERE id = 3;
-- Session A
UPDATE products SET stock = stock - 1 WHERE id = 4 AND stock >= 1;
SELECT id, name, stock FROM products WHERE id = 4;
Result
A | mysql> SELECT id, name, price, version FROM products WHERE id = 3;
A | +----+----------+--------+---------+
A | | id | name     | price  | version |
A | +----+----------+--------+---------+
A | |  3 | Pixel 10 | 799.00 |       1 |
A | +----+----------+--------+---------+
A | 1 row in set (0.000 sec)
B | mysql> SELECT id, name, price, version FROM products WHERE id = 3;
B | +----+----------+--------+---------+
B | | id | name     | price  | version |
B | +----+----------+--------+---------+
B | |  3 | Pixel 10 | 799.00 |       1 |
B | +----+----------+--------+---------+
B | 1 row in set (0.000 sec)
A | mysql> UPDATE products SET price = 749.00, version = version + 1 WHERE id = 3 AND version = 1;
A | Query OK, 1 row affected (0.005 sec)
A | Rows matched: 1  Changed: 1  Warnings: 0
B | mysql> UPDATE products SET price = 699.00, version = version + 1 WHERE id = 3 AND version = 1;
B | Query OK, 0 rows affected (0.001 sec)
B | Rows matched: 0  Changed: 0  Warnings: 0
B | mysql> SELECT id, name, price, version FROM products WHERE id = 3;
B | +----+----------+--------+---------+
B | | id | name     | price  | version |
B | +----+----------+--------+---------+
B | |  3 | Pixel 10 | 749.00 |       2 |
B | +----+----------+--------+---------+
B | 1 row in set (0.000 sec)
A | mysql> UPDATE products SET stock = stock - 1 WHERE id = 4 AND stock >= 1;
A | Query OK, 0 rows affected (0.000 sec)
A | Rows matched: 0  Changed: 0  Warnings: 0
A | mysql> SELECT id, name, stock FROM products WHERE id = 4;
A | +----+-----------+-------+
A | | id | name      | stock |
A | +----+-----------+-------+
A | |  4 | iPhone 17 |     0 |
A | +----+-----------+-------+
A | 1 row in set (0.000 sec)
What’s happening
  1. A and B (say, two admins in two browser tabs) both load the Pixel 10: price 799, version 1. No locks are taken — these are plain reads, possibly minutes apart.
  2. A saves a new price of 749 with WHERE id = 3 AND version = 1 and bumps the version: Rows matched: 1. Version is now 2.
  3. B saves 699, also with version = 1. The row's version is 2, so the WHERE matches nothing: Query OK, 0 rows affected and Rows matched: 0. No error — the application must check the affected-row count (affectedRows in mysql2) and treat 0 as a conflict.
  4. B reloads and sees A's 749 at version 2, and can decide again. Without the version check, B's write would have silently replaced A's — a lost update.
  5. The last statement shows the simplest tool of all for counters: a guarded atomic update. SET stock = stock - 1 WHERE id = 4 AND stock >= 1 checks and writes in one statement, under one row lock. The iPhone is out of stock, so it matches 0 rows and nothing goes negative.
Advanced50 buyers, 20 in stock: four strategies measured

Fifty concurrent "buy one" requests from a pool of 10 connections, against a product with 20 in stock, using each strategy in turn:

oversell.mjs
import mysql from "mysql2/promise";
import { setTimeout as sleep } from "node:timers/promises";

const pool = mysql.createPool({ host: "127.0.0.1", port: 3307, user: "root", password: "study", database: "ch09", connectionLimit: 10 });
const BUYERS = 50;

// 1. Naive read-modify-write: read stock, decide in JS, write the new number back.
async function naive() {
  const [[row]] = await pool.query("SELECT stock FROM products WHERE id = 1");
  if (row.stock < 1) return false;
  await pool.query("UPDATE products SET stock = ? WHERE id = 1", [row.stock - 1]);
  return true;
}

// 2. Atomic guarded update: the check and the write are one statement.
async function atomic() {
  const [res] = await pool.query("UPDATE products SET stock = stock - 1 WHERE id = 1 AND stock >= 1");
  return res.affectedRows === 1;
}

// 3. Optimistic: remember the version you read, write only if it is unchanged, retry otherwise.
async function optimistic() {
  for (let attempt = 1; attempt <= 20; attempt++) {
    const [[row]] = await pool.query("SELECT stock, version FROM products WHERE id = 1");
    if (row.stock < 1) return false;
    const [res] = await pool.query(
      "UPDATE products SET stock = ?, version = version + 1 WHERE id = 1 AND version = ?",
      [row.stock - 1, row.version],
    );
    if (res.affectedRows === 1) return true;
    optimistic.conflicts++;
    await sleep(Math.random() * 5 * attempt); // jittered back-off, or 50 buyers keep colliding
  }
  throw new Error("gave up after 20 attempts");
}
optimistic.conflicts = 0;

// 4. Pessimistic: lock the row first (FOR UPDATE), then decide and write inside the transaction.
async function pessimistic() {
  const conn = await pool.getConnection();
  try {
    await conn.beginTransaction();
    const [[row]] = await conn.query("SELECT stock FROM products WHERE id = 1 FOR UPDATE");
    if (row.stock < 1) { await conn.rollback(); return false; }
    await conn.query("UPDATE products SET stock = ? WHERE id = 1", [row.stock - 1]);
    await conn.commit();
    return true;
  } finally {
    conn.release();
  }
}

for (const strategy of [naive, atomic, optimistic, pessimistic]) {
  await pool.query("UPDATE products SET stock = 20, version = 1 WHERE id = 1");
  const t0 = performance.now();
  const results = await Promise.all(Array.from({ length: BUYERS }, () => strategy()));
  const ms = (performance.now() - t0).toFixed(0);
  const [[{ stock }]] = await pool.query("SELECT stock FROM products WHERE id = 1");
  const sold = results.filter(Boolean).length;
  const extra = strategy === optimistic ? `, version conflicts retried: ${optimistic.conflicts}` : "";
  console.log(`${strategy.name.padEnd(11)} sold ${String(sold).padStart(2)}, stock left ${String(stock).padStart(2)}, ${ms} ms${extra}`);
}
await pool.end();
Result
naive       sold 50, stock left 19, 13 ms
atomic      sold 20, stock left  0, 43 ms
optimistic  sold 20, stock left  0, 76 ms, version conflicts retried: 231
pessimistic sold 20, stock left  0, 44 ms
What’s happening
  1. Naive: all 50 buyers were told "sold", yet the stock only went from 20 to 19. Almost every request read 20, computed 19 and wrote 19 — 49 lost updates and 30 more items sold than existed. This is the bug in countless "check then update" endpoints, and it happened under InnoDB's default isolation.
  2. Atomic guarded update: exactly 20 sold, stock 0, the other 30 buyers got affectedRows === 0 ("sold out"). One statement per buyer, row lock held for microseconds. The best option whenever the change fits in one UPDATE.
  3. Optimistic: also exactly 20 sold — but with 231 version conflicts and retries in this run (181–231 over four runs), and the slowest time. Fifty buyers fighting over one hot row is the worst case for optimistic locking; the first version of this script, without the back-off, gave up after 20 attempts per buyer. Optimistic locking shines when conflicts are rare.
  4. Pessimistic FOR UPDATE: exactly 20 sold. Buyers queued on the row lock one at a time, which for one hot row is efficient: no wasted work, no retries.
  5. Repeated runs gave the same counts every time; the timings (tens of milliseconds) vary run to run on the shared sandbox.
AdvancedTip: choosing a locking strategy

SQL and MySQL written fresh; MongoDB built from Rohit’s “mongoDB interview” doc. Every query was run on MySQL 9.7, MongoDB 9.0, Redis 8.10 or PostgreSQL 18.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.