트랜잭션 계층

트랜잭션 계층 (Transaction Layer)

CockroachDB가 어떤 것보다 중요하게 생각하는 건 데이터의 **일관성(consistency)**이에요. 일관성이 없으면 개발자는 믿을 수 있는 도구를 만들 수 없고, 놓치기 어려운 미묘한 이상 현상 때문에 비즈니스가 피해를 보게 되죠. 이 일관성을 보장하기 위해 CockroachDB는 트랜잭션 계층에서 ACID 트랜잭션 의미론을 완전히 지원해요. 이 페이지에서는 그 내부가 어떻게 돌아가는지 설명해 드릴게요.

출처: CockroachDB 공식 문서 — Transaction Layer

개요

CockroachDB에서는 모든 문장이 트랜잭션으로 처리돼요. 단일 문장조차 예외가 없어서, 모든 문장 뒤에 COMMIT이 붙는 것처럼 동작하는데 이걸 "autocommit 모드"라고 불러요. 그리고 CockroachDB의 트랜잭션은 클러스터 전체(여러 range, 여러 테이블에 걸쳐)에 걸칠 수 있기 때문에, Parallel Commits라는 분산 원자 커밋 프로토콜로 정확성을 달성해요.

쓰기와 읽기 (1단계)

쓰기

쓰기 연산을 실행할 때 트랜잭션 계층은 값을 바로 디스크에 쓰지 않아요. 대신 분산 트랜잭션을 중재할 몇 가지를 만드는데, 구체적으로는:

  • Write intents — 복제되며 "임시 값 + 배타 잠금"의 결합처럼 동작해요. 표준 값과 같지만 클러스터에 저장된 트랜잭션 레코드를 가리키는 포인터를 포함해요.
  • 트랜잭션 쓰기의 미복제 잠금(unreplicated locks) — 커밋되지 않은 임시 상태를 나타내고, 동시성 제어 메커니즘이 노드별 인메모리 잠금 테이블에 저장해요.
  • 첫 쓰기가 발생한 range에 저장되는 트랜잭션 레코드 — 트랜잭션의 현재 상태(PENDING, STAGING, COMMITTED, ABORTED 중 하나)를 담아요.

write intent가 만들어지면서 CockroachDB는 더 새로운 커밋 값을 확인해요. 더 새로운 커밋 값이 있으면 트랜잭션이 재시작될 수 있고, 같은 키에 기존 write intent나 잠금이 있으면 트랜잭션 충돌로 처리돼요. SQL 제약 조건 통과 실패 같은 다른 이유면 트랜잭션은 중단(abort)돼요.

읽기

트랜잭션이 중단되지 않았다면 읽기 연산을 실행해요. 읽기가 표준 MVCC 값만 만나면 문제없지만, 기존 잠금을 만나면 트랜잭션 충돌로 해결해야 해요. CockroachDB는 두 종류의 읽기를 제공해요:

  • 강한 일관 읽기(non-stale reads) — 기본값이자 가장 흔한 유형이에요. leaseholder를 통과해서, 읽기 트랜잭션이 시작되기 전에 커밋된 작성자(격리 수준에 따라 트랜잭션 또는 문장 단위)의 모든 쓰기를 봐요. 항상 정확하고 최신 데이터를 돌려줘요.
  • 오래된 읽기(stale reads) — 약간 오래된 데이터를 감수하고 더 빠른 읽기를 원할 때 유용해요. 읽기 전용 트랜잭션에서만 쓸 수 있고, 로컬 복제본에서 절대 closed timestamp보다 높지 않은 타임스탬프로 읽어서 leaseholder를 거치지 않아도 일관성을 보장해요.

읽기는 선택적으로 잠금을 걸 수 있어요. **배타 잠금(exclusive lock)**은 행에 대한 동시 쓰기와 배타 잠금을 막아요. **공유 잠금(shared lock)**은 동시 쓰기와 배타 잠금을 막지만 여러 트랜잭션이 동시에 공유 잠금을 가질 수 있어요. 공유 잠금을 가진 트랜잭션들끼리는 그 행을 쓸 수 없고, 상호 읽기 전용 접근을 보장하며 항상 최신 값을 읽어요.

커밋 (2단계)

CockroachDB는 실행 중인 트랜잭션의 레코드가 ABORTED인지 확인하고, 그렇다면 클라이언트에게 중단을 알려요. 그렇지 않으면 트랜잭션 레코드 상태를 STAGING으로 바꾸고, 트랜잭션의 대기 중인 write intent가 클러스터 전체에 성공적으로 복제됐는지 확인해요. 이 검사를 통과하면 클라이언트에게 트랜잭션 성공을 알리고 정리(cleanup) 단계로 넘어가요. 이 시점에서 트랜잭션은 커밋된 상태예요.

정리 (비동기 3단계)

트랜잭션이 커밋된 후에는 그렇게 표시하고 모든 write intent를 해소해야 해요. 조정 노드는 자신이 쓴 모든 키를 추적하고 있다가, 트랜잭션 레코드 상태를 STAGING에서 COMMITTED로 옮기고 write intent를 트랜잭션 레코드를 가리키는 요소를 제거해 MVCC 값으로 해소하며 write intent를 삭제해요. 이건 순수한 최적화일 뿐이에요. 이후에 write intent를 만나는 연산은 항상 트랜잭션 레코드를 확인하고, 어떤 연산이든 트랜잭션 레코드 상태를 확인해서 write intent를 해소하거나 제거할 수 있어요.

시간과 하이브리드 논리 시계 (HLC)

분산 시스템에서 순서와 인과관계는 풀기 어려운 문제예요. 단순히 Raft 합의에만 의존해 직렬화 가능성을 유지할 수도 있지만, 읽기 성능이 비효율적이게 돼요. 그래서 CockroachDB는 하이브리드 논리 시계(HLC)를 구현해요. HLC는 물리적 성분(항상 로컬 벽시계에 가까움)과 논리적 성분(같은 물리 시각의 사건을 구분)으로 구성되고, HLC 시간은 항상 벽시계 시간 이상이에요. 게이트웨이 노드는 트랜잭션의 타임스탬프를 HLC 시간으로 정해요. 이 타임스탬프는 값의 버전 추적(MVCC)과 트랜잭션 격리 보장 모두에 사용돼요.

최대 클록 오프셋 강제

CockroachDB는 데이터 일관성을 유지하기 위해 어느 정도의 클록 동기화를 요구해요. 노드가 자기 클록이 클러스터의 다른 노드 절반 이상과 설정된 클록 오프셋 경계의 80%만큼 어긋났다고 감지하면 즉시 크래시해요. SERIALIZABLE 격리에서는 클록 스큐와 무관하게 직렬화 가능성이 유지되지만, 오프셋 경계를 벗어나는 스큐는 인과적으로 의존하는 트랜잭션 사이에서 단일 키 선형화(linearizability) 위반을 일으킬 수 있어요. 그래서 각 노드에서 NTP 같은 클록 동기화 소프트웨어를 실행해 클록이 너무 멀리 흘러가지 않게 막는 게 중요해요.

CockroachDB는 값이 읽힐 때마다 그 연산의 타임스탬프를 **타임스탬프 캐시(timestamp cache)**에 저장해요. 이는 수행된 읽기에 대한 정보를 보관하는 자료구조로, 어떤 트랜잭션 t1이 행을 읽으면 이후에 그 행에 쓰려는 트랜잭션 t2가 t1 뒤로 정렬되도록 보장해 직렬화를 유지해요. 쓰기가 발생하면 그 타임스탬프를 타임스탬프 캐시와 대조하고, 캐시의 최신 값보다 이르면 트랜잭션의 타임스탬프를 더 늦은 시각으로 밀어(push) 올려요. SERIALIZABLE 격리에서 타임스탬프 밀기는 트랜잭션의 커밋 시점에 재시작을 유발할 수 있어요.

읽기 스냅샷

모든 트랜잭션은 관찰할 커밋 데이터를 정의하는 읽기 스냅샷으로 동작해요. 두 격리 수준 모두 전역적으로 일관된 읽기 스냅샷을 사용해요. SERIALIZABLE 트랜잭션은 read refreshing으로 트랜잭션별 읽기 스냅샷을 유지하고, READ COMMITTED 트랜잭션은 문장별 읽기 스냅샷을 사용해 트랜잭션 안의 각 문장마다 전진해요. READ COMMITTED 트랜잭션의 각 문장이 시작할 때 하이브리드 논리 시계에서 새 타임스탬프를 선택해 새 읽기 스냅샷을 세우고, 이전에 커밋된 트랜잭션들의 쓰기를 포착해요. 즉 READ COMMITTED 트랜잭션은 여러 읽기 스냅샷에 걸쳐 동작할 수 있고, 각 문장이 시작할 때 가져온 각 행의 최신 값만 읽는 걸 보장해요. 때문에 읽기가 문장 사이에 다르게 보일 수 있어요(문장 수준 읽기 커밋).

문장별 읽기 스냅샷 덕분에 READ COMMITTED 트랜잭션은 전체 트랜잭션이 아니라 개별 문장을 재시도해 충돌을 해소할 수 있어요. 문장 수준 재시도는 클라이언트 개입 없이 자동으로 수행되는 반면, SERIALIZABLE 격리에서 필요한 트랜잭션 수준 재시도는 보통 클라이언트 재시도가 필요해요.

더 알아보기