Skip to content

Transactions and Concurrency

ACID is the set of guarantees that a relational database transaction provides. Understanding what Each property actually guarantees — and what it does not — is critical for building correct Concurrent systems.

A transaction is an all-or-nothing unit of work. Either all operations in the transaction commit, or None of them do. If the transaction fails at any point (constraint violation, system crash, network Failure), the database rolls back to the state before the transaction began.

Implementation: the database writes changes to a write-ahead log (WAL) before applying them to The data files. On recovery, the WAL is replayed (committed transactions) or undone (uncommitted Transactions).

BEGIN;
UPDATE accounts SET balance = balance - 500 WHERE account_id = 1;
UPDATE accounts SET balance = balance + 500 WHERE account_id = 2;
-- If either UPDATE fails (e.g., insufficient funds), both are rolled back
COMMIT;

A transaction transforms the database from one valid state to another. All defined constraints (NOT NULL, UNIQUE, CHECK, FOREIGN KEY) must hold at transaction commit. This property is partially the Responsibility of the database (enforcing constraints) and partially the responsibility of the Application (writing correct transaction logic).

Consistency in ACID is not the same as consistency in the CAP theorem. ACID consistency means “the database satisfies all defined constraints.” CAP consistency means “every read returns the most Recent write.” They are different guarantees.

Concurrent transactions should not interfere with each other. The isolation level determines the Degree to which this is enforced. Higher isolation levels provide stronger guarantees but reduce Concurrency and performance.

Once a transaction commits, its effects are permanent, even in the event of a system crash, power Failure, or hardware fault. The database must guarantee that committed data can be recovered.

Implementation: committed WAL records are flushed to disk (fsync) before the COMMIT returns success To the client. The actual data pages may be flushed to disk later (write-back caching), but the WAL Is the authoritative source for recovery.

-- fsync is the bottleneck for commit latency
-- PostgreSQL: synchronous_commit = on (default) means COMMIT waits for WAL fsync
-- Setting synchronous_commit = off trades durability for latency (~10x faster commits)
-- but may lose the last 100ms of transactions on a crash

A transaction moves through a well-defined state machine:

stateDiagram-v2
    [*] --> Active : BEGIN
    Active --> PartiallyCommitted : last statement completes
    Active --> Failed : error
    PartiallyCommitted --> Committed : WAL flushed to disk
    PartiallyCommitted --> Failed : write failure
    Failed --> Aborted : rollback complete
    Committed --> [*]
    Aborted --> [*]
StateDescription
ActiveTransaction is executing statements
Partially CommittedAll statements executed; waiting for WAL flush
CommittedWAL flushed; changes are permanent
FailedAn error occurred; changes must be undone
AbortedRollback completed; database restored to pre-transaction state

SQL defines four isolation levels, each preventing a different set of concurrency anomalies. The SQL Standard defines three anomalies: dirty reads, non-repeatable reads, and phantom reads.

Dirty Read: Transaction T1 reads a value written by T2 that has not yet committed. If T2 rolls Back, T1 has read data that never existed.

Non-Repeatable Read: Transaction T1 reads a row, then T2 updates or deletes that row and Commits. When T1 re-reads the row, it sees different data.

Phantom Read: Transaction T1 reads a set of rows matching a condition, then T2 inserts a new row Matching that condition and commits. When T1 re-executes the query, it sees a new (phantom) row.

Isolation LevelDirty ReadNon-Repeatable ReadPhantom ReadLock-Based Implementation
READ UNCOMMITTEDPossiblePossiblePossibleNone
READ COMMITTEDPreventedPossiblePossibleRow-level shared locks (duration of read)
REPEATABLE READPreventedPreventedPossible*Row-level locks held until end of transaction
SERIALIZABLEPreventedPreventedPreventedRange locks or snapshot isolation

*PostgreSQL”s REPEATABLE READ actually prevents phantom reads through its snapshot-based Implementation, exceeding the SQL standard’s requirement.

SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
  • No locking for reads
  • Can read uncommitted (dirty) data
  • Fastest but most dangerous
  • Rarely used in practice
  • PostgreSQL treats this as READ COMMITTED (it always prevents dirty reads)

Use case: approximate aggregate queries where exact precision is not required (e.g., “roughly how Many orders today?”).

SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
  • Default in PostgreSQL, Oracle, and SQL Server
  • Each statement within a transaction sees a fresh snapshot of committed data
  • Prevents dirty reads
  • Does NOT prevent non-repeatable reads or phantom reads
T1: BEGIN ISOLATION LEVEL READ COMMITTED;
T1: SELECT balance FROM accounts WHERE id = 1; -- returns 1000
T2: BEGIN;
T2: UPDATE accounts SET balance = 900 WHERE id = 1;
T2: COMMIT;
T1: SELECT balance FROM accounts WHERE id = 1; -- returns 900 (non-repeatable read)
T1: COMMIT;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
  • Default in MySQL (InnoDB)
  • The transaction sees a snapshot as of its first read
  • Prevents dirty reads and non-repeatable reads
  • PostgreSQL prevents phantom reads too; MySQL does not
T1: BEGIN ISOLATION LEVEL REPEATABLE READ;
T1: SELECT balance FROM accounts WHERE id = 1; -- returns 1000
T2: BEGIN;
T2: UPDATE accounts SET balance = 900 WHERE id = 1;
T2: COMMIT;
T1: SELECT balance FROM accounts WHERE id = 1; -- returns 1000 (repeatable read guaranteed)
T1: UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- ERROR: could not serialize access due to concurrent update
-- PostgreSQL detects the conflict and aborts T1