Database reliability engineering
Lab: cause four incidents and contain them
The idea in 30 seconds
On a throwaway PostgreSQL, cause the four most common self-inflicted incidents: a lock queue behind a migration, a forgotten transaction that stops vacuum, connections running out, and a runaway query. For each one, find the cause with pg_stat_activity, then turn on the guard that would have contained it and watch the incident shrink to a single error.
Helps to know first: Locks and locking strategies (coming soon), Vacuum, autovacuum and bloat (coming soon), Connection management and pooling (coming soon), Backups, restore and point-in-time recovery (coming soon)
On this page
Before you start
You need a PostgreSQL you can throw away, the shop practice database loaded, and three terminals open on it, called A, B and C below. Use a superuser for A, B and C (on a local install, the user you created the database with). Step 3 opens about a hundred connections from a shell loop.
Create a role to play the application:
CREATE ROLE app LOGIN;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app;
Our numbers come from PostgreSQL 18.3 on an Apple M1 Pro laptop. The waits you see will depend on how quickly you type; the shape of each result should match ours.
1. The lock queue
Terminal A, a long report that has not finished:
BEGIN;
SELECT count(*) FROM orders;
-- leave this transaction open
Terminal B, the migration:
ALTER TABLE orders ADD COLUMN note text;
-- hangs
Terminal C, an ordinary application query:
SELECT count(*) FROM orders;
-- also hangs
Now open a fourth terminal (or cancel C with Ctrl-C and reuse it) and find out who is waiting for whom:
SELECT pid, state, wait_event_type, wait_event, pg_blocking_pids(pid) AS blocked_by,
now() - query_start AS waiting, left(query, 40) AS query
FROM pg_stat_activity
WHERE datname = current_database() AND pid <> pg_backend_pid()
ORDER BY query_start;
Read the blocked_by column as a chain: the reads are blocked by the migration, the migration by the report. In our scripted run, five reads arrived one second after the migration and each waited 17.9 seconds, until the report’s transaction ended. The migration itself then took under a millisecond.
Run COMMIT; in A. Everything finishes at once. Drop the column again (ALTER TABLE orders DROP COLUMN note;) and repeat the step, but this time start terminal B with:
SET lock_timeout = '2s';
ALTER TABLE orders ADD COLUMN note text;
ERROR: canceling statement due to lock timeout
| Our result | Yours | |
|---|---|---|
Each read, no lock_timeout | 17,948 ms | |
Each read, migration with lock_timeout = '2s' | 992 ms | |
| The migration alone, nothing in its way | 0.8 ms |
With the timeout, the migration gave up after two seconds and the reads it had been holding ran immediately. The migration failed, which is the point: a failed migration can be retried a minute later; a queue of blocked checkouts cannot be undone. Run COMMIT; in A when you are done.
2. The forgotten transaction
Make a table worth vacuuming:
CREATE TABLE events (id bigint PRIMARY KEY, payload text);
INSERT INTO events SELECT g, repeat('x', 100) FROM generate_series(1, 1000000) g;
VACUUM ANALYZE events;
Terminal A, a transaction that wrote something and then was forgotten, as when application code returns early without committing:
BEGIN;
UPDATE customers SET city = city WHERE id = 1;
-- leave it open and do nothing
Terminal B, normal cleanup:
DELETE FROM events WHERE id <= 500000;
VACUUM (VERBOSE) events;
Find the line starting tuples:. Ours:
tuples: 0 removed, 999982 remain, 500000 are dead but not yet removable
Vacuum removed nothing. The open transaction in A started before the delete, so as far as PostgreSQL knows it might still need to see those rows. Find the culprit:
SELECT pid, state, backend_xid, now() - xact_start AS open_for, left(query, 40) AS last_query
FROM pg_stat_activity WHERE state = 'idle in transaction';
last_query is the last statement it ran, not something it is running: an idle transaction is doing nothing at all. Run COMMIT; in A, then vacuum again in B:
tuples: 500000 removed, 250002 remain, 0 are dead but not yet removable
On a real system the transaction is not in a terminal you control. It is in an application server, maybe for hours, while every table in the database accumulates dead rows. Now contain it. In B:
ALTER ROLE app SET idle_in_transaction_session_timeout = '5s';
Connect a new terminal as app (psql -U app -d shop), start the same forgotten transaction, wait ten seconds and run anything:
FATAL: terminating connection due to idle-in-transaction timeout
server closed the connection unexpectedly
The session was ended, its transaction rolled back, and its hold on vacuum released.
3. Connections run out
Check the limits:
SHOW max_connections; -- ours: 100
SHOW superuser_reserved_connections; -- ours: 3
From a shell, open 97 sleeping connections as app. That is every slot not reserved for superusers, minus one for the superuser session you will check with:
for i in $(seq 1 97); do psql -U app -d shop -qc "SELECT pg_sleep(30)" >/dev/null 2>&1 & done
While they sleep:
psql -U app -d shop -c "SELECT 1"
FATAL: remaining connection slots are reserved for roles with the SUPERUSER attribute
psql -d shop -c "SELECT 'superuser still gets in'" # works
The three reserved slots are what let an administrator connect, look at pg_stat_activity and end sessions during exactly this kind of incident. If your monitoring, backups or on-call tools connect as ordinary roles, they are locked out with the application. PostgreSQL 16 added reserved_connections, which keeps more slots for roles granted pg_use_reserved_connections; give that role to monitoring, not to the application.
4. The runaway query
Give the application a statement limit and run something no application should:
ALTER ROLE app SET statement_timeout = '2s';
As app:
SELECT count(*) FROM events a JOIN events b ON a.payload = b.payload;
ERROR: canceling statement due to statement timeout
Time: 2021.037 ms
Without the limit, this join of 500,000 identical payloads against each other would run until someone noticed.
What you measured
- Five trivial reads waited 18 seconds behind a migration that needed under a millisecond. With
lock_timeout, they waited one. - One idle transaction stopped vacuum from removing any of 500,000 dead rows.
- At 97 application connections, the 98th was refused while a superuser could still get in.
- Each guard turned an open-ended incident into one clear error.
Clean up
ALTER ROLE app RESET ALL;
DROP TABLE IF EXISTS events;
ALTER TABLE orders DROP COLUMN IF EXISTS note;
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM app;
DROP ROLE app;
Check yourself
In step 1,
pg_blocking_pidslisted the migration as the blocker of the reads, although the migration held no lock. Why?Show answer
It was waiting for an
ACCESS EXCLUSIVElock that conflicts with the reads’ lock, and it was ahead of them in the queue. PostgreSQL counts queued conflicting requests as blockers (a soft block), not just locks already held.Vacuum reported 500,000 rows “dead but not yet removable”. What is the first query you run, and what are you looking for?
Show answer
pg_stat_activity, looking for sessions inidle in transactionor with an oldxact_startand a non-nullbackend_xidorbackend_xmin: the oldest open transaction is what holds cleanup back.Why should monitoring not connect with the same role as the application?
Show answer
When the application uses up every ordinary connection slot, monitoring would be refused at the moment it is needed most. A separate role with reserved slots can still connect.
Finished reading?
Saved in this browser only.
Sources
Where each claim on this page comes from.
- 1.PostgreSQL documentation: 27.2.3 pg_stat_activityPrimary· §27.2.3 pg_stat_activity
The state values, including "idle in transaction", and the backend_xid, backend_xmin, wait_event_type and wait_event columns.
- 2.PostgreSQL documentation: 9.27 System Information Functions and OperatorsPrimary· §9.27.1 Session Information Functions: pg_blocking_pids
pg_blocking_pids lists both the sessions holding a conflicting lock and those queued ahead with a conflicting request.
- 3.PostgreSQL documentation: 19.11 Client Connection DefaultsPrimary· §19.11.1 Statement Behavior
Behaviour of lock_timeout, statement_timeout and idle_in_transaction_session_timeout, including that an open transaction prevents vacuum from removing recently-dead rows.
- 4.PostgreSQL documentation: 19.3 Connections and AuthenticationPrimary· §19.3.1 Connection Settings: superuser_reserved_connections, reserved_connections
superuser_reserved_connections defaults to 3 and keeps those slots for superusers; reserved_connections keeps further slots for roles with pg_use_reserved_connections.
- 5.PostgreSQL documentation: 24.1 Routine VacuumingPrimary· §24.1.2 Recovering Disk Space
Vacuum can only remove a dead row version once no transaction can still see it.
- 6.PostgreSQL documentation: Release 16Primary· Changes: Server Configuration
reserved_connections and pg_use_reserved_connections were added in PostgreSQL 16.
Go further
What to read next, and why it is worth the time.
- 1.PostgreSQL documentation: 27.2.3 pg_stat_activityPrimary· §27.2.3
Every column of the view you will reach for first in an incident, with what each state and wait event means.
Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.