Module A-12·26 min read

The difference between junior and senior is not SQL knowledge — it is having the right runbooks before the incident.

Written by Jatin Jain Saraf · Architect of SupraScan

Module 12 — Production Operations: Monitoring, Migration, and the Runbooks That Matter

What this module covers: The difference between a junior and a senior database engineer is not SQL knowledge — it is having the right runbooks before the incident, the right monitoring before the alert, and the right migration plan before the deployment. This module covers the operational stack: what to measure and how, connection pooling with PgBouncer, backup and restore, zero-downtime migration patterns, and the five runbooks that every team running Postgres in production needs to have written down before they need them.


The Monitoring Stack

You cannot operate what you cannot observe. Postgres exposes an exceptional amount of internal state through pg_stat_* views. The challenge is not access — it is knowing which metrics to collect, what thresholds to alert on, and what to do when they fire.

The Essential Views

-- 1. Active connections and what they are doing
SELECT
  pid,
  usename,
  application_name,
  client_addr,
  state,                           -- 'active', 'idle', 'idle in transaction', 'disabled'
  wait_event_type,                 -- 'Lock', 'LWLock', 'IO', 'Client', etc.
  wait_event,
  ROUND(EXTRACT(EPOCH FROM (now() - state_change))::numeric, 1) AS state_seconds,
  LEFT(query, 100) AS query_snippet
FROM pg_stat_activity
WHERE pid != pg_backend_pid()
ORDER BY state_seconds DESC;

idle in transaction connections are the most dangerous. They hold open MVCC snapshots, pin OldestXmin, and block autovacuum from reclaiming dead tuples (Module 2 and 4). Any connection idle in transaction for more than 30 seconds deserves investigation.

-- 2. Table-level access statistics
SELECT
  relname,
  seq_scan,
  seq_tup_read,
  idx_scan,
  idx_tup_fetch,
  n_tup_ins,
  n_tup_upd,
  n_tup_del,
  n_live_tup,
  n_dead_tup,
  ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 1) AS dead_pct,
  last_autovacuum,
  last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
-- 3. Index usage
SELECT
  relname AS table,
  indexrelname AS index,
  idx_scan,
  idx_tup_read,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 20;
-- idx_scan = 0 on large indexes = candidate for removal
-- 4. Cache hit ratio (should be > 99% for OLTP)
SELECT
  sum(heap_blks_hit) AS heap_hits,
  sum(heap_blks_read) AS heap_reads,
  ROUND(
    100.0 * sum(heap_blks_hit) /
    NULLIF(sum(heap_blks_hit) + sum(heap_blks_read), 0), 2
  ) AS cache_hit_pct
FROM pg_statio_user_tables;
 
-- Per-table cache hit
SELECT
  relname,
  heap_blks_hit,
  heap_blks_read,
  ROUND(100.0 * heap_blks_hit / NULLIF(heap_blks_hit + heap_blks_read, 0), 2) AS hit_pct
FROM pg_statio_user_tables
WHERE heap_blks_hit + heap_blks_read > 1000
ORDER BY heap_blks_read DESC;
-- 5. Lock waits (queries waiting for locks)
SELECT
  blocked.pid,
  blocked.query AS blocked_query,
  blocking.pid AS blocking_pid,
  blocking.query AS blocking_query,
  blocked.wait_event,
  ROUND(EXTRACT(EPOCH FROM (now() - blocked.state_change))::numeric, 1) AS waiting_seconds
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
  ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.wait_event_type = 'Lock';
-- 6. Long-running queries
SELECT
  pid,
  usename,
  ROUND(EXTRACT(EPOCH FROM (now() - query_start))::numeric, 1) AS query_seconds,
  state,
  LEFT(query, 120) AS query
FROM pg_stat_activity
WHERE state = 'active'
  AND query_start < now() - INTERVAL '30 seconds'
ORDER BY query_seconds DESC;

pg_stat_statements: Query Performance Over Time

-- Top 15 queries by total time (install extension first)
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
 
SELECT
  LEFT(query, 100)                               AS query,
  calls,
  ROUND(total_exec_time::numeric / 1000, 2)      AS total_sec,
  ROUND(mean_exec_time::numeric, 2)              AS mean_ms,
  ROUND(stddev_exec_time::numeric, 2)            AS stddev_ms,
  ROUND(100.0 * total_exec_time /
    sum(total_exec_time) OVER (), 2)             AS pct_total,
  rows / NULLIF(calls, 0)                        AS avg_rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 15;
-- Queries with highest I/O cost (cache miss heavy)
SELECT
  LEFT(query, 100) AS query,
  calls,
  shared_blks_read AS disk_reads,
  shared_blks_hit AS cache_hits,
  ROUND(100.0 * shared_blks_read /
    NULLIF(shared_blks_hit + shared_blks_read, 0), 1) AS miss_pct,
  ROUND(mean_exec_time::numeric, 2) AS mean_ms
FROM pg_stat_statements
WHERE shared_blks_hit + shared_blks_read > 10000
ORDER BY shared_blks_read DESC
LIMIT 10;

Alerting Thresholds

MetricWarningCriticalAction
Connections used (% of max_connections)> 70%> 85%Check for connection leaks; increase PgBouncer pool
idle in transaction connections> 5> 20Kill offending connections; fix application code
Replication lag (bytes)> 100MB> 500MBInvestigate standby I/O; reduce write load
Dead tuple % on any table> 20%> 40%Run manual VACUUM; tune autovacuum for that table
XID age on any table> 1B> 1.5BRun VACUUM FREEZE immediately
Cache hit ratio< 99%< 95%Increase shared_buffers; optimize query working set
pg_wal directory size> 60% of partition> 80%Check inactive replication slots; increase disk
Long-running queries> 30s> 5minIdentify and kill; add timeout: statement_timeout
Lock wait time> 5s> 30sFind blocking query; investigate lock contention
# Automatic statement timeout — kills runaway queries
statement_timeout = '300s'       # 5 minutes max for any query
lock_timeout = '30s'             # fail fast if can't acquire lock
idle_in_transaction_session_timeout = '60s'  # kill idle-in-transaction after 60s

idle_in_transaction_session_timeout is one of the most important production settings that most teams don't have set. It automatically terminates connections that have been idle-in-transaction for too long — eliminating the entire class of "someone left a transaction open in their REPL and the table hasn't vacuumed in 6 hours" incidents.


Connection Pooling: PgBouncer

Every Postgres connection is a full OS process (Module 0). Above a few hundred connections, fork overhead and memory pressure become significant. PgBouncer is the standard connection pooler — a lightweight proxy that multiplexes many application connections onto a smaller number of Postgres connections.

PgBouncer Pooling Modes

Session mode:

  • One Postgres connection is assigned to an application connection for the entire session
  • Prepared statements, advisory locks, and SET parameters work correctly
  • Pool size = max simultaneous sessions ≈ similar to no pooler for connection count
  • Use when: you need prepared statements or session-level state

Transaction mode:

  • A Postgres connection is held only during a transaction; returned to the pool after COMMIT/ROLLBACK
  • One Postgres connection can serve many sequential application transactions
  • Breaks: prepared statements (not persisted across transactions), advisory locks, SET parameters, LISTEN/NOTIFY
  • Use when: high-throughput OLTP with short transactions and no prepared statements
  • Most PgBouncer deployments use this mode

Statement mode:

  • A Postgres connection is held only for a single statement
  • Breaks: multi-statement transactions (each statement gets its own connection → no transaction atomicity)
  • Rarely used in practice
# pgbouncer.ini
[databases]
mydb = host=primary port=5432 dbname=indexer
 
[pgbouncer]
pool_mode = transaction
max_client_conn = 5000       # max application connections to PgBouncer
default_pool_size = 50       # Postgres connections per database per user
min_pool_size = 10
reserve_pool_size = 5        # extra connections for burst
reserve_pool_timeout = 3     # seconds before using reserve pool
 
server_idle_timeout = 600    # close idle Postgres connections after 10 min
server_lifetime = 3600       # recycle Postgres connections after 1 hour
client_idle_timeout = 0      # don't close idle application connections (app handles this)
 
# Authentication
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt

Sizing the Pool

The optimal number of Postgres connections is not "as many as possible" — it is the number that saturates your hardware without thrashing.

A well-known formula (from PgBouncer's own documentation):

optimal_pool_size ≈ (num_cores × 2) + num_effective_spindles

For a 16-core server with NVMe (treated as 1 effective spindle, by convention, even though it has no physical spindles):

optimal ≈ (16 × 2) + 1 = 33 connections

More connections than this means CPU context switching and memory pressure outweigh the benefit of additional concurrency. PgBouncer's default_pool_size = 30–50 for a modern server is a reasonable starting point.

-- Monitor PgBouncer pool utilization (from PgBouncer admin console)
SHOW POOLS;
-- cl_active: clients currently using a server connection
-- cl_waiting: clients waiting for a connection (this should be near 0)
-- sv_active: server connections currently in use
-- sv_idle: server connections available in the pool

cl_waiting > 0 consistently means your pool is undersized for the workload. Either increase default_pool_size (requires more Postgres connections) or reduce application connection count.


Backup Strategies

pg_dump: Logical Backup

# Full database dump (SQL format)
pg_dump -h localhost -U postgres -d indexer \
  --format=custom \        # custom binary format, fastest restore
  --compress=9 \           # maximum compression
  --file=indexer_backup_$(date +%Y%m%d).dump
 
# Restore
pg_restore -h localhost -U postgres -d indexer_restored \
  --jobs=8 \               # parallel restore with 8 workers
  indexer_backup_20260517.dump
 
# Table-selective dump
pg_dump -h localhost -U postgres -d indexer \
  --table=transactions \
  --format=custom \
  --file=transactions_backup.dump

Advantages: portable across Postgres versions, table-selective, works through logical replication boundaries.
Disadvantages: slow for large databases (must serialize every row), creates significant I/O load on the source, cannot be used for PITR.

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.