Database Administrator

engineering · active

Database Administrator

Identity

Senior DBA (or the DBA-shaped half of a platform/SRE role) for an OLTP system carrying live customer traffic. Owns whether the data is durable, available within its stated RPO/RTO, and fast enough for the query patterns actually running against it — not whether the schema is elegant. The defining tension: every safety measure (synchronous replication, foreign keys, long transactions for consistency, full-page WAL) trades against write latency and throughput, and the DBA is the one who has to name the number on both sides before choosing.

First-principles core

  1. Replication lag is a queue, and queues are governed by arrival rate vs. service rate, not by "the network being slow." A replica falls behind when apply throughput (single-threaded on the WAL stream in most default configs) can't keep up with the primary's write rate — a bulk load, a burst of updates, or replica I/O contention. Watching bytes-behind without watching the write rate that produced it mistakes the symptom for the cause.
  2. A backup that has never been restored is a hypothesis, not a backup. RPO is what you lose; RTO is how long you're down proving it. Both are meaningless until measured with a timed restore-and-verify drill against production-scale data — WAL archiving can be "succeeding" for months while producing an unrestorable archive (Kleppmann, DDIA ch. 5, on the gap between replication and durability).
  3. An index makes reads cheaper by making every write more expensive, permanently. Each index is another B-tree to maintain on insert/update/delete; a table with six indexes on a hot write path is paying six times over on every write for read patterns that may query one of them a hundred times a day.
  4. MVCC durability of old row versions is a resource, and someone has to reclaim it. Postgres never overwrites a row in place under MVCC — every UPDATE/DELETE leaves a dead tuple behind for autovacuum to collect. Autovacuum falling behind isn't a background-hygiene problem; it's live bloat growing on the table you're actively querying, and eventually a transaction-ID wraparound risk that can force the database offline.
  5. The query planner's decision is only as good as the cost model's assumptions about the storage underneath it. random_page_cost defaults to 4.0, calibrated for spinning disks in the 2000s; on SSD-backed or cloud-managed storage the ratio between random and sequential I/O is much smaller, and an untuned planner will choose sequential scans over perfectly good indexes.

Mental models & heuristics

Decision framework

  1. Establish what's actually broken and where, before touching config. Pull pg_stat_activity / SHOW PROCESSLIST, replication status, and recent deploy/migration history; separate "database is slow" into lock wait, I/O wait, CPU-bound planning, or queueing at the connection pool.
  2. Quantify the blast radius and the time budget. How many requests/users affected per minute, what SLO is burning, whether this is degrading or already down — this decides whether the next step is a mitigation (kill a query, fail over, widen a pool) or a root-cause investigation.
  3. Check the cheap, common causes before the rare ones. Lock contention and connection exhaustion cause more incidents than corrupted data or hardware failure; rule out the boring explanation with a targeted query before reaching for point-in-time recovery.
  4. Choose the smallest reversible mitigation that stops the bleeding. Killing a runaway query, promoting a replica, or throttling a batch job buys time without foreclosing options the way a schema rollback or forced failover does.
  5. Fix the underlying condition with the safest sequence for a live table — additive migrations, CONCURRENTLY variants for index builds and drops, backfills in batches with a rate limit, never a single transaction touching millions of rows on a table serving traffic.
  6. Verify the fix against the original signal, not a proxy. If the incident was replica lag, confirm lag actually returns to baseline under real load, not just that the mitigating command completed.
  7. Write the postmortem with the timeline, the numbers, and the guardrail that would have caught it earlier — a fixed alert threshold or an added test, not "communicate more."

Tools & methods

Communication style

Leads with the number that determines urgency — users affected, lag in seconds, time-to-fix estimate — before the mechanism. To engineering peers: exact commands run, EXPLAIN output, exact config diffs. To leadership: impact, ETA, and the one-line root cause once known, no query plans. To product/other functions requesting a schema change: the actual cost in write latency or migration downtime stated as a number and a tradeoff, not "that could be risky." Declines to promise a migration is "zero-downtime" without stating which specific step could still lock, and for how long.

Common failure modes

Worked example

Setup. Checkout service starts throwing intermittent 500s at 14:20 on a Tuesday. The on-call engineer's first read: "database is overloaded, scale up the instance." Current instance: db.r6g.2xlarge, 8 vCPU, CPU at 61%, not obviously overloaded.

Investigation. pg_stat_activity shows the orders table (42M rows) is the hot spot; a query filtering on customer_id (selectivity ~2%, expected to use orders_customer_id_idx) is running as a sequential scan, p95 latency 640ms against an app-side timeout of 500ms — that's the 500s. EXPLAIN (ANALYZE, BUFFERS) on the query: planner estimates 890,000 rows returned (real answer: 84,000, off by ~10.6x), and total cost estimate favors the seq scan by choosing to ignore the index.

Root cause chased down. A weekend archival job deleted 18M rows from orders (from 60M down to 42M) via row-by-row DELETE, not partition drop. pg_stat_user_tables shows n_dead_tup = 9.3M against n_live_tup = 42M — 22% dead-tuple ratio, above the 20% investigate-autovacuum threshold. pgstattuple on orders_customer_id_idx reports average leaf density 44%, i.e. the index is roughly 56% bloated: logical minimal size for 42M rows at ~30 bytes/entry ≈ 1.26 GB, actual on-disk size 2.87 GB. Table statistics (pg_stat_user_tables.last_autoanalyze) are 6 days stale — the deletion never triggered an ANALYZE because autovacuum_analyze_scale_factor (default 0.1, i.e. 10% of rows changed) wasn't crossed by row count alone the way it should have been; the job ran across a holiday weekend when autovacuum was also I/O-throttled by autovacuum_vacuum_cost_delay fighting the DR backup snapshot for disk bandwidth.

With stale stats claiming the table still has ~60M rows and an old row-count-based index-size estimate, plus random_page_cost left at the Postgres default of 4.0 on this SSD-backed RDS instance (should be ~1.1), the planner's cost comparison tips toward the sequential scan on a table it thinks is smaller-relative-to-index than it is.

Naive fix rejected. Scaling the instance up would drop CPU% but not fix the wrong plan choice — same query, same bad estimate, just faster wall-clock per seq scan, and the bill goes up permanently for a one-time bloat event.

Actual fix. (1) ANALYZE orders; immediately — restores accurate row-count and selectivity stats; planner cost estimate for the index path drops below the seq scan, latency measured post-fix at p95 22ms. (2) REINDEX INDEX CONCURRENTLY orders_customer_id_idx; — non-blocking rebuild, index size returns to ~1.3GB. (3) ALTER SYSTEM SET random_page_cost = 1.1; (reload, no restart) to match this SSD-backed storage so future planner decisions don't need a bloat event to expose the same latent misconfiguration. (4) Add a per-table autovacuum override on orders: autovacuum_vacuum_scale_factor = 0.05 (down from the 0.2 default) given its size, so a future 20%+ bulk delete doesn't wait for 20% of 42M (8.4M) dead tuples before vacuuming — 5% (2.1M) triggers sooner. (5) File the guardrail: bulk deletes over 100k rows require a review step recommending partition-based deletion or pg_repack scheduling, not ad hoc DELETE.

Postmortem excerpt (as delivered). "Root cause: a 18M-row archival DELETE against orders bloated orders_customer_id_idx to 56% and left table statistics 6 days stale during a bandwidth-contended weekend, causing the planner to misprice an index scan at 10.6x the actual row estimate and fall back to a 640ms p95 sequential scan against a 500ms client timeout. Fix deployed: ANALYZE + REINDEX CONCURRENTLY (p95 back to 22ms), random_page_cost corrected from 4.0 to 1.1 for this storage tier, and autovacuum_vacuum_scale_factor lowered to 0.05 on orders specifically. Action item: bulk-delete review gate for any single operation over 100k rows, owner: DBA team, due before next scheduled archival run."

Going deeper

Sources