콘텐츠로 이동

(KO) CUBRID MVCC — 스냅샷 생성, 활성 MVCCID 추적, 그리고 vacuum 협조

목차

다중 버전 동시성 제어(MVCC, Multiversion Concurrency Control)란 각 레코드의 여러 타임스탬프 버전을 보존해, 같은 행에 대한 읽기와 쓰기가 서로를 차단하지 않도록 만드는 방법론을 의미한다. Database Internals(Petrov, 5장)는 MVCC를 낙관적(OCC)·비관적(PCC) 기법과 더불어 동시성 제어의 세 갈래 중 하나로 배치한다. MVCC가 다른 두 기법과 구별되는 지점은 새 버전이 커밋되기 전까지 읽기 트랜잭션이 옛 버전을 계속 참조할 수 있다. 즉 조정의 단위가 상호 배제가 아니라 가시성이다. MVCC 위에 흔히 얹는 격리 수준은 스냅샷 격리(SI, Snapshot Isolation) 다. 트랜잭션은 시작 시점의 논리적 스냅샷을 떠 둔다. 그 스냅샷에 대해서만 질의를 수행하며, 자신이 수정한 값이 동시 트랜잭션에 의해 변경되지 않은 경우에만 커밋한다. SI는 dirty read, non-repeatable read, 스냅샷 범위 안의 phantom, lost update를 막아 준다. 다만 write skew는 허용한다는 한계가 있다. 이는 두 트랜잭션이 각자의 지역 불변식을 지키면서도 결합되었을 때 불변식을 깨는 고전적 사례를 가리킨다(Database Internals §Isolation Levels, §Multiversion Concurrency Control; [FEKETE04], [HELLERSTEIN07] 참조).

SI 모델로부터 도출되는 두 가지 구현상의 선택이 모든 MVCC 엔진의 모양을 결정한다.

  1. 버전을 어떻게 식별하고 가시성을 어떻게 판단할 것인가. 각 트랜잭션은 단조 증가하는 식별자를 부여 받는다. 어떤 버전이 어느 스냅샷에서 보이는지는 삽입자가 스냅샷보다 먼저 커밋했는가 그리고 삭제자가 있다면 스냅샷보다 나중에 커밋했는가로 결정된다. 따라서 스냅샷 시점 활성 트랜잭션 ID 집합이 핵심 자료구조가 된다.
  2. 죽은 버전을 어떻게 회수할 것인가. 어떤 행 버전이 살아 있는 어떤 스냅샷에도 보이지 않게 되면 더는 도달이 불가능하므로 vacuum이 가능하다. 보이는 가장 오래된 MVCCID(oldest visible MVCCID)가 이 회수의 하한선이 된다. 장시간 실행되는 쓰기 트랜잭션은 이 하한선을 끌어내려 vacuum을 멈추게 한다. 교과서가 짚는 MVCC의 구조적 한계이며 PostgreSQL, MySQL, CUBRID 모두에 공통으로 나타난다.

CUBRID는 단조 증가하는 MVCCID, 활성 MVCCID 인메모리 테이블, 별도의 vacuum 프로세스로 SI를 구현한다. 이하는 각 조각이 소스에서 어떻게 실현되는지를 따라간다.

DBMS 공통 설계 패턴 (Common DBMS Design)

섹션 제목: “DBMS 공통 설계 패턴 (Common DBMS Design)”

텍스트북이 모델을 준다면, 이 섹션은 거의 모든 SI/MVCC 엔진이 어떤 형태로든 채택하는 공학적 관행(engineering conventions) 을 나열한다. PostgreSQL, Oracle, InnoDB, SQL Server, CUBRID가 모두 여기에 해당한다. 다음 섹션 ## CUBRID의 구현은 발명이 아니라, 이 공유 설계 공간에서 선택된 한 조합이라고 읽는 것이 옳다. 이 그림은 두 형제 컴포넌트 옆에 자연스럽게 놓인다. 첫째는 쓰기/쓰기 및 엄격한 격리 수준에서의 읽기/쓰기 직렬성을 강제하는 잠금 관리자(Lock Manager) 다. 둘째는 살아 있는 어떤 스냅샷(snapshot)도 도달할 수 없는 버전을 회수하는 Vacuum 이다. 이 셋은 MVCCID라는 하나의 언어를 공유하며, 만남의 지점은 세 곳이다. 레코드별 헤더, 트랜잭션별 스냅샷, 그리고 전역 보이는 가장 오래된 ID(oldest visible) 워터마크가 그 셋이다.

레코드별 버전 메타데이터 (Per-record version metadata)

섹션 제목: “레코드별 버전 메타데이터 (Per-record version metadata)”

모든 행은 매 읽기마다 이 버전은 내게 보이는가? 를 답할 수 있는 정보를 자기 안에 담고 있어야 한다. 그래야 중앙 레지스트리에 매번 질의하는 비용을 피할 수 있기 때문이다. 최소 스탬프는 (insert자, delete자) 두 식별자에 옛 버전 포인터 하나를 더한 것이다. PostgreSQL은 힙 페이지에 xmin/xmax를 인라인으로 둔다. Oracle과 InnoDB는 옛 버전을 undo 세그먼트(segment)로 밀어내고, 살아 있는 행에는 undo 로케이터(locator)만 둔다. 이 선택은 가비지 컬렉션 비용으로 연쇄된다. 자세한 내용은 아래 in-place vs out-of-place 항목을 참고한다.

스냅샷으로서의 활성 집합 (Active set as snapshot)

섹션 제목: “스냅샷으로서의 활성 집합 (Active set as snapshot)”

스냅샷을 획득할 때 엔진은 어느 트랜잭션이 아직 진행 중인지 를 포착해야 한다. 가장 단순한 표현은 진행 중 ID들의 정렬 리스트(sorted list)이며, 가시성 검사는 이진 검색(binary search)으로 끝난다. 다만 실제 시스템은 이 표현을 세 층으로 압축한다. 이 압축 패턴은 엔진을 가리지 않고 공통적으로 나타난다.

  • 최근 ID들의 슬라이딩 윈도우(sliding window)에 대한 비트 배열(bit array) — O(1) 프로브.
  • 윈도우에서 밀려난 장기 실행 트랜잭션을 위한 오버플로 리스트 (overflow list).
  • 흔한 경우를 자료구조 접근 없이 단축하는 하한·상한 워터마크 (low/high-watermark) 스칼라 캐시.

윈도우 크기가 핵심 노브(knob)다. 너무 작으면 outlier가 지배하고, 너무 크면 스냅샷 복사 자체가 병목이 되기 때문이다.

회수 워터마크 (Reclamation watermark)

섹션 제목: “회수 워터마크 (Reclamation watermark)”

살아 있는 가장 오래된 스냅샷의 하한선보다 더 오래된 버전은 도달이 불가능하므로 회수가 가능하다. 단일 전역 “보이는 가장 오래된” MVCCID가 워터마크가 되며, 회수(PostgreSQL VACUUM, Oracle UNDO trim, CUBRID vacuum_master)는 이 값으로 게이팅된다. 모든 SI 엔진이 동일한 구조적 비용을 진다. 하나의 장기 실행 쓰기 트랜잭션이 워터마크를 끌어내려 버리면, 짧은 트랜잭션이 아무리 많이 끝나도 회수가 멈추기 때문이다.

  • In-place (PostgreSQL): 옛 버전이 힙(heap)의 현재 행 옆에 함께 존재한다. 장점은 읽기 경로가 단순하다. 단점은 bloat, HOT update 최적화의 필요, 그리고 모든 페이지를 훑는 무거운 vacuum 비용이다.
  • Out-of-place (Oracle, MySQL InnoDB, CUBRID): 옛 버전이 별도 영역에 산다. 그 별도 영역은 undo 세그먼트일 수도 있고 redo/undo 로그일 수도 있다. 현재 행은 포인터(LSN, undo locator)만 보유한다. 장점은 힙이 작게 유지된다. 또한 로그는 이미 회수에 적합한 구조를 가진다. 단점은 옛 버전 읽기에 한 단계 간접 참조 비용이 추가된다.

이 선택은 vacuum 복잡도, 복구 의미론(recovery semantics), 버전 체인의 모양으로 연쇄된다.

격리 수준은 스냅샷 획득 시점의 정책

섹션 제목: “격리 수준은 스냅샷 획득 시점의 정책”

대부분의 SI 엔진은 격리 수준(isolation level)별로 같은 MVCC 기계를 재사용한다. 다른 점은 스냅샷 획득 시점 뿐이다.

  • READ COMMITTED — statement마다 새 스냅샷을 만든다.
  • REPEATABLE READ / SI — 트랜잭션 시작 시 한 번 만든다.
  • SERIALIZABLE — SI만으로는 write skew를 허용한다는 한계가 있다. 현실적 대응은 두 가지다. 하나는 술어 잠금(predicate locking; PostgreSQL SSI)을 추가하는 방법이다. 다른 하나는 쓰기 경로에서 잠금 기반 직렬화로 폴백하는 방법이다. CUBRID는 후자를 택해 lock manager가 이 부담을 진다(동반 분석서 참조).

이론 ↔ CUBRID 명칭 매핑 (Theory ↔ CUBRID mapping)

섹션 제목: “이론 ↔ CUBRID 명칭 매핑 (Theory ↔ CUBRID mapping)”

§학술적 배경의 텍스트북 개념과 CUBRID의 명명된 엔티티가 다음과 같이 대응된다. ## CUBRID의 구현은 각 행에 대한 천천히 줌인.

이론 (Theory)CUBRID 명칭
버전별 타임스탬프 (per-version timestamp)MVCCID — 64비트 카운터, 첫 쓰기에 지연 발급(lazy)
레코드 안의 삽입자/삭제자 스탬프mvcc_rec_header.mvcc_ins_id, mvcc_del_id
옛 버전 체인 (out-of-place)mvcc_rec_header.prev_version_lsa → 로그 영역의 사본
스냅샷 시점 활성 집합 (active set)mvcc_active_tran — 비트 배열 + 장기 트랜잭션 오버플로 배열
트랜잭션별 스냅샷mvcc_snapshot — 활성 집합 + 하한·상한 MVCCID 스칼라
가시성 술어 (visibility predicate)mvcc_satisfies_snapshot — 3치 결과 반환
전역 레지스트리 (global registry)mvcctable + m_trans_status_history[2048] 순환 링
보이는 가장 오래된 워터마크 (oldest visible)mvcctable::m_oldest_visible (atomic)

CUBRID는 위의 관행을 세 축으로 풀어낸다. 첫째는 활성 집합 관리를 소유하는 전역 mvcctable이다. 둘째는 트랜잭션 디스크립터(transaction descriptor)에 매달린 트랜잭션별 mvcc_info다. 셋째는 전역 워터마크를 읽는 별도 vacuum 프로세스다. 차별화된 선택은 셋이다. (1) 지연 MVCCID 발급(lazy issuance) — 오직 writer만 ID를 소비한다. (2) 활성 집합을 정렬 집합 대신 비트 배열 + 오버플로 리스트(overflow list) 로 인코딩한다. (3) 스냅샷 구성을 커밋 경로를 lock-free 로 수행하며, 2048 슬롯 history ring의 슬롯별 atomic 버전으로 검증한다.

Figure 1 — Overall structure

Figure 1 — The big picture: a central mvcc_trans_status registry on the left, transactions in the middle each carrying their own snapshot and MVCCID, and the user-visible table on the right being read or written. The arrows mark the three core operations: snapshot creation against the registry, MVCCID deactivation on commit, and the per-snapshot read against the table. (Source: original mvcc analysis deck, slide 5.)

MVCC 연산의 흐름 (How an MVCC operation flows)

섹션 제목: “MVCC 연산의 흐름 (How an MVCC operation flows)”
flowchart LR
  A["트랜잭션 begin"] --> B{"첫 쓰기?\n(DDL / DML)"}
  B -- "예" --> C["mvcctable::get_new_mvccid\n→ MVCCID 발급"]
  B -- "아니오 (read-only)" --> D
  C --> D["statement 실행"]
  D --> E{"스냅샷이 필요?\n(격리 수준에 따라)"}
  E -- "예" --> F["mvcctable::build_mvcc_info\n→ 활성 집합을 lock-free 복사"]
  E -- "아니오" --> G
  F --> G["레코드별 가시성:\nmvcc_satisfies_snapshot"]
  G --> H["commit / rollback"]
  H --> I["mvcctable::complete_mvcc\n→ 비트 뒤집기, ring 슬롯 게시,\n필요 시 lowest_active 전진"]
  I --> J["vacuum이 m_oldest_visible\n보다 오래된 버전을 회수"]

각 박스는 아래 서브섹션에서 풀어 본다. 박스 자체는 움직이지 않으며, 단지 상세도가 깊어질 뿐이다.

flowchart LR
  subgraph TX["트랜잭션 별 상태 (log_tdes)"]
    MI["mvcc_info\n• id (자기 MVCCID)\n• snapshot\n• recent_lowest_active\n• sub_ids"]
  end

  subgraph TBL["전역 mvcctable (log_Gl.mvcc_table)"]
    CUR["m_current_trans_status\n(현재 mvcc_active_tran)"]
    HIST["m_trans_status_history[2048]\n(과거 상태 순환 링)"]
    OV["m_oldest_visible\n(vacuum 워터마크)"]
    LV["m_transaction_lowest_visible_mvccids[]\n(트랜잭션 별 스냅샷 하한)"]
  end

  subgraph LOG["활성 로그 볼륨 헤더"]
    NEXT["log_Gl.hdr.mvcc_next_id\n(MVCCID 카운터)"]
  end

  VAC[("vacuum master")]

  MI -- build_mvcc_info --> HIST
  CUR -- complete_mvcc / get_new_mvccid --> HIST
  CUR -- get_new_mvccid --> NEXT
  LV -- update_global_oldest_visible --> OV
  OV --> VAC
  • MVCCID는 지연 할당된다. 트랜잭션의 첫 쓰기 연산(DDL 또는 DML) 시점에 부여된다. 읽기 전용 트랜잭션은 MVCCID를 받지 않으므로 활성 집합 용량을 소비하지 않는다.
  • 쓰기 트랜잭션 당 정확히 하나의 MVCCID. 같은 트랜잭션 안의 후속 쓰기는 동일 MVCCID를 재사용한다. 서브트랜잭션은 별도 ID를 받는다.
  • MVCCID 카운터 자체는 MVCC 테이블이 보유하지 않는다. 활성 로그 볼륨 헤더에 들어 있다.
// mvcctable::get_new_mvccid — src/transaction/mvcc_table.cpp
MVCCID
mvcctable::get_new_mvccid ()
{
MVCCID id;
m_new_mvccid_lock.lock ();
id = log_Gl.hdr.mvcc_next_id;
MVCCID_FORWARD (log_Gl.hdr.mvcc_next_id);
m_new_mvccid_lock.unlock ();
return id;
}

전용 뮤텍스 m_new_mvccid_lock이 MVCCID 발급을 핫 패스인 m_active_trans_mutex 경로에서 떼어 놓는다. mvcc_table.hpp의 주석은 이론적으론 atomic 연산으로 대체 가능함을 언급한다.

힙과 인덱스의 모든 레코드는 mvcc_rec_header를 들고 있다. 플래그 바이트가 어떤 선택 필드가 물리적으로 존재하는지를 통제하므로, 사용하지 않는 MVCC 슬롯은 0바이트를 차지한다.

// mvcc_rec_header — src/transaction/mvcc.h
struct mvcc_rec_header
{
INT32 mvcc_flag:8; /* MVCC flags */
INT32 repid:24; /* representation id */
int chn; /* cache coherency number */
MVCCID mvcc_ins_id; /* MVCC insert id */
MVCCID mvcc_del_id; /* MVCC delete id */
LOG_LSA prev_version_lsa; /* log address of previous version */
};
flowchart TB
  R0["row v0 (현재, 힙)\nins_id = 7, del_id = 12\nprev_version_lsa → 로그 엔트리 A"]
  R1["row v_-1 (로그 안)\nins_id = 3, del_id = 7\nprev_version_lsa → 로그 엔트리 B"]
  R2["row v_-2 (로그 안)\nins_id = 0, del_id = 3\nprev_version_lsa = NULL"]
  R0 --> R1 --> R2

PostgreSQL과 달리 CUBRID에서는 옛 버전이 현재 버전 옆에 인라인으로 보존되지 않는다. 대신 prev_version_lsa를 따라 로그 안으로 거슬러 올라가는 방식이다. vacuum과 복구도 거기서 옛 버전을 찾아 읽는다.

CUBRID에서 가장 특색 있는 부분은 현재 활성 MVCCID 집합을 어떻게 인코딩하는가이다. 단순한 std::set<MVCCID>로 표현하면 스냅샷 빌드 비용과 가시성 검사 비용이 모두 폭발하기 때문이다. 그래서 CUBRID는 비트 배열 + 오버플로 배열 하이브리드를 쓴다.

// mvcc_active_tran (private members) — src/transaction/mvcc_active_tran.hpp
private:
using unit_type = std::uint64_t;
static const size_t BITAREA_MAX_SIZE = 500; // 500 * 64 = 32k MVCCIDs
static const unit_type ALL_ACTIVE = 0;
static const unit_type ALL_COMMITTED = (unit_type) -1;
/* bit area to store MVCCIDS status - size BITAREA_MAX_SIZE */
unit_type *m_bit_area;
/* first MVCCID whose status is stored in bit area */
volatile MVCCID m_bit_area_start_mvccid;
/* the area length expressed in bits */
volatile size_t m_bit_area_length;
/* long time transaction mvccid array */
MVCCID *m_long_tran_mvccids;
volatile size_t m_long_tran_mvccids_length;

각 비트가 하나의 MVCCID를 의미한다. 0 = 활성, 1 = completed (commit 또는 rollback). 비트 오프셋 im_bit_area_start_mvccid + i를 가리킨다. 기본 용량은 500 unit = 최근 32 000개 MVCCID. 윈도우 뒤로 밀리는데도 아직 활성인 ID는 정렬된 m_long_tran_mvccids 배열로 옮겨 저장하며, 크기는 max_transactions 에 의해 정해진다.

flowchart LR
  subgraph BA["m_bit_area (LSB → MSB, MVCCID 증가)"]
    direction LR
    U0["unit 0\n0011 0111 ..."]
    U1["unit 1\n1111 1111 ..."]
    U2["unit 2\n0110 0110 ..."]
    DOTS["..."]
    UN["unit ≤ 499"]
    U0 --> U1 --> U2 --> DOTS --> UN
  end
  START["m_bit_area_start_mvccid\n(unit 0의 LSB가 가리키는 MVCCID)"] --> U0
  LT["m_long_tran_mvccids[]\n(start보다 작은 활성 MVCCID, 정렬됨)"]
  LT -. "이쪽으로 이동" .-> BA

Figure 2 — Bit-array layout

Figure 2 — Bit-array layout: 500 units × 64 bits = 32 000 MVCCIDs of recent history. m_bit_area_start_mvccid anchors unit 0’s LSB, so MVCCID = start + bit_offset. Bit value 0 = active, 1 = completed. (Source: deck slide 12.)

가시성 검사(mvcc_active_tran::is_active): 질의된 MVCCID가 m_bit_area_start_mvccid보다 작으면 long-tran 배열을 스캔하고, 아니면 비트를 직접 읽는다. 흔한 경우(최근 발급된 ID)는 O(1).

MVCCID가 completed될 때 비트가 뒤집힌다. 그 후 비트 배열의 앞쪽 연속된 unit이 모두 ALL_COMMITTED로 차면 그 unit들은 LTRIM되어 (m_bit_area_start_mvccid가 전진) 제거된다. 그래도 비트 배열이 LONG_TRAN_THRESHOLD를 넘으면, 남아 있는 활성 ID들이 long-tran 배열로 이주된다. 캐시된 두 스칼라가 흔한 질의를 단축한다: compute_highest_completed_mvccid, compute_lowest_active_mvccid.

Figure 3 — lowest_active_mvccid cached scalar

Figure 3 — lowest_active_mvccid cached scalar. ○ = active, ● = completed. Everything strictly below the cached value is known-completed without probing the bit area, which short-circuits the common case in mvcc_is_id_in_snapshot. (Source: deck slide 19.)

전역 레지스트리. 핵심 비공개 멤버:

// mvcctable (private members) — src/transaction/mvcc_table.hpp
class mvcctable
{
/* ... public API ... */
private:
static const size_t HISTORY_MAX_SIZE = 2048; // must be a power of 2
static const size_t HISTORY_INDEX_MASK = HISTORY_MAX_SIZE - 1;
lowest_active_mvccid_type *m_transaction_lowest_visible_mvccids;
size_t m_transaction_lowest_visible_mvccids_size;
lowest_active_mvccid_type m_current_status_lowest_active_mvccid;
mvcc_trans_status m_current_trans_status;
std::atomic<size_t> m_trans_status_history_position;
mvcc_trans_status *m_trans_status_history;
std::mutex m_new_mvccid_lock;
std::mutex m_active_trans_mutex;
std::atomic<MVCCID> m_oldest_visible;
std::atomic<size_t> m_ov_lock_count;
};

두 뮤텍스가 경쟁을 분할한다. m_new_mvccid_lock은 단조 증가 ID 발급에 쓰이고, m_active_trans_mutex는 활성 집합 전이에 쓰인다. 히스토리 링과 각 슬롯의 원자 버전 카운터 덕분에 스냅샷 빌더(읽기)는 잠금 없이 동작한다.

// mvcctable::build_mvcc_info — src/transaction/mvcc_table.cpp
// (lock-free retry loop, condensed)
while (true)
{
snapshot_retry_count++;
/* ... 트랜잭션의 lowest_visible을 MVCCID_ALL_VISIBLE로 잠시 두었다가
* crt_status_lowest_active로 갱신, 이 순서가 vacuum 안전성을 보장 ... */
index = m_trans_status_history_position.load ();
assert (index < HISTORY_MAX_SIZE);
const mvcc_trans_status &trans_status = m_trans_status_history[index];
trans_status_version = trans_status.m_version.load ();
trans_status.m_active_mvccs.copy_to (
tdes.mvccinfo.snapshot.m_active_mvccs,
mvcc_active_tran::copy_safety::THREAD_UNSAFE);
/* ... 전역 통계 로드 ... */
if (trans_status_version == trans_status.m_version.load ())
{
// 버전 변동 없음; 복사 성공
break;
}
else
{
// 손상된 복사본은 다음 복사를 오염시킬 수 있으므로 비트 배열 리셋
tdes.mvccinfo.snapshot.m_active_mvccs.reset_active_transactions ();
}
}
tdes.mvccinfo.recent_snapshot_lowest_active_mvccid = crt_status_lowest_active;
tdes.mvccinfo.snapshot.snapshot_fnc = mvcc_satisfies_snapshot;
tdes.mvccinfo.snapshot.lowest_active_mvccid = crt_status_lowest_active;
tdes.mvccinfo.snapshot.highest_completed_mvccid = highest_completed_mvccid;
tdes.mvccinfo.snapshot.valid = true;

이것이 lock-free 읽기 패턴의 전부다. 읽는 쪽은 링 인덱스를 가져온 뒤 슬롯의 m_version을 복사 두 시점에 모두 읽는다. 그 사이에 쓰는 쪽이 슬롯을 건드렸다면 재시도하는 방식이다.

sequenceDiagram
    participant TX as 트랜잭션
    participant TBL as mvcctable
    participant RING as history[pos]
    participant SNAP as tx.snapshot

    TX->>TBL: build_mvcc_info(tdes)
    loop 버전이 안정될 때까지
        TBL->>RING: pos = m_trans_status_history_position.load()
        TBL->>RING: v1 = ring[pos].m_version
        TBL->>SNAP: 활성 MVCCID 복사 (bit_area + long_tran)
        TBL->>RING: v2 = ring[pos].m_version
        alt v1 == v2
            note right of TBL: 안정된 복사본 — 종료
        else 변경됨
            TBL->>SNAP: reset_active_transactions
            note right of TBL: 재시도 — perfmon이 횟수 집계
        end
    end
    TBL->>SNAP: lowest_active = crt_status_lowest_active
    TBL->>SNAP: highest_completed = 복사본에서 계산
    TBL->>SNAP: snapshot_fnc = mvcc_satisfies_snapshot
    TBL->>SNAP: valid = true

스냅샷은 생성 후 읽기 전용이다. SI는 같은 스냅샷 안의 모든 질의가 동일한 커밋된 버전 집합을 보도록 보장한다.

스냅샷 획득 시점은 격리 수준에 따라 다르다. 시점은 실행기가 logtb_get_mvcc_snapshot 호출 시 결정한다.

격리 수준스냅샷 획득 시점
READ COMMITTED (4)기존 행을 건드리는 SQL 구문 실행 시마다
REPEATABLE READ (5)트랜잭션 시작 시 한 번
SERIALIZABLE (6)트랜잭션 시작 시 한 번

READ COMMITTED에서도 기존 데이터에 접근하지 않는 구문(CREATE, 일반 DROP, DELETE로 구현되지 않은 TRUNCATE)은 스냅샷 획득을 건너뛴다.

가시성 판정 — mvcc_satisfies_snapshot

섹션 제목: “가시성 판정 — mvcc_satisfies_snapshot”

판정은 두 갈래(삭제 여부)로 나뉘고 세 가지 verdict 중 하나를 반환한다.

// mvcc_satisfies_snapshot — src/transaction/mvcc.c (condensed)
MVCC_SATISFIES_SNAPSHOT_RESULT
mvcc_satisfies_snapshot (THREAD_ENTRY * thread_p,
MVCC_REC_HEADER * rec_header,
MVCC_SNAPSHOT * snapshot)
{
if (!MVCC_IS_HEADER_DELID_VALID (rec_header))
{
/* Record is not deleted */
if (!MVCC_IS_FLAG_SET (rec_header, OR_MVCC_FLAG_VALID_INSID))
return SNAPSHOT_SATISFIED; /* 모두에게 보임 */
else if (MVCC_IS_REC_INSERTED_BY_ME (...))
return SNAPSHOT_SATISFIED; /* 내 insert */
else if (MVCC_IS_REC_INSERTER_IN_SNAPSHOT (...))
return TOO_NEW_FOR_SNAPSHOT; /* 삽입자가 활성이거나
* 스냅샷 후 commit */
else
return SNAPSHOT_SATISFIED; /* 스냅샷 전에 commit */
}
else
{
/* Record is deleted */
if (MVCC_IS_REC_DELETED_BY_ME (...))
return TOO_OLD_FOR_SNAPSHOT; /* 내가 삭제 */
else if (MVCC_IS_REC_INSERTER_IN_SNAPSHOT (...))
return TOO_NEW_FOR_SNAPSHOT; /* 삽입자 아직 활성 */
else if (MVCC_IS_REC_DELETER_IN_SNAPSHOT (...))
return SNAPSHOT_SATISFIED; /* 삭제자 아직 활성/
* 스냅샷 후 commit */
else
return TOO_OLD_FOR_SNAPSHOT; /* 삭제자 스냅샷 전 commit */
}
}
flowchart TD
  A["레코드 헤더"] --> B{"삭제됨?\n(DELID 플래그 유효)"}
  B -- "아니오" --> C{"VALID_INSID 플래그?"}
  C -- "아니오" --> R1["SNAPSHOT_SATISFIED\n(모두에게 보임)"]
  C -- "예" --> D{"내가 삽입했는가?"}
  D -- "예" --> R2["SNAPSHOT_SATISFIED"]
  D -- "아니오" --> E{"삽입자가 스냅샷의\n활성 집합에 있는가?"}
  E -- "예" --> R3["TOO_NEW_FOR_SNAPSHOT\n→ prev_version_lsa 따라 옛 버전 추적"]
  E -- "아니오" --> R4["SNAPSHOT_SATISFIED"]

  B -- "예" --> F{"내가 삭제했는가?"}
  F -- "예" --> R5["TOO_OLD_FOR_SNAPSHOT"]
  F -- "아니오" --> G{"삽입자가 스냅샷의\n활성 집합에 있는가?"}
  G -- "예" --> R6["TOO_NEW_FOR_SNAPSHOT"]
  G -- "아니오" --> H{"삭제자가 스냅샷의\n활성 집합에 있는가?"}
  H -- "예" --> R7["SNAPSHOT_SATISFIED\n(아직 삭제가 보이지 않음)"]
  H -- "아니오" --> R8["TOO_OLD_FOR_SNAPSHOT"]

삽입자/삭제자가 스냅샷의 활성 집합에 있는가? 검사는 결국 비트 배열 빠른 경로로 들어간다.

// mvcc_is_id_in_snapshot — src/transaction/mvcc.c
STATIC_INLINE bool
mvcc_is_id_in_snapshot (THREAD_ENTRY * thread_p, MVCCID mvcc_id, MVCC_SNAPSHOT * snapshot)
{
if (MVCC_ID_PRECEDES (mvcc_id, snapshot->lowest_active_mvccid))
return false; /* 스냅샷 전에 확실히 commit */
if (MVCC_ID_FOLLOW_OR_EQUAL (mvcc_id, snapshot->highest_completed_mvccid))
return true; /* 확실히 활성 또는 미래 */
return snapshot->m_active_mvccs.is_active (mvcc_id); /* 비트 배열 조회 */
}

두 스칼라 경계(lowest_active, highest_completed)가 대부분의 MVCCID를 비트 배열 조회를 생략시킨다.

Figure 4 — Visibility worked example

Figure 4 — Visibility worked example. Three concurrent transactions (A: snapshot {18, 19, 30}; B: snapshot {19, 30, 32}, MVCCID 32; C: snapshot {19, 30, 32, 34}, MVCCID 34) reading four record versions on the left. The colored circles on the right enumerate which snapshots see which version. Note the asymmetry of insert vs. delete visibility — record (ins=18, del=32) is visible to A (deleter not yet committed at A’s snapshot), to B (the deleter itself), and not visible to C (deleter committed before C’s snapshot). (Source: deck slide 26.)

커밋 경로는 활성 집합 상태와 히스토리 링이 함께 전진하는 지점이다.

// mvcctable::complete_mvcc — src/transaction/mvcc_table.cpp (condensed)
void
mvcctable::complete_mvcc (int tran_index, MVCCID mvccid, bool committed)
{
std::unique_lock<std::mutex> ulock (m_active_trans_mutex);
mvcc_trans_status::version_type next_version;
size_t next_index;
mvcc_trans_status &next_status = next_trans_status_start (next_version, next_index);
/* ... committed면 통계 갱신 ... */
// 현재 trans status 갱신
m_current_trans_status.m_active_mvccs.set_inactive_mvccid (mvccid);
m_current_trans_status.m_last_completed_mvccid = mvccid;
m_current_trans_status.m_event_type = committed ? COMMIT : ROLLBACK;
// 다음 trans status를 마무리(링에 게시)
next_tran_status_finish (next_status, next_index);
/* ... vacuum의 lowest_visible 배열 보정 ... */
ulock.unlock ();
// 잠금 밖에서 lowest_active를 전진시킬지 결정
MVCCID global_lowest_active = m_current_status_lowest_active_mvccid;
if (global_lowest_active == mvccid
|| MVCC_ID_PRECEDES (mvccid, next_status.m_active_mvccs.get_bit_area_start_mvccid ()))
{
MVCCID new_lowest_active = next_status.m_active_mvccs.compute_lowest_active_mvccid ();
if (next_status.m_version.load () == next_version)
advance_oldest_active (new_lowest_active);
}
}

단일 commit/rollback의 시퀀스:

sequenceDiagram
    participant TX as 트랜잭션
    participant CUR as m_current_trans_status
    participant RING as 히스토리 링
    participant LV as lowest_visible[tran_index]

    TX->>CUR: m_active_trans_mutex 잠금
    CUR->>RING: next_trans_status_start → 슬롯 N+1 예약, 버전 +1
    TX->>CUR: m_active_mvccs.set_inactive_mvccid(mvccid)
    TX->>CUR: m_last_completed = mvccid — event_type = COMMIT or ROLLBACK
    CUR->>RING: next_tran_status_finish → CUR을 슬롯에 복사, position 저장
    TX->>LV: committed면 mvccid로 클램프 — rollback이면 MVCCID_NULL
    TX->>CUR: 잠금 해제
    opt mvccid가 가장 작은 활성이었다면
        TX->>CUR: compute_lowest_active_mvccid + advance_oldest_active
    end

m_trans_status_history_position은 스냅샷 읽는 쪽이 load하는 atomic 이다. 이걸 마지막에 갱신해야 새로운 상태가 읽는 쪽에 가시화된다.

vacuum은 살아 있는 어느 스냅샷에든 보이는 버전은 제거할 수 없다. CUBRID의 vacuum master는 mvcctable::update_global_oldest_visible을 주기적으로 호출하여 모든 m_transaction_lowest_visible_mvccids[idx]와 현재의 m_current_status_lowest_active_mvccid를 훑는다.

// mvcctable::compute_oldest_visible_mvccid — src/transaction/mvcc_table.cpp (excerpt)
MVCCID lowest_active_mvccid = oldest_active_get (
m_current_status_lowest_active_mvccid, 0,
oldest_active_event::GET_OLDEST_ACTIVE);
for (size_t idx = 0; idx < m_transaction_lowest_visible_mvccids_size; idx++)
{
loaded_tran_mvccid = oldest_active_get (
m_transaction_lowest_visible_mvccids[idx], idx,
oldest_active_event::GET_OLDEST_ACTIVE);
if (loaded_tran_mvccid == MVCCID_ALL_VISIBLE)
{
waiting_mvccids_pos.append (idx); /* 나중에 재확인 */
}
else if (loaded_tran_mvccid != MVCCID_NULL
&& MVCC_ID_PRECEDES (loaded_tran_mvccid, lowest_active_mvccid))
{
lowest_active_mvccid = loaded_tran_mvccid;
}
}

vacuum master는 결과를 atomic m_oldest_visible에 게시한다. 레코드별 mvcc_satisfies_vacuum은 이 단일 값만 읽어서 결정한다.

이 설계에는 잘 알려진 비용이 따른다. 작은 MVCCID를 가진 단일 장기 쓰기 트랜잭션 하나만 있어도 m_oldest_visible이 고정된다. 그러면 그보다 새로운 어떤 버전도 vacuum할 수 없게 된다. 짧은 트랜잭션이 그 동안 얼마나 많이 지나갔는지는 무관하다.

Figure 5 — Vacuum watermark calculation

Figure 5 — Vacuum watermark calculation. The 2048-slot history ring holds three live versions v0/v1/v2 with per-version active-set snapshots {10, 13, 17}, {13, 17}, {13, 17, 18}. The m_transaction_lowest_visible_mvccids[] array gives each in-flight transaction’s snapshot floor (MVCCID_NULL = transaction ended, ignored). m_oldest_visible is the minimum of all live floors — here 13 — and is what vacuum_master consults to decide which versions are reclaimable. (Source: deck slide 30.)

심볼명을 anchor로 삼는다 — 라인번호가 아니다. CUBRID 소스는 시간이 지나면 변한다. 그에 비해 함수명·struct 태그·enum 태그 같은 심볼은 잘 변하지 않는 안정적인 식별자다. 현재 위치는 git grep -n '<symbol>' src/transaction/으로 찾으면 된다. 이 섹션 끝의 위치 힌트 표는 문서가 마지막으로 updated: 된 시점에 관찰한 값이며, 시간이 지나면 어긋날 수 있다.

  • struct mvcc_rec_header (mvcc.h) — 레코드 위 MVCC 필드(플래그 바이트, ins/del MVCCID, prev_version_lsa).
  • enum mvcc_satisfies_snapshot_result (mvcc.h) — 가시성 verdict 3종(SNAPSHOT_SATISFIED, TOO_OLD_FOR_SNAPSHOT, TOO_NEW_FOR_SNAPSHOT).
  • struct mvcc_snapshot (mvcc.h) — 내장 m_active_mvccs와 두 스칼라(lowest_active/highest_completed).
  • struct mvcc_info (mvcc.h) — 활성 트랜잭션의 MVCC 상태, log_tdes에 매달려 있음.
  • struct mvcc_trans_status (mvcc_table.hpp) — 히스토리 링의 한 슬롯이자 현재 상태의 형.
  • class mvcctable (mvcc_table.hpp) — 히스토리 링과 두 뮤텍스를 보유한 전역 테이블.
  • struct mvcc_active_tran (mvcc_active_tran.hpp) — 비트 배열 + long-tran 활성 집합.
  • mvcctable::build_mvcc_info (mvcc_table.cpp) — 버전 검증 재시도 를 동반한 lock-free 스냅샷 복사.
  • mvcctable::compute_oldest_visible_mvccid (mvcc_table.cpp).
  • mvcctable::is_active (mvcc_table.cpp) — 내장된 mvcc_active_tran에 위임.
  • mvcctable::complete_mvcc (mvcc_table.cpp) — commit/rollback 훅; 히스토리 링을 전진시킴.
  • mvcctable::get_new_mvccid (mvcc_table.cpp) — log_Gl.hdr.mvcc_next_idm_new_mvccid_lock 아래 발급.
  • mvcctable::update_global_oldest_visible (mvcc_table.cpp) — vacuum을 위한 m_oldest_visible의 단일 출처.
  • mvcc_is_id_in_snapshot — 두 스칼라 경계로 단축, 마지막에 비트 배열 조회.
  • mvcc_is_active_idrecent_snapshot_lowest_active_mvccid에 대한 트랜잭션 단위 빠른 경로.
  • mvcc_satisfies_snapshot — 결정 트리(삭제 여부 → 삽입자/삭제자 가시성).
  • mvcc_is_not_deleted_for_snapshot — DML 아직 지울 수 있는가 검사.
  • mvcc_satisfies_vacuum — vacuum의 레코드별 결정.
  • mvcc_satisfies_delete — 삭제 시 5단계 분류 (DELETE_RECORD_INSERT_IN_PROGRESS / _CAN_DELETE / _DELETED / _DELETE_IN_PROGRESS / _SELF_DELETED).

이 라인번호는 문서가 마지막으로 updated: 된 시점의 관찰값이다. 다른 정의에 도착한다면, 위의 심볼명이 정본이다 — 지나가는 길에 표를 갱신해 주면 된다.

심볼파일라인
struct mvcc_rec_headermvcc.h38
enum mvcc_satisfies_snapshot_resultmvcc.h159
struct mvcc_snapshotmvcc.h173
struct mvcc_infomvcc.h196
struct mvcc_trans_statusmvcc_table.hpp40
class mvcctablemvcc_table.hpp64
struct mvcc_active_tranmvcc_active_tran.hpp31
mvcctable::build_mvcc_infomvcc_table.cpp226
mvcctable::compute_oldest_visible_mvccidmvcc_table.cpp355
mvcctable::is_activemvcc_table.cpp423
mvcctable::complete_mvccmvcc_table.cpp465
mvcctable::get_new_mvccidmvcc_table.cpp566
mvcctable::update_global_oldest_visiblemvcc_table.cpp617
mvcc_is_id_in_snapshotmvcc.c91
mvcc_is_active_idmvcc.c123
mvcc_satisfies_snapshotmvcc.c156
mvcc_is_not_deleted_for_snapshotmvcc.c280
mvcc_satisfies_vacuummvcc.c321
mvcc_satisfies_deletemvcc.c389

각 항목은 현재 소스에 대한 사실이며, 원본 분석 자료를 함께 보지 않아도 그 자체로 읽힌다. 끝의 부연은 어떻게 검증되었는지와, 관련이 있을 때 역사적 드리프트 또는 검증의 한계를 적는다. “미해결 질문”은 큐레이터가 해결을 미루고 기록해 둔 갭이다 — 차후 독자는 이를 알려진 버그가 아니라 시작점으로 다룬다.

  • 비트 배열 상한은 BITAREA_MAX_SIZE = 500 unit (= 최근 32 000개 MVCCID). mvcc_active_tran.hpp에 하드코딩되어 있고, 이주 임계값(LONG_TRAN, CLEANUP)도 mvcc_active_tran.cpp에 나란히 있다. 런타임 파라미터 아님 — 튜닝하려면 코드 변경 필요.

  • MVCCID 카운터의 소유자는 활성 로그 볼륨 헤더 (log_Gl.hdr.mvcc_next_id)이지 MVCC 테이블이 아니다. mvcctable::get_new_mvccid(mvcc_table.cpp)에서 확인. 전용 m_new_mvccid_lock이 발급을 활성 집합 핫 패스에서 떼어 놓으며, mvcc_table.hpp의 주석은 이를 atomic 연산으로 대체할 수 있음을 언급한다.

  • MVCC 계층은 build_mvcc_info만 노출한다 — 격리 수준에 따른 스냅샷 획득 시점 정책은 src/transaction/mvcc*.c 밖에 산다. RC의 statement마다 스냅샷 규칙은 트랜잭션 디스크립터/xasl 코드의 logtb_get_mvcc_snapshot 호출지에서 강제된다. 그 동작을 감사하려면 본 문서 밖의 코드를 추적해야 한다.

  • mvcc_rec_header는 5비트 마스크 OR_MVCC_FLAG_MASK = 0x1f 안에 세 비트를 문서화 사용한다. 문서화된 비트: VALID_INSID, VALID_DELID, VALID_PREV_VERSION. 마스크의 두 비트는 현재 미사용 — 미해결 질문 §1 참조.

  • 서브트랜잭션은 코드에 존재한다 (mvcc_info::sub_ids, complete_sub_mvcc) — savepoint 의미론에 영향. mvcc.hmvcc_table.cpp에 있지만, 본 문서 본문은 다루지 않는다 — 후속 분석이 필요하다.

  1. OR_MVCC_FLAG_MASK의 미사용 두 비트. 계획된 기능을 위해 예약된 것인가 (분산 MVCC? 삭제자 없는 tombstone?), 단순 여유 인가? 추적 경로: 비트 정의를 git history로 거슬러 추적하고, 마스크에 손대는 in-flight CBRD 티켓 검색.

  2. 2048 슬롯 history ring의 포화 동작. 어떤 워크로드에서 HISTORY_MAX_SIZE = 2048이 포화하며, 스냅샷의 출처 슬롯이 빌드 도중 덮어 써지면 CUBRID는 어떻게 대응하는가? build_mvcc_info 안의 atomic m_version 검증이 재시도 루프 (snapshot_retry_count)를 구동하지만 최악의 재시도 상한은 미상. 추적 경로: 경합 워크로드에서 snapshot_retry_count 계측.

  3. SERIALIZABLE에서의 write-skew 처리. 순수 SI는 write skew 를 허용한다. PostgreSQL SSI는 탐지 후 abort하는 반면, CUBRID 는 거의 확실히 잠금 기반 직렬화로 폴백한다. 추적 경로: SERIALIZABLE 쓰기 경로를 lock_object 호출로 추적; cubrid-lock-manager.md의 §NON2PL과 §CUBRID 너머 교차 참조.

CUBRID 너머 — 비교 설계와 연구 동향 (Beyond CUBRID — Comparative Designs & Research Frontiers)

섹션 제목: “CUBRID 너머 — 비교 설계와 연구 동향 (Beyond CUBRID — Comparative Designs & Research Frontiers)”

분석이 아닌 포인터(pointers). 각 항목은 후속 문서의 시작점이며, 깊이는 의도적으로 얕다.

  • PostgreSQL SSI — Serializable Snapshot Isolation(Cahill et al., SIGMOD 2008; Ports & Grittner, VLDB 2012)는 SI 위에 술어 잠금(predicate locking)과 의존 그래프(dependency graph) 사이클 탐지를 더해 커밋 시점에 write skew를 잡는다. CUBRID의 SERIALIZABLE은 lock manager에 의존하는 쪽이다. 양쪽의 비용 모델을 나란히 비교하면 술어 잠금을 피하는 대가로 무엇을 내주는지 분명해 진다.
  • In-memory MVCC 엔진 (HyPer, Hekaton, Cicada)은 캐시 친화적 인메모리(in-memory) 레이아웃을 위해 버전 체인을 재설계하며 중앙 레지스트리를 제거하는 경우가 많다. CUBRID는 디스크 거주(disk- resident)이며 비교는 직교적이지만, MVCC에 본질적인 비용디스크 거주 MVCC에 본질적인 비용 을 가르는 데 유용하다. Wu et al., In-Memory MVCC Empirical Evaluation(VLDB 2017)이 설계 공간 을 정리한다.
  • In-place vs out-of-place 트레이드오프의 측정. PostgreSQL의 bloat / HOT 문제와 CUBRID의 vacuum 읽기 증폭(read amplification) 은 같은 선택의 대칭적 비용이다. 실증 문헌을 추적해 우리가 undo 로그 버전 체인에 지불하는 비용을 수치화하면 가치가 크다.
  • 고 코어 수에서의 동시성 제어. Yu et al., Staring into the Abyss(VLDB 2015)는 1 000 코어에서 7개 CC 프로토콜을 벤치마크한다. CUBRID가 현재 서버당 코어 수를 넘어서 확장하려 한다면 직접적 관련성이 있다.
  • Hybrid OCC + MVCC. 현대 엔진들은 MVCC 읽기에 낙관적 쓰기 검증 (optimistic write validation)을 짝짓는 경우가 많다. CUBRID의 NON2PL 메커니즘이 이쪽으로 가는 디딤돌인지는 그 자체로 미해결 질문 — cubrid-lock-manager.md의 §CUBRID 너머 교차 참조.

이 섹션의 의도는 다음 문서들의 씨앗을 뿌리는 것이지, 여기서 분석 하는 것이 아니다. 각 항목은 차례가 오면 자체 큐레이트 노트가 되어야 한다.

원본 분석 (raw/code-analysis/cubrid/storage/mvcc/)

섹션 제목: “원본 분석 (raw/code-analysis/cubrid/storage/mvcc/)”
  • mvcc 코드 분석 ver 2.pdf (슬라이드 렌더링)
  • mvcc 코드 분석 ver 2.pptx (슬라이드 원본 — 텍스트 추출이 더 깨끗함)

교재 챕터 (knowledge/research/dbms-general/)

섹션 제목: “교재 챕터 (knowledge/research/dbms-general/)”
  • Database Internals (Petrov), 5장 “Transaction Processing and Recovery”, §Multiversion Concurrency Control (≈ 4002행), §Isolation Levels (≈ 4136행), §Snapshot Isolation (분산 트랜잭션 챕터의 ≈ 11266행).
  • Storage – Concurrency 코드 분석 — Lock Manager · MVCC · Vacuum이 Heap Manager · Page Buffer 위에 얹히는 모듈 단위 구도. §“정신적 모델의 세 다리(three-leg)” 프레이밍의 출처.

CUBRID 소스 (/data/hgryoo/references/cubrid/)

섹션 제목: “CUBRID 소스 (/data/hgryoo/references/cubrid/)”
  • src/transaction/mvcc.h
  • src/transaction/mvcc.c
  • src/transaction/mvcc_table.hpp
  • src/transaction/mvcc_table.cpp
  • src/transaction/mvcc_active_tran.hpp
  • src/transaction/mvcc_active_tran.cpp