Contents
Course · 06
How does a database keep data correct and still find it fast?
Transactions, isolation, indexes and storage engines, then the SQL vs NoSQL choice. Facts are checked against the PostgreSQL 18 docs (current on 2026-10-25), MySQL 8.4, Cassandra, DynamoDB and MongoDB docs.
A database is correct because of transactions (ACID) and fast because of indexes; every speed trick costs something somewhere else.
- Isolation levels trade safety for speed. PostgreSQL defaults to Read Committed; MySQL InnoDB to Repeatable Read. Of the four levels, only Serializable stops write skew.
- The WAL makes commits durable with one sequential write, so data pages can be written later.
- B-trees are good for reads and ranges; LSM-trees are good for heavy writes; hash indexes only do equality.
- Indexes speed reads but slow every insert, update and delete.
- Pick SQL for relations and transactions; pick NoSQL when one access pattern needs huge scale.
max_connections in PostgreSQL—Words you need
Transactions
01The relational model
A relation is a table. Each row is one fact. Each column has one type. A primary key names a row uniquely. A foreign key points at another table's primary key. A join combines tables on those keys at query time.
CREATE TABLE users (id bigint PRIMARY KEY, email text UNIQUE NOT NULL);
CREATE TABLE orders (id bigint PRIMARY KEY,
user_id bigint REFERENCES users(id),
total numeric(10,2), created_at timestamptz);
SELECT u.email, o.total FROM orders o JOIN users u ON u.id = o.user_id;
NOT NULL, UNIQUE, REFERENCES) let the database reject bad data before it is stored.Relational is the default choice when data has many relationships and you need ad-hoc queries.
Source: Codd 1970, CACM · PostgreSQL tutorial: concepts
02ACID, one letter at a time
| Letter | Plain meaning | Bank-transfer example | How the DB does it |
|---|---|---|---|
| Atomicity | All steps happen, or none | Debit and credit both happen, or neither | Rollback on error; WAL replay ignores uncommitted work |
| Consistency | Rules hold before and after | Balance never goes below 0 if a CHECK says so | Constraints, foreign keys, triggers; partly the app's job |
| Isolation | Running transactions do not see each other's half-done work | A report never sees money "in flight" | MVCC snapshots and locks; strength depends on the level |
| Durability | Once committed, it survives a crash | The ATM says "done", power fails, money still moved | WAL flushed to disk before commit returns |
When an interviewer says "ACID", the real follow-up is "which isolation level?". Be ready for it.
03Isolation levels and the anomalies they allow
| Level | Dirty read | Non-repeatable read | Phantom read | Serialization anomaly |
|---|---|---|---|---|
| Read uncommitted | Allowed, but not in PG | Possible | Possible | Possible |
| Read committed (PG default) | Not possible | Possible | Possible | Possible |
| Repeatable read | Not possible | Not possible | Allowed, but not in PG | Possible |
| Serializable | Not possible | Not possible | Not possible | Not possible |
| Anomaly | What happens |
|---|---|
| Dirty read | You read data another transaction has not committed yet. |
| Non-repeatable read | You read a row twice and get different values, because someone committed in between. |
| Phantom read | You run the same WHERE query twice and get a different set of rows. |
| Lost update | Two transactions read, change and write the same row; one write wipes out the other. |
| Write skew | Two transactions read overlapping data, each writes something different, and together they break a rule neither broke alone. |
could not serialize access due to concurrent update, which prevents lost updates. Write skew is shown by PG's mytab example (two transactions sum class 1 and class 2 and insert into the other): "If either transaction were running at the Repeatable Read isolation level, both would be allowed to commit". Serializable (SSI) rolls one back with SQLSTATE 40001, and the app must retry. MySQL InnoDB defaults to Repeatable Read and uses next-key/gap locks for locking reads.Read Committed is the common default, and it allows lost updates. Fix with atomic UPDATE ... SET x = x - 1, SELECT FOR UPDATE, a version column, or Serializable + retry.
Source: PostgreSQL Transaction Isolation · MySQL 8.4 InnoDB isolation levels
04MVCC basics
MVCC means multi-version concurrency control. An update does not overwrite a row in place. It writes a new version and marks the old one as ended. Each transaction reads the versions that were committed when its snapshot was taken.
| Level | When the snapshot is taken |
|---|---|
| Read committed | New snapshot for every statement |
| Repeatable read / Serializable | One snapshot for the whole transaction (at its first statement) |
VACUUM), plus extra storage. InnoDB likewise reads from a snapshot "established by the first read" under Repeatable Read.MVCC is why a long report query does not freeze your checkout page, and why long-open transactions bloat tables.
Source: PostgreSQL MVCC intro · MySQL InnoDB isolation
05Locking: pessimistic vs optimistic
| Style | How | Good when | Cost |
|---|---|---|---|
| Pessimistic | SELECT ... FOR UPDATE locks rows until commit | Conflicts are common (hot seats, stock) | Waiting, deadlocks |
| Optimistic | Read a version; UPDATE ... WHERE id=? AND version=?; 0 rows = conflict, retry | Conflicts are rare | Retries under contention |
| Atomic statement | UPDATE stock SET n = n - 1 WHERE id=? AND n > 0 | Simple counters | Only fits simple logic |
BEGIN;
SELECT * FROM seats WHERE id = 7 FOR UPDATE; -- others wait here
UPDATE seats SET booked_by = 42 WHERE id = 7;
COMMIT;
-- job queue: many workers, no waiting on each other
SELECT id FROM jobs WHERE state='new' LIMIT 10 FOR UPDATE SKIP LOCKED;
FOR UPDATE blocks other UPDATE, DELETE and locking SELECTs on those rows until the transaction ends. NOWAIT errors instead of waiting; SKIP LOCKED skips locked rows, which the docs say suits "a queue-like table" but gives "an inconsistent view". Deadlocks: two transactions lock rows in opposite order; PostgreSQL detects it and aborts one. Advice: always lock in the same order and retry. Martin Fowler names the optimistic pattern "Optimistic Offline Lock": detect a conflict and roll back.Default to an atomic UPDATE; use a version column for user edits; use FOR UPDATE only for short, hot critical sections.
Source: PostgreSQL explicit locking · PostgreSQL SELECT locking clause · Fowler, Optimistic Offline Lock
Storage and indexes
06Write-ahead log (WAL)
Changing a table touches pages scattered all over the disk. Writing them all at each commit would be slow. So the database first appends a small change record to the WAL and flushes only that. The changed pages stay in memory and reach the data files later, at a checkpoint.
"Durable" means "in the log on disk", not "in the table file". Replication and recovery both read the log.
Source: PostgreSQL WAL intro
07Indexes: B-tree vs hash vs LSM-tree
A B-tree keeps keys sorted in pages. Each page points to child pages. You walk from the root to a leaf. Because keys are sorted, ranges like BETWEEN and ORDER BY are cheap.
An LSM-tree (log-structured merge-tree) never edits files in place. Writes go to a log and a sorted in-memory table. Full tables are flushed as sorted, immutable files called SSTables. Compaction merges them in the background. Reads may check several files, so engines use Bloom filters to skip files.
| Index | Good at | Weak at | Seen in |
|---|---|---|---|
| B-tree | Equality and ranges (< <= = >= >, BETWEEN, sort); steady read latency | Random writes update pages in place | PostgreSQL default, MySQL InnoDB |
| Hash | Only = | No ranges, no sort | PostgreSQL USING HASH |
| LSM-tree | Very high write rates (sequential appends) | Reads may touch many files; compaction uses I/O | Cassandra, RocksDB |
Read-heavy with ranges: B-tree. Write-heavy firehose (logs, metrics, events): LSM. Exact lookup only: hash (or just a B-tree).
Source: PostgreSQL index types · PG page layout · Cassandra storage engine · RocksDB compaction · O'Neil et al. 1996
08Composite and covering indexes, and when indexes hurt
CREATE INDEX idx_orders ON orders (user_id, created_at);
-- uses the index well: WHERE user_id = 42
-- uses the index well: WHERE user_id = 42 AND created_at > now() - interval '7 days'
-- poor fit: WHERE created_at > now() - interval '7 days' -- leading column missing
CREATE INDEX idx_cover ON orders (user_id) INCLUDE (total); -- covering: SELECT total WHERE user_id=?
| Idea | Rule |
|---|---|
| Leftmost prefix | Equality on leading columns, then a range on the next column, narrows the scan. Skipping the first column mostly wastes the index. |
| Column order | Put equality columns first, the range or sort column last. |
| Covering index | If every column the query needs is in the index, the DB can answer from the index alone (index-only scan). |
| Cost of indexes | Every insert, update and delete must also update each index. |
Design indexes from your top queries, not from your columns. Drop the ones nobody uses.
Source: PG multicolumn indexes · PG index-only scans · PG indexes intro
09Query planning and EXPLAIN
EXPLAIN SELECT * FROM tenk1;
Seq Scan on tenk1 (cost=0.00..445.00 rows=10000 width=244)
EXPLAIN ANALYZE SELECT * FROM tenk1 WHERE unique1 < 10; -- really runs it
| You see | It means |
|---|---|
| Seq Scan | Reads the whole table. Fine for small tables or when most rows match. |
| Index Scan | Walks the index, then fetches each row. Good for few rows. |
| Bitmap Heap Scan | Collects row locations from the index, sorts them, then reads pages in order. Good for many rows. |
cost=a..b | Planner's estimate: startup cost .. total cost (arbitrary units). |
rows= estimated vs actual | Big gaps mean stale statistics; run ANALYZE. |
UPDATE or DELETE (wrap it in a transaction you roll back).In an interview, "I'd check EXPLAIN ANALYZE for a Seq Scan on a big table" is the right first step for a slow query.
Source: PostgreSQL Using EXPLAIN
Modelling and choosing
10Normalization vs denormalization
| Form | Rule (textbook) | Breaks it |
|---|---|---|
| 1NF | Each cell holds one value; no repeating groups | phones = "111,222" |
| 2NF | 1NF, and no column depends on only part of a composite key | (order_id, product_id) → product_name |
| 3NF | 2NF, and no column depends on another non-key column | orders.user_city copied from users |
| Choice | Win | Cost |
|---|---|---|
| Normalize | One place to update; no conflicting copies; flexible queries | Joins at read time |
| Denormalize | Fast reads, no joins; fits NoSQL and read-heavy views | Every copy must be updated; risk of drift |
Normalize the source of truth. Denormalize read models (caches, search indexes, materialized views) that you can rebuild.
Source: Codd 1970 · Cassandra data modeling · MongoDB data modeling
11NoSQL families
Name the family by the access pattern: by key → KV; whole object → document; huge time-ordered writes → wide-column; "friends of friends" → graph.
Source: DynamoDB core components · DynamoDB quotas · MongoDB limits · Cassandra data modeling · Neo4j graph database
12SQL vs NoSQL, and BASE
| Need | Lean | Why |
|---|---|---|
| Money, inventory, multi-row invariants | SQL | ACID transactions across rows and tables |
| Ad-hoc queries, reporting, joins | SQL | Planner + indexes handle new questions |
| Known access pattern at huge scale | NoSQL (KV / wide-column) | Partitioned by key, scales out horizontally |
| Very high write rate (events, metrics) | Wide-column / LSM engine | Sequential writes, compaction later |
| Flexible, nested, changing shape | Document | No schema migration for each new field |
| Deep relationships | Graph | Traversal instead of many self-joins |
| Unsure, small to medium | PostgreSQL | Does most things well; easy to change later |
Start from the access patterns and consistency needs, then pick. "NoSQL scales, SQL doesn't" is a weak answer.
Source: Pritchett, BASE: An Acid Alternative (ACM Queue) · MongoDB data modeling
App side
13Connection pooling
Opening a DB connection costs a TCP handshake, TLS, auth, and (in PostgreSQL) a server process. A pool keeps some connections open and lends them out. It also caps how many connections hit the database.
| PgBouncer mode | A server connection is held for | Note |
|---|---|---|
| Session | The whole client connection | "Most polite"; all features work |
| Transaction | One transaction | Best reuse; session state (e.g. SET) does not carry over |
| Statement | One statement | Multi-statement transactions are not allowed |
max_connections default "is typically 100 connections". With 50 app servers × 20 threads you would want 1,000, so you need a pool in each app (e.g. HikariCP) and often a shared pooler such as PgBouncer, which needs about 2 kB per client connection. Pool size should match what the DB can run in parallel, not the number of app threads.More connections is not more throughput. Size the pool to the database, and queue in the app.
Source: PG connection settings · PgBouncer features
14The N+1 query problem
# N+1: 1 query for 10 books + 10 queries for authors = 11 queries
books = Book.limit(10)
books.each { |b| puts b.author.last_name }
# Eager loading: 2 queries total
books = Book.includes(:author).limit(10)
# SELECT authors.* FROM authors WHERE authors.id IN (1,2,...,10)
includes cuts it to 2 using an IN list (or a LEFT OUTER JOIN). Every ORM has the same trap (JPA lazy loading, Django select_related / prefetch_related). Each query pays a network round trip, so 1,000 items = 1,000 round trips. Detect it by counting queries per request in logs or APM.Batch with JOIN or IN, or use a DataLoader in GraphQL. Count queries per request in tests.
15Pagination at the database level
-- OFFSET: page 1000 still reads and throws away 20,000 rows
SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 20000;
-- Keyset (cursor): start after the last row you saw
SELECT * FROM posts
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC LIMIT 20;
| Method | Speed on deep pages | Stable when rows are added? | Jump to page 57? |
|---|---|---|---|
| LIMIT / OFFSET | Slower the deeper you go | No: rows shift, you see duplicates or skips | Yes |
| Keyset / cursor | Same speed at any depth (with an index on the sort keys) | Yes | No, only next/previous |
ORDER BY that gives a unique order, LIMIT/OFFSET pages "will give inconsistent results". Add a unique tie-breaker like id to the sort.Infinite scroll and APIs: keyset with an opaque cursor. Admin tables with page numbers: OFFSET is fine for small data.
Source: PostgreSQL LIMIT and OFFSET
Interview prep
—When to use what
| Situation | Pick | Why |
|---|---|---|
| Decrement stock safely | Atomic UPDATE ... SET n = n - 1 WHERE n > 0 | No read-modify-write race |
| User edits a form for minutes | Optimistic lock (version column) | No long-held locks |
| Short hot critical section | SELECT ... FOR UPDATE | Others wait briefly instead of failing |
| Rule spans several rows ("at least one doctor on call") | Serializable + retry, or lock the rows involved | Stops write skew |
| Worker queue in Postgres | FOR UPDATE SKIP LOCKED | Workers do not block each other |
| Query filters on a then sorts by b | Index on (a, b) | Leftmost prefix, sorted output |
| Hot query reads 2 columns | Covering index (INCLUDE) | Index-only scan |
| Write-heavy event firehose | LSM store (Cassandra / RocksDB) | Sequential writes |
| Many app servers, one Postgres | App pool + PgBouncer (transaction mode) | Stay under max_connections |
| Infinite scroll | Keyset pagination | Fast at any depth, stable |
—Common mistakes
—Interview questions
Show answer
CHECK (balance >= 0) or foreign keys; the app owns business rules. Isolation: another transaction never sees A debited but B not yet credited; how strict depends on the isolation level. Durability: once commit returns, the WAL record is on disk, so a crash right after does not undo it. Follow-up: "Which letter is weakest?" C, because the DB only enforces what you declare; and I is configurable.Show answer
Show answer
<, BETWEEN), prefix LIKE, and gives rows in order for ORDER BY. It's PostgreSQL's default. A hash index stores a 32-bit hash of the value, so it supports only =; no ranges, no sorting. In practice a B-tree is almost always the right choice; hash can be a bit smaller for long values with pure equality lookups. Follow-up: what about LSM-trees? They are a storage design for write-heavy engines, not a Postgres index type.Show answer
Show answer
Show answer
UPDATE items SET stock = stock - 1 WHERE id = ? AND stock > 0, and check the affected row count; 0 means sold out. The row lock taken by the update serializes the two buyers. Alternatives: SELECT ... FOR UPDATE then update, inside one transaction; or optimistic locking with a version column and retry. In PostgreSQL Repeatable Read, the second updater fails with "could not serialize access due to concurrent update". At large scale: reserve stock in Redis with DECR, then confirm in the DB.Show answer
mytab example shows both commit at Repeatable Read. Serializable (SSI) detects the read/write dependency and aborts one with 40001. Other fixes: SELECT ... FOR UPDATE on the rows read, or a constraint that encodes the rule.Show answer
SELECT ... FOR UPDATE; others wait. Good when conflicts are frequent and the critical section is short, e.g. seat booking. Risks: waiting, deadlocks, locks held across slow code. Optimistic: read a version number, then UPDATE ... WHERE id = ? AND version = ?; if 0 rows changed, someone else won, so reload and retry or show an error. Good when conflicts are rare and the user takes minutes (editing a form), since nothing is held. Under heavy contention optimistic causes retry storms. Fowler calls it "Optimistic Offline Lock".Show answer
WHERE user_id = ?, and for WHERE user_id = ? AND created_at > ?, and for WHERE user_id = ? ORDER BY created_at DESC LIMIT 20, since rows come out already sorted. It's a poor fit for WHERE created_at > ? alone because the leading column has no condition; PostgreSQL may still scan the whole index but it's not efficient. Rule from the docs: equality on leading columns plus a range on the next column narrows the scan. If I also need total, adding INCLUDE (total) enables an index-only scan.Show answer
pg_stat_user_indexes) and keep indexes driven by real top queries.Show answer
Show answer
EXPLAIN (ANALYZE, BUFFERS) on it with real parameters. Look for a Seq Scan on a large table, a big gap between estimated and actual rows (stale stats → run ANALYZE), nested loops over many rows, or a sort spilling to disk. Then: add or fix an index matching the WHERE and ORDER BY (leftmost prefix), rewrite to avoid functions on indexed columns, add a covering index, or paginate with keyset. Check for lock waits and N+1 patterns in the app. Measure again after each change.Show answer
includes, select_related, JPA fetch join) which uses a JOIN or one IN (...) query, giving 2 queries. In GraphQL, a DataLoader batches lookups per request. Detect it by logging query count per request and failing tests above a threshold.Show answer
max_connections is typically 100. Pool size should match what the DB can execute in parallel (roughly its cores and disk), not app threads; extra requests should queue in the app. With many app instances, add PgBouncer in transaction mode so thousands of clients share a few server connections. Caveat: transaction mode breaks session state like SET and session-level advisory locks.Show answer
LIMIT 20 OFFSET 20000 makes the server compute and discard 20,000 rows (PostgreSQL docs), so deep pages are slow, and new rows shift pages, causing duplicates or skips. Keyset pagination remembers the last row's sort key: WHERE (created_at, id) < (:t, :id) ORDER BY created_at DESC, id DESC LIMIT 20. With an index on those columns every page costs the same and results are stable. Downside: no "jump to page 57". I always add a unique tie-breaker (id) and expose an opaque cursor in the API.Show answer
Show answer
Show answer
conversation_id and clustering column message_id (time-ordered, e.g. a time-based UUID) descending. Writes are append-heavy, which suits LSM. Pagination is keyset on message_id. To avoid giant partitions for busy groups, add a time bucket to the partition key: (conversation_id, month). User and membership data stay in a relational DB. Unread counts in Redis. Follow-up: search goes to a separate index.Show answer
Show answer
jobs(id, state, run_at, attempts, payload) with an index on (state, run_at). Each worker runs, in one transaction: SELECT id FROM jobs WHERE state='new' AND run_at <= now() ORDER BY run_at LIMIT 10 FOR UPDATE SKIP LOCKED, marks them running, commits, does the work, then marks done or schedules a retry. SKIP LOCKED means workers never wait on each other; the docs say it suits "a queue-like table". Crashed workers: a reaper resets jobs stuck in running past a timeout, so handlers must be idempotent. Fine up to moderate rates; beyond that, use a real broker (page 09).Show answer
—Cheat sheet
| Fact | Value | Source |
|---|---|---|
| PostgreSQL default isolation | Read Committed | PG transaction-iso |
| MySQL InnoDB default isolation | Repeatable Read | MySQL 8.4 |
| PG Repeatable Read | Snapshot isolation; no phantoms; write skew possible | PG transaction-iso |
| Serialization failure | SQLSTATE 40001; retry | PG transaction-iso |
| Default index type | B-tree (hash = equality only) | PG indexes-types |
| Page size | Usually 8 kB | PG storage-page-layout |
| Composite index rule | Equality on leading columns + range on next; > 3 columns rarely helps | PG indexes-multicolumn |
| WAL rule | Log flushed before data pages | PG wal-intro |
max_connections | Typically 100 | PG runtime-config-connection |
| PgBouncer memory | ~2 kB per connection | PgBouncer |
| DynamoDB indexes | 20 GSIs (default quota), 5 LSIs per table | DynamoDB quotas |
| MongoDB document | 16 MiB max | MongoDB limits |
| LSM-tree origin | O'Neil et al., 1996 | Acta Informatica |
| BASE | Pritchett, ACM Queue, 2008 | ACM Queue |
Reference
—Sources
Course