Command Palette

Search for a command to run...

Unit 1.2 · Databases in Production: PostgreSQL

Connections, Pooling, and Query Performance

Why 'too many connections' happens, PgBouncer and RDS Proxy, reading EXPLAIN ANALYZE, indexes that help (and hurt), pg_stat_statements, and lock contention.

Intermediate 55 min 5 lab steps 2 failure drills

Start here

The mental model

Each database connection is an expensive, dedicated assistant. Twenty app pods × a pool of 20 connections each is 400 assistants, most of them idle, all using memory. A POOLER is a receptionist: thousands of clients talk to it, and it hands each query to one of a small team of real connections only while the query runs.

For performance, the database is almost never 'just slow'. It is doing too much work for a specific query, usually reading millions of rows to return ten. EXPLAIN shows you the work; an index usually removes it.

Go deeper

How it works inside

01Connection math and poolers

Total connections = pods × pool size per pod (+ migrations, cron jobs, and humans). Autoscaling makes it worse: when the HPA scales from 10 to 40 pods during a traffic spike, connections quadruple exactly when the database is busiest (SRE course, cascading failure).

PGBOUNCER in TRANSACTION mode assigns a server connection per transaction and returns it afterwards, so 2,000 client connections can share 50 real ones. The catch: session state (prepared statements in older drivers, SET commands, advisory locks, LISTEN) doesn't survive between transactions. RDS PROXY is the managed equivalent, and it also speeds up failover. Rule of thumb for the real pool: a few connections per CPU core of the database is usually the throughput sweet spot; more just adds contention.

Connection math and poolersdiagram
Rendering diagram…

02Reading a query plan

EXPLAIN ANALYZE runs the query and shows each step with estimated vs actual rows and time. Read it from the innermost, most-indented node outward. The red flags: Seq Scan on a big table with a selective filter (Rows Removed by Filter: 4999990), a large gap between estimated and actual rows (stale statistics, so run ANALYZE), Sort Method: external merge Disk (work_mem too small), and nested loops over large inputs.

03Indexes

A B-tree index turns 'scan 5 million rows' into 'walk a tree of ~4 levels'. Index columns you FILTER, JOIN, or ORDER BY on, with the most selective and equality columns first in composite indexes ((customer_id, created_at) serves WHERE customer_id = ? ORDER BY created_at DESC). Partial indexes (WHERE status = 'pending') and covering indexes (INCLUDE) help specific hot queries. Other types: GIN for JSONB and full-text, BRIN for huge append-only time-series tables.

Indexes aren't free: each one slows every write and uses disk and cache. Unused indexes show idx_scan = 0 in pg_stat_user_indexes. On a live table, always CREATE INDEX CONCURRENTLY, because a plain CREATE INDEX blocks writes for the whole build.

04pg_stat_statements and locks

The pg_stat_statements extension aggregates every normalised query with its call count, total and mean time, and rows. Sorting by total_exec_time finds what the database spends its life on, which is often a fast query called a million times rather than one slow query.

LOCKS: most schema changes take an ACCESS EXCLUSIVE lock. An ALTER TABLE waiting behind a long SELECT then blocks every new query behind itself, and a 2-second change becomes a full outage. Always set lock_timeout (e.g. SET lock_timeout = '3s') before DDL in production, and retry.

Do it

Hands-on lab

  1. 1

    Create a table big enough to be slow

    Five million orders across 100,000 customers.

    terminal
    $ psql postgresql://postgres:pw@localhost/shop <<'SQL'
    create table orders(id bigserial primary key, customer_id int, status text, total numeric, created_at timestamptz);
    insert into orders(customer_id,status,total,created_at)
    select (random()*100000)::int, (array['paid','pending','shipped'])[1+(random()*2)::int], random()*200, now() - random()*interval '365 days'
    from generate_series(1,5000000);
    analyze orders;
    SQL
    ── expected output ──
    INSERT 0 5000000
    ANALYZE
  2. 2

    Read the plan for a slow query

    The page 'my recent orders' runs this. Note Parallel Seq Scan and the rows removed by the filter.

    terminal
    $ psql postgresql://postgres:pw@localhost/shop -c "explain analyze select * from orders where customer_id = 4242 order by created_at desc limit 10"
    ── expected output ──
    Limit (cost=... rows=10) (actual time=212.4..214.9 rows=10 loops=1)
    -> Gather Merge (actual time=212.4..214.8 rows=10 loops=1)
    -> Sort (actual time=196.1..196.1 rows=6 loops=3)
    Sort Key: created_at DESC
    -> Parallel Seq Scan on orders (actual time=0.9..195.8 rows=17 loops=3)
    Filter: (customer_id = 4242)
    Rows Removed by Filter: 1666650
    Execution Time: 215.1 ms
  3. 3

    Add the right index, concurrently

    A composite index matching both the filter and the sort makes the query read exactly 10 index entries: about 1,000× faster.

    terminal
    $ psql postgresql://postgres:pw@localhost/shop -c 'create index concurrently orders_customer_created_idx on orders (customer_id, created_at desc)'
    psql postgresql://postgres:pw@localhost/shop -c "explain analyze select * from orders where customer_id = 4242 order by created_at desc limit 10"
    ── expected output ──
    Limit (actual time=0.041..0.066 rows=10 loops=1)
    -> Index Scan using orders_customer_created_idx on orders (actual time=0.040..0.063 rows=10 loops=1)
    Index Cond: (customer_id = 4242)
    Execution Time: 0.089 ms
  4. 4

    Find the top queries with pg_stat_statements

    Enable the extension (it needs shared_preload_libraries, already enabled on RDS), then rank queries by total time.

    terminal
    $ docker exec pg bash -c "echo \"shared_preload_libraries='pg_stat_statements'\" >> /var/lib/postgresql/data/postgresql.conf" && docker restart pg
    psql postgresql://postgres:pw@localhost/shop -c 'create extension pg_stat_statements'
    # ...run your app or pgbench for a while, then:
    psql postgresql://postgres:pw@localhost/shop -c "select calls, round(total_exec_time) total_ms, round(mean_exec_time,2) mean_ms, left(query,50) from pg_stat_statements order by total_exec_time desc limit 3"
    ── expected output ──
    calls | total_ms | mean_ms | left
    --------+----------+---------+----------------------------------------------------
    912044 | 184022 | 0.20 | select * from carts where session_id = $1
    1204 | 96110 | 79.83 | select * from orders where status = $1 order by cr
    48 | 30211 | 629.40 | select count(*) from orders where created_at > $1
  5. 5

    Put PgBouncer in front

    A minimal PgBouncer config in transaction mode. Apps connect to port 6432 instead of 5432. SHOW POOLS on the admin console shows clients waiting (cl_waiting), which is the metric to alert on.

    pgbouncer.iniwhole fileini
    [databases]
    shop = host=pg port=5432 dbname=shop
    
    [pgbouncer]
    listen_addr = 0.0.0.0
    listen_port = 6432
    auth_type = scram-sha-256
    auth_file = /etc/pgbouncer/userlist.txt
    pool_mode = transaction
    max_client_conn = 2000
    default_pool_size = 40
    server_idle_timeout = 60

Operate it

Knobs that matter

SettingDefaultWhat it doesWhen to change it
pool_mode (PgBouncer)sessionWhen a server connection is returned to the pool.transaction for web apps; session only if you rely on session features.
default_pool_size (PgBouncer)20Server connections per user/database pair.Start around 2–4 × DB vCPUs and adjust using cl_waiting and DB CPU.
lock_timeout0 (off)Max time to wait for a lock.Set to a few seconds in migration sessions so DDL fails fast instead of queueing everyone.
random_page_cost4Planner's cost for random I/O.Lower to ~1.1 on SSD/EBS so the planner uses indexes appropriately.
autovacuum_vacuum_scale_factor0.2Fraction of a table that must change before autovacuum.Lower (0.01–0.05) per table on huge, hot tables so vacuum runs before bloat builds.

3am practice

Failure drills

Each drill is a real failure mode. Read the scenario and the output, decide what's wrong, then reveal the diagnosis.

Drill #1

FATAL: too many connections during a traffic spike

Black Friday: the HPA scales the API from 12 to 48 pods. Minutes later, new pods crash-loop.

terminal
$ kubectl logs deploy/api | tail -1
── what you'll see ──
FATAL: sorry, too many clients already (SQLSTATE 53300)

Drill #2

A two-second migration takes the site down

A deploy runs ALTER TABLE orders ADD COLUMN note text while an analyst's 10-minute report is running.

terminal
$ psql -c "select pid, wait_event_type, state, left(query,40) from pg_stat_activity where wait_event_type = 'Lock'" | head -6
── what you'll see ──
pid | wait_event_type | state | left
-------+-----------------+--------+------------------------------------------
5101 | Lock | active | ALTER TABLE orders ADD COLUMN note text
5102 | Lock | active | select * from orders where customer_id =
5103 | Lock | active | insert into orders (customer_id, status,
... (1,240 rows)

The bigger picture

Connects to

Prove it

Interview questions

01

The database is slow. How do you investigate?

02

Why use PgBouncer, and what breaks in transaction mode?

0/3 · 0%