← Recipes

阅读中文版本

Hunt Down idle in transaction Sessions in PostgreSQL

Find sessions holding a transaction open while doing nothing, measure their age and lock footprint, and close the leak at the application layer.

~30 mindatabasepostgresqldebugging

Task

Identify idle in transaction sessions, quantify their age and held locks, trace them to application code, and stop the leak.

When to use this

Autovacuum warnings appear nightly and random UPDATEs block for minutes. pg_stat_activity shows dozens of sessions idle in transaction for hours.

Prerequisites

  • PostgreSQL 12 or newer with superuser or pg_monitor access
  • A database experiencing lock waits or vacuum issues
  • Application code with transaction management access

Steps

1. List idle-in-transaction sessions sorted by age.

psql -d app -c "SELECT pid, now() - xact_start AS xact_age, state, left(query, 60) AS last_query FROM pg_stat_activity WHERE state = 'idle in transaction' ORDER BY xact_start LIMIT 10;"

Expected output: Sessions print with xact_age such as 03:21:07, meaning a transaction has been open for hours while the connection sits idle.

2. Check what locks the oldest session holds.

psql -d app -c "SELECT l.pid, l.mode, c.relname FROM pg_locks l JOIN pg_class c ON l.relation = c.oid WHERE l.pid = 48213 ORDER BY c.relname;"

Expected output: Row and relation locks held by the session are listed, typically including a RowExclusiveLock on the hottest table that other writers now queue behind.

3. Identify which application owns the connection.

psql -d app -c "SELECT pid, usename, application_name, client_addr, backend_start FROM pg_stat_activity WHERE pid = 48213;"

Expected output: The application_name and client_addr point at the specific service instance, narrowing the search to one deploy.

4. Find the leak in code: a transaction opened without commit or rollback.

// leak: early return leaves the transaction open
async function transfer(from, to, amount) {
  const tx = await pool.connect();
  try {
    await tx.query("BEGIN");
    const bal = await tx.query("SELECT balance FROM accounts WHERE id = $1", [from]);
    if (bal.rows[0].balance < amount) return { ok: false };   // <-- leak
    await tx.query("UPDATE accounts SET balance = balance - $1 WHERE id = $2", [amount, from]);
    await tx.query("UPDATE accounts SET balance = balance + $1 WHERE id = $2", [amount, to]);
    await tx.query("COMMIT");
    return { ok: true };
  } finally {
    tx.release(); // releases the connection but NOT the open transaction
  }
}

// fix: wrap in a helper that always finishes the transaction
async function withTx(pool, fn) {
  const tx = await pool.connect();
  try {
    await tx.query("BEGIN");
    const out = await fn(tx);
    await tx.query("COMMIT");
    return out;
  } catch (err) {
    await tx.query("ROLLBACK");
    throw err;
  } finally {
    tx.release();
  }
}

Expected output: After adopting withTx, every code path ends with COMMIT or ROLLBACK, and idle-in-transaction counts drop to zero within the pool lifetime.

5. Add a server-side safety net that reaps forgotten transactions.

psql -d app -c "SHOW idle_in_transaction_session_timeout;" && psql -d app -c "ALTER SYSTEM SET idle_in_transaction_session_timeout = '5min';" && psql -d app -c "SELECT pg_reload_conf();"

Expected output: The timeout changes from 0 (disabled) to 5min; leaked sessions are now terminated by the server, converting a silent leak into a logged error.

Troubleshooting

Problem

Sessions disappear before you can inspect them

Solution

Poll pg_stat_activity on a loop writing to a table, or enable log_statements tracking, so the next occurrence is captured with full context.

Problem

pool.release does not close the transaction

Solution

Releasing a connection back to the pool keeps its transaction state; always issue COMMIT or ROLLBACK before release, or use the helper pattern that guarantees it.

Problem

ORM hides the transaction boundaries

Solution

Search for the callback or decorator that opens a unit of work; early returns inside it are the classic leak, and stack traces from the timeout errors will name the exact function.

Limitations

  • idle_in_transaction_session_timeout kills the connection, surfacing as client errors; treat it as a tripwire, not the fix itself.
  • Lock queries need the pid of a live session; if the blocker exits, use the lock logs or pg_lock_history equivalents instead.

An open transaction doing nothing is the worst kind

A session idle in transaction holds locks, blocks vacuum from cleaning dead rows, and stretches snapshot age until queries bloat. The state is visible in pg_stat_activity, and the cause is almost always an application code path that returns early between BEGIN and COMMIT.

-- Who is idle, and for how long?
SELECT pid,
       now() - xact_start AS xact_age,
       application_name,
       left(query, 50) AS last_query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_start
LIMIT 10;

--   pid  | xact_age  | application_name | last_query
--  48213 | 03:21:07  | orders-api-v3     | SELECT balance FROM accounts ...

-- Stop the bleeding, then fix the code
ALTER SYSTEM SET idle_in_transaction_session_timeout = '5min';
SELECT pg_reload_conf();

Order matters: age before blame

Sort by xact_start, not by pid, so the oldest transaction leads the list. The oldest session is usually the blocker for everyone else, and its application_name tells you which service to grep for a BEGIN without a matching COMMIT.

The timeout is a smoke detector

Setting idle_in_transaction_session_timeout reaps forgotten transactions in five minutes, which protects the database from indefinite lock holding. But each reap surfaces as a client error with a stack trace, and that trace is how you find the leaking function to fix for real.