본문으로 건너뛰기

3.4 락

PostgreSQL의 락은 세 갈래다 — heavy-weight lock(SQL이 직접 잡는 락, 가장 친숙), light-weight lock(LWLock)(서버 내부의 짧은 mutex), predicate lock(SSI 전용 가상 락). 운영자가 매일 마주치는 heavy-weight lock을 중심으로 정리하고 나머지는 짧게 봅니다.

테이블 락 8단계

LOCK TABLE 또는 SQL이 자동으로 잡는 테이블 락은 8개 강도가 있습니다. 강한 락이 약한 락과 충돌하지 않는 점이 핵심입니다.

락 모드누가 자동으로 잡는가
ACCESS SHARESELECT
ROW SHARESELECT FOR UPDATE/SHARE
ROW EXCLUSIVEINSERT, UPDATE, DELETE, MERGE
SHARE UPDATE EXCLUSIVEVACUUM (non-FULL), ANALYZE, CREATE INDEX CONCURRENTLY, ALTER TABLE 일부
SHARECREATE INDEX (비-CONCURRENTLY)
SHARE ROW EXCLUSIVE거의 안 쓰임 — 명시적 LOCK만
EXCLUSIVE거의 안 쓰임
ACCESS EXCLUSIVEDROP TABLE, TRUNCATE, REINDEX, VACUUM FULL, CLUSTER, ALTER TABLE 대부분

충돌 매트릭스 핵심

  • ACCESS SHARE(읽기)는 ACCESS EXCLUSIVE(DDL)와만 충돌
  • 즉, 일반 SELECT는 DDL 외에는 다른 트랜잭션의 락에 거의 안 막힌다
  • ROW EXCLUSIVE 들끼리는 충돌하지 않음 — 두 INSERT/UPDATE가 같이 가능

전체 매트릭스는 공식 문서 mvcc-locks.html에 정리되어 있습니다. 운영자가 외워야 할 핵심은 다음.

운영 사례
운영 중 ALTER TABLE ADD COLUMN이 트래픽을 멈춤ACCESS EXCLUSIVE — SELECT까지 막음
CREATE INDEX CONCURRENTLYSHARE UPDATE EXCLUSIVE — 트래픽 막지 않음
TRUNCATE로 한순간에 비우려다 5초 정지ACCESS EXCLUSIVE
VACUUM이 부하 없이 돌아감SHARE UPDATE EXCLUSIVE — SELECT/INSERT/UPDATE 가능
ALTER TABLE의 함정: 대부분 ALTER TABLE은 ACCESS EXCLUSIVE를 잡습니다. 운영 중 큰 테이블에 컬럼 추가하면 테이블 전체가 freeze됩니다. PG 11+의 ADD COLUMN ... DEFAULT <constant>는 메타데이터만 갱신해 빠르지만, default가 volatile 함수면 전체 rewrite가 일어납니다.

Row-level lock

UPDATE/DELETE/SELECT FOR UPDATE는 row 단위 락을 잡습니다. 테이블 락(ROW EXCLUSIVE)은 약해서 다른 트랜잭션을 거의 안 막지만, 같은 row를 만지면 wait가 걸린다.

명령row 락 모드
SELECT ... FOR UPDATE가장 강한 row lock. UPDATE/DELETE도 막음
SELECT ... FOR NO KEY UPDATEUPDATE는 막지만 FK 참조는 안 막음
SELECT ... FOR SHARE다른 UPDATE는 막지만, 같은 FOR SHARE는 허용
SELECT ... FOR KEY SHAREFK 보장에 필요한 최소 락 (FK 검증 시 PG가 자동으로 사용)

NOWAIT, SKIP LOCKED

-- 잠긴 row를 만나면 즉시 ERROR
SELECT * FROM jobs WHERE status = 'pending' LIMIT 1 FOR UPDATE NOWAIT;

-- 잠긴 row는 건너뜀 (큐 패턴에 유용)
SELECT * FROM jobs WHERE status = 'pending' LIMIT 1 FOR UPDATE SKIP LOCKED;

SKIP LOCKED는 PostgreSQL 9.5+에서 들어왔습니다. 배치 워커가 같은 큐 테이블에서 일을 나눠 가져가는 패턴에 표준입니다.

pg_locks — 현재 락 상태

SELECT locktype, relation::regclass, mode, granted, pid
  FROM pg_locks
 WHERE NOT granted
 ORDER BY pid;

granted = false인 락이 누가 막혀 있는 락입니다.

누가 누구를 막고 있나

PostgreSQL 9.6+의 pg_blocking_pids()가 가장 편합니다.

SELECT pid,
       pg_blocking_pids(pid) AS blocking,
       query
  FROM pg_stat_activity
 WHERE cardinality(pg_blocking_pids(pid)) > 0;

출력 예:

 pid  | blocking | query
------+----------+--------------------------------
 2222 | {1111}   | UPDATE accounts SET balance ...

pid 2222pid 1111에 막혀 있습니다. pid 1111이 무얼 하고 있는지 다시 조회.

데드락

두 트랜잭션이 서로의 락을 기다리면 PostgreSQL이 자동으로 감지하고 한쪽을 abort합니다.

    %%{init: {'theme':'base','themeVariables':{
  'primaryColor':'#ede9fe',
  'primaryTextColor':'#3b0764',
  'primaryBorderColor':'#6d28d9',
  'lineColor':'#1d4ed8',
  'secondaryColor':'#d1fae5',
  'tertiaryColor':'#fef3c7',
  'noteBkgColor':'#fef3c7',
  'noteBorderColor':'#b45309',
  'noteTextColor':'#78350f',
  'actorBkg':'#dbeafe',
  'actorBorder':'#1d4ed8',
  'actorTextColor':'#1e3a8a',
  'activationBkgColor':'#d1fae5',
  'activationBorderColor':'#047857',
  'signalColor':'#1d4ed8',
  'signalTextColor':'#1e3a8a',
  'labelBoxBkgColor':'#fef3c7',
  'labelBoxBorderColor':'#b45309',
  'labelTextColor':'#78350f'
}}}%%
sequenceDiagram
  participant T1 as Tx A
  participant T2 as Tx B
  T1->>T1: UPDATE row 1
  T2->>T2: UPDATE row 2
  T1->>T1: UPDATE row 2 (wait)
  T2->>T2: UPDATE row 1 (wait)
  Note over T1,T2: 데드락 감지 후<br/>한쪽 트랜잭션 abort
  

데드락 감지 주기는 deadlock_timeout(기본 1초)입니다. 락 대기가 1초를 넘으면 그때 deadlock 그래프를 검사한다 — 즉, 1초 이내 풀리는 경합은 무료, 1초 넘어가는 것만 검사 비용을 치릅니다.

데드락이 자주 발생하면:

  • 모든 트랜잭션이 같은 순서로 row를 잡도록 코드 정리
  • 트랜잭션을 짧게
  • 적절한 인덱스로 row 단위 락 범위를 좁힘

advisory lock 활용

SELECT pg_advisory_xact_lock(123) 같은 임의의 정수 키로 락을 잡을 수 있습니다. 자세한 내용은 다음 절(3.5)에서.

Light-weight lock (LWLock)

PostgreSQL 내부에서 짧게 잡는 mutex. shared_buffers의 페이지 lock, WAL insert lock, ProcArray lock 등. SQL 레벨에서는 안 보이지만 대기 시간이 길어지면 pg_stat_activity.wait_event / wait_event_type에 노출됩니다.

SELECT pid, wait_event_type, wait_event, state, query
  FROM pg_stat_activity
 WHERE wait_event IS NOT NULL
   AND state = 'active';

wait_event_typeLWLock이고 wait_eventWALInsert·BufferContent·ProcArray 등으로 자주 잡히면 공유 자원 경합입니다. 흔한 원인:

LWLock의미흔한 원인
WALInsertWAL 버퍼 쓰기 직렬화매우 높은 commit TPS
BufferContent같은 페이지 동시 수정인기 row 집중 UPDATE
ProcArrayXID·스냅샷 처리매우 많은 연결 수
SubtransSLRUsubtransaction 처리SAVEPOINT 폭주 (14.4 안티패턴)

Predicate lock (SIReadLock)

Serializable 격리에서 SSI가 충돌을 추적하기 위해 잡는 가상 락입니다. 실제로 다른 트랜잭션을 막지 않고, 단지 직렬화 가능성 판단에만 씁니다. pg_locks에서 locktype = 'page'·'tuple'·'relation'이면서 mode = 'SIReadLock'으로 나타납니다.

운영자가 직접 다룰 일은 거의 없습니다. max_pred_locks_per_transaction(기본 64)·max_pred_locks_per_relation 파라미터로 메타데이터 풀 크기를 조절.

자주 쓰는 진단 SQL

-- 현재 락 대기 그래프
SELECT bl.pid          AS blocked_pid,
       bl.usename      AS blocked_user,
       bl.query        AS blocked_query,
       kl.pid          AS blocking_pid,
       kl.usename      AS blocking_user,
       kl.query        AS blocking_query
  FROM pg_stat_activity bl
  JOIN LATERAL (
    SELECT *
      FROM pg_stat_activity
     WHERE pid = ANY(pg_blocking_pids(bl.pid))
  ) kl ON true
 WHERE cardinality(pg_blocking_pids(bl.pid)) > 0;

-- 종료 후보
SELECT pg_terminate_backend(<pid>);    -- 세션 강제 종료
SELECT pg_cancel_backend(<pid>);       -- 현재 쿼리만 취소

pg_terminate_backend는 트랜잭션 전체를 끝낸다 — 마지막 수단.

운영 체크리스트

점검빈도
pg_blocking_pids 점검 알람실시간
idle in transaction 세션 길이 알람실시간
deadlocks 카운터(pg_stat_database) 증가일 단위
wait_event 상위 N개 추적 (pg_stat_statements / sampling)주 단위

정리

  • 락은 heavy-weight(SQL이 잡는), LWLock(내부 mutex), predicate(SSI) 세 갈래
  • 테이블 락은 8단계 — 운영에서 중요한 건 ACCESS EXCLUSIVE(DDL 대부분)와 SHARE UPDATE EXCLUSIVE(VACUUM 친화)
  • Row lock: FOR UPDATE / FOR SHARE / FOR KEY SHARE / FOR NO KEY UPDATE 네 단계
  • SKIP LOCKED로 잠긴 row 건너뛰기 — 큐 패턴 표준
  • 데드락은 deadlock_timeout(1초) 후 자동 감지, 한쪽 abort
  • 진단은 pg_blocking_pids() + pg_stat_activity.wait_event를 중심으로

다음 절(3.5)에서는 사용자 정의 동시성 제어에 쓰는 advisory lock을 봅니다.