soobook
DATABASE

Lock Strategies

동시성 문제를 푸는 도구로 가장 자주 사용되는 것은 Lock 이다. Lock 을 사용하는 패턴은 여러가지가 있다.

송금 시스템을 떠올려보자. 같은 계좌에서 두 트랜잭션이 동시에 출금하려 들면, 잔고 검증을 통과한 두 요청이 모두 차감되어 잔고가 마이너스로 떨어질 수 있다. 손실 갱신(lost update)이라 부르는 문제다. 락 없이는 막기 어렵다.

락의 종류가 한두 가지가 아니라는 게 문제다. DB가 자동으로 잡아주는 락이 있고, 앱이 명시적으로 거는 락도 있고, 아예 외부 시스템에서 빌려오는 락도 있다.

이 글은 락의 카탈로그가 아니라 의사결정 지도를 지향한다.

어떤 자원을 보호할지, 충돌이 얼마나 자주 일어나는지, 단일 노드인지 분산 환경인지에 따라 무엇을 고를지 정리해본다.

Three Axes

세 기준에 따라 나누면 보기가 쉽다.

무엇을 잡는가누가 잡는가안전성 모델
PessimisticDB row / tableDB 엔진단일 노드, 강한 보장
Optimistic버전/해시 비교앱 코드DB 트랜잭션에 의존
Advisory / Distributed임의의 자원 식별자DB 또는 외부 시스템협조적, fencing 필요

세 방식이 서로 배타적인 건 아니다. 한 시스템 안에서도 락을 섞어 쓰는 경우도 많다.

Pessimistic Lock

비관적 락, 이름 그대로 “충돌이 날 거라고 미리 가정한다”는 입장이다.

바꾸기 전에 먼저 잡고, 끝나면 풀어준다. 비관적 락의 세부 구현은 DB마다 다르다. 이 글은 PostgreSQL을 기준으로 살펴본다.

트랜잭션이 자동으로 거는 락이 대부분이고, 사용자가 직접 잡는 경우도 있다.

Table-Level Lock

PostgreSQL의 테이블 락 모드는 8단계다. 약한 것부터 강한 것 순서로 보면 이렇다.

모드시점충돌 대상
ACCESS SHARESELECTACCESS EXCLUSIVE
ROW SHARESELECT FOR UPDATE/SHAREEXCLUSIVE 이상
ROW EXCLUSIVEINSERT / UPDATE / DELETESHARE 이상
SHARE UPDATE EXCLUSIVEVACUUM, ANALYZE, CREATE INDEX CONCURRENTLY자기 자신 이상
SHARECREATE INDEX (non-CONCURRENTLY)ROW EXCLUSIVE 이상
SHARE ROW EXCLUSIVE(드물게 명시)SHARE 이상
EXCLUSIVE(사용자 명시)ACCESS SHARE 외 모두
ACCESS EXCLUSIVEDROP, TRUNCATE, REINDEX, VACUUM FULL모든 것

대부분 자동이라 평소에는 신경 쓸 일이 없다.

의식해야 하는 건 마이그레이션이나 운영 정비 작업이다.

ACCESS EXCLUSIVE는 SELECT조차 막기 때문에, ALTER TABLE ADD COLUMN ... DEFAULT ... 한 줄이 무심코 운영 트래픽을 멈춰버릴 수 있다.

Row-Level Lock

행 단위 락은 4단계다. SELECT 절 끝에 붙여서 명시적으로 요청한다.

용도
FOR KEY SHAREFK 참조용, 키 변경만 막음
FOR SHARE읽었으니 다른 트랜잭션이 못 바꾸게
FOR NO KEY UPDATE키 외 컬럼 수정 예정
FOR UPDATE행 자체를 수정할 예정

송금 예제로 보자.

BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- balance check in app
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

FOR UPDATE가 붙으면 같은 행을 노리는 다른 트랜잭션은 COMMIT 시점까지 대기한다. 손실 갱신은 막히지만, 트랜잭션이 길어지면 그만큼 대기 큐가 쌓인다.

대기를 피하고 싶을 때는 NOWAIT을 붙여 바로 실패시키거나, SKIP LOCKED로 잠긴 행을 건너뛰면 된다. 작업 큐 워커가 다음 잡을 가져올 때 흔히 쓰는 패턴이다.

SELECT id FROM jobs
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;

여러 워커가 같은 쿼리를 동시에 던져도 서로 다른 행을 가져간다. 별도의 큐 시스템 없이 PostgreSQL만으로 잡 디스패처를 굴린다.

Two-Phase Locking과 MVCC

이론적으로 비관적 락의 정석은 2PL(Two-Phase Locking)이다.

트랜잭션을 락 획득만 하는 growing 단계와 락 해제만 하는 shrinking 단계로 나눠서 직렬성을 보장한다.

Strict 2PL은 모든 락을 COMMIT 시점까지 들고 가는 변형이다.

PostgreSQL은 2PL을 쓰지 않는다.

MVCC + Serializable Snapshot Isolation(SSI)으로 같은 보장을 더 적은 락으로 얻는다.

반면 MySQL InnoDB는 Strict 2PL을 그대로 사용한다. 읽기는 쓰기를 막지 않고, 쓰기도 읽기를 막지 않는다.

자세한 메커니즘은 PostgreSQL MVCC와 격리 수준 글에서 다뤘다.

오해하기 쉬운 부분이 있다. MVCC를 쓴다고 Row Lock 이 사라진 건 아니다.

UPDATE는 여전히 행에 락을 걸고, FOR UPDATE도 그대로 동작한다.

MVCC가 줄여준 건 읽기와 쓰기의 충돌이지, 쓰기와 쓰기의 충돌이 아니다.

Deadlock

두 트랜잭션이 서로의 락을 기다리면 영원히 풀리지 않는다. 푸는 길은 두 갈래다.

  • 예방: 모든 트랜잭션이 자원을 같은 순서로 잡도록 강제한다. 예를 들어 송금이라면 작은 ID 계좌부터 락을 건다
  • 감지: 일단 두고, 데드락이 생기면 깨뜨린다.

예방이 깔끔하긴 한데, ORM과 동적 쿼리가 섞이면 순서를 강제하기가 어려울 때도 있다.

PostgreSQL은 wait-for graph를 주기적으로 검사해 사이클이 보이면 한쪽 트랜잭션을 abort 시키는 매커니즘이 있다. (deadlock_timeout 기본 1초)

Optimistic Lock

낙관적 락, 이쪽은 반대로 “어차피 충돌이 거의 안 난다”고 가정한다. 미리 잡지 않고, 커밋 직전에 충돌이 있는지 확인한다. 충돌이 보이면 처음부터 다시 시도한다.

Version Column

가장 흔한 구현은 행에 version이나 updated_at 컬럼을 하나 두는 방식이다.

-- read with version
SELECT id, balance, version FROM accounts WHERE id = 1;
-- (version = 7)

-- write only if version unchanged, then bump
UPDATE accounts
SET balance = 900, version = 8
WHERE id = 1 AND version = 7;

다른 트랜잭션이 먼저 커밋해 version이 8로 올라갔다면, 두 번째 UPDATE의 WHERE version = 7은 0건을 반환한다. 앱은 그걸 충돌로 보고 처음부터 다시 시도한다.

행 락을 잡지 않으니 대기 큐도 없다. 다만 충돌이 잦아지면 재시도 비용이 빠르게 늘어 오히려 비관적 락보다 느려진다.

CAS와 ETag

사실 위의 Version Column 과 같은 아이디어가 이름만 바뀐거나 다름이 없다.

CPU의 Compare-And-Swap, HTTP의 If-Match와 ETag, DynamoDB의 Conditional Write, Redis의 WATCH/MULTI까지 모두 본질이 같다.

REST API에서 자주 보는 패턴이다.

GET /accounts/1
ETag: "v7"
{ "balance": 1000 }

PUT /accounts/1
If-Match: "v7"
{ "balance": 900 }

서버가 현재 ETag와 If-Match를 비교해 일치하지 않으면 412 Precondition Failed를 돌려준다. 클라이언트가 새 상태를 다시 받아 재시도한다.

Advisory Lock

여기까지의 락은 전부 테이블의 행을 대상으로 삼았다. 그런데 잡고 싶은 자원이 row가 아닌 경우가 종종 생긴다.

  • 외부 API 호출의 중복 실행 방지 (한 번에 한 워커만 결제 게이트웨이를 호출)
  • Leader election (여러 인스턴스 중 하나만 크론 잡 실행)
  • 마이그레이션 게이트 (배포 중 한 인스턴스만 스키마 변경)

이런 자원은 DB row가 아니다. 그렇다고 row를 억지로 만들어 FOR UPDATE를 걸면 의미가 모호해진다. PostgreSQL은 이런 케이스를 위해 Advisory Lock을 제공한다.

-- session lock: held until session ends, regardless of txn
SELECT pg_advisory_lock(12345);
-- ... work ...
SELECT pg_advisory_unlock(12345);

-- transaction lock: released on COMMIT or ROLLBACK
SELECT pg_advisory_xact_lock(12345);

-- non-blocking variant: returns false if already held
SELECT pg_try_advisory_lock(12345);

키는 그냥 정수다. 의미는 앱이 알아서 붙인다(cooperative lock). DB는 그 키가 어떤 자원을 보호하는지도 모르고, 같은 키를 누가 어떤 의도로 잡는지도 모른다. 이 모호함이 강점이자 약점이다.

한 줄로 정리하면, 보호 대상이 row가 아니거나 row가 아직 존재하지 않을 때 Advisory가 맞다.

Distributed Pitfall

Advisory Lock은 단일 PostgreSQL 인스턴스 안에서만 의미가 있다.

Replica로 복제도 안 되고, primary가 장애 후 새 primary로 failover되면 락 정보가 통째로 사라진다.

그래서 “Postgres advisory를 분산 락으로 쓰자”는 아이디어에는 함정이 깔려 있다.

failover 도중 두 개의 primary가 잠깐이라도 공존하는 split-brain 상황이 오면 같은 키가 양쪽에서 동시에 잡힐 수 있다.

같은 클러스터를 보는 한 응용 안에서 락을 직렬화하는 용도로는 충분하지만, 진짜 분산 락이 필요한 상황에는 부족하다.

Distributed Lock

여러 노드가 하나의 자원을 놓고 경합하는 상황에서는 단일 DB의 락만으로 부족하다.

이때 Redis, etcd 같은 외부 시스템을 락 서버로 끌어다 쓴다.

Why Fencing Token

분산 락의 가장 큰 함정은, 락을 잡았다고 믿는 시점과 실제로 자원에 쓰기를 보내는 시점이 갈린다는 데 있다.

그 사이에 GC pause나 네트워크 지연이 끼어들면 이미 TTL이 만료된 락을 들고 자원을 건드릴 수 있다.

전형적인 시나리오를 그려보자.

Client 1이 락을 잡았는데 GC pause로 멈춘 사이 TTL이 끝난다.

Client 2가 락을 새로 잡고 작업을 시작한다.

그 와중에 Client 1이 깨어나서 자기가 아직 락을 들고 있다고 믿은 채로 쓰기를 보낸다.

자원 입장에서는 두 클라이언트의 충돌하는 쓰기를 모두 받아들이게 된다.

이를 막는 방법이 fencing token이다.

락을 잡을 때 단조 증가하는 토큰을 함께 받고, 보호 자원 쪽에서는 더 작은 토큰의 쓰기를 거절한다.

Client 1은 token=33, Client 2는 token=34를 받는다.

자원 서버는 마지막으로 본 토큰을 기억해두고, 뒤늦게 도착한 33은 그냥 무시한다.

References