Part · Chapter

Transactions and concurrency

Every INSERT, UPDATE and DELETE at work runs inside a transaction, whether or not the developer typed BEGIN.

The short version

  • All or nothing. A transaction commits or rolls back as one unit.
  • Autocommit is on. Without BEGIN or START TRANSACTION, every statement commits by itself.
  • Defaults differ. InnoDB runs at REPEATABLE READ, PostgreSQL at READ COMMITTED.
  • MVCC frees readers. Each reader sees a snapshot, so readers do not block writers.
  • Locks cause the hangs. One uncommitted transaction can queue up the whole application.
  • Deadlocks are normal. Order your writes, keep transactions short, retry the loser.

What a transaction is

A transaction is a group of statements the database treats as one unit of work. Either all of it happens or none of it does, even after a crash. An order row without its items is corrupt data, so both inserts go in one transaction.

Remember

A transaction is the unit of all-or-nothing. Nothing inside it is visible to others until COMMIT, and everything inside it can be undone until then.

ACID

Four promises, and what each one buys you.

PromiseWhat it guarantees
AtomicityAll statements succeed together or fail together.
ConsistencyConstraints hold at commit, so one valid state becomes another.
IsolationConcurrent transactions do not see each other's half-finished work.
DurabilityOnce COMMIT returns, the change survives a crash.

Durability is why each engine writes the change to a log first, InnoDB's redo log and PostgreSQL's write-ahead log, flushed at COMMIT and replayed after a crash.

The commands, autocommit and DDL

Transaction control is a small family: START TRANSACTION or BEGIN, COMMIT, ROLLBACK, and SAVEPOINT. A savepoint is a bookmark inside a transaction, and ROLLBACK TO SAVEPOINT undoes only what came after it while keeping the transaction open.

Illustration · Both
START TRANSACTION;                   -- BEGIN works in both engines
INSERT INTO orders (id, customer_id, total) VALUES (15, 2, 65.00);
SAVEPOINT items_start;
INSERT INTO order_items (order_id, product_id, qty) VALUES (15, 999, 1);  -- fails
ROLLBACK TO SAVEPOINT items_start;   -- keep the order, drop the failed part
INSERT INTO order_items (order_id, product_id, qty) VALUES (15, 101, 2);
COMMIT;                              -- or ROLLBACK to discard all of it

Autocommit is on by default in both engines, so every statement is its own tiny transaction. A multi-statement transaction exists only once you say START TRANSACTION or BEGIN. MySQL Autocommit is a server variable, autocommit. PostgreSQL The server has no such setting, and clients emulate the switch by sending BEGIN for you. A forgotten toggle in a GUI client is why an update that worked is never seen by anyone else.

MySQL DDL commits implicitly. Any CREATE, ALTER, DROP or TRUNCATE commits the open transaction, and the DDL itself can never be rolled back. PostgreSQL DDL is transactional: create three tables, change your mind, ROLLBACK, and the catalog is exactly as it was.

Careful

A MySQL script that reads BEGIN, ALTER TABLE, UPDATE, ROLLBACK on error does not do what its author thinks: the ALTER already committed everything before it.

Isolation levels and the anomalies they prevent

Isolation is a dial. Turned down, transactions overlap more and three anomalies become possible: a dirty read (you read uncommitted work that then rolls back), a non-repeatable read (a row you read again has changed), and a phantom read (a row appeared, so the same query returns more rows).

LevelDirty readNon-repeatable readPhantom readNotes
READ UNCOMMITTEDpossiblepossiblepossiblePostgreSQL treats it as READ COMMITTED
READ COMMITTEDnopossiblepossiblePostgreSQL default. Each statement sees a fresh snapshot
REPEATABLE READnonoallowed by the standard, prevented by both enginesInnoDB default. One snapshot for the whole transaction
SERIALIZABLEnononoTransactions behave as if run one after another

InnoDB default

REPEATABLE READ

PostgreSQL default

READ COMMITTED

Check it

SELECT @@transaction_isolation · SHOW transaction_isolation

MVCC: why readers do not block writers

Both engines get their isolation from MVCC, multi-version concurrency control. The engine keeps several versions of a row, and each transaction reads the versions committed as of its own snapshot, so a long report never blocks the checkout. Old versions are cleaned up later, by InnoDB's purge threads and by PostgreSQL's autovacuum, and a transaction left open for hours holds that cleanup back and bloats the server.

Locks

MVCC handles readers. Writers still take turns on the same row: an UPDATE or DELETE holds an exclusive row lock until COMMIT or ROLLBACK, and a second transaction wanting that row waits. Most "the database is hanging" tickets are that wait.

A plain SELECT takes no row locks. When the application reads a row in order to change it, use SELECT ... FOR UPDATE so two clerks cannot overwrite each other. Add SKIP LOCKED and each worker takes the next row nobody else is holding, which is the queue pattern.

Illustration · Both
-- Each worker runs this inside its own transaction
SELECT id, total FROM orders
WHERE status = 'pending'
ORDER BY id LIMIT 1
FOR UPDATE SKIP LOCKED;

A row lock wait in MySQL ends after innodb_lock_wait_timeout, 50 seconds, with ERROR 1205; PostgreSQL waits forever unless you set lock_timeout. Metadata locks are the other surprise: every statement holds a shared lock on each table definition until its transaction ends, so a long report makes your ALTER wait and every later query queues behind the ALTER.

Deadlocks

A deadlock is two transactions each waiting for a lock the other holds. Nobody can finish, so both engines detect the cycle, roll one transaction back (MySQL ERROR 1213, PostgreSQL SQLSTATE 40P01) and expect the application to retry. The cures, most effective first: touch tables and rows in a consistent order everywhere, keep transactions short, and retry on those errors. An occasional deadlock is normal; a hundred a minute is a code review.

InnoDB keeps the last one in SHOW ENGINE INNODB STATUS under LATEST DETECTED DEADLOCK: each transaction's statement, the locks it holds, the lock it waits for, and the victim. innodb_print_all_deadlocks = ON writes every deadlock to the error log.

Ask the AI

Paste the report instead of decoding it: "MySQL 8.4, InnoDB. Here is the LATEST DETECTED DEADLOCK section from SHOW ENGINE INNODB STATUS: [paste]. Explain which statement held which lock, and how to reorder the updates to avoid the cycle."

Finding blockers and killing sessions

When the site is frozen, one session usually holds a lock and a queue has formed behind it. Find the head of the queue, kill it only if it is idle or clearly stuck, then find the code path that left the transaction open. A kill rolls the work back, which can take as long as the update did.

What you needMySQLPostgreSQL
Who waits on whomSELECT * FROM sys.innodb_lock_waits;SELECT pid, state, pg_blocking_pids(pid) FROM pg_stat_activity;
Waits on a table definitionSELECT * FROM sys.schema_table_lock_waits;SELECT * FROM pg_locks WHERE NOT granted;
Stop the current statementKILL QUERY 42;SELECT pg_cancel_backend(42);
End the sessionKILL 42;SELECT pg_terminate_backend(42);

The state "idle in transaction" (PostgreSQL) or an innodb_trx row with an old trx_started and no query (MySQL) is the same animal: an application opened a transaction and wandered off. PostgreSQL has a backstop, idle_in_transaction_session_timeout, off by default; MySQL has nothing beyond wait_timeout, so the fix is the application.

For you as a DBA

Keep the blocker query for each engine saved in your client, and set a lock_timeout before any DDL. Killing treats the symptom; lock ordering and short transactions treat the cause.

Go deeper

Quick check

1. On MySQL, you run START TRANSACTION, insert three orders, then CREATE TABLE audit_log, then ROLLBACK. What is in the database afterwards?

DDL in MySQL causes an implicit commit of whatever came before it, and the DDL itself is never undone by ROLLBACK. PostgreSQL would have rolled back both. Reread "The commands, autocommit and DDL".

2. Which pair of default isolation levels is correct?

InnoDB defaults to REPEATABLE READ (one snapshot for the whole transaction) and PostgreSQL to READ COMMITTED (a fresh snapshot per statement). Reread "Isolation levels and the anomalies they prevent".

3. A developer's UPDATE on MySQL sat for about 50 seconds and then failed with ERROR 1205 "Lock wait timeout exceeded". What happened?

1205 is a plain lock wait that ran out of patience (default 50 s). Deadlocks are detected at once and come back as 1213. Reread "Locks" and "Deadlocks".

4. Several worker processes must each take a different pending order without ever processing the same one twice. Which feature is built for that?

SKIP LOCKED hands each worker the next row nobody else is holding, and the lock keeps it theirs until COMMIT. A table lock would serialize all workers. Reread "Locks".

5. pg_stat_activity shows a session that has been "idle in transaction" for 40 minutes. Why does that matter even if nobody is waiting on it right now?

An open transaction pins old row versions (dead tuples in PostgreSQL, undo history in InnoDB) and holds row and metadata locks until it ends. Reread "Finding blockers and killing sessions".