Concurrency — When Two People Touch One Row
Collide Two Sessions and See
한국어 원문으로 표시합니다.
목표
혼자 돌릴 때는 맞는데 둘이 동시에 하면 틀리는 코드를 직접 만들고 고칩니다. 동시성 문제는 재현이 어려워서 어렵습니다. 그래서 이 실습은 전부 세션 두 개를 실제로 띄워 부딪히게 합니다.
환경
PostgreSQL 16 이 이미 떠 있습니다(5432, DB labdb, 사용자 lab).
export PATH=/usr/lib/postgresql/16/bin:$PATH
psql -h 127.0.0.1 -U lab -d labdb
두 세션 만드는 법
한쪽을 백그라운드로 띄우고 그동안 다른 쪽을 칩니다.
psql -h 127.0.0.1 -U lab -d labdb -c "begin; select * from stock where id=1 for update; select pg_sleep(15);" &
sleep 1
# 여기서 두 번째 세션 작업
wait
준비
create table stock(id int primary key, qty int not null);
insert into stock values (1,10),(2,10);
단계
- 잠금 대기 만들기 →
01-block.txt lock_timeout→02-timeout.txt- 데드락 일부러 내기 →
03-deadlock.txt - 순서를 맞춰 없애기 →
04-order.txt - 동시 20회 차감에도 음수 없이 →
05-stock.txt skip locked로 워커 큐 →06-queue.txt- 어드바이저리 잠금 →
07-advisory.txt - 정리 →
08-notes.md
참고
3단계는 실패가 정답입니다. deadlock detected 의 DETAIL 두 줄이
누가 무엇을 기다렸는지 정확히 알려 줍니다 — 그 두 줄을 읽는 법이
이 실습에서 가장 오래 쓰이는 기술입니다.
잠금 대기를 만든다
세션 두 개로 같은 행을 for update 하세요. 두 번째가 기다리는 동안 누가 누구를 막고 있는지 찾아 01-block.txt 에 남깁니다.
먼저 create table stock(id int primary key, qty int not null); insert into stock values (1,10),(2,10);. 한쪽은 백그라운드로: psql -h 127.0.0.1 -U lab -d labdb -c "begin; select * from stock where id=1 for update; select pg_sleep(15);" &. 그다음 다른 세션에서 select pid, pg_blocking_pids(pid) from pg_stat_activity where cardinality(pg_blocking_pids(pid))>0; 를 저장하세요.
무한히 기다리지 않게 한다
lock_timeout 을 걸고 잠긴 행을 요청해 취소되는 것을 확인하세요. 그 오류 전문을 02-timeout.txt 에 남깁니다.
set lock_timeout='1s'; begin; select * from stock where id=1 for update; — canceling statement due to lock timeout 이 나옵니다. 이걸 안 걸면 대기가 커넥션을 물고, 그 커넥션이 풀을 다 먹으면 DB 가 아니라 애플리케이션이 멈춥니다.
데드락을 일부러 만든다
두 트랜잭션이 서로 반대 순서로 id 1·2 를 갱신하게 해서 데드락을 내세요. deadlock detected 가 든 오류 전문을 03-deadlock.txt 에 남깁니다.
A 는 1 → (잠깐 쉬고) → 2, B 는 2 → (잠깐 쉬고) → 1. select pg_sleep(2) 를 사이에 넣어 겹치게 하세요. 둘을 백그라운드로 동시에 띄우고 wait 합니다. DETAIL 두 줄이 누가 무엇을 기다렸는지 정확히 알려 줍니다 — 그 두 줄을 읽는 법이 이 단계의 목적입니다.
순서를 맞춰 없앤다
같은 두 작업을 둘 다 id 오름차순으로 잠그게 바꿔 데드락 없이 끝나게 하세요. 두 번 이상 돌려도 실패가 없어야 합니다.
정렬 기준이 무엇이든 상관없고 모두가 같은 기준을 쓰는 것만 중요합니다. 결과를 04-order.txt 에 남기세요. 데드락은 완전히 없앨 수는 없으므로 애플리케이션은 40P01 재시도를 갖춰야 하지만, 순서를 맞추면 대부분이 사라집니다.
재고가 음수가 되지 않게
stock 의 id=1 을 10 으로 되돌린 뒤 동시에 20번 차감을 시도하세요. 재고 10 에 20번이면 최종 수량은 정확히 0 이어야 합니다. 결과를 05-stock.txt 에 남깁니다.
두 가지 중 하나면 됩니다 — for update 로 읽고 판단하거나, 한 문장으로 끝내거나(update stock set qty=qty-1 where id=1 and qty>0). 가능하면 후자가 쌉니다. 동시 실행은 for i in $(seq 20); do (psql ... &) ; done; wait.
음수가 아닌 것만으로는 통과하지 못합니다. 순진하게 select 로 읽고 그 값을 빼면 음수는 안 나오지만 결과가 9 쯤에서 멈춥니다 — 갱신이 사라진 것입니다. 그게 이 단계에서 잡으려는 버그입니다.
워커가 줄 서지 않게
jobs 테이블을 만들고 워커 둘이 서로 다른 작업을 집어 가게 하세요. 06-queue.txt 에 worker1=... / worker2=... 두 줄로 각자 집은 id 를 남깁니다.
for update 만 쓰면 두 번째 워커가 기다립니다. for update skip locked 는 잠긴 행을 건너뜁니다. 첫 워커가 1,2 를 잡고 있으면 두 번째는 기다리지 않고 3,4 를 집습니다. 파일은 이 형식으로 — 각각 한 줄씩, 쓴 SQL 도 함께 남기세요:
worker1=1,2
worker2=3,4
행이 없는 것에 잠금을 건다
pg_try_advisory_lock 으로 같은 키를 두 세션이 동시에 잡지 못하는 것을 보이세요. 07-advisory.txt 에 session1= / session2= 두 줄로 결과를 남깁니다.
한 세션이 select pg_try_advisory_lock(42) 로 얻은 뒤, 다른 세션에서 같은 키를 시도하면 f 가 나옵니다(같은 세션에서 다시 하면 재진입이라 t 입니다 — 그건 증명이 안 됩니다). 파일은 이 형식으로:
session1=t
session2=f
커넥션 풀에서는 반납 전에 반드시 pg_advisory_unlock 하세요.
세 가지를 정리한다
08-notes.md 에 세 줄 이상. 데드락을 없애는 규칙 한 가지, lock_timeout 이 없으면 어디가 먼저 멈추는지, skip locked 가 바꾸는 것.
본문에 순서, 커넥션, skip locked 가 들어가야 합니다. 두 번째가 실무에서 가장 자주 오진하는 부분입니다 — 증상은 'DB 가 느리다' 가 아니라 '서버가 응답을 안 한다' 입니다.