Postgres 동시성 [기본락과 MVCC]

데이터베이스단에서 동시성 확보는 디비 자체적으로 해주는 것과 프로그래머가 직접 코드로 짜야하는 걸로 나뉜다. 그중 데이터베이스에서 제공해주는 부분을 알아본다.

“1. 기본적인 잠금(shared/exclusive lock)”과 “2. Isolation level 구현(MVCC)” 이 두가지가 데이터베이스에서 이미 완성시켜둔 동시성 도구들이다. 이 둘의 조합으로 기본적인 동시성 제어를 구성한다.

1. 기본 잠금 - Shared Lock, Exclusive Lock

모든 데이터베이스에서는 update, delete문이 실행되는 동안 해당 row를 잠궈버린다. 다른 쿼리중에서 update, delete문이 같은 row에 대해 실행하려한다면 이전 쿼리의 트랜잭션이 끝날때까지 기다려야한다. INSERT, UPDATE, DELETE문은 트랜잭션을 명시적으로 설정하지않아도 데이터베이스가 알아서 트랜잭션을 실행하고 작업이 완료되면 트랜잭션을 끝낸다. 반대로 기본적으로 아무것도 설정하지않은(트랜잭션 X, for update 설정 X) select문은 트랜잭션도, Lock도 이용하지않는다.

여기서 쓰이는 잠금은 “Shared Lock”과 “Exclusive Lock”이다.

Shared Lock: 내가 이 데이터 보고 있으니까 데이터 바꾸지마. 보기만 해. 근데 너도 같이 잠글수 있어(shared lock만)
Exclusive Lock: 내가 데이터 수정할거니까 보기만하고 수정하지마..

두가지 잠금 모두 다른 트랜잭션에서 데이터 수정을 불가능하게 만든다. 둘의 차이점은 잠금을 걸었을 때 다른 잠금이 동시에 잠금을 걸수 있는지 없는지 여부다. Exclusive Lock은 이름처럼 어떠한 락도 공존할수 없다. Shared Lock은 이름처럼 공존할수 있다. 다만 공존할수 있는 Lock은 Shared Lock뿐이다. 만약 트랜잭션A에서 row1에 Shared Lock을 건 상태라면 다른 트랜잭션에서도 row1에 Shared Lock을 걸수 있다. Shared Lock이 겹쳤을 때는 마지막 Shared Lock을 건 트랜잭션이 Lock을 풀면 해당 row의 shared Lock이 풀린다. Exclusive Lock은 동시에 다른 잠금을 걸수 없다.

2. MVCC (Multi Version Concurrency Control)

MVCC는 대부분의 관계형 데이터베이스에서 통용되는 규칙인 “Isolation Level”을 구현한 것이다.

MVCC의 논리적 기반

MVCC는 데이터베이스의 기본 저장방식인 COMMIT을 이용한다. 데이터베이스는 데이터를 저장, 수정할 때 1차로 메모리에 저장한뒤에 트랜젝션이 COMMIT되면 DISK에 저장한다. (매커니즘에 따라 여러 커밋이 뭉쳐서 disk에 저장되기도 한다.) 커밋한 데이터만 읽어들일건지, 커밋하지않았지만 메모리에 저장된 데이터도 읽어들일건지에 따라 Isolation Level이 나뉜다.

Read Committed는 커밋된 데이터만 읽는다.
Repeatable Read는 커밋되지 않은 데이터도 읽는다. 하지만 트랜잭션이 시작된 시점에도 존재했던 데이터만 읽는다.

더 자세히 알아보자.

MVCC의 물리적 기반

Postgres MVCC는 Hint bit, commit log, snapshot, xmin, xmax 데이터를 이용하여 유효한 데이터를 판단한다.

xmin, xmax

먼저 xmin, xmax부터 알야아한다.(hint bit에서도 쓰이고 snapshot에서도 쓰이기 때문에) xmin, xmax는 모든 row가 가지고 있는 컬럼이다. 일반 컬럼이 아니고 시스템 컬럼이라서 명시적으로 검색하지않는 이상 기본적으로는 보이지않는다.
INSERT문이 실행되면서 row가 생성될 때 해당 트랜잭션의 ID를 해당 row의 xmin에 설정한다. xmin는 직관적으로 이해가 가지만 반면에 xmax는 조금 생소할수 있다. postgres는 기본적으로 update 작업을 할 때 기존 데이터를 변경하지 않는다. 변경하는 대신에 기존 데이터의 xmax에 현재 트랜잭션 ID를 설정한다. 그리고 업데이드된 데이터를 가진 새로운 row를 생성한다. INSERT와 마찬가지로 xmin은 현재 트랜잭션 ID가 설정된다. DELETE는 기존 row의 xmax에 현재 트랜잭션 ID를 설정하고 끝이다.
정리하자면 특정 row에 xmax값이 존재한다면 그 값은 가장 최신의 값이 아니라고 생각할수 있다. 여기서 짚고 넘어가야할 하나는 xmin 값이 있다는게 커밋됐다는 의미는 아니다. row들이 커밋되기전에 임시로 저장되어있듯이 row들의 모든 컬럼들도(시스템컬럼 또한) 임시로 저장된 상태이다. 커밋 여부는 커밋로그와 hint bit에서만 확실시 할수 있다.


xmin, xmax를 대략 알았다면 다시 처음으로 돌아가자. 쿼리문에 대해 MVCC는 아래와 같은 순서로 작동한다.

0. SELECT문을 WHERE절과 같이 실행.

1. WHERE 조건 확인

WHERE절의 조건대로 데이터를 필터링한다.

2.1 커밋 여부 확인 - Hint bit

WHERE 필터링된 row들의 header를 확인하여 hint bit를 확인하여 커밋 여부를 파악한다.

모든 개별 row들은 개별 http 요청들이 헤더를 가지듯이 header를 가지고 있다. header안에 hint bit가 있는데 아래 4가지를 boolean값으로 가지고 있다.

1
2
3
4
XMIN_COMMITTED -- creating transaction is known committed
XMIN_ABORTED -- creating transaction is known aborted
XMAX_COMMITTED -- same, for the deleting transaction
XMAX_ABORTED -- ditto

이름만 봐도 알수 있듯이 XMIN_COMMITTED가 true이면 커밋된 것을 의미하고 XMIN_ABORTED가 true라면 롤백된 트랜잭션을 의미한다.

2.2 커밋 여부 확인 - commit log

사실 hint bit는 commit log의 캐시버전이다. 그래서 hint bit부터 확인하고 hint bit가 없다면 커밋 로그를 확인하게 된다.

3. snapshot

(스냅샷은 내용이 좀 많다.) 데이터베이스 복구에 이용하는 스냅샷과는 이름만 같다. 하지만 특정시점의 데이터를 나타낸다는 것은 비슷하다. MVCC의 스냅샷은 트랜잭션이 실행되는 동안 “특정시점에 생성”되어 데이터 가시성을 판단하는 과정을 거친다. 먼저 스냅샷의 내용물은 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct SerializedSnapshotData
{
	TransactionId xmin;     // 현재 활성화돼있는 트랜잭션 중 가장 작은 트랜잭션 ID
	TransactionId xmax;     // 현재 활성화돼있는 트랜잭션 중 가장 큰 트랜잭션 ID
	uint32		xcnt;
	int32		subxcnt;
	bool		suboverflowed;
	bool		takenDuringRecovery;
	CommandId	curcid;
	TimestampTz whenTaken;
	XLogRecPtr	lsn;
} SerializedSnapshotData;

// https://github.com/postgres/postgres/blob/06c418e163e913966e17cb2d3fb1c5f8a8d58308/src/backend/utils/time/snapmgr.c

위에서 이야기한 xmin, xmax를 가지고 있다. 위 단계에서 필터링된 row들은 모두 xmin, xmax 컬럼을 가지고 있는데(어떠한 모든 데이터들도 가지고 있다.) 이 컬럼들과 스냅샷의 xmin, xmax를 비교하여 어떤 row를 보여줄지 정한다.

그리고 스냅샷을 언제 만드는지에 차이를 둬 Reqd Committed, Repeatable Read를 나눠 구현한다. (MVCC는 데이터베이스 Isolation을 구현한 것이니까)

“Snapshot의 xmin, xmax 비교” + “스냅샷을 만드는 시점의 차이”

Repeatable Read

Repeatable Read 레벨에서의 스냅샷 생성 시기는 트랜잭션 마다 단 한번이다. 트랜잭션이 시작할 때 스냅샷을 생성하고 한 트랜잭션 안에 있는 모든 쿼리가 같은 스냅샷을 이용하게 된다. 스냅샷에 존재하는 xmin값은 스냅샷 생성시기에 활성화되어 있던 모든 트랜잭션 중 가장 먼저 실행되고 있떤 값이다. 그렇기 때문에 스냅샷의 xid보다 큰 값의 xid를 가지고 있는 row는 아직 커밋되지않은 것으로 간주된다. 그리하여 결과에 포함되지않는다.

그리고 제일 핵심으로는 Repeatable Read는 한 트랜잭션내에서는 같은 결과를 보여줘야하기때문에 최저활성 트랜잭션 ID보다 이후에 생성/수정된 데이터는 보여주지 않는 방식으로 구현한다.

Read Committed

Read Committed는 스냅샷을 이용하지 않는다고 생각해도 된다. 개별 쿼리가 실행되는 시점에 커밋된 데이터인지 아닌지만 판단하면 되기때문이다. 같은 트랜잭션안의 두 쿼리가 실행될 때 첫 번째 쿼리가 실행된 후에 다른 트랜잭션에서 새로운 데이터를 만들고 커밋하면 첫번째 쿼리에서는 안보이던 데이터가 두번째 쿼리에서는 보이게된다.(같은 조건의 쿼리들이 같은 트랜잭션안에서 다른 값을 보여줄수 있다.) Read Committed는 커밋로그와 Hint bit만으로도 구현이 가능하다.

Serializable

Serialzable의 스냅샷 이용방식은 Repeatable Read와 거의 똑같다. 트랜잭신이 시작했을 때만 스냅샷을 생성하고 해당 트랜잭션동안 그 스냅샷을 이용한다.(Repeatable Read와 같이 스냅샷 이전에 커밋된 데이터만 보인다.) 한가지 다른점은 하나의 트랜잭션이 건드린 데이터는 해당 트랜잭션이 끝나기전까지 다른 트랜잭션이 못건드리게 한다. 건드리면 늦게 건드린 명령은 롤백된다. (SSI를 찾아보면 좋다.)

4. 완전 마지막 단계가 하나 있다.

위 단계를 거치면서 필터링된 데이터들이 남게 된다. 그 데이터중에서는 여러개의 row가 사실은 하나의 데이터일때가 있다.(postgres에서는 Update/Delete시 기존 데이터를 지우는게 아니라 xmax에 transaction id를 설정하니깐) 그럴 때 하나의 값만을 보여주기 위해 xmax값이 존재하는 데이터는 보이지않고 xmin만 설정돼 있는 데이터를 보여주게 된다.