OnCallReady

Lesson 25.21 · PostgreSQL Operations · 31 min read

Transactions, MVCC and locks

In plain words

Imagine a shared document where nobody ever erases anything: to change a sentence, you write the new version below and cross out the old one, noting the time. Everyone reading sees the version that was current when they started reading, so nobody waits for anybody to finish writing.

Locks are the sticky notes that say "I am editing this paragraph - wait". If someone puts a sticky note on and then goes to lunch, everyone who needs that paragraph waits until they come back.

Why this lesson exists

The most confusing PostgreSQL outage looks like this: CPU idle, disk idle, no errors - and every request times out. Nothing is slow; everything is waiting. One transaction holds a lock, and a queue of sessions waits behind it. The cure takes ten seconds once you can read pg_stat_activity and pg_locks: find the session at the root of the chain and end it. This lesson is how transactions, row versions and locks work, so the chain makes sense, and the exact queries to find and break it.

What you need to know already: pg_stat_activity (lesson 1), psql and the open-transaction trap (lesson 3), roles (lesson 4). Processes and signals from the Linux chapters.

The words you need first

Row versions

Look at the hidden columns of one order, update it, and look again:

$ sudo -u postgres psql -d orders -c "select ctid, xmin, xmax, id, status from orders where id = 7"
 ctid  | xmin | xmax | id |  status
-------+------+------+----+----------
 (0,7) |  766 |  767 |  7 | refunded
(1 row)
$ sudo -u postgres psql -d orders -c "update orders set status = 'shipped' where id = 7"
UPDATE 1
$ sudo -u postgres psql -d orders -c "select ctid, xmin, xmax, id, status from orders where id = 7"
   ctid   | xmin | xmax | id | status
----------+------+------+----+---------
 (370,40) |  823 |    0 |  7 | shipped
(1 row)

The row moved: a new ctid (page, item - its physical place) and a new xmin (the transaction that wrote it). The old version is still in the table, marked dead - nobody can see it, but it takes space until VACUUM cleans it. Every UPDATE in PostgreSQL is an insert plus a delete; a table updated a million times a day writes a million dead tuples a day.

What MVCC buys you: readers never block writers and writers never block readers. A long report (SELECT) does not stop the app from updating, and an update does not make the report wait - each sees its own consistent snapshot. The price is the dead tuples, and the rule that a dead tuple can only be removed when no running transaction could still need it. One transaction left open for hours keeps every tuple that died since it started. That is the link to the next lesson.

Isolation levels

levelwhat a transaction seeswhen to use
READ COMMITTED (default)a new snapshot for each statement: rows other transactions committed meanwhile appearalmost everything
REPEATABLE READone snapshot for the whole transactionreports that must be consistent; may fail with could not serialize access due to concurrent update
SERIALIZABLEas if transactions ran one after anothercorrectness-critical logic; the app must retry on SQLSTATE 40001

(READ UNCOMMITTED exists in the syntax but behaves as READ COMMITTED: PostgreSQL never shows uncommitted data.)

Locks

Every statement takes a lock on each table it touches - most of them so weak you never notice:

modetaken byconflicts with
ACCESS SHARESELECTonly ACCESS EXCLUSIVE
ROW SHARESELECT ... FOR UPDATE / FOR SHAREEXCLUSIVE, ACCESS EXCLUSIVE
ROW EXCLUSIVEINSERT, UPDATE, DELETE, MERGESHARE and stronger
SHARE UPDATE EXCLUSIVEVACUUM (not FULL), ANALYZE, CREATE INDEX CONCURRENTLY, some ALTER TABLEitself and stronger
SHARECREATE INDEX (not concurrently)writes: blocks every INSERT/UPDATE/DELETE
SHARE ROW EXCLUSIVECREATE TRIGGER, some ALTER TABLEwrites and itself
EXCLUSIVEREFRESH MATERIALIZED VIEW CONCURRENTLYeverything except ACCESS SHARE
ACCESS EXCLUSIVEDROP, TRUNCATE, VACUUM FULL, REINDEX (not concurrently), most ALTER TABLEeverything, even plain SELECT

Two rows of that table cause most lock incidents: ACCESS EXCLUSIVE (a migration's ALTER TABLE stops even reads) and SHARE (a plain CREATE INDEX stops all writes for as long as it builds). Lesson 10 is the safe way to run both.

Row locks are separate: UPDATE, DELETE and SELECT ... FOR UPDATE lock the rows they touch until the transaction ends. Two transactions updating the same row: the second waits for the first to commit or roll back. Different rows: no waiting at all.

Every lock is held until the transaction ends - not until the statement ends. That is why a transaction that is open but doing nothing is the classic root of a chain.

A blocking chain, live

A session in the background opens a transaction, updates order 42 and then waits for its client (the way an app does when it calls another service in the middle of a transaction):

Now the app tries to update the same order, with a 5 second statement timeout:

$ sudo -u postgres psql -d orders -c "set statement_timeout = '5s'" -c "update orders set status = 'shipped' where id = 42"
SET
ERROR:  canceling statement due to statement timeout
CONTEXT:  while updating tuple (0,3) in relation "orders"

It waited the full 5 seconds and was cancelled by its own statement_timeout - to the app this is a timeout, not a database error; nothing in the server was wrong. Start two more in the background so there is something to look at:

$ sudo -u postgres psql -c "select pid, usename, state, wait_event_type, wait_event, now() - xact_start as xact_age, left(query, 50) as query from pg_stat_activity where datname = 'orders' order by xact_start"
  pid  |  usename   |        state        | wait_event_type |  wait_event   |  xact_age  |                       query
-------+------------+---------------------+-----------------+---------------+------------+----------------------------------------------------
 17418 | orders_app | idle in transaction | Client          | ClientRead    | 00:00:49.1 | UPDATE orders SET status = 'paid', updated_at = no
 17428 | postgres   | active              | Lock            | transactionid |            | update orders set status = 'refunded' where id = 4
 17431 | postgres   | active              | Lock            | tuple         |            | select count(*) from orders where id = 42 for upda
(3 rows)

Read it top to bottom:

pg_blocking_pids() says who waits for whom directly:

$ sudo -u postgres psql -c "select pid, pg_blocking_pids(pid) as blocked_by, wait_event, left(query, 45) as query from pg_stat_activity where cardinality(pg_blocking_pids(pid)) > 0"
  pid  | blocked_by |  wait_event   |                     query
-------+------------+---------------+-----------------------------------------------
 17428 | {17418}    | transactionid | update orders set status = 'refunded' where i
 17431 | {17428}    | tuple         | select count(*) from orders where id = 42 for
(2 rows)

A chain of three: the second waiter is blocked by the first, the first by the idle in transaction session. The root is the PID that appears in blocked_by but is not blocked itself - one query finds it in any chain:

select distinct b from pg_stat_activity, unnest(pg_blocking_pids(pid)) as b
where cardinality(pg_blocking_pids(b)) = 0;

shows the raw locks - granted = f is a waiter:

$ sudo -u postgres psql -c "select l.pid, l.locktype, l.mode, l.granted, c.relname from pg_locks l left join pg_class c on c.oid = l.relation where l.pid in (select pid from pg_stat_activity where datname = 'orders') and l.locktype in ('relation', 'transactionid', 'tuple') order by l.granted desc, l.pid"
  pid  |   locktype    |       mode       | granted |   relname
-------+---------------+------------------+---------+-------------
 17418 | relation      | RowExclusiveLock | t       | orders
 17418 | relation      | RowExclusiveLock | t       | orders_pkey
 17418 | transactionid | ExclusiveLock    | t       |
 17428 | tuple         | ExclusiveLock    | t       | orders
 17428 | relation      | RowExclusiveLock | t       | orders
 17428 | relation      | RowExclusiveLock | t       | orders_pkey
 17431 | relation      | RowShareLock     | t       | orders
 17431 | relation      | RowShareLock     | t       | orders_pkey
 17428 | transactionid | ShareLock        | f       |
 17431 | tuple         | ExclusiveLock    | f       | orders
(10 rows)

Breaking the chain

Two functions, both safe, both need superuser or membership of pg_signal_backend (and non-superusers cannot touch superuser sessions):

effectthe client sees
pg_cancel_backend(pid)cancels the current query (SIGINT); the session stays, its transaction is aborted only if it was running a statementERROR: canceling statement due to user request
pg_terminate_backend(pid)ends the whole session (SIGTERM): its transaction rolls back, its locks are releasedFATAL: terminating connection due to administrator command

The root here is idle - there is no query to cancel - so it has to be terminated:

$ sudo -u postgres psql -Atc "select pg_terminate_backend(pid) from pg_stat_activity where state = 'idle in transaction' and usename = 'orders_app'"
t
$ sudo -u postgres psql -c "select pid, state, wait_event_type, left(query, 50) as query from pg_stat_activity where datname = 'orders'"
 pid | state | wait_event_type | query
-----+-------+-----------------+-------
(0 rows)
$ sudo -u postgres psql -d orders -Atc "select status from orders where id = 42"
refunded

The waiters got their locks the moment the root's transaction rolled back, ran, and finished. (Which update won? The queue is first come, first served - and the root's own change was rolled back.) Before you terminate anything in production, save what you saw (the query, the user, the client address, how long it was open) - the team that owns the app needs it to fix the real bug.

Never kill -9 a backend from the shell: the postmaster takes a backend dying from SIGKILL as possible shared-memory corruption and restarts every connection. kill <pid> (SIGTERM) is the same as pg_terminate_backend, but the SQL function checks permissions and is what your runbook should say.

Timeouts: stop it happening again

settingendstypical
statement_timeouta statement that runs too long30s for an OLTP app role; never globally for everyone
lock_timeouta statement that waits for a lock too long2s-5s for migrations (lesson 10)
idle_in_transaction_session_timeouta session idle inside a transaction too long (terminates it)60s-5min
idle_session_timeoutan idle session outside a transactionrarely: pools reconnect anyway
transaction_timeout (17+)a transaction older than this, idle or notas a backstop

Set them per role or database rather than for the whole server, so maintenance jobs and pg_dump are not caught:

$ sudo -u postgres psql -c "alter role orders_app set idle_in_transaction_session_timeout = '60s'"
ALTER ROLE
$ sudo -u postgres psql -c "select rolname, rolconfig from pg_roles where rolname = 'orders_app'"
  rolname   |                 rolconfig
------------+-------------------------------------------
 orders_app | {idle_in_transaction_session_timeout=60s}
(1 row)

ALTER ROLE ... SET applies to new sessions of that role. The server logs the ones it ends: FATAL: terminating connection due to idle-in-transaction timeout.

And make waits visible: with log_lock_waits = on the server logs every lock wait longer than deadlock_timeout (1 second) with the PIDs that hold the lock and the queue:

$ sudo -u postgres psql -Atc "show log_lock_waits" -c "show deadlock_timeout"
off
1s

Deadlocks

Two transactions that each hold a lock the other wants can never both finish. PostgreSQL checks for this after a session has waited deadlock_timeout and kills one of them. Two transactions updating the same two orders in opposite order:

$ sudo -u postgres psql -d orders -c "begin" -c "update orders set status = 'paid' where id = 1" -c "select pg_sleep(2)" -c "update orders set status = 'paid' where id = 2" -c "commit" > /tmp/a.out 2>&1 &
[1] 17450
$ sudo -u postgres psql -d orders -c "begin" -c "update orders set status = 'paid' where id = 2" -c "select pg_sleep(2)" -c "update orders set status = 'paid' where id = 1" -c "commit" > /tmp/b.out 2>&1 &
[2] 17453
$ cat /tmp/a.out /tmp/b.out
$ sudo grep -A 4 'deadlock detected' /var/log/postgresql/postgresql-18-main.log | head -n 5

One transaction got ERROR: deadlock detected and rolled back; the other carried on and committed. A deadlock is not an outage - it is a bug in the order the app takes locks. The fix is in the code: always lock rows in the same order (for example by primary key), keep transactions short, and retry on SQLSTATE 40P01.

In an interview: "Requests time out but the database CPU is idle - what do you check?" - locks: pg_stat_activity for sessions active with wait_event_type = Lock and for idle in transaction sessions with old xact_start; pg_blocking_pids(pid) to walk the chain to its root; terminate the root with pg_terminate_backend (cancel will not help an idle session); then prevent it with idle_in_transaction_session_timeout and lock_timeout.

What you can do now

Why it helps

The most confusing outage is the one where the database is idle and every request times out: nothing is slow, everything is waiting behind one transaction. Understanding row versions, lock modes and the open-transaction trap lets you read pg_stat_activity and pg_locks, walk the chain with pg_blocking_pids to its root, and end exactly that one session.

The timeouts from this lesson (idle_in_transaction_session_timeout, lock_timeout, statement_timeout) are what you set afterwards so it cannot happen again.

Commands in this lesson

psql cat grep

FAQ

Why does an idle session block other sessions?

Locks are held until the transaction ends, not until the statement ends. A session that ran BEGIN and an UPDATE, then waits for its client, still holds the row locks. Every other transaction that wants those rows waits. In pg_stat_activity it shows as idle in transaction, waiting on ClientRead, with an old xact_start.

Should I use pg_cancel_backend or pg_terminate_backend?

pg_cancel_backend stops the query a session is running and leaves the session open. pg_terminate_backend ends the whole session, rolling back its transaction and releasing its locks. A blocker that is idle in transaction has no running query, so cancelling does nothing: terminate it, after saving what it was doing for the app team.

How do I find the root of a blocking chain?

pg_blocking_pids(pid) lists the sessions a waiter waits for. Waiters can also queue behind each other on the same row, so a chain can be several deep. The root is a PID that appears in someone's blocked_by list but is not blocked itself; one query over pg_stat_activity and unnest(pg_blocking_pids(pid)) finds it.

Why does a plain SELECT ever matter for locks?

Every SELECT takes an ACCESS SHARE lock on the tables it reads, held until its transaction ends. It conflicts only with ACCESS EXCLUSIVE, which most ALTER TABLE forms, DROP and TRUNCATE need. So a long report can make a migration wait - and everything queued behind the migration waits too.

Is a deadlock an outage?

No. PostgreSQL detects it after deadlock_timeout (one second), cancels one of the two transactions with "deadlock detected" and the other continues. It is a bug in the order the application locks rows: always lock in the same order (for example by primary key), keep transactions short, and retry on SQLSTATE 40P01.

In an interview Mid

Requests time out but the database CPU is idle. What do you check?

Waiting, not working - almost always locks. In pg_stat_activity look for many sessions active with wait_event_type = Lock and for sessions idle in transaction with an old xact_start. Use pg_blocking_pids(pid) to walk the chain to its root (a blocker that is not blocked itself), and pg_locks with granted = false for detail. Save what the root was doing, then end it with pg_terminate_backend - an idle session has no query to cancel. Prevent it with idle_in_transaction_session_timeout for the app role, lock_timeout for migrations and log_lock_waits.

Also asked: What is MVCC and why do readers not block writers? · What is the difference between pg_cancel_backend and pg_terminate_backend? · How should an application handle deadlocks?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.