14. Performance tuning and operations¶
Time: ~3 hours Prerequisites: 02, 04, 05, 06
1. In plain words¶
Modules 01 to 13 explained what the database does with one statement. This module is about keeping a real server healthy while thousands of statements arrive per second, for years: how many connections it can take, how much memory each part gets, how you find the query that is hurting everybody, how dead rows get cleaned up, how you get the data back after a disk dies, and how a second server takes over when the first one goes away.
The analogy is running a bank branch rather than serving one customer. You decide how many counters are open (connections and pooling), how big the filing cabinets on the desks are (memory settings), you watch the queue and ask who is taking so long (monitoring), you send the cleaners in at the right time (VACUUM), you photocopy the ledgers every night and keep the receipts in between (backups and WAL archiving), and you keep a second branch across the road that copies every entry and can open its doors if this one floods (replication and failover).
For an interview this is where "I have used PostgreSQL" becomes "I have run PostgreSQL". The
questions are concrete: what does pgbouncer's transaction mode break, how do you size
work_mem, why did VACUUM not remove anything, what is your RPO, and what does Oracle call
the thing PostgreSQL calls a schema.
2. The real thing¶
2.1 Connections and pooling¶
PostgreSQL forks one backend process per connection (module 01). Each backend costs memory
(typically a few MB of private memory, more once it has touched many catalog entries and cached
plans), a slot in the shared ProcArray that every snapshot scans, and lock-table entries.
Hundreds of mostly idle connections are therefore not free: snapshot creation gets slower as
max_connections grows, and a burst where all of them become active at once oversubscribes the
CPUs and makes everything slower, not faster.
The fix is a connection pooler: many client connections share a small number of server connections. pgbouncer is the standard one: a single-threaded, event-driven proxy (the same design as Redis, module 13) that speaks the PostgreSQL protocol on both sides.
| Pool mode | A server connection is assigned to a client... | What breaks | Use |
|---|---|---|---|
session |
for the whole client session | nothing; but no multiplexing gain for long sessions | legacy apps, tools like psql |
transaction |
for one transaction, returned at COMMIT/ROLLBACK | any session state: SET (non-LOCAL), session advisory locks, LISTEN, temp tables kept across transactions, WITH HOLD cursors, protocol-level prepared statements unless pgbouncer 1.21+ with max_prepared_statements set |
the default for OLTP application servers |
statement |
for one statement; multi-statement transactions refused | everything transactional | rare; autocommit-only workloads |
Transaction mode is what gives the large multiplexing gain: 2000 application threads that each
spend most of their time outside a transaction can share 40 server connections. It also explains
why idle in transaction is so harmful behind a pooler: that client holds a server connection
(and a snapshot that blocks VACUUM) while doing nothing.
Sizing. The often-quoted starting point from the PostgreSQL wiki is roughly
connections = (2 x CPU cores) + effective spindles of active connections; it is a rule of
thumb to start measuring from, not a law. The real answer is the number at which throughput
stops rising when you load-test with pgbench. For a 4-core server that is typically in the
tens, not the hundreds. Set max_connections a little above the pool size plus admin
headroom (superuser_reserved_connections, replication, monitoring).
Your RPC application server already has a connection pool inside the C process. pgbouncer still helps when there are many such processes (each with its own pool), when they run on several machines, or when a deployment restarts them all at once and they reconnect in a storm.
2.2 Memory and WAL settings¶
The settings that matter, with starting points for a dedicated 16 GB RAM, 4-core, SSD OLTP server. These are conventional starting values used in the community (pgtune-style), not measured optima: change one at a time and measure with the queries in section 3.
| Setting | What it controls | Starting point | Notes |
|---|---|---|---|
shared_buffers |
PostgreSQL's own page cache (module 02) | 4GB (~25% of RAM) | the OS page cache holds much of the rest; more is not always better (double buffering, checkpoint volume) |
effective_cache_size |
the planner's assumption of total cache (shared + OS) | 12GB (~75%) | allocates nothing; makes index scans look cheaper |
work_mem |
per sort/hash node per backend before spilling to temp files | 16MB to 64MB | multiply by connections x nodes per query; raise per role or per session for reports |
maintenance_work_mem |
VACUUM, CREATE INDEX, ALTER TABLE ADD FK | 1GB | big index builds and vacuums go faster |
autovacuum_work_mem |
dead-tuple memory per autovacuum worker | -1 (uses maintenance_work_mem) | in 16, at most 1GB is used for the dead-TID array |
wal_buffers |
WAL in shared memory before write | -1 (auto, 1/32 of shared_buffers, max 16MB) | rarely touched |
max_wal_size |
WAL between checkpoints before one is forced | 4GB to 16GB | too small: checkpoints_req dominates, many FPIs (module 04) |
checkpoint_timeout |
time between checkpoints | 15min | longer: fewer FPIs, longer crash recovery |
checkpoint_completion_target |
spread checkpoint writes over this fraction | 0.9 (default since 14) | smooths I/O |
synchronous_commit |
wait for WAL flush (and standbys) at commit | on | off per session for bulk loads: lose at most ~3 x wal_writer_delay of commits on crash, never corruption |
wal_compression |
compress full-page images | lz4 (or zstd) | cuts WAL volume; costs some CPU |
random_page_cost |
planner's cost of a random page read | 1.1 on SSD (default 4) | the default models spinning disks |
effective_io_concurrency |
prefetch depth for bitmap heap scans | 200 on SSD | |
huge_pages |
2 MB pages for shared memory | try | fewer TLB misses with big shared_buffers (needs OS config) |
statement_timeout, lock_timeout, idle_in_transaction_session_timeout |
guard rails | per role: e.g. 30s, 5s, 60s | set on the application role, not globally (it would kill maintenance jobs) |
Settings live in postgresql.conf, can be overridden by ALTER SYSTEM (written to
postgresql.auto.conf), per database or role (ALTER ROLE loans_app SET work_mem = '32MB'),
per session (SET), or per transaction (SET LOCAL). SELECT name, setting, source FROM
pg_settings shows which one won. Some need a restart (shared_buffers, max_connections,
shared_preload_libraries, wal_level); most need only SELECT pg_reload_conf();
pg_settings.context says which.
2.3 Finding the problem: the observability toolkit¶
pg_stat_statements (module 06) aggregates every statement by its normalised text
(constants replaced by $1): calls, total and mean time, rows, buffers hit and read, temp blocks,
WAL bytes. It answers "what is the server spending its time on" and is the first query in every
incident. It needs shared_preload_libraries = 'pg_stat_statements' and a restart.
pg_stat_activity is one row per backend now: state (active, idle, idle in
transaction), wait_event_type/wait_event (what it is blocked on: Lock, LWLock, IO,
Client), xact_start, query_start, backend_xmin (the snapshot that holds the VACUUM
horizon), and the current query text. Sample it every few seconds and you have a poor man's
active-session history (Oracle's ASH, section 2.8).
pg_locks is the lock table: one row per held or awaited lock, with granted. Join it to
pg_stat_activity, or use pg_blocking_pids(pid) to get the blockers of a waiting backend
directly. Row locks appear as waits on a transactionid, because a row lock lives in the
tuple's xmax (module 05).
The slow query log writes to the server log (on Ubuntu,
/var/log/postgresql/postgresql-16-main.log):
log_min_duration_statement = 250ms # every statement slower than this, with its duration
log_lock_waits = on # any lock wait longer than deadlock_timeout (1s)
log_temp_files = 0 # every temp file (sorts and hashes spilling)
log_checkpoints = on # default on since 15: duration, buffers written, why
log_autovacuum_min_duration = 1s # autovacuum runs that took longer than this
auto_explain.log_min_duration = 500ms # the PLAN of slow statements (module 06)
log_line_prefix = '%m [%p] %q%u@%d %a ' # time, pid, user, database, application_name
pgBadger turns that log into an HTML report. Set application_name in every client's
connection string (your C server: application_name=rpcsrv-node3) so you can tell callers apart.
Cumulative statistics views for trends: pg_stat_user_tables (seq vs index scans, dead
tuples, last vacuum), pg_stat_user_indexes (unused indexes), pg_stat_database (commits,
rollbacks, deadlocks, temp bytes), pg_stat_bgwriter (checkpoints), pg_stat_wal,
pg_stat_replication, pg_stat_progress_vacuum / _create_index / _basebackup.
Export them to Prometheus with postgres_exporter and graph rates, not raw counters.
2.4 VACUUM tuning¶
Module 05 explained why VACUUM exists. Operations is about making it keep up.
When autovacuum starts on a table:
n_dead_tup > autovacuum_vacuum_threshold (50) + autovacuum_vacuum_scale_factor (0.2) x reltuples.
For app_users with 2 M rows that is 400,050 dead tuples; for a 100 M row table, 20 M. Hot
tables need per-table settings:
ALTER TABLE loans.app_users SET (autovacuum_vacuum_scale_factor = 0.01, -- 1%: ~20k dead rows
autovacuum_vacuum_threshold = 1000,
autovacuum_analyze_scale_factor = 0.02);
-- Insert-only tables (audit, archive) never get dead tuples; since 13, inserts also trigger
-- vacuum (autovacuum_vacuum_insert_scale_factor) so their visibility map and freezing keep up.
ALTER TABLE loans.application_audit SET (autovacuum_vacuum_insert_scale_factor = 0.05);
How fast it runs: autovacuum is throttled by a cost budget. Each page hit, miss and dirtied
costs points (vacuum_cost_page_hit 1, _miss 2 in 16, _dirty 20); after
autovacuum_vacuum_cost_limit points (default 200, shared by all workers) it sleeps
autovacuum_vacuum_cost_delay (2 ms). On modern SSDs the default budget is often too low for a
busy system: raising the limit to 1000 to 2000 is the usual first step. autovacuum_max_workers
(3) is how many tables are vacuumed at once; more workers share the same cost limit, so raise the
limit together with the workers.
What stops VACUUM from removing rows (the "horizon"): the oldest snapshot anywhere.
Culprits, in the order you check them: a long or idle in transaction session
(pg_stat_activity.backend_xmin), a replication slot that is behind (pg_replication_slots),
a hot standby with hot_standby_feedback = on running a long query, an orphaned prepared
transaction (pg_prepared_xacts). VACUUM (VERBOSE) prints "N dead row versions cannot be
removed yet, oldest xmin: X".
Freezing and wraparound. Transaction ids are 32-bit; tuples older than
vacuum_freeze_min_age get frozen so their xid can be reused. Any table whose relfrozenxid age
passes autovacuum_freeze_max_age (200 M) gets an aggressive anti-wraparound vacuum that cannot
be cancelled by lock conflicts. If ages still approach about 2 billion, the server refuses new
xids to protect data. Monitor age(datfrozenxid) (section 3) and alert far below that.
Bloat that VACUUM cannot fix. VACUUM makes space reusable inside the file; it does not shrink
it (except trailing empty pages). After a mass delete, VACUUM FULL rewrites the table under an
ACCESS EXCLUSIVE lock (an outage); pg_repack does the same online with a trigger-based copy.
Indexes: REINDEX INDEX CONCURRENTLY (module 03).
2.5 Backups¶
Two kinds, with different purposes:
Logical: pg_dump / pg_restore |
Physical: pg_basebackup + WAL archive |
|
|---|---|---|
| What | SQL-level dump of one database: DDL + data | a copy of the whole cluster's data directory files |
| Consistency | one REPEATABLE READ snapshot (module 05) | fuzzy copy made consistent by replaying WAL from the backup's start LSN (module 04) |
| Point-in-time recovery | no: only the moment of the dump | yes, to any moment covered by archived WAL |
| Portability | across major versions and architectures; selective (-t, -n) |
same major version and platform only |
| Speed at scale | slow to restore (rebuilds every index) | restore = copy files + replay |
| Use | upgrades, copying a schema, small databases, extra safety net | the real backup of a production cluster |
# Logical: custom (-Fc) or directory (-Fd) format allows parallel and selective restore
pg_dump -d loans -Fd -j 4 -f /backup/loans_$(date +%F)
pg_restore -d loans_restore -j 4 /backup/loans_2026-10-12
pg_dumpall --globals-only > /backup/globals.sql # roles and tablespaces are not in pg_dump
# Physical base backup, streaming the WAL needed to make it consistent
pg_basebackup -h localhost -U replicator -D /backup/base_$(date +%F) -Ft -z -X stream -P
PITR needs continuous WAL archiving: archive_mode = on and an archive_command (or
archive_library) that copies every completed 16 MB WAL segment somewhere safe. Restore = base
backup + restore_command + recovery_target_time (module 04 exercise 5). In production use a
tool that does archiving, retention, parallelism, compression, encryption and verification:
pgBackRest or Barman (WAL-G in cloud setups). Two numbers define the design: RPO
(how much data you may lose: the archive lag, or zero with synchronous replication) and RTO
(how long recovery may take: base backup size / restore speed + WAL replay time). A backup that
has never been restored is a hope, not a backup: schedule restore tests.
2.6 Replication¶
Streaming (physical) replication. A standby connects to the primary with the replication
protocol, receives the WAL byte stream as it is written (walsender on the primary,
walreceiver on the standby), and replays it continuously with the same redo code as crash
recovery. The standby is a byte-identical copy of the whole cluster and serves read-only queries
(hot standby). Key settings and facts:
wal_level = replica(default),max_wal_senders, a role withREPLICATION, apg_hba.confline forreplication.- Asynchronous by default: the primary commits without waiting; a crash of the primary can
lose the last transactions not yet received (RPO = replication lag, usually milliseconds to
seconds). Synchronous:
synchronous_standby_names = 'FIRST 1 (s1, s2)'or'ANY 1 (...)'andsynchronous_commit = on(flush on standby) /remote_apply(visible on standby) /remote_write. Commit latency now includes a network round trip; if every synchronous standby is down, commits wait. - Replication slots make the primary keep WAL until the standby has it, so a disconnected
standby can catch up instead of needing a rebuild; the price is that a dead standby makes WAL
pile up until the disk is full.
max_slot_wal_keep_sizecaps it. - Conflicts on the standby: replaying a VACUUM that removes rows a standby query still needs
cancels that query (
canceling statement due to conflict with recovery) aftermax_standby_streaming_delay.hot_standby_feedback = onstops the cancellations by holding back VACUUM on the primary (bloat). Pick which pain you prefer, per standby. - Lag:
pg_stat_replicationon the primary (write_lag,flush_lag,replay_lag), andpg_last_xact_replay_timestamp()on the standby.
Logical replication. The primary decodes WAL into row changes (INSERT/UPDATE/DELETE per
table) with the pgoutput plugin and sends them to subscribers, which apply them as ordinary SQL
writes. CREATE PUBLICATION on the source, CREATE SUBSCRIPTION on the target.
| Physical streaming | Logical | |
|---|---|---|
| Unit | WAL bytes, whole cluster | row changes, chosen tables |
| Target | read-only identical copy | writable database with its own indexes and extra tables |
| Versions | same major version | across major versions (upgrades with near-zero downtime) |
| Not replicated | nothing | DDL, sequence values (sync them by hand at cutover), large objects; tables need a primary key or REPLICA IDENTITY for UPDATE/DELETE |
| Use | HA, read scaling, backups from standby | upgrades, CDC, consolidating or splitting databases, feeding analytics |
Logical decoding is also how change data capture tools (Debezium) stream PostgreSQL changes to Kafka. That is the "CDC" row of module 12's cutover table, when the source is PostgreSQL.
2.7 Failover and high availability¶
Replication gives you a copy; failover is the decision and mechanics of making the copy the
primary. PostgreSQL itself only provides the mechanics (pg_promote(), pg_ctl promote,
timelines, pg_rewind); the decision needs an external manager.
- Detection and decision: Patroni is the common choice. Each node runs an agent that holds a leader key with a TTL in a distributed configuration store (etcd, Consul, ZooKeeper). The primary's agent renews the key; if it stops (node dead, network partition), a standby's agent acquires the key, promotes its PostgreSQL, and the others re-point to it. The consensus store is what prevents two primaries: only the key holder may be primary, and a primary that cannot renew the key demotes itself.
- Split brain is the failure to prevent: two nodes accepting writes. Defences are the leader lock above, fencing (the old primary is stopped or isolated before promotion, the "STONITH" idea), and synchronous replication so no acknowledged commit exists only on the old primary.
- Timelines: every promotion starts a new timeline id; WAL from the old timeline after the
fork point is not valid on the new one. The old primary rejoins as a standby after
pg_rewindrewinds its divergent pages (needswal_log_hints = onor data checksums). - Routing clients to the primary: a virtual IP, HAProxy health-checking Patroni's REST API,
or libpq multi-host connection strings:
host=pg1,pg2,pg3 target_session_attrs=read-write(libpq tries each host until it finds a writable one). Your C server's reconnect logic must treat a failover as "connection lost, retry the transaction" (the same retry loop as 40001). - What async failover costs: with asynchronous replication, the promoted standby may be missing the last few acknowledged commits. Either accept a small RPO (and say it out loud), or pay commit latency for synchronous replication to at least one standby.
Contrast with YugabyteDB (module 11): every tablet is a Raft group; a leader's failure triggers an election among the tablet's replicas within seconds, automatically, and a write is acknowledged only after a majority has it, so RPO is zero for a single-node loss with no external manager. PostgreSQL HA is assembled from parts; distributed SQL has it built in, and pays for it with consensus latency on every write.
2.8 Oracle, SQL Server and PostgreSQL: an honest comparison¶
Your resume lists Oracle and SQL Server. In an interview, the safe and senior position is precise knowledge of the differences that change application behaviour, and honesty about the depth of your hands-on use. The table is what you should be able to say without notes.
Terminology and architecture
| Concept | Oracle | SQL Server | PostgreSQL |
|---|---|---|---|
| Server process model | one instance = shared memory (SGA) + background processes; dedicated or shared server processes | one process, many threads (SQLOS scheduler) | postmaster + one process per connection |
| "Database" | the whole set of files; one database per instance traditionally, PDBs inside a CDB since 12c | many databases per instance | many databases per cluster; no cross-database queries without extensions |
| "Schema" | = a user's namespace (user and schema are the same object) | namespace inside a database (dbo) |
namespace inside a database, independent of users |
| Old row versions for readers | undo segments; current row in the block, old versions rebuilt from undo (ORA-01555 "snapshot too old") | locks by default; with RCSI/SNAPSHOT, row versions in the tempdb version store (or in the user database with Accelerated Database Recovery, 2019+) | all versions in the heap itself; VACUUM removes dead ones (module 05) |
| Write-ahead log | redo log (online + archived) | transaction log (.ldf) | WAL (pg_wal) |
| Table organisation | heap by default; index-organised tables (IOT) optional | usually a clustered index (the table is the B-tree on its key) | heap always; CLUSTER reorders once and is not maintained |
| Auto ids | sequences; identity columns since 12c | IDENTITY; sequences since 2012 |
GENERATED ... AS IDENTITY over sequences |
| Empty string | '' is NULL |
'' is not NULL |
'' is not NULL |
| Optimiser hints | yes, /*+ INDEX(t idx) */ widely used |
yes, table hints and OPTION (...); Query Store plan forcing |
none in core (extension pg_hint_plan) |
| Plans for parameters | bind peeking, adaptive cursor sharing | plan cache with parameter sniffing (a classic SQL Server incident) | custom vs generic plans after 5 executions (module 06) |
| Plan and wait analysis | EXPLAIN PLAN, DBMS_XPLAN, AWR / ASH reports |
actual execution plan, DMVs (sys.dm_exec_query_stats), Query Store |
EXPLAIN (ANALYZE, BUFFERS), pg_stat_statements, pg_stat_activity |
| HA | Data Guard (standby), RAC (shared-disk active-active) | Always On availability groups, failover cluster instances | streaming replication + Patroni |
| Backup | RMAN | full / differential / log backups | pg_basebackup + WAL archive (pgBackRest) |
Isolation and concurrency
| Oracle | SQL Server | PostgreSQL | |
|---|---|---|---|
| Default level | READ COMMITTED, with statement-level read consistency from undo | READ COMMITTED with shared locks (on-premises default; RCSI is a database option, on by default in Azure SQL Database) | READ COMMITTED with a new MVCC snapshot per statement |
| Do readers block writers? | no | yes under the locking default; no with RCSI | no |
| Levels offered | READ COMMITTED, SERIALIZABLE (and READ ONLY) | READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SNAPSHOT, SERIALIZABLE | READ UNCOMMITTED (behaves as RC), READ COMMITTED, REPEATABLE READ, SERIALIZABLE |
| What "SERIALIZABLE" really is | snapshot isolation: write skew is possible; conflicts raise ORA-08177 | true serializable through key-range locks (blocking, deadlock-prone) | SSI: snapshot isolation plus dependency tracking; aborts with 40001 instead of blocking (module 05) |
| Snapshot isolation by name | (that is its SERIALIZABLE) | SNAPSHOT (needs ALLOW_SNAPSHOT_ISOLATION) |
REPEATABLE READ |
| Deadlock victim error | ORA-00060 | 1205 | 40P01 |
| Lock escalation | none (row locks live in the block) | row/page locks escalate to table locks past thresholds | none (row locks live in the tuple's xmax) |
YugabyteDB YSQL (module 11) supports SERIALIZABLE, REPEATABLE READ (snapshot) and READ COMMITTED, and its default behaves as snapshot isolation unless read-committed support is enabled by a flag; check the version you run before you promise anything.
Procedural languages: PL/SQL, T-SQL, PL/pgSQL
| PL/SQL (Oracle) | T-SQL (SQL Server) | PL/pgSQL (PostgreSQL) | |
|---|---|---|---|
| Structure | blocks; packages (spec + body, package state per session) | batches and procedures; no packages | functions and procedures; schemas as namespaces, no packages |
| Error handling | EXCEPTION WHEN ... THEN; RAISE_APPLICATION_ERROR(-20001, ...) |
BEGIN TRY ... END TRY BEGIN CATCH ... END CATCH, THROW, @@ERROR; SET XACT_ABORT ON to abort the transaction on any error |
EXCEPTION WHEN ... blocks are subtransactions (cost, module 08); RAISE ... USING ERRCODE |
| Transaction control | COMMIT in procedures; autonomous transactions (PRAGMA AUTONOMOUS_TRANSACTION) for logging that survives rollback |
COMMIT/ROLLBACK in procedures, nested BEGIN TRAN only counts (@@TRANCOUNT) |
COMMIT/ROLLBACK only in procedures called at top level; no autonomous transactions (dblink or a second connection) |
| Bulk work | BULK COLLECT, FORALL to cut context switches between PL/SQL and SQL engines |
set-based statements, table variables, #temp tables |
set-based SQL; RETURN QUERY; no bulk-bind syntax needed |
| Temp tables | global temporary tables (definition permanent, data per session) | #local and ##global temp tables in tempdb |
session temp tables created at runtime (catalog churn if overused) |
| Output to caller | OUT params, ref cursors, pipelined table functions | result sets returned directly from procedures, OUT params | RETURNS TABLE / SETOF functions (materialised), OUT params, refcursors |
| Upsert | MERGE |
MERGE (with known race caveats) or UPDATE-then-INSERT with locking hints |
INSERT ... ON CONFLICT; MERGE since 15 |
The honest one-liner for an interview: "My deep, daily work is PostgreSQL and PL/pgSQL, and the YugabyteDB migration. I have worked with Oracle and SQL Server, and the differences I watch for when moving code between them are the default isolation and what SERIALIZABLE really means, undo versus heap versioning versus the tempdb version store, empty string as NULL in Oracle, parameter sniffing in SQL Server, and how error handling and transactions behave in PL/SQL, T-SQL and PL/pgSQL." Then answer at the depth you actually have.
3. Code¶
code/m14_monitoring_queries.sql contains the fourteen queries of an incident runbook:
settings, top statements, sessions, long transactions, blocking chains, locks, cache hit ratio,
dead tuples, autovacuum thresholds and progress, wraparound age, unused indexes, checkpoints,
WAL, replication and slots, and sizes. Run it as a whole once, then keep it open during
incidents. The three you will use most:
-- What costs the server most (pg_stat_statements)
SELECT left(regexp_replace(query, '\s+', ' ', 'g'), 70) AS query, calls,
round(total_exec_time::numeric, 1) AS total_ms, round(mean_exec_time::numeric, 3) AS mean_ms,
shared_blks_hit + shared_blks_read AS blocks, rows / greatest(calls, 1) AS rows_per_call,
temp_blks_written
FROM pg_stat_statements
ORDER BY total_exec_time DESC LIMIT 15;
-- Who blocks whom: follow blocking_pid until you reach a session that is not blocked itself
SELECT blocked.pid AS blocked_pid, now() - blocked.query_start AS waiting_for,
blocking.pid AS blocking_pid, blocking.state AS blocking_state,
now() - blocking.xact_start AS blocking_xact_age, left(blocking.query, 50) AS blocking_query
FROM pg_stat_activity blocked
JOIN LATERAL unnest(pg_blocking_pids(blocked.pid)) AS b(pid) ON true
JOIN pg_stat_activity blocking ON blocking.pid = b.pid;
-- What holds back VACUUM: the oldest snapshots
SELECT pid, state, now() - xact_start AS xact_age, backend_xmin, left(query, 60)
FROM pg_stat_activity WHERE backend_xmin IS NOT NULL ORDER BY age(backend_xmin) DESC LIMIT 5;
pgbouncer in front of the WSL PostgreSQL (sudo apt install pgbouncer):
; /etc/pgbouncer/pgbouncer.ini
[databases]
loans = host=127.0.0.1 port=5432 dbname=loans
[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
default_pool_size = 20 ; server connections per (database, user) pair
max_client_conn = 2000 ; client connections pgbouncer accepts
max_prepared_statements = 100 ; 1.21+: protocol-level prepared statements work in transaction mode
server_reset_query = ; empty in transaction mode (DISCARD ALL is for session mode)
# load test: 200 clients through pgbouncer versus directly (pgbench ships with postgresql-contrib)
pgbench -i -s 20 loans_bench # in a separate database
pgbench -h 127.0.0.1 -p 6432 -c 200 -j 4 -T 60 -S loans_bench # through the pooler
pgbench -h 127.0.0.1 -p 5432 -c 200 -j 4 -T 60 -S loans_bench # direct (raise max_connections first)
psql -h 127.0.0.1 -p 6432 -U $USER pgbouncer -c "SHOW POOLS;" # cl_active, cl_waiting, sv_active, sv_idle
A streaming replica on the same WSL machine (port 5435; 5433 and 5434 are used by docker):
sudo -u postgres psql -c "CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'repl'"
echo "host replication replicator 127.0.0.1/32 scram-sha-256" | sudo tee -a /etc/postgresql/16/main/pg_hba.conf
sudo service postgresql reload
sudo pg_createcluster 16 replica --port 5435 # creates config and an empty data directory
sudo rm -rf /var/lib/postgresql/16/replica/*
sudo -u postgres env PGPASSWORD=repl pg_basebackup -h 127.0.0.1 -p 5432 -U replicator \
-D /var/lib/postgresql/16/replica -R -X stream -C -S replica1 -P
# -R writes standby.signal and primary_conninfo; -C -S creates and uses replication slot replica1
sudo pg_ctlcluster 16 replica start
psql -p 5432 -c "SELECT application_name, state, sync_state, replay_lag FROM pg_stat_replication"
psql -p 5435 loans -c "SELECT pg_is_in_recovery(), count(*) FROM loans.branches" # true, 500
# failover drill
sudo pg_ctlcluster 16 main stop
psql -p 5435 -c "SELECT pg_promote()"
psql -p 5435 -c "SELECT timeline_id FROM pg_control_checkpoint()" # 2: a new timeline
Logical replication of the master tables into a reporting database (needs
wal_level = logical and a restart):
-- in database loans (publisher)
CREATE PUBLICATION pub_master FOR TABLE loans.branches, loans.loan_products, loans.workflow_states;
-- A subscription to a database in the SAME cluster must use a pre-created slot,
-- otherwise CREATE SUBSCRIPTION waits forever for its own transaction:
SELECT pg_create_logical_replication_slot('sub_master', 'pgoutput');
createdb loans_reporting
psql -d loans_reporting -c "CREATE SCHEMA loans"
pg_dump -d loans -s -t 'loans.branches' -t 'loans.loan_products' -t 'loans.workflow_states' \
| psql -d loans_reporting # DDL is not replicated: copy it first
# Two errors are expected: the cache-version triggers on branches and loan_products refer to a
# function that is not copied. The reporting side does not need them (apply workers do not fire
# ordinary triggers anyway).
psql -d loans_reporting -c "CREATE SUBSCRIPTION sub_master CONNECTION 'dbname=loans'
PUBLICATION pub_master WITH (create_slot = false, slot_name = 'sub_master')"
psql -d loans -c "UPDATE loans.branches SET name = name || ' *' WHERE code = 'BR0007'"
psql -d loans_reporting -c "SELECT name FROM loans.branches WHERE code = 'BR0007'" # changed
4. Under the hood (senior-level)¶
Why many connections slow down every query. Taking a snapshot (GetSnapshotData) means
reading every backend's advertised xid from the shared ProcArray. PostgreSQL 14 made this much
cheaper by keeping the xids in a dense array, but the cost still scales with the number of
connected backends, and each backend also holds catalog and plan caches in private memory.
Context switching between more runnable processes than cores adds latency without adding
throughput. A pooler turns "2000 connections, 40 busy" into "40 connections, 40 busy", which is
the shape the engine is good at.
pgbouncer's mechanics. One thread, libevent, a state machine per client and per server
connection. In transaction mode it watches the ReadyForQuery message's transaction status byte
(I idle, T in transaction, E failed): when it returns to I, the server connection goes
back to the pool. That is exactly why session state leaks between clients in this mode: the
server backend persists, so a SET search_path by client A is seen by client B's next
transaction on the same backend. Use SET LOCAL, or set things per role.
shared_buffers versus the OS page cache. A page read by PostgreSQL usually exists twice:
in shared_buffers and in the kernel's page cache (PostgreSQL uses buffered I/O, not
O_DIRECT, in 16). The clock-sweep replacement in shared buffers protects frequently used
pages; the kernel's LRU catches the rest. A huge shared_buffers reduces the useful OS cache,
increases the dirty data each checkpoint must write, and makes the buffer mapping table bigger.
That is why the conventional starting point is a quarter of RAM rather than most of it.
What a checkpoint costs, precisely. A checkpoint writes every dirty buffer, then fsyncs every
touched file, then writes a checkpoint record. After it, the first modification of each page
writes a full-page image to WAL (module 04). So frequent checkpoints cost twice: the write
storm, and a WAL volume spike from FPIs. checkpoint_completion_target = 0.9 spreads the writes;
a larger max_wal_size reduces the frequency; wal_compression shrinks the FPIs.
How streaming replication is wired. The standby's walreceiver connects with
replication=true, sends START_REPLICATION SLOT replica1 PHYSICAL 0/3000000, and the primary
starts a walsender that reads WAL from pg_wal (or memory) and streams XLogData messages.
The standby writes them to its own pg_wal, flushes, and reports positions back
(write/flush/apply LSNs), which is where pg_stat_replication's lag columns come from and
what synchronous commit waits on. The startup process replays the WAL with the same redo
functions used after a crash. Logical replication runs the same walsender but passes WAL
through logical decoding, which reassembles each transaction's changes (spilling large ones
to disk) and emits them at commit in commit order.
Backups without stopping writes. pg_basebackup calls pg_backup_start, which forces a
checkpoint and records its redo LSN, then copies files while they change. The copy is
inconsistent (torn pages are possible), and that is fine: recovery replays WAL from the recorded
redo LSN, and full-page writes overwrite any torn page with an intact image (module 04). The
backup is usable only together with the WAL from its start to its end, which is why -X stream
or an archive is mandatory.
Autovacuum's scheduling. The launcher wakes every autovacuum_naptime (1 min) per database
and starts workers up to autovacuum_max_workers; each worker picks tables over their thresholds
from the statistics system. The cost budget is shared ("balanced") across active workers, so
three workers each get a third of autovacuum_vacuum_cost_limit unless a table has its own.
Anti-wraparound vacuums ignore the lock-conflict cancellation that ordinary autovacuum yields to,
which is how an ALTER TABLE can suddenly queue behind a vacuum you cannot cancel.
5. In production¶
- The connection storm. All application nodes restart after a deploy and each opens its
full pool at once:
max_connectionsreached,FATAL: sorry, too many clients already, and the health checks fail too. Fixes: pgbouncer in front, staggered restarts, pools that grow lazily, andsuperuser_reserved_connectionsso you can still log in. work_memout of memory. Someone setwork_mem = 1GBglobally to fix one report; under load the kernel's OOM killer kills a backend, the postmaster restarts all backends, everyone is disconnected. Set big values per role or per session only.- "The database is slow" at 3 AM, in order:
pg_stat_activity(what is active, what waits on what), blocking chains,pg_stat_statementsdeltas since the last snapshot, the slow query log and auto_explain plans, then OS metrics (iostat -x,vmstat, disk full?). Write the timeline as you go; the post-mortem needs it. - Idle in transaction behind a pooler. One code path forgot to COMMIT before an RPC to
another service. VACUUM stopped removing rows cluster-wide, tables bloated, queries slowed over
days.
idle_in_transaction_session_timeouton the application role turns this into an error in that one code path. - The replication slot that filled the disk. A decommissioned standby or a stopped CDC
connector left its slot; WAL accumulated until the primary's disk filled and the primary
stopped. Alert on
pg_replication_slotsretained WAL; setmax_slot_wal_keep_size. - Backups that do not restore. The archive command returned success while writing to a full
disk; nobody tested a restore for months. Use pgBackRest's
checkandverify, and restore a backup into a scratch server on a schedule. - Failover that lost data. Asynchronous replica promoted after the primary died with 2 seconds of lag: those acknowledged payments do not exist on the new primary. Either accept a documented RPO, or run synchronous replication to a standby in another rack and accept the latency. For a banking ledger, the second.
- Major version upgrades.
pg_upgrade --linktakes minutes of downtime for any size (it reuses data files); logical replication to a new-version cluster then a switchover takes seconds of downtime but more preparation (DDL freeze, sequences, slots). RunANALYZEafter either, because planner statistics are not carried over by pg_upgrade in 16. - Mapping to your system: the RPC server's DB pool size per node x number of nodes must stay
under the pooler's
max_client_conn; login procedures from module 08 run in transaction mode without session state; the slow query log is where the login regression (module 06) would have shown up first; on YugabyteDB, failover is Raft's job but your client's retry loop is still yours.
6. Try it¶
- (easy) Enable
pg_stat_statements(README setup), runcode/m06_slow_vs_fast_queries.sql, then section 1 ofcode/m14_monitoring_queries.sql. Done when you can name the top three statements by total time and explain fromblocksversusrows_per_callwhich one lacks an index. - (easy) Start two
psqlsessions; in one runBEGIN; UPDATE loans.accounts SET balance = balance WHERE id = 1;and leave it. In the other run sections 3, 4 and 5 of the monitoring file. Done when you can point at theidle in transactionsession, itsbackend_xmin, and the lock rows, and end it withpg_terminate_backend. - (medium) Install pgbouncer in transaction mode as in section 3 and run the two pgbench
commands with
-c 200. Done when you have both TPS numbers, theSHOW POOLSoutput, and can explain whySET search_pathfrom one client leaks to another in this mode (show it). - (medium) Take a
pg_dump -Fd -j 4ofloans, restore it intoloans_restorewithpg_restore -j 4, and time both. Then create the streaming replica on port 5435, runm09_seed.sqlon the primary while watchingreplay_lag, and promote the replica. Done when you have the restore time, a lag observation, and the new timeline id. - (hard) Make autovacuum fall behind: run a
pgbenchcustom script that updatesfailed_attemptson randomapp_usersrows at full speed for 5 minutes with default settings, recordingn_dead_tupevery 30 s. Then apply the per-table settings from section 2.4 and raiseautovacuum_vacuum_cost_limitto 2000, and repeat. Done when you have two curves of dead tuples over time and can explain the difference with the trigger formula and the cost budget. - (hard) Set up the logical replication of the master tables into
loans_reporting, then use it to rehearse a "zero-downtime" cutover: add a publication forloan_applicationstoo, let it catch up, stop writes on the source, check row counts and a parity sample withfn_parity_hash_loanon both sides, and write the five-line runbook you would use. Done when counts and hashes match and the runbook names the sequence-sync step.
7. Interview questions¶
Q: Why do you need pgbouncer, and what breaks in transaction mode? A: PostgreSQL runs a process per connection, so hundreds of mostly idle connections waste memory, slow snapshot creation and oversubscribe CPUs under bursts. pgbouncer multiplexes many client connections onto a small server pool. In transaction mode a server connection is handed back at every commit, so any session state breaks: session SETs, session advisory locks, LISTEN, temp tables across transactions, WITH HOLD cursors, and prepared statements unless pgbouncer 1.21+ with max_prepared_statements.
Q: How do you set shared_buffers and work_mem? A: shared_buffers is PostgreSQL's page cache; a quarter of RAM is the conventional start because the OS page cache holds the rest and very large values increase checkpoint work. work_mem is per sort or hash node per backend, so the worst case is connections times nodes times work_mem; keep the global value modest, raise it per role or session for reports, and watch temp_blks_written in pg_stat_statements and log_temp_files to see spills.
Q: VACUUM runs but dead tuples are not removed. Why? A: Something holds back the xmin horizon: a long or idle-in-transaction session, a lagging replication slot, a standby with hot_standby_feedback running a long query, or an orphaned prepared transaction. VACUUM VERBOSE reports "dead row versions cannot be removed yet, oldest xmin"; pg_stat_activity.backend_xmin, pg_replication_slots and pg_prepared_xacts find the culprit.
Q: pg_dump or pg_basebackup? A: pg_dump is a logical, consistent snapshot of one database, portable across versions and selective, but it only restores to the moment of the dump and rebuilds every index on restore. pg_basebackup copies the cluster's files and, with continuous WAL archiving, allows point-in-time recovery to any moment; it is the production backup, ideally through pgBackRest or Barman, with scheduled restore tests to prove RPO and RTO.
Q: Physical versus logical replication? A: Physical streaming ships WAL bytes and produces a read-only, byte-identical copy of the whole cluster on the same major version: HA and read scaling. Logical replication decodes WAL into row changes per table and applies them as SQL on a writable target, across versions: upgrades, CDC, partial copies. Logical does not carry DDL or sequence values, and needs a replica identity (normally the primary key) on each table that receives updates or deletes.
Q: How does failover work for PostgreSQL, and what can go wrong? A: PostgreSQL provides promotion and timelines; a manager such as Patroni decides, using a leader lock in etcd or Consul so only one node may be primary, promotes a standby and re-points the others, while clients find the new primary through a proxy or libpq target_session_attrs. The risks are split brain, which the consensus lock and fencing prevent, and data loss with asynchronous replication, which synchronous replication to at least one standby removes at the cost of commit latency. The old primary rejoins after pg_rewind.
Q: What does "SERIALIZABLE" mean in Oracle, SQL Server and PostgreSQL? A: In Oracle it is snapshot isolation, so write skew is still possible and conflicts raise ORA-08177. In SQL Server it is lock-based serializability with key-range locks, which blocks and deadlocks more. In PostgreSQL it is Serializable Snapshot Isolation: snapshot reads plus read/write dependency tracking that aborts one transaction with 40001 instead of blocking. SQL Server's SNAPSHOT level and PostgreSQL's REPEATABLE READ are the snapshot isolation equivalents.
Q: Name three behavioural differences that bite when moving code from Oracle or SQL Server to PostgreSQL.
A: Oracle treats the empty string as NULL, PostgreSQL does not, so WHERE col = '' and NOT NULL
constraints change meaning. SQL Server's default READ COMMITTED takes shared locks so readers
block writers, while PostgreSQL never blocks readers, which changes code that relied on blocking
for correctness. Error handling differs: a PL/pgSQL EXCEPTION block is a subtransaction with a
cost, there are no autonomous transactions for logging, and T-SQL's TRY/CATCH with XACT_ABORT
semantics do not map one to one.
8. Recap¶
- Process per connection: pool with pgbouncer; transaction mode multiplexes but forbids session state.
- shared_buffers ~25% of RAM to start, effective_cache_size is only a planner hint, work_mem is per node per backend.
- max_wal_size and checkpoint_timeout trade checkpoint frequency (FPIs, write storms) against recovery time.
- First tools in an incident: pg_stat_activity, pg_blocking_pids, pg_stat_statements, slow log with auto_explain.
- Autovacuum: per-table scale factors for hot tables, a bigger cost limit, and hunt whatever holds the xmin horizon.
- Watch xid age; anti-wraparound vacuums cannot be cancelled and the server stops near 2 billion.
- pg_dump for logical copies and upgrades; pg_basebackup plus WAL archiving for PITR; test restores.
- Streaming replication = WAL bytes, read-only copy; logical = row changes, writable target, cross-version.
- Failover needs a manager (Patroni + etcd), fencing against split brain, and an honest RPO.
- Oracle: undo, '' is NULL, SERIALIZABLE is SI. SQL Server: locking RC by default, tempdb versions, key-range SERIALIZABLE. PostgreSQL: heap MVCC, SSI.