Files

149 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Transactions, Isolation, and Locking
Concurrency bugs pass every single-user test. The defense is knowing which anomaly your isolation level still allows and closing that one hole explicitly.
Contents: Isolation Levels · Anomalies · Engine Defaults · Explicit Locks · Lock Ordering · Deadlocks · Retry Loops · Optimistic Locking · Idempotency · Advisory Locks · Long Transactions · Transaction Boundaries
## Isolation Levels and What They Still Allow
| Level | Dirty read | Non-repeatable read | Phantom | Write skew |
|---|---|---|---|---|
| READ UNCOMMITTED | Possible (not in PostgreSQL — it behaves as READ COMMITTED) | Possible | Possible | Possible |
| READ COMMITTED | No | Possible | Possible | Possible |
| REPEATABLE READ | No | No | No in PostgreSQL and InnoDB (MVCC snapshot); allowed by the standard | Possible |
| SERIALIZABLE | No | No | No | No |
Each statement in READ COMMITTED takes a fresh snapshot: a multi-statement report can see two different states of the database and produce totals that never existed. Reports that must be internally consistent run in REPEATABLE READ.
## The Anomalies, With Their Real Shapes
- **Lost update**: two sessions read `balance = 100`, both write `balance - 10`, the result is 90 instead of 80. Read-modify-write in application code always has this bug unless locked or made atomic.
- **Non-repeatable read**: the same `SELECT` in one transaction returns different values because another committed in between. Breaks "check then act".
- **Phantom**: a range query returns new rows on re-execution. Breaks "count then enforce a maximum".
- **Write skew**: two transactions each read a set, each verifies an invariant, each writes a *different* row, and together they break the invariant. The classic case: two doctors both cancel their on-call shift because each sees the other still on call. No level below SERIALIZABLE prevents it — and it is the anomaly people assume REPEATABLE READ covers.
## Engine Defaults (know yours before reasoning)
| Engine | Default level | Note |
|---|---|---|
| PostgreSQL | READ COMMITTED | SERIALIZABLE uses SSI: no extra locks, but transactions abort with `40001` |
| MySQL / InnoDB | REPEATABLE READ | Uses next-key (gap) locks, so it blocks phantoms by locking ranges — more deadlocks than PostgreSQL at the same level |
| MariaDB | REPEATABLE READ | As InnoDB |
| SQLite | SERIALIZABLE | One writer at a time; `PRAGMA journal_mode=WAL` lets readers proceed during a write |
| SQL Server | READ COMMITTED (lock-based) | Readers block writers unless `READ_COMMITTED_SNAPSHOT ON` — turning it on is usually the single biggest concurrency win on SQL Server |
## Explicit Row Locks
```sql
-- Lock the rows you are about to modify; other writers wait
SELECT * FROM inventory WHERE product_id = 5 FOR UPDATE;
-- Weaker: prevents deletion/key change, allows concurrent non-key updates
SELECT * FROM users WHERE id = 5 FOR SHARE; -- FOR KEY SHARE in PostgreSQL
-- Fail immediately instead of queueing (interactive paths, health checks)
SELECT * FROM jobs WHERE id = 5 FOR UPDATE NOWAIT;
-- Skip contended rows (queues; each worker gets a disjoint set)
SELECT id FROM jobs WHERE status = 'pending'
ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED;
```
- `FOR UPDATE` locks rows the query *returns*. In READ COMMITTED, a row modified after your snapshot is re-read at the new version — your `WHERE` may no longer be true for it. Re-check the condition after acquiring the lock.
- `FOR UPDATE` combined with `LEFT JOIN` locks rows from the outer table too unless you scope it (`FOR UPDATE OF t`).
- `SKIP LOCKED` changes semantics, not just performance: the query is no longer "the oldest pending job", it is "the oldest one nobody else holds". That is correct for queues and wrong for reports.
- Row locks are released only at COMMIT or ROLLBACK — never at the end of the statement.
## Lock Ordering (the deadlock prevention that actually works)
Two transactions locking `{1,2}` and `{2,1}` deadlock; both locking `{1,2}` queue. Impose a total order everywhere rows are locked together:
```sql
SELECT * FROM accounts WHERE id IN (:a, :b) ORDER BY id FOR UPDATE;
```
The order must be consistent across *code paths*, not just within one function — a transfer routine and a batch-settlement job locking the same two accounts in different orders is the standard production deadlock.
Additional ordering hazards:
- Parent/child: inserting a child takes a lock on the parent row for FK validation. A job that updates parents and another that inserts children touch the same rows in opposite order.
- Index maintenance: two inserts with no row in common can still contend on the same index page or, in InnoDB, the same gap.
- Upserts: `INSERT ... ON CONFLICT` may take locks in key order determined by the values, so batches should be sorted by key before insert.
## Reading a Deadlock Report
- PostgreSQL logs both statements and the lock each was waiting on. The victim is chosen by cost; it is not necessarily the guilty one.
- MySQL: `SHOW ENGINE INNODB STATUS`, section `LATEST DETECTED DEADLOCK` — it holds only the most recent one, so capture it immediately.
- The two statements shown are where the transactions *waited*, not where they took their first lock. Read the whole transaction, not the reported line.
- Deadlock frequency rising with load and no shared rows → gap locks (InnoDB REPEATABLE READ) or missing indexes forcing wide range locks. An index that narrows the scan narrows the locks.
## The Retry Loop (non-optional at SERIALIZABLE)
Serialization failures and deadlocks are expected outcomes, not exceptions to log and drop.
```
attempt = 0
while attempt < 3:
begin
try:
do work; commit; break
except sqlstate in ('40001', '40P01'): # serialization failure, deadlock
rollback
attempt += 1
sleep(random jitter, e.g. 50ms × 2^attempt ± 25%)
except other:
rollback; raise
```
- Retry only the whole transaction. Retrying one statement inside a rolled-back transaction executes against nothing.
- The work must be idempotent or wholly inside the transaction — a retry that re-sends an email is worse than the original failure.
- Cap attempts (3 is a reasonable default; a house retry cap is a Thresholds preference) and jitter the backoff; synchronized retries recreate the same collision.
- Without this loop, SERIALIZABLE is strictly worse than REPEATABLE READ: same cost, plus failures you do not handle.
## Optimistic Locking (no database lock at all)
```sql
UPDATE documents
SET body = :body, version = version + 1
WHERE id = :id AND version = :expected_version;
-- 0 rows affected = someone else won; re-read and re-apply or surface a conflict
```
- Correct choice when conflicts are rare and the "transaction" spans a user thinking (an edit form open for minutes). Never hold a database lock across user time (SKILL.md rule 5).
- The application must check the affected-row count. Ignoring it discards the user's edit with no error — the most common implementation bug.
- A `updated_at` timestamp works as the version only if its resolution exceeds the update rate; an integer counter has no such failure.
## Idempotency and Exactly-Once Writes
- Give externally triggered writes a client-supplied idempotency key with a unique constraint: the second attempt violates the constraint, and the handler returns the first result.
- `INSERT ... ON CONFLICT DO NOTHING RETURNING id` returns no row when the insert was a no-op — fetch the existing row explicitly rather than treating the empty result as failure.
- Database transactions do not extend to external systems. Commit the database first and drive the external effect from a durable outbox row, or accept a window where one succeeded and the other did not.
- Retries of a batch must be safe at any prefix: chunk the work and record progress in the same transaction as the work.
## Advisory / Application Locks
```sql
SELECT pg_advisory_xact_lock(hashtext('nightly-rollup')); -- released at commit
SELECT GET_LOCK('nightly-rollup', 10); -- MySQL, with timeout
```
- Use for "only one worker runs this job", cron singletons, and serializing a migration across instances — cases where no row represents the resource.
- Prefer the transaction-scoped variant (`pg_advisory_xact_lock`). Session-scoped locks survive commit and leak when a connection is returned to a pool still holding one.
- Advisory locks live in a flat namespace of integers: hash a descriptive string and keep the list of names in one place, or two features end up sharing a lock.
- Under a transaction-mode pooler, session-level advisory locks are unusable.
## Long Transactions Are a Systemic Cost
- They hold row locks, block DDL, and in PostgreSQL pin dead tuples so nothing can be vacuumed database-wide — one forgotten `BEGIN` bloats every table.
- `idle in transaction` is worse than a long-running query: it holds everything while doing nothing. Set `idle_in_transaction_session_timeout` on the application role.
- The monitoring threshold is the canonical one: investigate any transaction open past 1 minute.
- A transaction that spans an HTTP call inherits that service's worst-case latency as its lock duration.
## Where Transaction Boundaries Belong
- One transaction per unit of business meaning. Framework "transaction per request" middleware makes every slow template render part of the transaction.
- Do not open a transaction to run a single statement — it is already atomic.
- Read-only work does not need a transaction unless it needs a consistent multi-statement snapshot; then declare it (`SET TRANSACTION READ ONLY` also lets the engine skip work).
- Nested transactions do not exist in most engines: an inner `BEGIN` is ignored or errors. Partial rollback needs savepoints, and a savepoint per loop iteration has real cost in PostgreSQL (each consumes a subtransaction slot; past 64 open subtransactions per session, other backends pay a lookup penalty).
- DDL inside a transaction is safe in PostgreSQL, SQLite, and SQL Server; MySQL commits implicitly (SKILL.md Traps).