Skip to content

Split View: DB 트랜잭션과 격리 수준 완전정복

|

DB 트랜잭션과 격리 수준 완전정복

들어가며 — 트랜잭션은 무엇을 약속하는가

데이터베이스에 돈이 오가면 무서운 질문이 생깁니다. "이체 도중에 서버가 죽으면 어떻게 되지?" 계좌 A에서 100원을 빼고 계좌 B에 100원을 더하는 두 문장 사이에서 프로세스가 죽으면, 돈은 사라질 수도 두 배가 될 수도 있습니다. 트랜잭션은 바로 이 공포를 없애기 위한 장치입니다.

트랜잭션의 약속은 단순합니다. "이 여러 문장을 하나의 덩어리로 취급하라. 전부 반영되거나, 하나도 반영되지 않거나 둘 중 하나다." 이 덩어리는 커밋(commit)되면 확정되고, 롤백(rollback)되면 통째로 없던 일이 됩니다. 그런데 이 단순한 약속을 여러 트랜잭션이 동시에 실행되는 환경에서 지키려면 놀랄 만큼 미묘한 문제들이 튀어나옵니다. 이 글은 그 미묘함을 정면으로 다룹니다.

개념을 직접 SQL로 실험해 보고 싶다면, 이 사이트의 SQL 놀이터, PostgreSQL 놀이터, DuckDB 놀이터에서 쿼리를 돌려 볼 수 있습니다.

ACID — 네 글자가 뜻하는 것

트랜잭션의 보장은 흔히 ACID라는 약자로 요약됩니다. 네 글자를 하나씩 정확히 봅시다.

  • Atomicity(원자성): 트랜잭션은 나눌 수 없는 하나의 단위입니다. 전부 성공하거나 전부 실패합니다. 이체의 "빼기"만 반영되고 "더하기"는 빠지는 일은 없습니다.
  • Consistency(일관성): 트랜잭션은 데이터베이스를 하나의 유효한 상태에서 또 다른 유효한 상태로 옮깁니다. 제약 조건(외래 키, 유니크, 체크)이 트랜잭션 경계에서 지켜집니다. 여기서의 일관성은 애플리케이션이 정의한 불변식을 뜻하며, 뒤에 나오는 분산 시스템의 "일관성(consistency)"과는 다른 개념입니다.
  • Isolation(격리성): 동시에 실행되는 트랜잭션들이 서로의 중간 상태를 보지 못하게 합니다. 이 글의 절반이 바로 이 격리성 이야기입니다. 완벽한 격리는 비싸기 때문에, 실무는 여러 단계의 격리 수준 중에서 고릅니다.
  • Durability(내구성): 커밋된 트랜잭션은 이후 시스템이 죽어도 살아남습니다. 보통 WAL(Write-Ahead Log, 미리 쓰기 로그)에 먼저 기록해 이를 보장합니다.

이 중 원자성과 내구성은 대체로 직관적입니다. 진짜 어려움은 격리성에 있습니다. "다른 트랜잭션의 중간 상태를 얼마나 보이게 할 것인가"라는 질문에는 하나의 정답이 없고, 성능과 정확성 사이의 스펙트럼만 있기 때문입니다.

왜 격리가 어려운가 — 동시성이라는 근본 문제

트랜잭션이 하나씩 순서대로 실행된다면 격리는 문제가 아닙니다. 어려움은 여러 트랜잭션이 겹쳐 실행될 때 생깁니다. 이론적으로 가장 안전한 상태는 "직렬 가능(serializable)"입니다. 즉 트랜잭션들이 실제로는 겹쳐 돌아도, 그 결과가 마치 어떤 순서로 하나씩 실행한 것과 똑같은 상태입니다.

문제는 이 완벽한 직렬성을 강제하면 비용이 크다는 것입니다. 트랜잭션들이 서로를 기다리며 동시성이 떨어지고, 처리량이 낮아집니다. 그래서 데이터베이스는 타협을 제공합니다. "이 정도 이상 현상까지는 허용할 테니, 그 대신 더 빠르게 돌아가겠다"는 단계들이 바로 **격리 수준(isolation level)**입니다.

각 격리 수준을 이해하는 열쇠는 "이 수준이 어떤 이상 현상을 막고, 어떤 것을 허용하는가"입니다. 그러니 먼저 이상 현상들부터 정확히 정의합시다.

이상 현상 네 가지 — 무엇이 잘못될 수 있나

격리가 약할 때 나타나는 대표적인 읽기 이상 현상은 다음과 같습니다. 각각을 구체적인 시나리오로 봅시다.

1. 더티 리드(dirty read) — 커밋되지 않은 값을 읽음. 트랜잭션 B가, 트랜잭션 A가 아직 커밋하지 않은 값을 읽습니다. 만약 A가 나중에 롤백하면, B는 존재한 적 없는 유령 값을 읽은 셈이 됩니다.

  A: UPDATE balance = 200 (아직 커밋 안 함)
  B:                          SELECT balance -> 200  (더티!)
  A: ROLLBACK               (200은 없던 값이 됨)
  B: 존재하지 않았던 200을 근거로 판단해 버림

2. 반복 불가능한 읽기(non-repeatable read) — 같은 행을 두 번 읽었더니 값이 다름. 트랜잭션 B가 같은 행을 두 번 읽는데, 그 사이에 다른 트랜잭션 A가 그 행을 수정하고 커밋해서, 두 읽기의 값이 달라집니다.

  B: SELECT balance -> 100
  A: UPDATE balance = 200; COMMIT
  B: SELECT balance -> 200   (같은 행인데 값이 바뀜)

3. 팬텀 리드(phantom read) — 같은 조건으로 두 번 조회했더니 행의 집합이 달라짐. B가 어떤 조건(예: "잔액 > 50")에 맞는 행들을 두 번 조회하는데, 그 사이 A가 그 조건에 맞는 새 행을 삽입(또는 삭제)해서, 결과 집합의 행 개수가 달라집니다. 반복 불가능한 읽기가 "이미 있던 행의 값 변화"라면, 팬텀은 "행 자체의 등장·소멸"입니다.

  B: SELECT count(*) WHERE balance > 50 -> 3건
  A: INSERT (balance = 500); COMMIT
  B: SELECT count(*) WHERE balance > 50 -> 4건  (유령 행 등장)

4. 쓰기 왜곡(write skew) — 각자 본 값은 맞지만 합쳐 보면 규칙 위반. 이것은 더 미묘합니다. 두 트랜잭션이 서로 겹치지 않는 행을 읽고 각자 갱신하는데, 둘 다 개별적으로는 규칙을 지켰지만 합쳐진 결과가 불변식을 깨뜨립니다. 고전적인 예가 "의사 당직" 규칙입니다. 항상 최소 한 명은 당직이어야 하는데, 두 의사가 동시에 "다른 한 명이 당직이니 나는 빠져도 되겠다"고 판단하고 각자 빠지면, 결과적으로 당직이 0명이 됩니다.

  규칙: 당직 의사는 항상 >= 1명
  현재: 앨리스(당직), 밥(당직) — 2명

  A(앨리스): SELECT count(당직) -> 2, "밥이 있으니 나는 빠져도 됨" -> 앨리스 off
  B(밥):     SELECT count(당직) -> 2, "앨리스가 있으니 나는 빠져도 됨" -> 밥 off
  둘 다 커밋 -> 당직 0명. 규칙 위반!

쓰기 왜곡이 특히 무서운 이유는, 각 트랜잭션만 따로 보면 완벽히 정당해 보이기 때문입니다. 이 현상은 스냅샷 격리(뒤에 설명)에서도 발생할 수 있어서, "직렬 가능"이 왜 필요한지를 보여 주는 대표적 사례입니다.

네 가지 표준 격리 수준

SQL 표준은 위 이상 현상들을 기준으로 네 가지 격리 수준을 정의합니다. 낮은 수준일수록 더 많은 이상 현상을 허용하고 더 빠르며, 높은 수준일수록 더 안전하고 더 비쌉니다.

  • READ UNCOMMITTED: 가장 약합니다. 더티 리드까지 허용합니다. 커밋되지 않은 값도 읽힐 수 있습니다. 실무에서 거의 쓰지 않습니다.
  • READ COMMITTED: 더티 리드는 막습니다. 커밋된 값만 읽습니다. 하지만 반복 불가능한 읽기와 팬텀은 여전히 허용됩니다. 많은 데이터베이스의 실질적 기본값입니다.
  • REPEATABLE READ: 반복 불가능한 읽기까지 막습니다. 트랜잭션이 시작할 때 본 행은 트랜잭션 내내 같은 값으로 보입니다. 표준상 팬텀은 허용되지만, 구현에 따라 팬텀까지 막기도 합니다.
  • SERIALIZABLE: 가장 강합니다. 모든 이상 현상을 막습니다. 결과가 트랜잭션들을 어떤 순서로 하나씩 실행한 것과 동일하도록 보장합니다.

표준이 정의한 "수준 대 이상 현상" 관계를 표로 정리하면 이렇습니다.

격리 수준더티 리드반복 불가능한 읽기팬텀 리드
READ UNCOMMITTED허용허용허용
READ COMMITTED방지허용허용
REPEATABLE READ방지방지허용(표준)
SERIALIZABLE방지방지방지

한 가지 중요한 주의: 이 표는 SQL "표준"의 정의일 뿐이고, 실제 데이터베이스의 동작은 표보다 강하거나 미묘하게 다릅니다. 예를 들어 뒤에서 보듯 PostgreSQL의 REPEATABLE READ는 표준이 허용하는 팬텀까지 실제로는 막습니다. 그래서 "내 DB에서 이 수준이 실제로 무엇을 보장하는가"는 항상 그 DB의 문서로 확인해야 합니다.

MVCC — 잠그지 않고 읽게 하는 마법

격리를 구현하는 방식에는 크게 두 계열이 있습니다. 하나는 잠금(locking), 다른 하나는 **다중 버전 동시성 제어(MVCC, Multi-Version Concurrency Control)**입니다. 오늘날 PostgreSQL, MySQL(InnoDB), Oracle 같은 주요 데이터베이스는 모두 MVCC를 씁니다.

MVCC의 핵심 아이디어는 이렇습니다. 행을 덮어쓰지 않고, 새 버전을 만든다. 어떤 행이 갱신되면 옛 버전이 그대로 남고 새 버전이 추가됩니다. 각 버전에는 "어느 트랜잭션이 만들었는지"를 나타내는 정보가 붙습니다. 트랜잭션이 데이터를 읽을 때는, 자신의 시점 기준으로 "보여야 하는 버전"만 골라 봅니다.

  행 x의 버전들 (시간 순):
    v1 (트랜잭션 10이 생성)
    v2 (트랜잭션 25가 생성)   <- 최신

  트랜잭션 20이 x를 읽으면:
    -> 20은 25의 결과를 볼 수 없음 (아직 없던 미래)
    -> v1을 본다  (자신의 스냅샷에 맞는 버전)

이 방식의 결정적 장점은 읽기가 쓰기를 막지 않고, 쓰기가 읽기를 막지 않는다는 것입니다. 읽는 쪽은 그냥 자기 시점에 맞는 옛 버전을 보면 되고, 쓰는 쪽은 새 버전을 만들면 됩니다. 서로 기다릴 필요가 없어 동시성이 크게 올라갑니다. 이렇게 트랜잭션이 시작 시점의 일관된 스냅샷을 보는 격리를 **스냅샷 격리(snapshot isolation)**라고 부릅니다.

대가도 있습니다. 옛 버전들이 쌓이므로 주기적으로 청소해야 합니다. PostgreSQL에서는 이 청소를 VACUUM이 담당하고, 청소가 밀리면 죽은 튜플이 쌓여 성능이 나빠집니다. 그리고 앞서 본 쓰기 왜곡은 순수한 스냅샷 격리만으로는 막히지 않습니다. 스냅샷은 각자 일관되지만, 두 스냅샷의 결정이 합쳐질 때의 충돌은 보지 못하기 때문입니다.

잠금 대 낙관적 동시성

동시 갱신 충돌을 다루는 전략은 크게 비관적(pessimistic)과 낙관적(optimistic) 두 가지로 나뉩니다.

비관적 잠금(pessimistic locking). "충돌이 일어날 것"이라고 가정하고, 데이터를 만질 때 먼저 잠급니다. 다른 트랜잭션은 잠금이 풀릴 때까지 기다립니다. 충돌이 잦은 상황에서 안전하지만, 대기와 교착 상태(deadlock)의 위험이 있습니다. 뒤에 나올 SELECT ... FOR UPDATE가 이 방식의 대표적 도구입니다.

낙관적 동시성 제어(optimistic concurrency control). "충돌은 드물 것"이라고 가정하고, 일단 잠그지 않고 진행합니다. 대신 커밋 직전에 "내가 읽은 이후에 이 데이터가 바뀌었는가"를 확인하고, 바뀌었으면 트랜잭션을 실패시켜 재시도하게 합니다. 흔한 구현은 버전 컬럼입니다. 행에 버전 번호를 두고, 갱신할 때 "내가 읽은 버전과 현재 버전이 같을 때만 갱신"하도록 조건을 겁니다.

-- 낙관적 잠금: 버전이 그대로일 때만 갱신
UPDATE accounts
SET balance = 200, version = version + 1
WHERE id = 42 AND version = 7;
-- 영향받은 행이 0이면 -> 그 사이 누군가 바꿨다는 뜻 -> 재시도

선택 기준은 충돌 빈도입니다. 충돌이 잦으면 비관적 잠금이 재시도 낭비를 줄여 유리하고, 충돌이 드물면 낙관적 방식이 대기를 없애 처리량을 높입니다. 웹 애플리케이션의 많은 갱신은 충돌이 드물어 낙관적 방식이 잘 맞습니다.

SELECT FOR UPDATE — 명시적 행 잠금

MVCC 덕분에 일반적인 읽기는 잠그지 않지만, 때로는 "내가 읽은 이 행을 아무도 못 건드리게 잠가 두고 싶다"가 필요합니다. 대표적인 경우가 읽고-검사하고-쓰기(read-modify-write) 패턴입니다. 잔액을 읽어서, 충분한지 검사하고, 차감하는 흐름이 그렇습니다.

이때 그냥 SELECT로 읽으면, 읽은 직후 다른 트랜잭션이 같은 행을 바꿔 버려 갱신 손실(lost update)이 날 수 있습니다. SELECT ... FOR UPDATE는 읽는 그 순간 해당 행에 쓰기 잠금을 걸어, 트랜잭션이 끝날 때까지 다른 트랜잭션이 그 행을 수정하지 못하게 합니다.

BEGIN;
-- 이 행을 잠근 채로 읽는다. 다른 트랜잭션은 이 행 수정 시 대기.
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- 애플리케이션에서 잔액 충분한지 검사 후
UPDATE accounts SET balance = balance - 100 WHERE id = 42;
COMMIT;  -- 커밋 시점에 잠금 해제

FOR UPDATE의 변형도 알아 두면 좋습니다. FOR SHARE는 공유 잠금으로, 다른 읽기는 허용하되 쓰기는 막습니다. FOR UPDATE SKIP LOCKED는 이미 잠긴 행을 건너뛰어, 여러 워커가 큐에서 서로 다른 작업을 집어 가는 작업 큐 패턴에 유용합니다. FOR UPDATE NOWAIT는 잠금을 못 얻으면 기다리지 않고 즉시 오류를 냅니다.

한 가지 조심할 것은 교착 상태입니다. 두 트랜잭션이 서로가 잠근 행을 반대 순서로 잠그려 하면 서로를 영원히 기다립니다. 데이터베이스가 이를 감지해 한쪽을 강제로 실패시키므로, 애플리케이션은 이 오류를 잡아 재시도할 준비가 되어 있어야 합니다. 교착을 줄이는 실전 요령은 "항상 같은 순서로 잠그기"입니다.

PostgreSQL 대 MySQL — 기본값이 다르다

여기서 실무에 직접 영향을 주는 차이를 짚습니다. 두 데이터베이스의 기본 격리 수준이 다릅니다.

  • PostgreSQL의 기본값은 READ COMMITTED입니다. 각 문장이 시작될 때의 최신 커밋 스냅샷을 봅니다. 그래서 한 트랜잭션 안에서도 문장마다 다른 스냅샷을 볼 수 있어, 반복 불가능한 읽기가 기본값에서 가능합니다.
  • MySQL(InnoDB)의 기본값은 REPEATABLE READ입니다. 트랜잭션의 첫 읽기 시점에 스냅샷을 잡아 트랜잭션 내내 유지합니다.

더 흥미로운 것은 각자의 상위 수준이 실제로 어떻게 동작하느냐입니다.

  • PostgreSQL의 REPEATABLE READ는 진정한 스냅샷 격리를 구현하며, 표준이 허용하는 팬텀 리드까지 실제로 막습니다. 단, 쓰기 왜곡은 여전히 가능합니다.
  • PostgreSQL의 SERIALIZABLE은 SSI(Serializable Snapshot Isolation)라는 기법으로, 스냅샷 격리 위에 위험한 의존성을 감지해 충돌하는 트랜잭션을 실패시킵니다. 그래서 쓰기 왜곡까지 막지만, 그만큼 직렬화 실패(오류 40001류)가 날 수 있어 애플리케이션이 재시도해야 합니다.
  • MySQL/InnoDB의 REPEATABLE READ는 일반 읽기에는 일관된 스냅샷을 주지만, 잠금 읽기(FOR UPDATE 등)에는 갭 잠금(gap lock)을 써서 팬텀 삽입을 막습니다. 순수한 스냅샷 격리와 잠금이 섞인 미묘한 동작이라, 같은 REPEATABLE READ라도 PostgreSQL과 세부 거동이 다릅니다.

이 차이가 실무에서 왜 중요할까요? 같은 애플리케이션 코드라도 어느 DB에 붙느냐에 따라 동시성 버그의 양상이 달라지기 때문입니다. PostgreSQL에서는 문제없던 코드가 MySQL에서 다르게 동작하거나 그 반대일 수 있습니다. 그래서 동시성이 걸린 로직은 반드시 실제 대상 DB의 기본 격리 수준을 알고, 필요하면 명시적으로 격리 수준을 올려서 검증해야 합니다.

실무 지침 정리

지금까지의 내용을 실전 관점에서 압축합니다.

먼저 기본 격리 수준을 정확히 알고 시작하세요. PostgreSQL은 READ COMMITTED, MySQL은 REPEATABLE READ가 기본입니다. 이걸 모르면 "왜 이 값이 트랜잭션 중간에 바뀌지?" 또는 그 반대의 혼란을 겪습니다.

다음으로 읽고-검사하고-쓰기 패턴을 조심하세요. 잔액 차감, 재고 감소, 좌석 예약처럼 "읽은 값을 근거로 쓰는" 로직은 갱신 손실의 온상입니다. 낙관적 버전 컬럼이나 SELECT ... FOR UPDATE로 명시적으로 보호하세요.

쓰기 왜곡을 기억하세요. 각 트랜잭션이 개별적으로 정당해 보여도, 합쳐지면 불변식을 깰 수 있습니다. 순수 스냅샷 격리로는 막히지 않으므로, 정말로 필요한 불변식이라면 SERIALIZABLE로 올리거나 명시적 잠금으로 강제하세요.

직렬화 실패와 교착에 대비한 재시도를 넣으세요. SERIALIZABLE이나 낙관적 방식, 잠금은 모두 "실패하면 다시 시도"를 전제로 합니다. 애플리케이션에 재시도 로직이 없으면 이 안전장치들이 오히려 장애가 됩니다.

마지막으로 트랜잭션을 짧게 유지하세요. 긴 트랜잭션은 잠금을 오래 쥐고, MVCC의 옛 버전 청소를 방해하며(PostgreSQL의 VACUUM 지연), 충돌 확률을 높입니다.

마치며

트랜잭션은 "여러 작업을 하나로 묶어 전부 또는 전무로 처리하라"는 단순한 약속에서 출발하지만, 여러 트랜잭션이 동시에 돌아가는 순간 격리라는 깊은 문제로 이어집니다. 더티 리드, 반복 불가능한 읽기, 팬텀, 쓰기 왜곡이라는 이상 현상들이 있고, 이를 얼마나 막을지가 네 가지 격리 수준으로 나뉩니다.

현대 데이터베이스는 대부분 MVCC로 "읽기가 쓰기를 막지 않는" 스냅샷 격리를 제공하고, 그 위에서 필요할 때 잠금이나 낙관적 버전으로 충돌을 다룹니다. 그리고 PostgreSQL과 MySQL은 기본 격리 수준부터 상위 수준의 세부 거동까지 다르므로, 실제 대상 DB의 동작을 아는 것이 무엇보다 중요합니다.

핵심은 이것입니다. 완벽한 격리는 비싸고, 완전히 격리를 포기하면 위험합니다. 그래서 우리는 "이 데이터에 어떤 이상 현상까지 허용할 수 있는가"를 정하고, 거기에 맞는 격리 수준과 동시성 전략을 고릅니다. 그 선택을 의식적으로 할 때, 트랜잭션은 공포의 대상이 아니라 신뢰할 수 있는 토대가 됩니다.

참고 자료

Database Transactions and Isolation Levels, Explained

Introduction — What a Transaction Promises

Money moving through a database raises a scary question: "What happens if the server dies mid-transfer?" If a process crashes between the statement that subtracts 100 from account A and the one that adds 100 to account B, money could vanish or double. Transactions exist to make that fear go away.

The promise of a transaction is simple: treat these statements as one unit — either all of them take effect, or none of them do. Once committed, the unit is permanent; if rolled back, the whole thing never happened. Yet keeping this simple promise while many transactions run at once surfaces surprisingly subtle problems. This post confronts that subtlety head-on.

If you want to experiment with these ideas in real SQL, you can run queries in this site's SQL Playground, PostgreSQL Playground, and DuckDB Playground.

ACID — What the Four Letters Mean

The guarantees of a transaction are usually summarized by the acronym ACID. Let's look at each letter precisely.

  • Atomicity: A transaction is an indivisible unit. It all succeeds or all fails. You never get the "subtract" of a transfer applied without the "add."
  • Consistency: A transaction moves the database from one valid state to another. Constraints (foreign keys, uniqueness, checks) hold at the transaction boundary. This "consistency" means the invariants your application defines, which is a different concept from the "consistency" of distributed systems discussed later.
  • Isolation: Concurrently running transactions cannot see each other's intermediate state. Half of this post is about isolation. Because perfect isolation is expensive, real systems choose among several isolation levels.
  • Durability: A committed transaction survives even if the system later crashes. This is usually guaranteed by writing to a WAL (Write-Ahead Log) first.

Of these, atomicity and durability are mostly intuitive. The real difficulty lives in isolation, because "how much of another transaction's intermediate state do we let you see" has no single right answer — only a spectrum between performance and correctness.

Why Isolation Is Hard — Concurrency Is the Root Problem

If transactions ran one at a time in sequence, isolation would be a non-issue. The difficulty arises when multiple transactions overlap in time. The theoretically safest outcome is "serializable": even though the transactions really do run concurrently, the resulting state is identical to some order in which they ran one after another.

The problem is that enforcing perfect serializability is costly. Transactions wait on one another, concurrency drops, and throughput suffers. So databases offer a compromise. The tiers of "I'll tolerate up to this much anomaly in exchange for running faster" are exactly the isolation levels.

The key to understanding each isolation level is: "which anomalies does this level prevent, and which does it allow?" So let's define the anomalies precisely first.

Four Anomalies — What Can Go Wrong

Here are the classic read anomalies that appear when isolation is weak. Let's see each as a concrete scenario.

1. Dirty read — reading an uncommitted value. Transaction B reads a value that transaction A has not yet committed. If A later rolls back, B has read a phantom value that never truly existed.

  A: UPDATE balance = 200 (not yet committed)
  B:                          SELECT balance -> 200  (dirty!)
  A: ROLLBACK               (200 becomes a value that never was)
  B: acts on a 200 that never existed

2. Non-repeatable read — the same row read twice yields different values. Transaction B reads the same row twice, and in between another transaction A modifies and commits that row, so the two reads differ.

  B: SELECT balance -> 100
  A: UPDATE balance = 200; COMMIT
  B: SELECT balance -> 200   (same row, value changed)

3. Phantom read — the same query twice returns a different set of rows. B queries rows matching a condition (say, "balance > 50") twice, and in between A inserts (or deletes) a row matching that condition, so the number of rows in the result set changes. If a non-repeatable read is a change to the value of an existing row, a phantom is the appearance or disappearance of a row itself.

  B: SELECT count(*) WHERE balance > 50 -> 3 rows
  A: INSERT (balance = 500); COMMIT
  B: SELECT count(*) WHERE balance > 50 -> 4 rows  (a phantom row appears)

4. Write skew — each read is valid, but combined they break a rule. This one is subtler. Two transactions read disjoint rows and each performs an update; individually each honored the rule, but the combined result violates an invariant. The classic example is an "on-call doctor" rule. At least one doctor must always be on call, but if two doctors simultaneously decide "the other one is on call, so I can drop off" and each drops off, the result is zero doctors on call.

  Rule: on-call doctors must be >= 1
  Now: Alice (on call), Bob (on call) — 2

  A (Alice): SELECT count(on_call) -> 2, "Bob is there, I can drop" -> Alice off
  B (Bob):   SELECT count(on_call) -> 2, "Alice is there, I can drop" -> Bob off
  Both commit -> 0 on call. Rule violated!

Write skew is especially insidious because each transaction, viewed alone, looks perfectly legitimate. It can occur even under snapshot isolation (explained below), which is precisely why "serializable" is sometimes needed.

The Four Standard Isolation Levels

The SQL standard defines four isolation levels in terms of the anomalies above. Lower levels allow more anomalies and run faster; higher levels are safer and more expensive.

  • READ UNCOMMITTED: The weakest. It permits dirty reads — even uncommitted values can be seen. Rarely used in practice.
  • READ COMMITTED: Prevents dirty reads; you only read committed values. But non-repeatable reads and phantoms are still allowed. It is the effective default in many databases.
  • REPEATABLE READ: Also prevents non-repeatable reads. Rows a transaction saw at its start appear with the same value throughout. Per the standard, phantoms are allowed, though some implementations prevent them too.
  • SERIALIZABLE: The strongest. It prevents all anomalies, guaranteeing the result is identical to running the transactions one at a time in some order.

The standard's "level versus anomaly" relationship, in a table:

Isolation LevelDirty ReadNon-Repeatable ReadPhantom Read
READ UNCOMMITTEDAllowedAllowedAllowed
READ COMMITTEDPreventedAllowedAllowed
REPEATABLE READPreventedPreventedAllowed (per standard)
SERIALIZABLEPreventedPreventedPrevented

One important caveat: this table is only the SQL standard's definition, and real database behavior is often stronger or subtly different. For example, as we'll see, PostgreSQL's REPEATABLE READ actually prevents even the phantoms the standard permits. So "what does this level actually guarantee on my database" must always be checked against that database's own documentation.

MVCC — The Magic of Reading Without Locking

There are broadly two families of ways to implement isolation. One is locking; the other is MVCC (Multi-Version Concurrency Control). Today's major databases — PostgreSQL, MySQL (InnoDB), Oracle — all use MVCC.

The core idea of MVCC is this: don't overwrite a row; create a new version of it. When a row is updated, the old version remains and a new version is added. Each version carries information about which transaction created it. When a transaction reads data, it picks only the version that "should be visible" from its own point in time.

  Versions of row x (in time order):
    v1 (created by transaction 10)
    v2 (created by transaction 25)   <- latest

  When transaction 20 reads x:
    -> 20 cannot see the result of 25 (a future that didn't exist yet)
    -> it reads v1  (the version matching its snapshot)

The decisive advantage is that reads don't block writes and writes don't block reads. Readers simply see the old version matching their point in time; writers just create a new version. Neither has to wait, so concurrency rises dramatically. Isolation in which a transaction sees a consistent snapshot as of its start is called snapshot isolation.

There is a cost. Old versions accumulate and must be cleaned up periodically. In PostgreSQL, VACUUM does this cleanup, and if it falls behind, dead tuples pile up and performance degrades. And the write skew we saw earlier is not prevented by pure snapshot isolation. Each snapshot is internally consistent, but the conflict that arises when two snapshots' decisions combine is invisible to them.

Locking versus Optimistic Concurrency

Strategies for handling concurrent update conflicts split broadly into pessimistic and optimistic.

Pessimistic locking. Assume "a conflict will happen" and lock the data before touching it. Other transactions wait until the lock is released. It's safe when conflicts are frequent, but it risks waiting and deadlock. The SELECT ... FOR UPDATE we'll see next is the classic tool for this approach.

Optimistic concurrency control. Assume "conflicts are rare" and proceed without locking. Instead, just before committing, check "has this data changed since I read it?" and, if so, fail the transaction so it retries. A common implementation is a version column: put a version number on the row and update only when "the version I read equals the current version."

-- Optimistic locking: update only if the version is unchanged
UPDATE accounts
SET balance = 200, version = version + 1
WHERE id = 42 AND version = 7;
-- If zero rows are affected -> someone changed it in the meantime -> retry

The choice comes down to conflict frequency. When conflicts are frequent, pessimistic locking reduces wasted retries; when they're rare, the optimistic approach eliminates waiting and raises throughput. Many updates in web applications rarely conflict, so the optimistic approach fits well.

SELECT FOR UPDATE — Explicit Row Locking

Thanks to MVCC, ordinary reads don't lock, but sometimes you need "lock this row I just read so nobody else can touch it." The classic case is the read-modify-write pattern: read a balance, check it's sufficient, then debit it.

If you read with a plain SELECT here, another transaction could change the same row right after you read it, causing a lost update. SELECT ... FOR UPDATE places a write lock on the row at the moment you read it, preventing other transactions from modifying that row until yours ends.

BEGIN;
-- Read this row while locking it. Other transactions wait to modify this row.
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- After the application checks the balance is sufficient
UPDATE accounts SET balance = balance - 100 WHERE id = 42;
COMMIT;  -- lock released at commit

It's worth knowing the variants of FOR UPDATE. FOR SHARE takes a shared lock, allowing other reads but blocking writes. FOR UPDATE SKIP LOCKED skips already-locked rows, which is useful for a work-queue pattern where multiple workers each grab a different job. FOR UPDATE NOWAIT errors out immediately instead of waiting if it can't acquire the lock.

One thing to watch is deadlock. If two transactions try to lock each other's rows in opposite order, they wait forever. The database detects this and forcibly fails one, so the application must be ready to catch that error and retry. A practical way to reduce deadlocks is "always lock in the same order."

PostgreSQL versus MySQL — The Defaults Differ

Here is a difference that directly affects real work: the two databases have different default isolation levels.

  • PostgreSQL defaults to READ COMMITTED. It sees the latest committed snapshot as of the start of each statement. So even within one transaction, different statements can see different snapshots, meaning non-repeatable reads are possible at the default.
  • MySQL (InnoDB) defaults to REPEATABLE READ. It takes a snapshot at the transaction's first read and keeps it throughout the transaction.

More interesting is how each engine's higher levels actually behave.

  • PostgreSQL's REPEATABLE READ implements true snapshot isolation and actually prevents even the phantom reads the standard permits. Write skew, however, is still possible.
  • PostgreSQL's SERIALIZABLE uses a technique called SSI (Serializable Snapshot Isolation), which sits atop snapshot isolation and detects dangerous dependencies, failing conflicting transactions. It thus prevents even write skew, but at the cost of possible serialization failures (errors of the 40001 kind) that the application must retry.
  • MySQL/InnoDB's REPEATABLE READ gives a consistent snapshot for ordinary reads, but for locking reads (FOR UPDATE, etc.) it uses gap locks to prevent phantom inserts. It's a subtle blend of snapshot isolation and locking, so even the same REPEATABLE READ behaves differently in detail from PostgreSQL.

Why does this difference matter in practice? Because the same application code can exhibit different concurrency bugs depending on which database it runs against. Code that was fine on PostgreSQL may behave differently on MySQL, or vice versa. So any logic involving concurrency must be validated with awareness of the target database's default isolation level, raising the level explicitly if needed.

Practical Guidance

Let's compress everything into practice.

First, know your default isolation level going in. PostgreSQL defaults to READ COMMITTED, MySQL to REPEATABLE READ. Not knowing this leads to confusion like "why did this value change mid-transaction?" — or the reverse.

Next, beware the read-modify-write pattern. Logic that "writes based on a value it just read" — debiting a balance, decrementing stock, reserving a seat — is a breeding ground for lost updates. Protect it explicitly with an optimistic version column or SELECT ... FOR UPDATE.

Remember write skew. Even when each transaction looks legitimate on its own, combined they can break an invariant. Pure snapshot isolation won't stop it, so for an invariant you truly need, raise to SERIALIZABLE or enforce it with explicit locks.

Add retries for serialization failures and deadlocks. SERIALIZABLE, optimistic approaches, and locking all assume "if it fails, try again." Without retry logic, these safety mechanisms become outages instead.

Finally, keep transactions short. Long transactions hold locks longer, obstruct MVCC's cleanup of old versions (delaying PostgreSQL's VACUUM), and raise the probability of conflict.

Wrapping Up

A transaction starts from the simple promise of "bundle these operations into one and process them all-or-nothing," but the moment several transactions run at once it opens onto the deep problem of isolation. There are anomalies — dirty reads, non-repeatable reads, phantoms, write skew — and how much of them you prevent splits into the four isolation levels.

Modern databases mostly provide snapshot isolation via MVCC, where "reads don't block writes," and on top of that handle conflicts with locking or optimistic versions when needed. And PostgreSQL and MySQL differ from their default isolation level down to the fine behavior of their higher levels, so knowing your actual target database's behavior matters most of all.

The heart of it is this: perfect isolation is expensive, and abandoning isolation entirely is dangerous. So we decide "which anomalies can this data tolerate," then pick the matching isolation level and concurrency strategy. Make that choice deliberately, and transactions become not an object of fear but a foundation you can trust.

References