Long-running transactions
Why one open transaction can make a whole database slowly get worse, how to find the session responsible, and what the Long-running transaction advisory is telling you.
This is the most common way a healthy Postgres database quietly becomes an unhealthy one. Nothing errors. Nothing is obviously slow at first. Queries just get a little worse every hour.
What actually happens
Postgres never overwrites a row in place. An UPDATE writes a new version of the row and marks the old one as no longer current; a DELETE only marks. The old versions stay on disk because some transaction that started earlier might still be entitled to see them.
Cleaning them up is VACUUM's job, and VACUUM has one rule: it can only remove a row version that is invisible to every transaction currently open. The oldest open transaction therefore sets a floor - the horizon - and nothing newer than that floor can be reclaimed, in the entire database.
So one session that ran BEGIN and then went idle holds the floor where it is. Meanwhile the application keeps writing. Dead row versions pile up behind the floor, and:
- Sequential scans read dead rows, check them, and discard them - work that produces nothing.
- Index scans follow entries pointing at rows that are no longer visible, costing an extra page read each.
- The table's files keep growing, so less of it fits in cache.
- Autovacuum keeps running, keeps finding nothing it is allowed to remove, and keeps costing I/O.
The moment that transaction commits or is killed, the floor moves and the next vacuum clears the backlog. The problem does not degrade gracefully and it does not resolve gradually - it holds, then it is gone.
The usual causes
- A
BEGINin application code with an earlyreturnor a thrown exception on a path that forgets to roll back. - An ORM that opens a transaction per request and holds it across an outbound HTTP call.
- A connection pool with a leaked connection that is mid-transaction.
- A long analytical query or a
pg_dump- these are legitimately holding the horizon, but they are also legitimately doing work. Duration alone does not make a transaction a bug. - An interactive
psqlsession where somebody typedBEGINan hour ago.
Finding it
The culprit is the session with the lowest backend_xmin, which is not necessarily the one that has been connected longest:
SELECT pid,
usename,
state,
age(backend_xmin) AS xmin_age,
now() - xact_start AS transaction_age,
now() - state_change AS in_this_state_for,
left(query, 120) AS query
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC
LIMIT 10;Read it like this:
state = 'idle in transaction'and a largetransaction_age- this is the bug. The session is holding the horizon and doing nothing with it.state = 'active'with a largetransaction_age- a genuinely long query. It is still holding the horizon, but killing it loses real work; decide deliberately.xmin_agein the millions with everything else looking fine - cleanup has been blocked for a long time. Expect a large backlog once it clears.
To end one, once you know which:
SELECT pg_cancel_backend(<pid>); -- cancel the query, keep the connection
SELECT pg_terminate_backend(<pid>); -- drop the connection entirelypg_cancel_backend does nothing to a session that is idle in a transaction - there is no query to cancel. Use pg_terminate_backend for those.
Checking the damage afterwards
SELECT relname,
n_live_tup,
n_dead_tup,
last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;Autovacuum will work through the backlog on its own. It is worth running VACUUM (VERBOSE) your_table; on the worst offender if you would rather not wait - it does not lock the table against reads or writes.
Note that VACUUM returns space for reuse by that table, it does not shrink the files on disk. If a table bloated badly enough that you want the space back, that is a rewrite - VACUUM FULL (takes an exclusive lock for the duration) or, better, see Large tables.
The advisory
CapyDB raises a Long-running transaction advisory when a transaction has been open past the threshold for a sustained period. It is a dashboard advisory - it does not email you - because the answer is almost always "look at pg_stat_activity and decide", not "wake up".
It exists alongside the Vacuum needed advisory on purpose. Vacuum needed measures dead rows: the symptom. This one measures the horizon: the cause. If both are open, fix this one and the other resolves on its own.
Preventing it
Your cell already has idle_in_transaction_session_timeout set to 10 minutes, which catches abandoned transactions without touching queries that are actually running - see Timeouts. What reaches the advisory is therefore usually a transaction that is genuinely still active.
Beyond that, the fixes are in application code:
- Open the transaction as late as possible and commit as early as possible. Never hold one across a network call to something that is not the database.
- Use your driver's scoped transaction helper (
db.transaction(...)) rather than issuingBEGINandCOMMITby hand, so an exception cannot skip the rollback. - Give reporting and batch work their own role with its own
statement_timeout, so a slow report is bounded without bounding the web app.
Timeouts
Which Postgres timeouts CapyDB sets for you, which ones it deliberately leaves alone, and how to set the rest so they survive the pooled endpoint.
Subtransactions
Why a savepoint you did not know you were creating can cost the whole database most of its throughput, and how to find out whether it is happening to you.