1. Courses
  2. Engineering Roles
  3. Database Administration with PostgreSQL
  4. Stop 1

35 minIntermediate

Inside a PostgreSQL server

Most database alerts at 3 a.m. trace back to a few moving parts: processes, memory, the write-ahead log and row versions. This lesson opens the box, so you can read what Mangrove's order database is doing and predict what will go wrong before it does.

By the end you can

  • Name the processes on a PostgreSQL server, what each one does, and why every connection costs memory
  • Follow one UPDATE from shared buffers to the WAL to the data files, and explain how crash recovery uses the WAL
  • Read xmin, xmax and ctid to see row versions, and decide when a dead version can be removed
  • Explain what VACUUM, VACUUM FULL, the visibility map and freezing do, and find what blocks cleanup
  • Check transaction ID age long before wraparound turns into an outage

The everyday picture

Picture the back office of a busy Mangrove store. A supervisor at the door never serves anyone. When a customer arrives, the supervisor gives that customer a clerk of their own for the whole visit. Ten customers, ten clerks. That is how PostgreSQL handles connections.

The clerks share one big work table in the middle of the room. They pull boxes from the stockroom onto the table, change what is inside, and leave them there. A night crew carries them back later, many boxes at once. But before a clerk tells a customer "done", the clerk writes the change in ink in a notebook at the till. If the lights go out, the night crew reads the notebook and redoes every change that never reached the stockroom. The work table is memory, the stockroom is disk, and the notebook is the write-ahead log.

One more habit: clerks never erase a price label. They stick a new label on top and cross out the old one, because a customer holding an old flyer may still ask about the old price. A cleaner peels off crossed-out labels, but only when no customer in the store could still need them. One customer who stays for six hours stops the cleaner for the whole shop. That is MVCC and vacuum.

One process per connection

When PostgreSQL starts, it runs one parent process, the . It does no query work itself. When a client connects, the postmaster starts a new operating system process for it, called a . The backend parses, plans and runs every query from that connection until it disconnects. So 300 connections from the NestJS APIs means 300 processes on the database VM.

Each backend has its own private memory: memory for sorts and hashes, plus caches of table definitions and query plans. An idle backend costs a few MB. A backend that has touched hundreds of tables, or is running a big sort, costs much more. That is the reason underneath the pooling advice in Running Postgres in production: connections are processes, and processes cost memory and CPU time even while they wait.

Next to the backends run the . On a PostgreSQL 16 primary you will see these:

  • checkpointer: every few minutes, writes all changed pages from memory to the data files, spread out so the disk is not flooded.
  • background writer: writes some changed pages ahead of time, so backends usually find a clean memory slot when they need one.
  • WAL writer: flushes the write-ahead log to disk in the background, every 200 ms by default.
  • autovacuum launcher: checks each database about once a minute (autovacuum_naptime) and starts autovacuum workers (3 at most by default) for tables that need cleaning.
  • WAL sender: one per connected replica or backup tool, streaming the log to it (Replication and read replicas).

The process model has one consequence every DBA must know. All these processes share one region of memory. If any backend dies in an unclean way, for example killed by the Linux , the postmaster cannot trust that shared memory any more. It logs terminating any other active server processes, disconnects every client, and runs crash recovery before it accepts connections again. One runaway report can drop every app order for a few seconds or longer, which is why the PostgreSQL docs recommend strict memory overcommit settings on Linux.

NestJS APIs, PgBouncer 1. connect postmaster 2. fork backend backend backend ... 3. read, change shared memory shared buffers WAL buffers checkpointer bg writer WAL writer dirty pages WAL data files pg_wal files vacuum autovacuum workers clean dead rows WAL sender streams to replica
The postmaster only starts processes. Each connection gets its own backend, all backends work on pages in shared memory, and background processes move pages and WAL to disk, clean dead rows and stream WAL to replicas.

Quick check

The Linux OOM killer kills one backend that was running a huge report on the order database. What happens to the 180 other connections?

Memory: shared buffers, the OS cache and work_mem

PostgreSQL stores tables and indexes in 8 kB . A backend never works on the data file directly. It first loads the page into , its own page cache, then reads or changes it there. A changed page that is not yet on disk is called a .

PostgreSQL reads files through the operating system, so Linux also keeps recently read blocks in its . A hot page can sit in both places at once. That is why shared buffers usually starts near 25% of RAM and not 80%: the rest of RAM still works as a second cache. A miss in shared buffers that hits the OS cache is still fast; only a miss in both goes to disk. In EXPLAIN (ANALYZE, BUFFERS), "shared hit" means found in shared buffers, and "read" means asked from the OS, which PostgreSQL cannot split into cache and disk.

When shared buffers is full, a backend that needs a slot takes a page nobody used lately. If that page is dirty, it must be written out first. The background writer keeps clean slots ready so backends rarely wait for that; since PostgreSQL 16, pg_stat_io shows how often client backends had to write pages themselves.

The other memory setting that matters is , private to each backend. It is a limit for each sort or hash step in a plan, not for the query or the connection. A sort that needs more spills to temporary files, and the plan shows Sort Method: external merge Disk: 210MB. Turn on log_temp_files to see which queries spill. Hash steps may use work_mem times hash_mem_multiplier, which is 2 by default since PostgreSQL 15. Running Postgres in production shows how quickly these limits multiply across connections; Finding and fixing slow queries covers tuning them with real numbers.

The write path: WAL first, data pages later

Writing every changed page to its data file at commit would be slow: one order touches pages in orders, order_line and several indexes, scattered across the disk. Instead, PostgreSQL records each change in the , an append-only log, and writes the data pages later. The rule is simple: the log record for a change must reach disk before the changed page does, and before the commit is confirmed.

Here is one UPDATE orders SET status = 'picked' from the Rider app:

  1. The backend loads the page into shared buffers and changes it there. The page is now dirty.
  2. It appends a WAL record describing the change to the WAL buffers in shared memory.
  3. At COMMIT, the backend writes the WAL up to its commit record into the pg_wal directory and calls fsync, which waits until the disk confirms. Only then does the client see COMMIT.
  4. Seconds or minutes later, the background writer or the checkpointer writes the dirty page to its data file.

One sequential write per commit is far cheaper than many random page writes, and commits arriving together can share one fsync.

A writes every page that was dirty when it started. PostgreSQL starts one when checkpoint_timeout passes (5 minutes by default) or when the WAL written since the last one nears max_wal_size (1 GB by default). It spreads its writes over most of the interval (checkpoint_completion_target, 0.9 by default since PostgreSQL 14). Afterwards, older 16 MB WAL segment files can be recycled, unless archiving, a replication slot or wal_keep_size still needs them (Backups you have actually restored and Replication and failover).

After a crash, the data files may hold a mix of old and new pages. On restart, the startup process finds the last checkpoint's redo point in the control file and replays every WAL record after it; the log shows redo starts at 3A/41000028. Every committed transaction comes back, and nothing uncommitted survives. To survive pages torn in half by a power cut, the first change to each page after a checkpoint writes the whole 8 kB page into the WAL (full_page_writes). That is why WAL volume jumps right after each checkpoint.

One UPDATE, to disk 1. UPDATE backend edits the page shared buffers page is dirty 2. WAL record the change, described WAL buffers fsync 3. COMMIT client hears COMMIT now pg_wal on disk durable 4. Later: checkpoint dirty page written data files old page until 4 5. Crash before 4? replay WAL from redo point of last checkpoint startup process replays WAL
The commit waits only for the WAL to reach disk. The data page follows later, at a checkpoint. If the server crashes in between, the startup process replays the WAL and rebuilds the page.

Quick check

The order database VM loses power two seconds after the app received COMMIT for order 5102. The checkpointer had not yet written that page. After the restart, where is the order?

MVCC: every UPDATE writes a new row version

PostgreSQL never changes a row in place. An UPDATE writes a new version of the row and marks the old version as ended; a DELETE only marks it. This is , introduced in Running Postgres in production. Every row version carries hidden system columns you can select:

  • xmin: the ID of the transaction that created this version.
  • xmax: the ID of the transaction that deleted or replaced it, or 0. (Row locks such as SELECT … FOR UPDATE also use xmax, so a non-zero xmax does not always mean the row is gone.)
  • ctid: where the version lives, as (page, item). A new version usually gets a new ctid.

Each statement (or each transaction under REPEATABLE READ, see Transactions, ACID, isolation and locks) takes a : in effect, the list of transactions that had committed at that moment. A version is visible if its xmin committed before the snapshot and its xmax is 0, aborted, or not yet committed for that snapshot. You can see a snapshot with SELECT pg_current_snapshot();. It prints the oldest running transaction, the next unused ID, and the running ones, such as 48211907:48211915:48211907,48211909.

When no snapshot anywhere can see an old version any more, it is a . The ID before which every snapshot agrees is the . VACUUM can remove a dead version only if the transaction that ended it is older than the horizon. And the horizon is set by the oldest thing that holds a snapshot:

  • a long-running query or transaction, including a session left idle in transaction (its backend_xmin in pg_stat_activity);
  • a replication slot whose consumer is behind or gone (xmin in pg_replication_slots);
  • a replica with hot_standby_feedback = on running a long query, which reports its snapshot to the primary;
  • a forgotten prepared transaction (pg_prepared_xacts).

One old snapshot holds back cleanup in every table of the database: a 5-hour transaction on a quiet table still stops vacuum on the busiest one.

If no indexed column changes and the new version fits on the same page, PostgreSQL makes a : no new index entries, and the old version can be pruned during normal page reads. Compare n_tup_hot_upd with n_tup_upd in pg_stat_user_tables; a lower fillfactor leaves room on each page for HOT updates.

Order 5102, 3 versions (0,2) placed xmin 900 xmax 950 UPDATE by 950 (0,9) picked xmin 950 xmax 990 UPDATE by 990 (1,4) delivered xmin 990 xmax 0 Transaction IDs 900 950 970 990 ended before horizon: (0,2) can be removed idle session sees (0,9): must keep horizon
A session idle in transaction since transaction 970 still sees the "picked" version, because 990 had not committed when its snapshot was taken. VACUUM may remove the "placed" version, which ended at 950, but must keep the "picked" one until that session ends.

Quick check

Autovacuum runs on the rider table every few minutes, yet n_dead_tup keeps rising all afternoon. A reporting session has been idle in transaction since 11:00. Why is vacuum not helping?

VACUUM, the visibility map and freezing

A plain on a table does four jobs in one pass:

  1. Removes dead versions older than the horizon: first their index entries, then the versions on the table pages. The space becomes free for new rows on the same table.
  2. Records free space in the free space map, so inserts know which pages have room.
  3. Updates the visibility map: two bits per page. "All-visible" means every version on the page is visible to everyone. Index-only scans use it to skip the table, and the next vacuum skips those pages. "All-frozen" means every version is also frozen (see below).
  4. Freezes old row versions, so their transaction IDs can be reused safely.

Plain VACUUM takes a SHARE UPDATE EXCLUSIVE lock, so reads and writes continue. It does not shrink the file, except for empty pages at the very end. After a 2 million row cleanup, order_line is the same size on disk, with reusable space inside. That is normal: a table settles at a size where vacuum frees about as much as new rows use.

VACUUM FULL is a different tool. It copies the live rows into a brand-new file and drops the old one, which does return space to the OS. But it holds an ACCESS EXCLUSIVE lock the whole time, blocking every read and write, and it needs enough free disk for the full new copy plus its indexes. On the 800 GB order database that means hours of downtime; Upgrades, maintenance and on-call covers the online alternatives.

Freezing and wraparound. Transaction IDs are 32-bit and compared in a circle: about 2 billion behind count as the past, 2 billion ahead as the future. With orders, rider updates and jobs, the order database uses millions of IDs a day, so the circle comes round within a year or two. Before an old xmin could look like the future, vacuum the row version: it marks it as visible to everyone, forever. Each table records its oldest unfrozen ID in pg_class.relfrozenxid; age(relfrozenxid) says how many transactions ago that was.

The settings form a ladder, checked for each table:

  • vacuum_freeze_min_age (50 million): versions older than this get frozen when vacuum visits their page.
  • vacuum_freeze_table_age (150 million): above this age, vacuum becomes aggressive and reads every page that is not already all-frozen.
  • autovacuum_freeze_max_age (200 million): autovacuum starts an anti-wraparound vacuum even if autovacuum is turned off. It shows as autovacuum: VACUUM public.order_line (to prevent wraparound) and, unlike normal autovacuum, it does not give way when a migration or another session asks for a conflicting lock.
  • vacuum_failsafe_age (1.6 billion, PostgreSQL 14 and later): vacuum drops its speed limits and skips index cleanup to finish freezing as fast as it can.

If freezing cannot finish (usually because an old snapshot, slot or prepared transaction holds the horizon), PostgreSQL logs warnings when about 40 million IDs remain, and refuses to assign new transaction IDs when about 3 million remain (the exact margins are version dependent). At that point every write fails until someone vacuums, which on order_line could take hours. Alert on age(datfrozenxid) far earlier, around 500 million to 1 billion.

Build it

We will look inside a PostgreSQL 16 server the way a DBA does on the first day: its processes, a row version being born, the WAL position, and the queries that find dead rows, old snapshots and transaction ID age. Run steps 1 to 3 on a test server or a restored copy; steps 4 to 6 are read-only and safe on production.

  1. See the processes

    On the database VM, list every process owned by the postgres user. The Ubuntu packages set cluster_name to 16/main, so it appears in each process title. Client backends show user, database, client address and what they are doing.

    Terminal
    ps -u postgres -o pid,ppid,cmd
    Output
        PID    PPID CMD
       1021       1 /usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/16/main -c config_file=/etc/postgresql/16/main/postgresql.conf
       1034    1021 postgres: 16/main: checkpointer
       1035    1021 postgres: 16/main: background writer
       1037    1021 postgres: 16/main: walwriter
       1038    1021 postgres: 16/main: autovacuum launcher
       1039    1021 postgres: 16/main: logical replication launcher
       2210    1021 postgres: 16/main: walsender replicator 10.20.0.12(51732) streaming 3A/7C0E1F28
       4811    1021 postgres: 16/main: app_orders orders 10.20.1.5(40112) idle
       4812    1021 postgres: 16/main: app_orders orders 10.20.1.5(40114) UPDATE

    Every process has PPID 1021: the postmaster started them all. The same picture from SQL works on a managed service too:

    SQL
    SELECT backend_type, state, count(*)
    FROM pg_stat_activity
    GROUP BY backend_type, state
    ORDER BY count(*) DESC;
  2. Watch an UPDATE create a new row version

    On a test database, make a small table and look at the hidden columns. Your transaction IDs will be different numbers; the pattern is what matters.

    SQL
    CREATE TABLE demo_order (id int PRIMARY KEY, status text NOT NULL);
    INSERT INTO demo_order VALUES (1, 'placed'), (2, 'placed'), (3, 'placed');
    
    SELECT ctid, xmin, xmax, id, status FROM demo_order;
    
    UPDATE demo_order SET status = 'picked' WHERE id = 2;   -- its own transaction
    
    SELECT ctid, xmin, xmax, id, status FROM demo_order;
    Output
     ctid  | xmin | xmax | id | status
    -------+------+------+----+--------
     (0,1) |  752 |    0 |  1 | placed
     (0,2) |  752 |    0 |  2 | placed
     (0,3) |  752 |    0 |  3 | placed
    
     ctid  | xmin | xmax | id | status
    -------+------+------+----+--------
     (0,1) |  752 |    0 |  1 | placed
     (0,3) |  752 |    0 |  3 | placed
     (0,4) |  753 |    0 |  2 | picked

    Row 2 now lives at (0,4) with a new xmin. The old version at (0,2) is still on the page, invisible to you: a dead tuple. Now open two psql sessions. In session A run BEGIN; UPDATE demo_order SET status = 'delivered' WHERE id = 2; and do not commit. In session B, select row 2 again: B still sees (0,4) with status picked, but its xmax is now A's transaction ID. Commit A, and B sees the new version. That is MVCC with no locks between reader and writer.

  3. Find the WAL position and checkpoint pressure

    SQL
    SELECT pg_current_wal_lsn(), pg_walfile_name(pg_current_wal_lsn());
    
    -- PostgreSQL 16: timed (by checkpoint_timeout) vs requested (WAL volume, manual)
    SELECT checkpoints_timed, checkpoints_req FROM pg_stat_bgwriter;
    -- PostgreSQL 17 and later moved these to:
    -- SELECT num_timed, num_requested FROM pg_stat_checkpointer;
    Output
     pg_current_wal_lsn |     pg_walfile_name
    --------------------+--------------------------
     3A/7C0E1F28        | 000000010000003A0000007C
    
     checkpoints_timed | checkpoints_req
    -------------------+-----------------
                  2841 |            1907

    Requested checkpoints are about 40% of the total here, which means WAL volume keeps forcing checkpoints before the timer. That points at a max_wal_size that is too small for the evening write rate. log_checkpoints is on by default since PostgreSQL 15; each checkpoint's log line shows how many buffers it wrote and how long it took.

  4. Find what is holding the horizon back

    Run these three whenever dead rows pile up. The oldest backend_xmin, slot xmin or prepared transaction is your suspect.

    SQL
    -- Sessions with the oldest snapshots
    SELECT pid, usename, application_name, state,
           now() - xact_start AS xact_age,
           age(backend_xmin)  AS xmin_age,      -- transactions behind
           left(query, 50)    AS last_query
    FROM pg_stat_activity
    WHERE backend_xmin IS NOT NULL
    ORDER BY age(backend_xmin) DESC
    LIMIT 5;
    
    -- Replication slots that pin old rows
    SELECT slot_name, active, age(xmin) AS xmin_age, age(catalog_xmin) AS catalog_xmin_age
    FROM pg_replication_slots;
    
    -- Forgotten two-phase transactions
    SELECT gid, prepared, owner, age(transaction) AS xid_age
    FROM pg_prepared_xacts;
  5. Watch dead tuples and transaction ID age

    SQL
    -- Where dead versions pile up, and whether updates are HOT
    SELECT relname, n_live_tup, n_dead_tup, n_tup_upd, n_tup_hot_upd, last_autovacuum
    FROM pg_stat_user_tables
    ORDER BY n_dead_tup DESC
    LIMIT 5;
    
    -- Tables closest to a forced freeze (200 million by default)
    SELECT c.oid::regclass AS table_name,
           age(c.relfrozenxid) AS xid_age,
           pg_size_pretty(pg_total_relation_size(c.oid)) AS size
    FROM pg_class c
    WHERE c.relkind IN ('r', 'm', 't')     -- tables, materialized views, TOAST
    ORDER BY age(c.relfrozenxid) DESC
    LIMIT 5;
    
    -- The number to alert on, per database
    SELECT datname, age(datfrozenxid) AS xid_age FROM pg_database ORDER BY 2 DESC;
  6. Make the server tell you what it is doing

    Add these to postgresql.conf (or the managed service's parameters) and reload. None of them needs a restart.

    INI
    log_autovacuum_min_duration = '10s'   # log every autovacuum that takes 10 s or more
    log_checkpoints = on                  # default since 15; keep it on
    log_temp_files = '10MB'               # log sorts and hashes that spill past work_mem
    log_lock_waits = on                   # log waits longer than deadlock_timeout (1 s)
    SQL
    -- End sessions that sit idle inside a transaction, per application role
    ALTER ROLE app_orders SET idle_in_transaction_session_timeout = '5min';
    ALTER ROLE warehouse_export SET idle_in_transaction_session_timeout = '15min';
    SELECT pg_reload_conf();   -- applies the postgresql.conf changes above

    The role settings apply to new connections. Pooled connections pick them up when the pool reconnects.

Bring it to your team

Ask these in your next database review or incident follow-up.

  • "How many backends do we run at the evening peak, and how much memory can they use together if every report sorts at once?"
  • "Which session holds the oldest snapshot right now, and does anything alert us when a transaction stays open for more than 30 minutes?"
  • "Which replication slots exist, is every one of them active, and who owns each?"
  • "What is age(datfrozenxid) on the order database, and at what value do we get paged?"
  • "How long did crash recovery take the last time we tested it, and does our max_wal_size still fit that target?"

Red flags

  • Sessions "idle in transaction" for hours, and no idle_in_transaction_session_timeout on any role
  • VACUUM FULL in a scheduled job, or run at the evening peak "to free space"
  • Someone cancelling a "to prevent wraparound" autovacuum so a migration can run
  • An inactive replication slot nobody can explain
  • synchronous_commit = off set server-wide on the order database without anyone writing down what can be lost

Practice

Find the rows that changed after the load

A script loaded five orders into orders in one transaction. Later transactions updated some of them, and one update wrote the same status back, so you cannot tell from the data alone. Use the xmin system column: list the id and ctid (as text) of every row whose current version was written by a later transaction than the load. Hint: xmin::text::bigint turns the transaction ID into a number you can compare. The starter guesses from the status column instead.

SQL exerciseRuns in a real PostgreSQL inside your browser
-- Which rows were updated after the load?
SELECT id, ctid::text AS ctid
FROM orders
WHERE status <> 'placed';
Ctrl + Enter runs
Show a solution
SQL
SELECT id, ctid::text AS ctid
FROM orders
WHERE xmin::text::bigint > (SELECT min(xmin::text::bigint) FROM orders);  -- newer than the load

The load wrote five versions at (0,1) to (0,5). Each UPDATE wrote a new version at the next free slot, (0,6), (0,7) and (0,8), even the one that changed nothing visible. The old versions at (0,2), (0,4) and (0,5) are now dead tuples waiting for vacuum. On a real server, compare xmin values only for recent changes: transaction IDs wrap around, so a bigger number is not always newer.

Pass the exercise to finish this task.

Which dead versions can vacuum remove?

The table tuple_version models what the pageinspect extension shows for two tables: each row version's t_ctid, t_xmin and t_xmax (0 means not ended). xact_status says whether each transaction committed or aborted. session is shaped like pg_stat_activity; the xmin horizon is the smallest backend_xmin (NULL means the session holds no snapshot). The rules:

  • live: xmin committed, and xmax is 0 or an aborted transaction
  • dead_removable: xmin aborted (a rolled-back insert), or xmax committed and below the horizon
  • dead_kept: xmax committed but not below the horizon

Return relname, live, dead_removable and dead_kept for each table. The starter treats every ended version as removable and ignores aborted transactions.

SQL exerciseRuns in a real PostgreSQL inside your browser
-- Live, removable and kept versions per table
SELECT relname,
       count(*) FILTER (WHERE t_xmax = 0)  AS live,
       count(*) FILTER (WHERE t_xmax <> 0) AS dead_removable,
       0                                   AS dead_kept
FROM tuple_version
GROUP BY relname;
Ctrl + Enter runs
Show a solution
SQL
WITH horizon AS (
  SELECT min(backend_xmin) AS xid FROM session      -- NULLs are ignored: 1205
)
SELECT v.relname,
       count(*) FILTER (WHERE ins.status = 'committed'
                          AND (v.t_xmax = 0 OR del.status = 'aborted'))        AS live,
       count(*) FILTER (WHERE ins.status = 'aborted'
                          OR (del.status = 'committed' AND v.t_xmax < h.xid))  AS dead_removable,
       count(*) FILTER (WHERE ins.status = 'committed'
                          AND del.status = 'committed' AND v.t_xmax >= h.xid) AS dead_kept
FROM tuple_version v
JOIN xact_status ins ON ins.xid = v.t_xmin          -- who created the version
LEFT JOIN xact_status del ON del.xid = v.t_xmax     -- who ended it (none when 0)
CROSS JOIN horizon h
GROUP BY v.relname, h.xid;

The idle-in-transaction session 4102 sets the horizon at 1205. Versions ended by 1207, 1208, 1209 and 1210 are dead, but that session might still see them, so they stay: this is what VACUUM VERBOSE reports as "dead but not yet removable". The version ended by 1203 is still live because 1203 rolled back, and the one created by 1203 is dead at once. End session 4102 and the horizon jumps to 1209, so three of the four kept versions become removable.

Pass the exercise to finish this task.

Check yourself

To cut write I/O at the evening peak, you raise max_wal_size from 1 GB to 32 GB and checkpoint_timeout to 30 minutes. Write I/O drops. Which new risk must you plan for?

The rider table keeps growing. VACUUM (VERBOSE) rider reports many versions "dead but not yet removable", but no session on the primary has an old backend_xmin. Where do you look next?

age(datfrozenxid) on the order database is 1.1 billion. An autovacuum "to prevent wraparound" has been running on order_line for 9 hours, and tonight's migration is stuck behind it. What do you do?

Flashcards

Open my review

Key takeaways

  • Every connection is a backend process with its own memory; one unclean backend death makes the postmaster reset every connection.
  • Changes go to shared buffers and the WAL; COMMIT waits only for the WAL flush. Checkpoints write pages later, and crash recovery replays WAL from the last checkpoint.
  • An UPDATE writes a new row version. Dead versions can be removed only after the oldest snapshot, slot or prepared transaction has moved past them.
  • Plain VACUUM removes dead versions, updates the visibility map and freezes old rows without blocking. VACUUM FULL blocks everything and needs a full copy's worth of disk.
  • Watch age(datfrozenxid) and the oldest backend_xmin. Never cancel an anti-wraparound vacuum; fix what held it back.

Sources

Episode transcriptClerks, notebooks and crossed-out labels · about 12 minutes · tap any line to play from there
  1. NadiaImran, picture a festival week. At six in the morning the rider table in the order database has grown from a few megabytes to a few hundred, and the nearest free rider query takes well over a hundred milliseconds instead of a few.
  2. ImranAnd nobody changed any code. So what happened?
  3. NadiaOne transaction stayed open for five hours. To see why that hurts a table it never touched much, we have to open the box and look inside a PostgreSQL server. That's today.
  4. ImranGood. To me Postgres is a black box that says yes or no.
  5. NadiaThen let's start with a picture. Think of the back office of a busy store. At the door stands a supervisor who never serves anyone. When a customer arrives, the supervisor hands them a clerk of their own for the whole visit.
  6. ImranSo ten customers means ten clerks.
  7. NadiaExactly. The clerks share one big work table. They pull boxes from the stockroom, change what's inside, and leave them on the table. A night crew carries them back later. But before a clerk says done to a customer, the clerk writes the change in ink in a notebook at the till.
  8. ImranAnd if the lights go out, someone reads the notebook and redoes the work.
  9. NadiaRight. And one more habit. Clerks never erase a price label. They stick a new one on top and cross out the old one, because a customer with an old flyer might still ask about it. A cleaner peels off crossed-out labels, but only when no customer could still need them.
  10. ImranSo one customer who wanders around for six hours blocks the cleaner for the whole shop. I think I see where the rider table story is going.
  11. NadiaLet's map it. If you can see your screen, the first diagram is a tall stack of boxes. If not, picture this from top to bottom: the app servers connect to one parent process called the postmaster, which starts one backend process for each connection.
  12. ImranThe postmaster is the supervisor and each backend is a clerk.
  13. NadiaYes. Three hundred connections from our NestJS APIs means three hundred operating system processes on the database machine. Each one has its own memory for sorts and hashes and its own caches. An idle one costs a few megabytes. A busy one, much more.
  14. ImranThat's why everyone keeps telling me to use a connection pooler.
  15. NadiaThat's the reason underneath. Then there are the background processes. The checkpointer writes changed pages to disk every few minutes, and the background writer keeps some clean memory slots ready.
  16. ImranAnd the others?
  17. NadiaThe WAL writer flushes the log. The autovacuum launcher starts workers that clean tables. And a WAL sender streams the log to each replica.
  18. ImranHere's my guess at a trap. If one backend crashes, only that one connection dies, because they are separate processes.
  19. NadiaThat's the reasonable guess, and it's wrong. All these processes share one region of memory. If a backend dies badly, say the Linux out of memory killer shoots it, the postmaster can't trust that shared memory any more. It disconnects every client and runs crash recovery.
  20. ImranSo one runaway report can drop every app order for a few seconds or longer. That's a much bigger blast radius than I thought.
  21. NadiaNow the memory itself. Postgres works on eight kilobyte pages. A backend never edits the file directly. It loads the page into shared buffers, Postgres's own cache, and changes it there. A changed page that isn't on disk yet is called a dirty page.
  22. ImranAnd shared buffers is usually about a quarter of the memory, right? Why not most of it?
  23. NadiaBecause Linux keeps its own cache of file blocks too, and Postgres reads through it. So the rest of the memory works as a second cache. The other setting to know is work mem. It's a limit for each sort or hash step in a query, not for the whole query. Go over it and the sort spills to temporary files on disk.
  24. ImranOkay. Now the notebook. What actually happens when the Rider app marks an order as picked?
  25. NadiaThe second diagram is a timeline with five steps. Step one, the backend changes the page in shared buffers. Step two, it adds a record describing the change to the WAL buffers. WAL means write-ahead log.
  26. NadiaStep three is the commit. The backend writes the log up to its commit record onto disk and waits for the disk to confirm. Only then does the app hear commit. Step four, seconds or minutes later, a checkpoint writes the dirty page into the data file.
  27. ImranSo at the moment the app hears commit, the order is only in the log, not in the table file?
  28. NadiaAnd that's fine, because of step five. If the server crashes before the checkpoint, the startup process replays the log from the last checkpoint and rebuilds the page. Every committed order comes back.
  29. ImranWhy not write the page at commit and skip all this?
  30. NadiaOne order touches pages in the orders table, the order line table and several indexes, all over the disk. Appending to one log is far cheaper, and commits that arrive together can share one flush.
  31. ImranWhat decides how often checkpoints happen?
  32. NadiaA timer, five minutes by default, or the amount of log written, about one gigabyte by default. Spacing them further apart means less disk writing, but a longer crash recovery, because there's more log to replay. So if you stretch them, measure recovery time on a restored copy.
  33. ImranNow the labels. You said Postgres never erases a row.
  34. NadiaRight. An update writes a new version of the row and marks the old one as ended. Every version carries hidden columns. Xmin is the transaction that created it. Xmax is the one that replaced or deleted it. And ctid is where it lives on disk, page and slot.
  35. ImranAnd each query sees the versions that fit its own moment in time?
  36. NadiaYes, through a snapshot, which is basically the list of transactions that had committed when the query started. When no snapshot anywhere can see an old version, it becomes a dead tuple. Vacuum removes dead tuples, but only those that ended before the oldest snapshot still open. We call that line the horizon.
  37. NadiaThe third diagram shows order five one oh two with three versions: placed, then picked, then delivered. Below them is a line of transaction numbers, with the horizon marked at nine seventy by an idle session.
  38. ImranSo the placed version ended at nine fifty, before the horizon, and can go. But the picked version ended at nine ninety, after it.
  39. NadiaAnd the idle session can still see the picked version, so vacuum must keep it. That's the crossed-out label the cleaner can't remove.
  40. ImranWhich brings us back to the rider table.
  41. NadiaRight. About six hundred rows, but every on-shift rider updates their row every ten seconds. Roughly two hundred thousand updates an hour. Normally autovacuum keeps up easily. Then the nightly export to the data warehouse ran as one long transaction from one in the morning until after six.
  42. ImranSo for five hours the horizon was stuck, and about a million dead rider versions piled up. Vacuum ran and said, sorry, these are dead but not yet removable.
  43. NadiaThose are almost its exact words. The fix was to copy each table in its own short transaction, and to alert when any transaction stays open more than thirty minutes.
  44. ImranCouldn't they just run the export on a replica?
  45. NadiaNot on its own. If the replica sends feedback to the primary, it reports its old snapshot back, and the problem returns. If it doesn't, the long export can be cancelled. The replication lesson covers that.
  46. ImranWhat else can hold the horizon besides a forgotten transaction?
  47. NadiaA replication slot whose reader fell behind, a replica running long queries with feedback on, and a forgotten prepared transaction. And remember, one old snapshot blocks cleanup in every table of the database, not only the ones it reads.
  48. ImranYou mentioned vacuum full in the lesson. That sounds like the stronger version.
  49. NadiaIt's a different tool. Plain vacuum makes dead space reusable while reads and writes continue. Vacuum full copies the whole table into a new file under a lock that blocks everything. On our eight hundred gigabyte order database that means hours of downtime.
  50. ImranAnd then there's wraparound, which always sounds scary.
  51. NadiaTransaction numbers are thirty-two bits and compared in a circle, about two billion behind count as the past. So vacuum freezes old rows, marking them visible to everyone forever. At two hundred million, autovacuum forces a freeze vacuum, and that one doesn't step aside for your migration.
  52. ImranSo if a migration is stuck behind it, the answer is not to cancel the vacuum.
  53. NadiaRight. It would restart soon and you'd lose hours of work. Postpone the migration, let it finish, and find what held freezing back. Alert on the age long before the limit, somewhere around five hundred million to a billion.
  54. ImranWhat does the Build it section have me do?
  55. NadiaYou list the server's processes and see that every one has the postmaster as its parent. You make a tiny test table, update one row, and watch it move to a new slot with a new xmin. With two sessions open, you see the old version's xmax set while the update is still uncommitted.
  56. NadiaThen you check the WAL position and how many checkpoints were forced by log volume, find the sessions and slots holding the horizon, list the tables with the most dead rows and the oldest freeze age, and set a timeout for sessions idle in a transaction.
  57. ImranAnd the practice tasks use those hidden columns too?
  58. NadiaYes. One uses xmin to find rows changed after a load, even when the data looks the same. The other gives you a model of row versions and a list of sessions, and asks which dead versions vacuum could remove right now.
  59. ImranFor my next review, I'd ask three things. Which session holds the oldest snapshot right now? Does every replication slot have an owner? And at what freeze age do we get paged?
  60. NadiaGood list. Add one: how long did crash recovery take the last time we tested it?
  61. NadiaNow a question for you, and take a second. The app gets commit for an order, and two seconds later the machine loses power before any checkpoint. Is the order still there after restart?
  62. ImranYes. The log was flushed before commit returned, so recovery replays it.
  63. NadiaExactly. Unless someone turned synchronous commit off. Then the last fraction of a second of commits can be lost, though nothing gets corrupted.
  64. ImranSo my three takeaways. Every connection is a process, and one crash resets them all. Commit waits for the log, and the table pages follow later. And an update leaves a dead row that only goes away once the oldest snapshot moves past it.
  65. NadiaOne nuance on the last one: the oldest snapshot can be a slot, a replica or a prepared transaction, not only a session you can see. Next time, we make sure we can get the order database back after the worst day: backups you have actually restored.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Schema design for a new feature

Tables, keys and constraints for a new feature, sample rows, and the queries the feature needs.

Open in the SQL Lab
Next stopBackups you have actually restored
Was this lesson helpful?
Questions and discussion

Loading the discussion…