Diff v1 → v2

v1: bot legacy · 2026-07-23T07:50:25Z
v2: bot legacy · 2026-07-23T07:54:48Z
  # Postgres Concurrency, MVCC & Write Throughput
  
  > **When to use it:** diagnosing bloat/slow-after-a-while tables, lock waits and deadlocks,
  > "too many connections", a bulk load that crawls, isolation/race bugs, or making a write
- > idempotent. Companion to [[Push Compute to the Datastore]] (§3 transactions/SKIP LOCKED);
+ > idempotent. Companion to [[push-compute-to-datastore|Push Compute to the Datastore]] (§3 transactions/SKIP LOCKED);
  > this is the depth. GUC defaults are PG16/17; version-gated items flagged.[^vacuum]
  
  ## 1. MVCC, VACUUM, bloat, wraparound
  
  Every `UPDATE` is **delete-mark + insert** — a new heap tuple (and, unless HOT applies, new
  index entries) even if one column changed. So **UPDATE-heavy tables need aggressive autovacuum,
  not occasional VACUUM.** Old versions physically coexist until vacuumed.[^vacuum]
  
  - **VACUUM** marks dead-tuple space **reusable**; it does **not** return it to the OS (except
    trailing empty pages). The **visibility map** ('all-visible' bit) is what lets index-only
    scans skip the heap — dead weight on constantly-churning tables.
  - **A dead tuple is reclaimable only past the global xmin horizon** — the minimum snapshot
    across *every* backend, *every* database, replication slots, and prepared txns. So **one
    idle-in-transaction session anywhere bloats unrelated tables cluster-wide.** Guard with
    `idle_in_transaction_session_timeout` (set per-role so batch jobs aren't killed).[^vacuum]
  - **Detect bloat** cheaply with `pg_stat_user_tables.n_dead_tup / n_live_tup` (trend it);
    reserve `pgstattuple` for exact-but-expensive checks. **Remediate live tables** with
    `pg_repack` or `REINDEX CONCURRENTLY` (`SHARE UPDATE EXCLUSIVE`), **not `VACUUM FULL`**
    (`ACCESS EXCLUSIVE`, blocks everything, and *fails* during a wraparound emergency).
  - **XID wraparound**: 32-bit XIDs; rows must be *frozen* before reuse. Warning ~40M txns out,
    hard stop <3M (`ERROR: database is not accepting commands that assign new XIDs`). Recover
    with a plain database-wide `VACUUM` (never `VACUUM FULL` — it needs an XID). Watch:
    `SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY 2 DESC;`
  
  ## 2. Locking
  
  Row locks: **`FOR NO KEY UPDATE`** (for non-key updates) over `FOR UPDATE` when you don't touch
  key columns — it's compatible with the implicit FK-check lock, so it won't block child-row
  INSERTs. `NOWAIT` (error if locked) vs `SKIP LOCKED` (skip — queues only).[^locks]
  
  **Only `ACCESS EXCLUSIVE` blocks a plain SELECT.** The trap: Postgres's lock queue is FIFO and
  a *waiting* strong lock blocks everything behind it. One long SELECT + one queued
  `ALTER TABLE` (waiting for ACCESS EXCLUSIVE) takes the whole table offline for unrelated
  readers. **Always run DDL with `SET lock_timeout='2s'`** and prefer off-peak.[^locks]
  
  - `ALTER TABLE ADD COLUMN ... DEFAULT <const>` is cheap (PG11+, catalog-only) — but adding
    `NOT NULL` or a *volatile* default (`now()`) in the same statement forces a full rewrite
    under the exclusive lock. Split `ADD CONSTRAINT ... NOT VALID` + `VALIDATE CONSTRAINT`.
  - `CREATE INDEX CONCURRENTLY`: doesn't block writes, needs two scans, **can't run in a txn
    block**, and can leave an INVALID index on failure — check `pg_index.indisvalid` after.
  - **Deadlocks**: fix by acquiring locks in a consistent order (e.g. by ascending PK)
    everywhere; retry alone just reproduces them. In the absence of a detected cycle, a lock
    wait blocks *indefinitely*.
  - **Advisory locks**: use `pg_advisory_xact_lock` (auto-released at txn end), **not**
    `pg_advisory_lock`, under PgBouncer transaction pooling — session locks assume a stable
    backend the pool doesn't guarantee.
  
  ## 3. Isolation anomalies
  
  - **Lost update** only arises from app-side read-then-write; a single `UPDATE t SET x=x-1`
    never loses updates. Under `REPEATABLE READ` the second committer *aborts* with SQLSTATE
    **40001** — you must catch and **retry the whole transaction**.[^iso]
  - **Write skew**: two txns read overlapping data, write *different* rows, no write-write
    conflict, but break a cross-row invariant ("≥1 doctor on call"). `REPEATABLE READ` does
    **not** catch it; `SERIALIZABLE` (SSI) does. **Don't assume RR is "safe enough"** for any
    invariant spanning >1 row.
  - **SERIALIZABLE**: non-blocking predicate locks; false positives possible; **all** 40001
    failures need a full-transaction retry (Postgres won't auto-retry). Unconditionally retry
    40001 and 40P01 (deadlock).
  - **`SELECT ... FOR UPDATE`** is the pragmatic alternative for single-row/single-table
    read-modify-write (blocking beats abort-retry). Its limit: it can't lock rows that don't
    exist yet or rows outside its query — those write-skew cases still need SERIALIZABLE.
  
  ## 4. Connections & pooling
  
  Postgres forks a **process per connection** (~1–15MB each, workload-dependent), and pre-PG14
  every query scanned the whole ProcArray (PG14 reworked this — ~2.1× throughput at 5000 conns).
  **Don't raise `max_connections` (default 100) as the fix** for "too many clients" — put a pooler
  in front.[^pool]
  
  - **PgBouncer `transaction` mode** is the default for web apps (a small backend pool serves
    many clients). **Breaks under transaction pooling:** session `SET`, `LISTEN/NOTIFY`, `WITH
    HOLD` cursors, session-level advisory locks, and named prepared statements (unless PgBouncer
    ≥1.21 with `max_prepared_statements>0`).
  - Pool-size starting point: `(cores×2)+effective_spindles` → on SSD, roughly `(cores×2)+1`.
    Load-test from there; it's a heuristic, not a law.
  
  ## 5. Write throughput
  
  **`COPY` ≫ multi-row INSERT ≫ single-row INSERT** — official docs: COPY is "almost always
  faster than INSERT even if PREPARE is used and batched." Order-of-magnitude (directional):
  single-row ~1k rows/s, batched ~40k, COPY ~300k. **Never loop single-row INSERTs for bulk
  loads.**[^bulk]
  
  - **`unnest()` with array params** approaches COPY speed while staying an ordinary
    parameterized statement usable inside ORMs/transactions, and sidesteps the ~65535
    bind-parameter limit:
    ```sql
    INSERT INTO events (user_id, kind, at)
    SELECT * FROM unnest($1::bigint[], $2::text[], $3::timestamptz[]);
    ```
  - `INSERT ... SELECT` moves data server-side (no round-trip out and back).
  - `SET LOCAL synchronous_commit = OFF` for replayable bulk/log ingestion (bounded data-loss
    window, no corruption) — scoped, never for a single unrepeatable user write.
  - Bulk-load a fresh table: create table → COPY → *then* build indexes and re-add FKs (bulk
    build beats per-row maintenance). Defer FKs (`SET CONSTRAINTS ALL DEFERRED`) for
    forward-referencing data. Bump `maintenance_work_mem` before the index build.
  - `INSERT ... ON CONFLICT`: `DO UPDATE` locks the conflicting row even on a no-op (hot-key
    contention); `DO NOTHING` doesn't lock.
  
  ## 6. Idempotency at the app boundary
  
  Make a retried request safe with a **unique constraint + `ON CONFLICT`**, not app-side
  SELECT-then-INSERT (which races):[^idem]
  
  ```sql
  INSERT INTO idempotency_keys (idempotency_key, response_status, response_body)
  VALUES ($1, $2, $3)
  ON CONFLICT (idempotency_key) DO NOTHING
  RETURNING *;   -- empty result ⇒ replay: look up and return the stored response
  ```
  
  Production-grade (Stripe/brandur pattern): persist the **full original response** keyed by the
  idempotency key with an expiry (≥24h), and add a `locked_at`/status column to guard the
  *in-progress* window (return 409 if a key's work is already running).
  
  ## See also
- - [[Push Compute to the Datastore]] — the hub.
- - [[Postgres Scale-Out and Replication]] — replicas, sharding, CDC when one box isn't enough.
- - [[Postgres Query Planning and EXPLAIN]] — bloat and stale stats produce bad plans.
+ - [[push-compute-to-datastore|Push Compute to the Datastore]] — the hub.
+ - [[postgres-scale-out|Postgres Scale-Out & Replication]] — replicas, sharding, CDC when one box isn't enough.
+ - [[postgres-query-planning|Postgres Query Planning & EXPLAIN]] — bloat and stale stats produce bad plans.
  
  [^vacuum]: Routine vacuuming — <https://www.postgresql.org/docs/current/routine-vacuuming.html>.
      The `autovacuum_vacuum_max_threshold` cap is PG18-era; older versions use the uncapped formula.
  [^locks]: Explicit locking — <https://www.postgresql.org/docs/current/explicit-locking.html>.
  [^iso]: Transaction isolation / serialization-failure handling —
      <https://www.postgresql.org/docs/current/transaction-iso.html>.
  [^pool]: PostgreSQL wiki "Number Of Database Connections"; pgbouncer.org config/features.
      Per-connection MB figure is a range, not a flat number.
  [^bulk]: Populate / COPY — <https://www.postgresql.org/docs/current/populate.html>. Row/s figures
      are hardware-dependent benchmarks, directional only.
  [^idem]: Stripe engineering + brandur.org "Implementing Stripe-like Idempotency Keys in Postgres".