Split View: 트랜잭션 격리 수준 완전 가이드: 데이터베이스가 아니라 애플리케이션이 책임지는 부분
트랜잭션 격리 수준 완전 가이드: 데이터베이스가 아니라 애플리케이션이 책임지는 부분
- 들어가며
- 1. PostgreSQL이 실제로 구현한 것
- 2. 어떤 수준을 고를 것인가
- 3. 직렬화 실패 재시도 계층
- 4. 행 잠금 사다리와 SKIP LOCKED
- 5. 어드바이저리 락 — 데이터 밖의 상호배제
- 6. 긴 트랜잭션이라는 진짜 비용
- 7. MySQL 8.4 InnoDB와 다른 지점
- 8. 관측 — 무엇을 상시로 봐야 하는가
- 퀴즈: 실력을 확인해 보세요
- 마치며
- 참고 자료
- 이어서 읽기
들어가며
이 블로그에는 이미 트랜잭션 격리 수준과 실제 이상 현상이 있습니다. 표준의 네 수준과 세 가지 이상 현상, 스냅샷 격리, write skew를 두 세션 SQL로 보여 주는 글입니다. 격리 수준의 이론을 다룬 글이라고 부를 수 있습니다.
이 글은 그 위에 얹는 운영 계약 편입니다. 격리 수준을 정확히 이해한 팀도 서비스에서는 여전히 사고를 냅니다. 이유는 격리 수준이 데이터베이스 쪽 절반만 책임지기 때문입니다. 나머지 절반은 애플리케이션의 몫입니다. 직렬화 실패가 났을 때 누가 재시도하는가, 잠금은 어느 강도로 걸어야 하는가, 큐 테이블은 어떻게 여러 소비자가 나눠 갖는가, 그리고 트랜잭션을 오래 열어 두면 데이터베이스에 무슨 일이 벌어지는가. 이 글은 그 목록을 다룹니다.
기준 엔진은 PostgreSQL 18 입니다. 격리 수준의 실제 동작은 엔진마다 크게 다르므로, MySQL 8.4 InnoDB와 다른 지점은 7절에서 따로 엔진 이름을 붙여 구분했습니다. 두 엔진을 뭉뚱그린 설명은 없느니만 못합니다.
1. PostgreSQL이 실제로 구현한 것
먼저 사실관계를 정확히 못박고 시작합니다. 여기 적은 내용은 모두 PostgreSQL 18 문서에서 확인한 것입니다.
PostgreSQL은 표준의 네 가지 격리 수준을 모두 요청받을 수 있지만, 내부적으로는 세 가지만 구현합니다. 문서 표현 그대로 "PostgreSQL의 Read Uncommitted 모드는 Read Committed처럼 동작합니다." 이것이 다중 버전 동시성 제어 구조에 표준 격리 수준을 대응시키는 유일하게 합리적인 방법이기 때문입니다. 즉 PostgreSQL에서 dirty read는 어떤 설정으로도 발생하지 않습니다.
두 번째로 중요한 사실. 문서의 격리 수준 표는 팬텀 읽기 항목에 "표준상 허용되지만 PostgreSQL에서는 발생하지 않음"이라고 적어 두었습니다. 문서 본문은 이렇게 설명합니다. "PostgreSQL의 Repeatable Read 구현은 팬텀 읽기를 허용하지 않는다. 표준은 특정 격리 수준에서 어떤 이상 현상이 일어나지 않아야 하는지를 규정하므로, 더 강한 보장은 허용된다."
따라서 표준의 표를 그대로 외운 사람이 PostgreSQL에서 "Repeatable Read니까 팬텀이 생기겠지"라고 판단하면 틀립니다. 반대로 "Repeatable Read면 안전하겠지"라고 판단하는 것도 틀립니다. Repeatable Read가 막지 못하는 것은 팬텀이 아니라 직렬화 이상(serialization anomaly) 입니다. 문서의 정의는 이렇습니다. "여러 트랜잭션을 성공적으로 커밋한 결과가, 그 트랜잭션들을 한 번에 하나씩 실행하는 어떤 순서로도 설명되지 않는 상태."
| 수준 | dirty read | non-repeatable read | phantom read | serialization anomaly |
|---|---|---|---|---|
| Read Uncommitted | 발생 안 함 | 발생 가능 | 발생 가능 | 발생 가능 |
| Read Committed | 발생 안 함 | 발생 가능 | 발생 가능 | 발생 가능 |
| Repeatable Read | 발생 안 함 | 발생 안 함 | 발생 안 함 | 발생 가능 |
| Serializable | 발생 안 함 | 발생 안 함 | 발생 안 함 | 발생 안 함 |
기본 격리 수준은 default_transaction_isolation이 결정하며 기본값은 read committed 입니다.
2. 어떤 수준을 고를 것인가
세 가지 실질적 선택지가 있고, 각각의 계약이 다릅니다.
Read Committed — 각 문장이 자기 시작 시점의 스냅샷을 봅니다. 같은 트랜잭션 안에서도 문장마다 다른 데이터를 볼 수 있습니다. 대부분의 짧은 OLTP 트랜잭션에는 이것으로 충분하고, 재시도 부담이 없다는 것이 최대 장점입니다. 대신 "읽고 판단해서 쓰는" 로직을 이 수준에서 쓰려면 반드시 명시적 잠금이 필요합니다.
Repeatable Read — 트랜잭션 전체가 하나의 스냅샷을 봅니다. 여러 테이블을 읽어 하나의 일관된 보고서를 만드는 작업에 적합합니다. 대신 갱신 충돌이 나면 could not serialize access due to concurrent update 오류로 트랜잭션이 중단되고, 애플리케이션이 재시도해야 합니다.
Serializable — 문서 표현으로 "Serializable Snapshot Isolation"이라는 기법으로 구현되어 있으며, 스냅샷 격리 위에 직렬화 이상 검사를 얹은 것입니다. 불변식이 여러 행에 걸쳐 있는 도메인(잔고 합계, 좌석 중복, 재고 총량)에서 명시적 잠금 설계를 대체할 수 있습니다. 대가는 재시도 비율과 예측 곤란성입니다.
문서가 Serializable에 대해 권고하는 항목들이 사실상 사용 조건입니다. 가능하면 트랜잭션을 READ ONLY로 선언할 것, 트랜잭션을 짧게 유지할 것, idle in transaction 상태를 오래 두지 말 것, 커넥션 풀로 동시 커넥션 수를 통제할 것. 읽기 전용 보고서 트랜잭션이라면 SERIALIZABLE READ ONLY DEFERRABLE을 쓸 수 있는데, 이 트랜잭션은 충돌이 발생할 수 없다는 사실이 확립될 때까지 블록되었다가 시작하므로 직렬화 실패로 중단되지 않습니다.
-- 재시도 없이 완전히 일관된 스냅샷이 필요한 야간 보고서
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;
SELECT ...;
COMMIT;
3. 직렬화 실패 재시도 계층
Repeatable Read 이상을 쓰기로 했다면 재시도는 선택이 아니라 필수입니다. 문서는 이 점을 두 번 강조합니다. "이 수준을 사용하는 애플리케이션은 직렬화 실패로 인한 트랜잭션 재시도를 준비해야 한다." 그리고 "직렬화 실패를 처리하는 일반화된 방법을 갖추는 것이 중요하다. 어떤 트랜잭션이 읽기·쓰기 의존 관계에 기여해 롤백되어야 할지 정확히 예측하기가 매우 어렵기 때문이다."
핵심 식별자는 SQLSTATE 40001 입니다. 직렬화 실패는 항상 이 값으로 돌아옵니다. 재시도 계층은 세 가지 성질을 가져야 합니다.
첫째, 트랜잭션 전체를 다시 실행해야 합니다. 실패한 문장만 다시 실행하는 것은 의미가 없습니다. 스냅샷 자체가 무효화되었기 때문입니다.
둘째, 재시도 횟수에 상한과 지수 백오프가 있어야 합니다. 상한이 없으면 경합이 심한 순간에 재시도가 재시도를 부르는 폭주가 발생합니다.
셋째, 부수 효과가 트랜잭션 밖에 있으면 안 됩니다. 트랜잭션 안에서 외부 API를 호출하거나 메시지를 발행했다면 재시도 때 중복 발생합니다. 외부 호출은 커밋 이후로 미루거나 아웃박스 패턴으로 트랜잭션 안의 테이블 쓰기로 바꿔야 합니다.
-- 재시도 대상 오류를 서버 쪽에서 확인하는 방법
-- 40001: serialization_failure, 40P01: deadlock_detected
DO $do$
BEGIN
-- 실제 애플리케이션에서는 이 로직이 클라이언트 계층에 있어야 한다
PERFORM 1;
EXCEPTION
WHEN serialization_failure OR deadlock_detected THEN
RAISE NOTICE 'retryable: %', SQLSTATE;
END;
$do$;
데드락(40P01)도 같은 재시도 계층에서 처리하는 것이 실용적입니다. 두 오류 모두 "다시 하면 성공할 수 있다"는 성질이 같습니다.
주의할 점 하나. 재시도 계층을 데이터베이스 함수 안에 두면 안 됩니다. 함수 안의 예외 처리 블록은 서브트랜잭션이므로, 바깥 트랜잭션의 스냅샷은 그대로입니다. 재시도는 반드시 트랜잭션을 시작한 계층, 즉 애플리케이션 코드에 있어야 합니다.
4. 행 잠금 사다리와 SKIP LOCKED
Read Committed에서 "읽고 판단해서 쓰는" 로직을 안전하게 만들려면 명시적 잠금이 필요합니다. PostgreSQL은 네 단계의 행 잠금을 제공하며, 문서의 구문은 FOR lock_strength [ OF from_reference ] [ NOWAIT | SKIP LOCKED ] 입니다.
강도 순서대로 정리하면 이렇습니다.
- FOR UPDATE — 가장 강한 잠금. 다른 트랜잭션의 UPDATE, DELETE와 네 종류 잠금 SELECT를 모두 막습니다.
- FOR NO KEY UPDATE — 약한 배타 잠금.
FOR KEY SHARE를 막지 않습니다. 유일 인덱스 컬럼을 건드리지 않는 UPDATE가 자동으로 취하는 잠금이기도 합니다. - FOR SHARE — 공유 잠금. UPDATE, DELETE,
FOR UPDATE,FOR NO KEY UPDATE는 막지만 다른FOR SHARE와FOR KEY SHARE는 막지 않습니다. - FOR KEY SHARE — 가장 약한 공유 잠금. DELETE와 키 값의 UPDATE만 막습니다. 외래 키 검사가 내부적으로 쓰는 잠금입니다.
행 잠금 충돌 관계를 표로 옮기면 이렇습니다. X 표시가 충돌입니다.
| 요청 → / 보유 ↓ | KEY SHARE | SHARE | NO KEY UPDATE | UPDATE |
|---|---|---|---|---|
| FOR KEY SHARE | X | |||
| FOR SHARE | X | X | ||
| FOR NO KEY UPDATE | X | X | X | |
| FOR UPDATE | X | X | X | X |
실무에서 가장 유용한 것은 강도가 아니라 대기 정책입니다. 문서 표현 그대로, NOWAIT는 선택된 행을 즉시 잠글 수 없으면 대기하지 않고 오류를 냅니다. SKIP LOCKED는 즉시 잠글 수 없는 행을 건너뜁니다. 문서는 SKIP LOCKED에 대해 "잠긴 행을 건너뛰는 것은 일관되지 않은 데이터 뷰를 제공하므로 범용 작업에는 적합하지 않지만, 여러 소비자가 큐 형태의 테이블에 접근할 때 잠금 경합을 피하는 데 쓸 수 있다"라고 명시합니다.
이것이 데이터베이스를 작업 큐로 쓰는 표준 패턴입니다.
-- 여러 워커가 서로 다른 작업을 집어 가는 큐
BEGIN;
WITH picked AS (
SELECT id
FROM jobs
WHERE status = 'PENDING'
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED
)
UPDATE jobs j
SET status = 'RUNNING', started_at = now()
FROM picked p
WHERE j.id = p.id
RETURNING j.id, j.payload;
COMMIT;
SKIP LOCKED가 없으면 워커 열 개가 모두 같은 첫 행에서 줄을 섭니다. 있으면 각자 다른 행을 집어 갑니다.
한 가지 함정도 문서에 적혀 있습니다. Read Committed에서 ORDER BY와 잠금 절을 함께 쓰면 결과가 정렬 순서를 벗어날 수 있습니다. 정렬이 먼저 적용된 뒤 잠금 대기가 발생하고, 대기가 풀렸을 때 정렬 컬럼 값이 이미 바뀌어 있을 수 있기 때문입니다. Repeatable Read 이상에서는 같은 상황이 40001 직렬화 실패가 됩니다.
5. 어드바이저리 락 — 데이터 밖의 상호배제
잠글 대상이 행이 아닌 경우가 있습니다. "이 배치는 동시에 한 번만 돌아야 한다", "이 외부 API 호출은 테넌트당 하나만" 같은 요건입니다. 이때 잠금 플래그 컬럼을 만들어 UPDATE로 표시하는 방식은 테이블 bloat를 만들고 실패 시 정리가 어렵습니다.
PostgreSQL의 어드바이저리 락은 이 용도로 설계되었습니다. 문서는 이를 "애플리케이션이 정의하는 의미를 갖는 잠금으로, 시스템이 사용법을 강제하지 않으므로 애플리케이션이 올바르게 써야 한다"라고 설명하며, 테이블 플래그보다 빠르고 테이블 bloat를 만들지 않으며 세션 종료 시 자동으로 정리된다는 장점을 듭니다.
두 가지 수준이 있습니다. 세션 수준은 명시적으로 해제하거나 세션이 끝날 때까지 유지되며 트랜잭션 롤백에도 살아남습니다. 트랜잭션 수준은 트랜잭션이 끝날 때 자동으로 해제됩니다. 짧은 상호배제에는 트랜잭션 수준이 안전합니다.
-- 트랜잭션 수준 어드바이저리 락: 획득 못 하면 즉시 false를 돌려준다
BEGIN;
SELECT pg_try_advisory_xact_lock(hashtext('nightly-settlement'));
-- true면 진행, false면 다른 인스턴스가 이미 돌고 있으므로 종료
COMMIT;
문서가 경고하는 함정이 하나 있습니다. LIMIT와 함께 쓸 때 SQL 평가 순서 때문에 의도보다 많은 잠금을 얻을 수 있습니다. 반드시 서브쿼리에서 LIMIT를 먼저 적용한 뒤 바깥에서 잠금 함수를 호출해야 합니다.
6. 긴 트랜잭션이라는 진짜 비용
격리 수준 논의에서 가장 자주 빠지는 주제입니다. 트랜잭션을 오래 열어 두면 무슨 일이 벌어지는가.
PostgreSQL의 MVCC는 갱신할 때 기존 행 버전을 그대로 두고 새 버전을 만듭니다. 낡은 버전은 "이 버전을 볼 수 있는 트랜잭션이 하나도 없을 때" 비로소 회수 가능합니다. 그런데 열려 있는 트랜잭션이 하나 있으면, 그 트랜잭션의 스냅샷보다 나중에 죽은 모든 행 버전이 데이터베이스 전체에서 회수되지 못합니다.
결과는 이렇습니다. 아침에 열어 두고 점심 먹으러 간 psql 세션 하나 때문에, 전혀 관계없는 주문 테이블의 죽은 행이 쌓이고, 인덱스가 부풀고, 순차 스캔이 읽어야 할 페이지가 늘어나고, 쿼리가 전반적으로 느려집니다. 그리고 트랜잭션 ID 소모가 계속되면 wraparound 방지를 위한 강제 vacuum까지 걸립니다.
방어선은 세 개의 타임아웃입니다. PostgreSQL 18 문서 기준으로 세 값 모두 기본값이 0, 즉 비활성 입니다. 기본 설정을 그대로 쓰는 서버는 이 방어선이 아예 없다는 뜻입니다.
statement_timeout— 지정한 시간을 넘는 문장을 중단합니다.lock_timeout— 테이블, 인덱스, 행 등의 잠금을 얻으려 기다리는 시간이 지정 시간을 넘으면 문장을 중단합니다.idle_in_transaction_session_timeout— 트랜잭션을 열어 둔 채 클라이언트 명령을 기다리며 놀고 있는 세션을 종료합니다.
PostgreSQL 17부터는 transaction_timeout도 있습니다. 트랜잭션 전체가 지정 시간을 넘으면 세션을 종료하며, 기본값은 역시 0입니다.
-- 데이터베이스 단위 기본값. 배치 계정은 따로 완화한다
ALTER DATABASE appdb SET statement_timeout = '30s';
ALTER DATABASE appdb SET idle_in_transaction_session_timeout = '60s';
ALTER ROLE batch_worker SET statement_timeout = '30min';
현재 열려 있는 오래된 트랜잭션은 이렇게 찾습니다.
SELECT pid, state, now() - xact_start AS xact_age,
now() - state_change AS state_age,
wait_event_type, wait_event, left(query, 60) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
AND now() - xact_start > interval '5 minutes'
ORDER BY xact_start;
7. MySQL 8.4 InnoDB와 다른 지점
여기부터는 엔진 이름을 붙여 읽어야 합니다. 두 엔진의 차이는 사소하지 않습니다.
기본 격리 수준이 다릅니다. MySQL 8.4 문서 기준 InnoDB의 기본 격리 수준은 REPEATABLE READ 입니다. PostgreSQL 18의 기본값은 Read Committed 입니다. 즉 같은 애플리케이션 코드를 두 엔진에 붙이면 기본 동작이 다릅니다.
Repeatable Read의 잠금 동작이 다릅니다. InnoDB에서 SELECT ... FOR UPDATE, SELECT ... FOR SHARE, UPDATE, DELETE는 검색 조건에 따라 다르게 동작합니다. 유일 인덱스에 유일한 값을 지정한 검색이면 찾은 인덱스 레코드만 잠그고 그 앞의 간격은 잠그지 않습니다. 그 밖의 검색 조건이면 스캔한 인덱스 범위를 갭 락 또는 넥스트 키 락으로 잠가 다른 세션이 그 범위에 삽입하는 것을 막습니다. 즉 InnoDB는 잠금 읽기에서 갭을 잠가 팬텀을 막습니다. PostgreSQL은 갭 락이라는 개념 자체가 없고, 스냅샷으로 팬텀을 막습니다.
Read Committed의 동작도 다릅니다. InnoDB의 Read Committed에서는 갭 잠금이 비활성화되고, 외래 키 제약 검사와 중복 키 검사에만 남습니다. 또한 UPDATE 문에서 이미 잠긴 행을 만나면 "준일관 읽기(semi-consistent read)"를 수행해 최신 커밋 버전을 MySQL 계층에 돌려주고, 그 값으로 WHERE 조건 일치 여부를 판단합니다. PostgreSQL에는 이런 동작이 없습니다.
Read Uncommitted의 의미가 다릅니다. InnoDB의 Read Uncommitted에서 SELECT는 잠금 없이 수행되며 이전 버전의 행이 사용될 수 있습니다. 즉 더티 리드가 실제로 발생합니다. PostgreSQL에서는 발생하지 않습니다.
Serializable의 구현이 다릅니다. InnoDB의 Serializable은 autocommit이 꺼져 있을 때 모든 평범한 SELECT를 암묵적으로 SELECT ... FOR SHARE로 변환합니다. 즉 잠금 기반입니다. PostgreSQL의 Serializable은 잠금이 아니라 직렬화 이상 검사 기반이며, 충돌 시 40001로 중단됩니다.
격리 수준을 지정하는 시스템 변수 이름도 다릅니다. MySQL은 transaction_isolation, PostgreSQL은 default_transaction_isolation입니다.
8. 관측 — 무엇을 상시로 봐야 하는가
동시성 문제는 재현이 어려우므로 지표를 미리 켜 두는 편이 낫습니다.
대기 이벤트. pg_stat_activity의 wait_event_type은 백엔드가 무엇을 기다리는지 알려 줍니다. 문서가 정의한 값에는 Lock(SQL에서 보이는 객체에 대한 무거운 잠금), LWLock(내부 자료구조 보호용 경량 잠금), BufferPin, IO, IPC, Client, Timeout, Activity, Extension이 있습니다. 동시성 조사에서는 Lock이 핵심입니다.
데드락 검출. PostgreSQL은 잠금 대기마다 데드락을 검사하지 않습니다. 비싸기 때문입니다. 대신 deadlock_timeout만큼 기다린 뒤에 검사합니다. 기본값은 1초 입니다. log_lock_waits를 켜 두면 같은 시간 기준으로 잠금 대기 로그가 남으므로, 데드락에 이르지 않은 긴 대기까지 잡을 수 있습니다.
-- 잠금 대기를 로그에 남긴다 (deadlock_timeout 기준)
ALTER SYSTEM SET log_lock_waits = on;
SELECT pg_reload_conf();
잠금 슬롯. max_locks_per_transaction의 기본값은 64입니다. 문서는 "64라는 기본값은 역사적으로 충분한 것으로 입증되었지만, 한 트랜잭션에서 많은 테이블을 건드리는 질의가 있다면 값을 올려야 할 수 있다"라고 하며 자식이 많은 부모 테이블 질의를 예로 듭니다. 파티션이 수백 개인 테이블을 조회하는 워크로드에서 실제로 부딪히는 한계입니다.
Serializable 전용 지표. Serializable을 쓴다면 술어 잠금(predicate lock) 관련 파라미터도 봐야 합니다. max_pred_locks_per_transaction의 기본값은 64, max_pred_locks_per_page의 기본값은 2, max_pred_locks_per_relation의 기본값은 -2입니다. 술어 잠금이 부족하면 잠금 단위가 페이지에서 관계 전체로 승격되면서 직렬화 실패가 급증합니다.
퀴즈: 실력을 확인해 보세요
퀴즈 1: PostgreSQL에서 READ UNCOMMITTED로 트랜잭션을 시작했습니다. 다른 트랜잭션이 커밋하지 않은 값을 읽을 수 있을까요?
정답: 읽을 수 없습니다. PostgreSQL의 Read Uncommitted는 Read Committed처럼 동작합니다.
설명: 문서는 "PostgreSQL에서는 네 가지 표준 격리 수준을 모두 요청할 수 있지만 내부적으로는 세 가지만 구현되어 있으며, Read Uncommitted 모드는 Read Committed처럼 동작한다"라고 명시합니다. 이유는 MVCC 구조에 표준 격리 수준을 대응시키는 유일하게 합리적인 방법이기 때문입니다. 다른 엔진과 다릅니다. MySQL 8.4 InnoDB의 Read Uncommitted에서는 SELECT가 잠금 없이 수행되고 이전 버전의 행을 볼 수 있으므로 더티 리드가 실제로 발생합니다. 이식성을 고려한다면 이 차이를 반드시 알고 있어야 합니다.
퀴즈 2: 아래 코드의 문제는 무엇인가요?
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
INSERT INTO orders (...) VALUES (...);
-- 여기서 결제 게이트웨이 HTTP 호출
UPDATE inventory SET qty = qty - 1 WHERE sku = 'A-1';
COMMIT;
정답: 트랜잭션 안에 외부 API 호출이 들어 있습니다. 직렬화 실패로 재시도하면 결제가 중복됩니다. 게다가 HTTP 응답을 기다리는 동안 트랜잭션이 열려 있어 VACUUM을 막습니다.
설명: Serializable은 40001 오류로 트랜잭션이 중단될 수 있고, 애플리케이션은 트랜잭션 전체를 재시도해야 합니다. 외부 호출이 트랜잭션 안에 있으면 재시도할 때마다 다시 호출됩니다. 해결은 외부 호출을 트랜잭션 밖으로 빼거나, 아웃박스 테이블에 이벤트를 기록하고 커밋 후 별도 워커가 발송하는 구조로 바꾸는 것입니다. 두 번째 문제도 심각합니다. 네트워크 지연이 몇 초만 되어도 그동안 데이터베이스 전체의 죽은 행 회수가 막힙니다. 문서가 Serializable 사용 시 "필요 이상의 것을 한 트랜잭션에 넣지 말 것"과 "idle in transaction 상태를 필요 이상으로 두지 말 것"을 권고하는 이유가 이것입니다.
퀴즈 3: 워커 열 개가 같은 jobs 테이블에서 작업을 가져가는데 처리량이 워커 한 개일 때와 같습니다. 무엇이 빠졌을까요?
정답: SKIP LOCKED입니다.
설명: SELECT ... FOR UPDATE LIMIT 1만 쓰면 모든 워커가 같은 첫 번째 행을 잠그려고 줄을 섭니다. 첫 워커가 처리하는 동안 나머지 아홉은 대기하므로 사실상 직렬 실행이 됩니다. 문서는 SKIP LOCKED에 대해 "여러 소비자가 큐 형태의 테이블에 접근할 때 잠금 경합을 피하는 데 쓸 수 있다"라고 명시합니다.
SELECT id FROM jobs
WHERE status = 'PENDING'
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED;
다만 같은 문서가 경고하듯 SKIP LOCKED는 일관되지 않은 뷰를 제공하므로 범용 조회에는 쓰면 안 됩니다. 큐 소비처럼 "아무 행이나 하나 집어 가면 되는" 경우에만 적합합니다.
퀴즈 4: 개발 서버에서 잘 돌던 배치가 운영에서 다른 트랜잭션을 몇 분씩 막습니다. 격리 수준을 낮추면 해결될까요?
정답: 아닙니다. 격리 수준과 잠금 대기는 별개의 문제입니다.
설명: 격리 수준은 "무엇을 볼 수 있는가"를 결정하고, 잠금은 "무엇을 기다려야 하는가"를 결정합니다. Read Committed로 낮춰도 UPDATE가 같은 행을 건드리면 여전히 기다립니다. 진단 순서는 이렇습니다. 먼저 pg_stat_activity에서 wait_event_type = 'Lock'인 세션을 찾고, 그 세션이 무엇을 기다리는지 확인합니다. 대개 원인은 배치가 한 트랜잭션에서 너무 많은 행을 갱신하고 있는 것입니다. 대응은 배치를 청크로 나눠 각 청크를 별도 트랜잭션으로 커밋하는 것이고, 예방은 lock_timeout을 설정해 무한 대기를 막는 것입니다. 기본값 0은 무한 대기를 뜻합니다.
퀴즈 5: Serializable로 바꿨더니 40001 오류가 급증했습니다. 통계를 보니 대부분의 쿼리가 Seq Scan입니다. 관련이 있을까요?
정답: 있습니다. 순차 스캔은 관계 수준의 술어 잠금을 유발합니다.
설명: PostgreSQL의 Serializable은 읽은 데이터에 술어 잠금을 걸어 읽기·쓰기 의존을 추적합니다. 인덱스 스캔이면 잠금 단위가 좁지만, 순차 스캔이면 테이블 전체가 잠금 대상이 되므로 무관한 트랜잭션끼리도 충돌로 판정됩니다. 문서 자체가 Serializable 성능 권고 항목으로 "순차 스캔을 피하도록 실행 계획을 최적화할 것"을 들고 random_page_cost와 cpu_tuple_cost 조정을 언급합니다. 함께 볼 것은 술어 잠금 한도입니다. max_pred_locks_per_transaction의 기본값 64, max_pred_locks_per_page의 기본값 2를 넘기면 잠금 단위가 승격되어 충돌이 더 늘어납니다.
마치며
격리 수준은 "높이면 안전해지는 손잡이"가 아닙니다. 데이터베이스와 애플리케이션 사이의 계약입니다. 계약의 조항은 이렇습니다. 데이터베이스는 정해진 이상 현상이 발생하지 않도록 보장한다. 대신 애플리케이션은 직렬화 실패를 재시도하고, 트랜잭션을 짧게 유지하고, 부수 효과를 트랜잭션 경계 밖으로 내보내지 않는다.
이 계약의 애플리케이션 쪽 조항을 지키지 않으면 격리 수준을 아무리 올려도 사고는 계속 납니다. 반대로 조항을 지키면 Read Committed에 명시적 잠금 몇 줄만으로도 대부분의 도메인이 안전해집니다.
두 세션으로 실제 이상 현상을 재현해 보고 싶다면 Postgres 놀이터에서 실험해 볼 수 있습니다.
참고 자료
- PostgreSQL 18, Transaction Isolation: https://www.postgresql.org/docs/18/transaction-iso.html (2026-08-15 확인)
- PostgreSQL 18, Explicit Locking: https://www.postgresql.org/docs/18/explicit-locking.html (2026-08-15 확인)
- PostgreSQL 18, SELECT (Locking Clause): https://www.postgresql.org/docs/18/sql-select.html (2026-08-15 확인)
- PostgreSQL 18, Client Connection Defaults: https://www.postgresql.org/docs/18/runtime-config-client.html (2026-08-15 확인)
- PostgreSQL 18, Lock Management: https://www.postgresql.org/docs/18/runtime-config-locks.html (2026-08-15 확인)
- PostgreSQL 18, Monitoring Database Activity: https://www.postgresql.org/docs/18/monitoring-stats.html (2026-08-15 확인)
- MySQL 8.4, Transaction Isolation Levels: https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html (2026-08-15 확인)
이어서 읽기
- 이전 편: SQL 실행계획 완전 가이드 — 옵티마이저가 계획을 고르는 과정
- 다음 편: 무중단 스키마 변경 완전 가이드 — DDL이 잡는 잠금 등급
- 트랜잭션 격리 수준과 실제 이상 현상 — 이론과 이상 현상 재현
- 데드락 진단과 예방 — 로그에서 두 쿼리를 특정하는 법
- Postgres 놀이터 — 두 세션 동시성 실험
The Complete Guide to Transaction Isolation Levels: The Part the Application Owns, Not the Database
- Introduction
- 1. What PostgreSQL Actually Implements
- 2. Which Level Should You Choose
- 3. The Serialization-Failure Retry Layer
- 4. The Row-Lock Ladder and SKIP LOCKED
- 5. Advisory Locks — Mutual Exclusion Outside the Data
- 6. The Real Cost of a Long Transaction
- 7. Where This Differs from MySQL 8.4 InnoDB
- 8. Observability — What to Watch at All Times
- Quiz: Check Your Understanding
- Conclusion
- References
- Further reading
Introduction
This blog already has Transaction Isolation Levels and the Anomalies You Actually Hit. That post shows the standard four levels and three anomalies, snapshot isolation, and write skew, all through two-session SQL. Call it the post that covers the theory of isolation levels.
This post is the layer laid on top of that: the operational contract. Teams that understand isolation levels precisely still cause incidents in production. The reason is that an isolation level only covers the database half of the deal. The other half belongs to the application: who retries when a serialization failure hits, how strong a lock to take, how multiple consumers split a queue table, and what happens to the database when a transaction stays open too long. This post covers that list.
The reference engine is PostgreSQL 18. Because the actual behavior of isolation levels varies enormously across engines, points where MySQL 8.4 InnoDB differs are called out by engine name separately in section 7. An explanation that blurs the two engines together is worse than no explanation at all.
1. What PostgreSQL Actually Implements
Let us nail down the facts precisely before anything else. Everything stated here has been confirmed against the PostgreSQL 18 documentation.
PostgreSQL can be asked for all four standard isolation levels, but internally implements only three. In the documentation's own words, "PostgreSQL's Read Uncommitted mode behaves like Read Committed." That is because this is the only sensible way to map the standard isolation levels onto a multiversion concurrency control architecture. In other words, a dirty read never happens in PostgreSQL, under any setting.
The second fact that matters. The documentation's isolation-level table marks the phantom-read cell as "permitted by the standard, but does not occur in PostgreSQL." The body text explains it this way: "PostgreSQL's Repeatable Read implementation does not allow phantom reads. Since the standard defines only which anomalies must not occur at a given isolation level, a stronger guarantee than the standard requires is permitted."
So anyone who memorized the standard's table and concludes "it is Repeatable Read, so phantoms must happen" in PostgreSQL is wrong. The opposite conclusion, "Repeatable Read must be safe," is just as wrong. What Repeatable Read fails to block is not a phantom but a serialization anomaly. The documentation's definition is: "a state where the result of successfully committing several transactions is inconsistent with every possible ordering of running those same transactions one at a time."
| Level | dirty read | non-repeatable read | phantom read | serialization anomaly |
|---|---|---|---|---|
| Read Uncommitted | Does not occur | Can occur | Can occur | Can occur |
| Read Committed | Does not occur | Can occur | Can occur | Can occur |
| Repeatable Read | Does not occur | Does not occur | Does not occur | Can occur |
| Serializable | Does not occur | Does not occur | Does not occur | Does not occur |
The default isolation level is decided by default_transaction_isolation, and the default value is read committed.
2. Which Level Should You Choose
There are three practically usable options, and each carries a different contract.
Read Committed — Each statement sees a snapshot taken at its own start time. Even within the same transaction, different statements can see different data. This is enough for most short OLTP transactions, and its biggest advantage is that it carries no retry burden. In exchange, any "read, decide, then write" logic run at this level requires explicit locking.
Repeatable Read — The entire transaction sees a single snapshot. This suits work that reads several tables to build one consistent report. In exchange, if an update conflict occurs, the transaction aborts with a could not serialize access due to concurrent update error, and the application must retry.
Serializable — In the documentation's words, this is implemented with a technique called "Serializable Snapshot Isolation," layering a serialization-anomaly check on top of snapshot isolation. It can replace an explicit-locking design in domains where an invariant spans multiple rows (a balance total, seat duplication, a total inventory count). The price is a retry rate and reduced predictability.
The documentation's recommendations for Serializable are, in effect, the conditions for using it at all: declare the transaction READ ONLY whenever possible, keep transactions short, do not leave a session sitting idle in transaction for long, and use a connection pool to control the number of concurrent connections. For a read-only reporting transaction, you can use SERIALIZABLE READ ONLY DEFERRABLE; this transaction blocks at the start until it is established that it cannot possibly conflict, so it is never aborted by a serialization failure.
-- A nightly report that needs a fully consistent snapshot, with no retries
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;
SELECT ...;
COMMIT;
3. The Serialization-Failure Retry Layer
Once you decide to use Repeatable Read or higher, retrying is not optional — it is mandatory. The documentation stresses this twice: "Applications using this level must be prepared to retry transactions due to serialization failures." And: "it is important to have a generalized approach for handling serialization failures, because it is very difficult to predict exactly which transaction contributing to a read/write dependency will need to be rolled back."
The key identifier is SQLSTATE 40001. A serialization failure always comes back with this value. A retry layer needs three properties.
First, the entire transaction must be re-executed. Re-running only the failed statement is meaningless, because the snapshot itself has been invalidated.
Second, the retry count must have a cap, with exponential backoff. Without a cap, a moment of heavy contention triggers a stampede where retries provoke more retries.
Third, side effects must not live outside the transaction. If you called an external API or published a message inside the transaction, it fires again, duplicated, on every retry. Push external calls to after commit, or convert them into a table write inside the transaction using the outbox pattern.
-- How to check for a retryable error on the server side
-- 40001: serialization_failure, 40P01: deadlock_detected
DO $do$
BEGIN
-- In a real application, this logic must live in the client layer
PERFORM 1;
EXCEPTION
WHEN serialization_failure OR deadlock_detected THEN
RAISE NOTICE 'retryable: %', SQLSTATE;
END;
$do$;
It is practical to handle deadlocks (40P01) in the same retry layer. Both errors share the same property: "doing it again can succeed."
One caution. Do not put the retry layer inside a database function. An exception-handling block inside a function is a subtransaction, so the outer transaction's snapshot stays exactly as it was. The retry must live at the layer that started the transaction — that is, in the application code.
4. The Row-Lock Ladder and SKIP LOCKED
Making "read, decide, then write" logic safe under Read Committed requires explicit locking. PostgreSQL offers four levels of row lock, with the documented syntax FOR lock_strength [ OF from_reference ] [ NOWAIT | SKIP LOCKED ].
Ordered from strongest to weakest:
- FOR UPDATE — The strongest lock. Blocks other transactions' UPDATE, DELETE, and all four kinds of locking SELECT.
- FOR NO KEY UPDATE — A weaker exclusive lock. Does not block
FOR KEY SHARE. This is also the lock an UPDATE takes automatically when it does not touch a unique-index column. - FOR SHARE — A shared lock. Blocks UPDATE, DELETE,
FOR UPDATE, andFOR NO KEY UPDATE, but does not block anotherFOR SHAREorFOR KEY SHARE. - FOR KEY SHARE — The weakest shared lock. Blocks only DELETE and an UPDATE of the key value. This is the lock a foreign-key check uses internally.
Here is the row-lock conflict relationship as a table. An X marks a conflict.
| Requested → / Held ↓ | KEY SHARE | SHARE | NO KEY UPDATE | UPDATE |
|---|---|---|---|---|
| FOR KEY SHARE | X | |||
| FOR SHARE | X | X | ||
| FOR NO KEY UPDATE | X | X | X | |
| FOR UPDATE | X | X | X | X |
In practice, what matters most is not lock strength but the wait policy. In the documentation's own words, NOWAIT reports an error, without waiting, if a selected row cannot be locked immediately. SKIP LOCKED skips rows that cannot be locked immediately. The documentation states of SKIP LOCKED that "skipping locked rows provides an inconsistent view of the data, so this is not suitable for general purpose work, but can be used to avoid lock contention with multiple consumers accessing a queue-like table."
This is the standard pattern for using a database as a work queue.
-- A queue where multiple workers each pick up different jobs
BEGIN;
WITH picked AS (
SELECT id
FROM jobs
WHERE status = 'PENDING'
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED
)
UPDATE jobs j
SET status = 'RUNNING', started_at = now()
FROM picked p
WHERE j.id = p.id
RETURNING j.id, j.payload;
COMMIT;
Without SKIP LOCKED, all ten workers queue up on the same first row. With it, each picks up a different row.
The documentation also flags one trap. Under Read Committed, combining ORDER BY with a locking clause can return results out of sort order. The sort is applied first, then the lock wait happens, and by the time the wait clears, the value of the sort column may already have changed. Under Repeatable Read or higher, the same situation instead becomes a 40001 serialization failure.
5. Advisory Locks — Mutual Exclusion Outside the Data
Sometimes what needs locking is not a row at all. Requirements like "this batch job must run only one at a time" or "only one of this external API call per tenant" are the classic case. Building a lock-flag column and marking it with an UPDATE for this creates table bloat and is hard to clean up after a failure.
PostgreSQL's advisory locks were designed for exactly this purpose. The documentation describes them as locks "that have application-defined meanings," where "the system does not enforce their use" so the application must use them correctly, and it lists their advantages: faster than a table flag, no table bloat created, and automatic cleanup when the session ends.
There are two levels. Session-level locks are held until explicitly released or the session ends, and they survive a transaction rollback. Transaction-level locks are released automatically when the transaction ends. For short mutual exclusion, the transaction level is the safe choice.
-- A transaction-level advisory lock: returns false immediately if it cannot be acquired
BEGIN;
SELECT pg_try_advisory_xact_lock(hashtext('nightly-settlement'));
-- true: proceed. false: another instance is already running, so exit
COMMIT;
There is one trap the documentation warns about. Used together with LIMIT, SQL's order of evaluation can cause you to acquire more locks than intended. You must apply LIMIT inside a subquery first, and call the lock function outside it.
6. The Real Cost of a Long Transaction
This is the topic most often left out of isolation-level discussions: what happens when a transaction is left open for a long time.
PostgreSQL's MVCC leaves the existing row version in place on an update and creates a new version instead. An old version can only be reclaimed once "there is no transaction left that can see this version." But if there is even one open transaction, every row version that died after that transaction's snapshot cannot be reclaimed anywhere in the database.
Here is the result. Because of a single psql session someone opened in the morning and left running while they went to lunch, dead rows pile up in a completely unrelated orders table, indexes bloat, sequential scans have to read more and more pages, and queries slow down across the board. And if transaction ID consumption keeps going, it eventually triggers a forced vacuum to guard against wraparound.
The line of defense is three timeouts. Per the PostgreSQL 18 documentation, all three default to 0, meaning disabled. A server left on default settings has no line of defense here at all.
statement_timeout— Aborts a statement that runs longer than the specified time.lock_timeout— Aborts a statement if the time spent waiting to acquire a lock on a table, index, row, or other object exceeds the specified time.idle_in_transaction_session_timeout— Terminates a session that is sitting idle, waiting for a client command, while it has a transaction left open.
Since PostgreSQL 17 there is also transaction_timeout. It terminates the session if the entire transaction runs longer than the specified time, and its default is likewise 0.
-- Database-wide defaults. Relax them separately for the batch account
ALTER DATABASE appdb SET statement_timeout = '30s';
ALTER DATABASE appdb SET idle_in_transaction_session_timeout = '60s';
ALTER ROLE batch_worker SET statement_timeout = '30min';
This is how you find currently open, long-running transactions.
SELECT pid, state, now() - xact_start AS xact_age,
now() - state_change AS state_age,
wait_event_type, wait_event, left(query, 60) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
AND now() - xact_start > interval '5 minutes'
ORDER BY xact_start;
7. Where This Differs from MySQL 8.4 InnoDB
From here on, read every statement with the engine name attached. The differences between the two engines are not trivial.
The default isolation level differs. Per the MySQL 8.4 documentation, InnoDB's default isolation level is REPEATABLE READ. PostgreSQL 18's default is Read Committed. That means the same application code produces different default behavior depending on which engine it is attached to.
The locking behavior of Repeatable Read differs. In InnoDB, SELECT ... FOR UPDATE, SELECT ... FOR SHARE, UPDATE, and DELETE behave differently depending on the search condition. A search that specifies a unique value against a unique index locks only the index record found, not the gap in front of it. For any other search condition, InnoDB locks the scanned index range with a gap lock or a next-key lock, blocking other sessions from inserting into that range. In other words, InnoDB blocks phantoms by locking gaps on locking reads. PostgreSQL has no concept of a gap lock at all; it blocks phantoms with snapshots instead.
Read Committed also behaves differently. Under InnoDB's Read Committed, gap locking is disabled and survives only for foreign-key constraint checks and duplicate-key checks. In addition, when an UPDATE statement encounters an already-locked row, InnoDB performs a "semi-consistent read," returning the latest committed version to the MySQL layer and using that value to decide whether the WHERE condition matches. PostgreSQL has no equivalent behavior.
Read Uncommitted means something different. Under InnoDB's Read Uncommitted, SELECT runs without locking and may use an earlier version of a row. That means a dirty read actually happens. It does not happen in PostgreSQL.
Serializable is implemented differently. InnoDB's Serializable implicitly converts every plain SELECT into SELECT ... FOR SHARE whenever autocommit is off — it is lock-based. PostgreSQL's Serializable is based not on locks but on a serialization-anomaly check, and aborts with 40001 on conflict.
Even the name of the system variable that sets the isolation level differs. MySQL uses transaction_isolation; PostgreSQL uses default_transaction_isolation.
8. Observability — What to Watch at All Times
Concurrency problems are hard to reproduce, so it is better to have the metrics switched on ahead of time.
Wait events. wait_event_type in pg_stat_activity tells you what a backend is waiting for. The values the documentation defines include Lock (a heavyweight lock on an object visible in SQL), LWLock (a lightweight lock protecting an internal data structure), BufferPin, IO, IPC, Client, Timeout, Activity, and Extension. For a concurrency investigation, Lock is the one that matters.
Deadlock detection. PostgreSQL does not check for a deadlock on every single lock wait, because that would be expensive. Instead it waits deadlock_timeout and only then checks. The default is 1 second. Turning on log_lock_waits logs lock waits against that same threshold, letting you catch long waits that never actually escalated into a deadlock.
-- Log lock waits (measured against deadlock_timeout)
ALTER SYSTEM SET log_lock_waits = on;
SELECT pg_reload_conf();
Lock slots. max_locks_per_transaction defaults to 64. The documentation says "the default of 64 has historically proven sufficient, but you might need to raise it if you have queries that touch many tables in a single transaction," citing a query against a parent table with many children as an example. This is a limit that workloads actually hit when querying a table with hundreds of partitions.
Metrics specific to Serializable. If you use Serializable, you also need to watch the parameters around predicate locks. max_pred_locks_per_transaction defaults to 64, max_pred_locks_per_page defaults to 2, and max_pred_locks_per_relation defaults to -2. When predicate locks run short, lock granularity is promoted from a page up to the entire relation, and serialization failures spike.
Quiz: Check Your Understanding
Quiz 1: You start a transaction in PostgreSQL with READ UNCOMMITTED. Can you read a value another transaction has not yet committed?
Answer: No, you cannot. PostgreSQL's Read Uncommitted behaves like Read Committed.
Explanation: The documentation states that "in PostgreSQL you can request any of the four standard isolation levels, but internally only three are implemented, and Read Uncommitted mode behaves like Read Committed." The reason is that this is the only sensible way to map the standard isolation levels onto the MVCC architecture. Other engines differ. Under MySQL 8.4 InnoDB's Read Uncommitted, SELECT runs without locking and can see an earlier version of a row, so a dirty read actually happens. If portability matters to you, you must know this difference.
Quiz 2: What is wrong with the code below?
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
INSERT INTO orders (...) VALUES (...);
-- The HTTP call to the payment gateway happens here
UPDATE inventory SET qty = qty - 1 WHERE sku = 'A-1';
COMMIT;
Answer: There is an external API call inside the transaction. If a serialization failure triggers a retry, the payment fires twice. Worse, the transaction stays open while it waits on the HTTP response, which blocks VACUUM the whole time.
Explanation: Serializable can abort a transaction with a 40001 error, and the application then has to retry the entire transaction. If the external call sits inside the transaction, it fires again on every retry. The fix is to move the external call outside the transaction, or restructure it so the event is recorded in an outbox table and a separate worker sends it after commit. The second problem is just as serious. Even a few seconds of network latency is enough to block dead-row reclamation across the entire database for that whole stretch. This is exactly why the documentation recommends, when using Serializable, not putting more into one transaction than necessary, and not leaving a session idle in transaction any longer than necessary.
Quiz 3: Ten workers are all pulling jobs from the same jobs table, but throughput is the same as with a single worker. What is missing?
Answer: SKIP LOCKED.
Explanation: With only SELECT ... FOR UPDATE LIMIT 1, every worker lines up trying to lock the same first row. While the first worker processes it, the other nine wait, so execution is effectively serial. The documentation states of SKIP LOCKED that it "can be used to avoid lock contention with multiple consumers accessing a queue-like table."
SELECT id FROM jobs
WHERE status = 'PENDING'
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED;
That said, as the same documentation warns, SKIP LOCKED provides an inconsistent view and must not be used for general-purpose queries. It is suitable only for cases like queue consumption, where "picking up any one available row" is good enough.
Quiz 4: A batch job that ran fine on the development server now blocks other transactions for minutes at a time in production. Will lowering the isolation level fix it?
Answer: No. Isolation level and lock waiting are separate problems.
Explanation: The isolation level determines "what you can see," while a lock determines "what you must wait for." Even lowered to Read Committed, an UPDATE still waits if it touches the same row. The diagnostic order is this: first find sessions in pg_stat_activity with wait_event_type = 'Lock', then check what that session is waiting for. The cause is usually that the batch job is updating too many rows inside a single transaction. The fix is to split the batch into chunks and commit each chunk as a separate transaction; the preventive measure is setting lock_timeout so it cannot wait forever. The default of 0 means an unbounded wait.
Quiz 5: After switching to Serializable, 40001 errors spiked. Statistics show most queries are Seq Scan. Is that related?
Answer: Yes. A sequential scan triggers a relation-level predicate lock.
Explanation: PostgreSQL's Serializable places predicate locks on the data it reads to track read/write dependencies. With an index scan, the lock granularity is narrow, but with a sequential scan, the entire table becomes the lock target, so even unrelated transactions get flagged as conflicting. The documentation itself lists, as a Serializable performance recommendation, "optimize the plan to avoid sequential scans," and mentions adjusting random_page_cost and cpu_tuple_cost. Also worth checking are the predicate-lock limits: once you exceed the default of 64 for max_pred_locks_per_transaction, or the default of 2 for max_pred_locks_per_page, lock granularity gets promoted and conflicts increase further.
Conclusion
An isolation level is not "a dial that gets safer the higher you turn it." It is a contract between the database and the application. The terms of the contract are these: the database guarantees that a defined set of anomalies will not occur. In exchange, the application retries serialization failures, keeps transactions short, and never lets a side effect cross outside the transaction boundary.
If you do not honor the application-side terms of this contract, incidents keep happening no matter how high you raise the isolation level. Conversely, if you honor them, a few lines of explicit locking on top of Read Committed are enough to make most domains safe.
If you want to reproduce real anomalies with two sessions, you can experiment in the PostgreSQL Playground.
References
- PostgreSQL 18, Transaction Isolation: https://www.postgresql.org/docs/18/transaction-iso.html (retrieved 2026-08-15)
- PostgreSQL 18, Explicit Locking: https://www.postgresql.org/docs/18/explicit-locking.html (retrieved 2026-08-15)
- PostgreSQL 18, SELECT (Locking Clause): https://www.postgresql.org/docs/18/sql-select.html (retrieved 2026-08-15)
- PostgreSQL 18, Client Connection Defaults: https://www.postgresql.org/docs/18/runtime-config-client.html (retrieved 2026-08-15)
- PostgreSQL 18, Lock Management: https://www.postgresql.org/docs/18/runtime-config-locks.html (retrieved 2026-08-15)
- PostgreSQL 18, Monitoring Database Activity: https://www.postgresql.org/docs/18/monitoring-stats.html (retrieved 2026-08-15)
- MySQL 8.4, Transaction Isolation Levels: https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html (retrieved 2026-08-15)
Further reading
- Previous: The Complete Guide to SQL Execution Plans — how the optimizer picks a plan
- Next: The Complete Guide to Zero-Downtime Schema Changes — the lock level each DDL takes
- Transaction Isolation Levels and the Anomalies You Actually Hit — theory and reproducing the anomalies
- Diagnosing and Preventing Deadlocks — how to pin down the two queries from the log
- PostgreSQL Playground — two-session concurrency experiments