콘텐츠로 이동

(KO) CUBRID 트랜잭션 — TDES, 격리 수준, 그리고 savepoint

목차

관계형 데이터베이스에서 트랜잭션은 엔진이 클라이언트에 파는 원자성 (atomicity) 과 격리성 (isolation) 의 단위다. Database Internals (Petrov) 5장 §Transactions 는 ACID 의 네 글자를 책임 지는 주체가 서로 다르다는 점을 강조한다. 원자성 (ACID-A) 은 복구 매니저가 WAL 과 CLR 로 책임지고, 내구성 (ACID-D) 은 commit 시점의 로그 force-flush 가 책임진다. 일관성 (ACID-C) 은 데이터 측 제약이 책임지고, 격리성 (ACID-I) 은 lock 매니저와 MVCC 가시성 기계가 함께 책임진다. 트랜잭션 모듈은 이 네 가닥이 한 지점에서 만나는 허브에 위치하면서 트랜잭션별 상태 를 소유한다. lock 매니저, 로그 매니저, MVCC 테이블, 복구 매니저 가 모두 그 상태를 읽고 쓴다.

이 상태의 단위가 트랜잭션 descriptor 다. CUBRID 에서는 TDES, PostgreSQL 에서는 PROC, InnoDB 에서는 trx_t 로 부른다. descriptor 가 지는 의무가 길다.

  • 연결이 끊기거나 다시 붙어도 변하지 않는 안정 식별자 (trid). 로그 레코드 모두에 이 trid가 박혀 들어간다.
  • 라이프사이클 상태 (active, committed, aborted, in-doubt 등). 복구의 analysis 패스가 재시작 시 이 상태를 다시 만들어 낸다.
  • 격리 수준. 이 값이 access 경로에서 가시성과 lock 획득의 모양 을 결정한다.
  • 일련의 LSA (head, tail, undo-next, postpone-next, savepoint, top-op). 이 LSA들이 복구와 rollback이 트랜잭션을 거꾸로 걸어가는 데 필요한 닻이다.
  • 그 외 부수 상태 — modified-classes 레지스트리, 복제 레코드, unique-index 통계, lob locator. commit / abort 시점에 정리 되어야 하는 자투리들이다.

이 모델을 실제로 구현할 때 모든 엔진이 마주치는 두 갈림길이 있고, 그 답이 본 문서의 골격을 만든다.

  1. TDES 가 어디에 살고 어떻게 명명되는가. 교과서적인 답은 트랜잭션 인덱스로 색인되는 고정 크기 트랜잭션 테이블이다. 변형은 테이블이 정적인지 동적인지, 인덱스를 어떻게 재사용 하는지, descriptor 안에서 hot 필드와 cold 필드를 분리하는지 정도다. CUBRID 은 고정 크기 테이블 측이며, 연속된 영역에서 슬롯을 할당하고 hint_free_index 로 빈 슬롯을 빠르게 찾는 방식을 쓴다.
  2. 격리를 어디서 강제하는가 — access 경계인가, statement 경계인가, 아니면 둘 다인가. 순수 SI 엔진은 가시성으로 읽기 격리를 강제한다. 순수 2PL 엔진은 lock으로 강제한다. 하이브리드 엔진 — CUBRID이 그렇다 — 은 SERIALIZABLE / REPEATABLE READ에서 스냅샷을 잡으면서 동시에 key-range 락도 잡는다. TDES 위의 isolation 필드가 이 디스패치의 키다.

이 두 질문의 답이 분명해지면, TDES 위 다른 모든 상태는 그 답을 구현하기 위해 존재하는 부속이라는 점이 보인다.

nested rollback, 격리 수준 토글, 복구를 지원하는 모든 관계형 엔진은 트랜잭션 descriptor 주변에서 비슷한 패턴을 채택한다.

작은 정수 인덱스 (transaction index) 로 색인되는 고정 크기 descriptor 배열이다. 절충은 분명하다 — 고정 크기는 클라이언트 수의 상한이 테이블 크기에 묶이지만, 인덱스 색인이 O(1)이고 메모리 레이아웃이 캐시 친화적이다. PostgreSQL은 MaxBackends 크기의 PROC 배열을 쓰고, InnoDB은 trx_sys->trx_list 를, SQL Server는 tid 해시를 쓴다. CUBRID은 고정 배열 진영이다.

격리는 TDES별 enum이며 보통 3-4 값을 가진다 (READ COMMITTED, REPEATABLE READ, SERIALIZABLE; 일부 엔진은 READ UNCOMMITTED를 추가). 이 값을 세 곳에서 읽는다. 첫째, 스냅샷 획득 시 (어떤 모양의 MVCC 스냅샷을 만들 것인가). 둘째, lock 획득 시 (key-range 락을 잡을 것인가 말 것인가). 셋째, statement 경계 시 (cursor stability 격리에서 짧은 락을 일찍 풀 것인가). 모든 엔진이 같은 3-way 스위치를 쓴다는 뜻이다.

여러 페이지를 atomic하게 만지는 연산 — B+Tree split, heap overflow 할당, 스키마 변경 — 은 서브트랜잭션 단위의 복구가 필요 하다. CUBRID 에서 system op, PostgreSQL 에서 subxact, InnoDB 에서 mtr 이라 부른다. TDES 는 진행 중인 system op 를 스택으로 관리한다. commit이 일어나면 top frame이 pop되고, 그 frame의 로그 범위가 부모로 합쳐진다. abort가 일어나면 frame이 pop되면서 그 범위가 rollback된다. 즉 트리 구조가 아니라 재귀-스택 구조다.

savepoint는 “savepoint 생성 시점의 가장 최근 로그 레코드 LSA에 이름을 붙인 것” 이다. rollback-to-savepoint는 “현재 tail LSA에서 savepoint LSA까지 모든 로그 레코드를 undo한다” 라는 뜻이다. 구현은 TDES 위의 사슬이다 — savept_lsa 와 각 savepoint 로그 레코드 안의 prv_savept 가 그 사슬을 만든다. 복구 시점에 사슬 을 다시 세우는 일도 같은 정의를 따른다.

Flag 모음이 아닌 상태 머신으로서의 lifecycle

섹션 제목: “Flag 모음이 아닌 상태 머신으로서의 lifecycle”

순진한 엔진은 bool committedbool aborted 두 개를 둔다. 실제 엔진은 10 개 이상의 값을 가진 enum 을 쓴다. 2PC 와 postpone 동작이 두 boolean 으로는 표현할 수 없는 전이를 만들어 내기 때문이다. committed-with-postpone, committed-informing- participants, 2PC-prepared, unilaterally-aborted 같은 상태들이 그 예다. CUBRID 의 TRAN_STATE 는 15 개 값을 가진다. PostgreSQL 은 2PC 가 별도 서브시스템이라 더 적게 쓰고, InnoDB 은 서버 측 2PC 가 없어 더 적다.

이론적 개념CUBRID 명칭
Transaction identifierLOG_TDES::trid (TRANID)
Transaction descriptorLOG_TDES (log_impl.h)
Transaction tableTRANTABLE log_Gl.trantable (log_impl.h)
Transaction state enumTRAN_STATE — 15개 상태 (log_comm.h)
Isolation level enumDB_TRAN_ISOLATION (compat/dbtran_def.h), TRAN_ISOLATION 으로 별칭
Active / committed / aborted predicatesLOG_ISTRAN_ACTIVE, LOG_ISTRAN_COMMITTED, LOG_ISTRAN_ABORTED 매크로 (log_impl.h)
MVCC info (가시성, 스냅샷)LOG_TDES::mvccinfo (MVCC_INFO)
Nested top-op 스택LOG_TDES::topops (LOG_TOPOPS_STACK, log_impl.h)
Top-op 로그 범위LOG_TOPOPS_ADDRESSES { lastparent_lsa; posp_lsa } per stack frame
SavepointLOG_TDES::savept_lsa + LOG_REC_SAVEPT 사슬
Postpone (commit 시 deferred 동작)LOG_TDES::posp_nxlsa + log_postpone_cache m_log_postpone_cache
Modified-class 레지스트리LOG_TDES::m_modified_classes (tx_transient_class_registry)
Per-tran B+Tree unique 통계LOG_TDES::m_multiupd_stats (multi_index_unique_stats)
2PC coordinator infoLOG_TDES::coord (LOG_2PC_COORDINATOR *, cubrid-2pc.md 에서 다룸)
2PC global tran infoLOG_TDES::gtrinfo (LOG_2PC_GTRINFO)
복구 시점 TDES 부가 정보LOG_TDES::rcv (LOG_RCV_TDES)
서버 측 commit 진입점xtran_server_commit (transaction_sr.c)
서버 측 abort 진입점xtran_server_abort (transaction_sr.c)
클라이언트 측 committran_commit (transaction_cl.c)
인덱스 재사용 할당logtb_assign_tran_index (log_tran_table.c)
System op (서브트랜잭션) 스택 pushlog_sysop_start (log_manager.c)

트랜잭션 모듈에는 네 개의 이동 부품이 있다. 모든 살아 있는 TDES 를 보관하는 trantable, TDES 그 자체, trantable 의 엔트리 들이 따라 움직이는 라이프사이클 상태 머신, 그리고 TDES 가 서브 트랜잭션 rollback 을 위해 소유하는 system-op 스택이다. 이 순서 로 본다.

flowchart LR
  subgraph CL["클라이언트 측 (transaction_cl.c)"]
    TCL["tm_Tran_index\ntm_Tran_isolation\ntm_Tran_ID"]
    API["tran_commit\ntran_abort\ntran_savepoint_internal"]
    TCL --> API
  end
  subgraph SR["서버 측 (transaction_sr.c)"]
    XSC["xtran_server_commit"]
    XSA["xtran_server_abort"]
    XSV["xtran_server_savepoint"]
  end
  subgraph TT["Trantable (log_Gl.trantable)"]
    HDR["TRANTABLE { num_total_indices, hint_free_index, all_tdes[] }"]
    T1["LOG_TDES idx=1\ntrid=42"]
    T2["LOG_TDES idx=2\ntrid=43"]
    Tn["..."]
    HDR --> T1
    HDR --> T2
    HDR --> Tn
  end
  subgraph LM["log_manager"]
    LC["log_commit"]
    LA["log_abort"]
    LS["log_sysop_start / commit / abort"]
  end
  API -->|RPC| XSC
  API -->|RPC| XSA
  API -->|RPC| XSV
  XSC --> LC
  XSA --> LA
  LC --> T1
  LA --> T1
  LS --> T1

이 그림이 보여주는 것은 트랜잭션 모듈이 걸쳐 있는 세 개의 경계다. (a) 클라이언트와 서버 사이. 클라이언트 측 TDES 는 얇은 그림자다 (tm_Tran_* 글로벌 변수들). 무거운 상태는 서버 측에 산다. (b) TDES 와 trantable 사이. trantable 이 슬롯을 소유하고, 인덱스로의 lookup 이 O(1) 이다. (c) TDES 와 로그 사이. commit, abort, savepoint, system-op 호출이 일어날 때 TDES 와 로그가 함께 갱신된다. 두 상태가 lockstep 으로 움직이는 것이 이 모듈의 정합성 약속이다.

log_impl.hLOG_TDES 가 트랜잭션 모듈의 중심 데이터 구조다. 큰 struct이며, 아래는 주석을 곁들여 핵심만 추린 슬라이스 다.

// LOG_TDES — src/transaction/log_impl.h
struct log_tdes
{
/* === MVCC and identity === */
MVCC_INFO mvccinfo; /* MVCC info — snapshot, MVCCID, sub-IDs */
int tran_index; /* Index into trantable */
TRANID trid; /* Stable transaction identifier */
/* === lifecycle === */
bool isloose_end;
TRAN_STATE state; /* 15-value enum */
TRAN_ISOLATION isolation; /* READ_COMMITTED | REPEATABLE_READ | SERIALIZABLE */
int wait_msecs; /* Lock wait timeout */
/* === LSA chain — these are the recovery anchors === */
LOG_LSA head_lsa; /* First record of this transaction */
LOG_LSA tail_lsa; /* Last record */
LOG_LSA undo_nxlsa; /* Next record to undo (compensate-aware) */
LOG_LSA posp_nxlsa; /* First / next postpone record */
LOG_LSA savept_lsa; /* Last user/system savepoint */
LOG_LSA topop_lsa; /* Last system op */
LOG_LSA tail_topresult_lsa; /* Last partial abort/commit */
LOG_LSA commit_abort_lsa; /* Commit/abort record (used by checkpoint) */
/* === client identity, locking, and 2PC === */
int client_id;
int gtrid; /* Global tran ID for 2PC */
CLIENTIDS client;
SYNC_RMUTEX rmutex_topop; /* Reentrant mutex serialising sysop begin/end */
LOG_TOPOPS_STACK topops; /* Active sub-transactional ops */
LOG_2PC_GTRINFO gtrinfo;
LOG_2PC_COORDINATOR *coord; /* NULL unless this site is the 2PC coordinator */
/* === per-transaction caches and stats === */
int num_unique_btrees;
multi_index_unique_stats m_multiupd_stats;
volatile sig_atomic_t interrupt;
tx_transient_class_registry m_modified_classes;
int num_transient_classnames;
int num_repl_records;
struct log_repl *repl_records;
LOG_LSA repl_insert_lsa;
LOG_LSA repl_update_lsa;
void *first_save_entry;
int suppress_replication;
struct lob_rb_root lob_locator_root;
INT64 query_timeout;
INT64 query_start_time;
INT64 tran_start_time;
XASL_ID xasl_id;
LK_RES *waiting_for_res; /* The lock-resource I'm blocked on, if any */
int disable_modifications;
TRAN_ABORT_REASON tran_abort_reason;
int num_exec_queries;
DB_VALUE_ARRAY bind_history[MAX_NUM_EXEC_QUERY_HISTORY];
int num_log_records_written;
LOG_TRAN_UPDATE_STATS log_upd_stats;
bool has_deadlock_priority;
bool block_global_oldest_active_until_commit;
bool is_user_active;
LOG_RCV_TDES rcv; /* Recovery-time annotations only */
log_postpone_cache m_log_postpone_cache;
bool has_supplemental_log;
char *ddl_sql_user_text;
// ... member functions for sysop locking and oldest-mvccid pinning ...
};

밀도가 높지만 층은 깔끔하게 나뉜다. 첫 블록 (mvcc info, identity) 은 모든 페이지 access가 읽는 구간이다. 두 번째 블록 (state, isolation, wait_msecs) 은 lock 획득과 가시성 결정이 읽는 구간이 다. LSA 사슬은 복구 측이 의지하는 약속이다 — 모든 TDES는 analysis 가 자기를 식별할 수 있게 head_lsa/tail_lsa 를 두고, rollback 이 가능하도록 undo_nxlsa 를 두고, postpone replay를 위한 posp_nxlsa, 부분 rollback 의미론을 위한 savept_lsa / topop_lsa / tail_topresult_lsa 를 둔다. 나머지 필드들은 부기에 가깝다 — 이 트랜잭션이 기다리고 있는 lock 자원, commit 시 정리해야 하는 lob 들, 다운스트림으로 보낼 복제 레코드, commit 시 무효화해야 하는 modified-class 들.

log_impl.h 의 trantable은 작은 헤더와 그 뒤를 잇는 TDES 연속 영역으로 구성된다.

// TRANTABLE — src/transaction/log_impl.h
struct trantable
{
int num_total_indices; /* Capacity (configured at boot) */
int num_assigned_indices; /* Currently in use */
int num_coord_loose_end_indices;
int num_prepared_loose_end_indices;
int hint_free_index; /* Speeds up next assignment */
volatile sig_atomic_t num_interrupts;
LOG_ADDR_TDESAREA *area; /* Linked list of TDES storage areas */
LOG_TDES **all_tdes; /* Indexed pointer table */
};

이 구조에서 짚을 점이 두 가지다. (a) lookup 비용. all_tdes 는 트랜잭션 인덱스로 색인되는 포인터 테이블이라 LOG_FIND_TDES(idx) 가 한 번의 load 로 끝난다. (b) 영역의 사슬. area 는 단일 블록이 아니라 연속 할당 영역들의 사슬이다. 그래서 테이블이 (logtb_grow_* 경로) 커지더라도 기존 포인터를 무효화 하지 않고 자랄 수 있다.

logtb_assign_tran_index (log_tran_table.c:796) 가 슬롯 할당자다. 이 함수는 hint_free_index 를 이용해 빈 슬롯을 빨리 찾고, 필요시 새 영역을 할당하고, 새 TDES를 초기화한다 — 첫 head_lsa 와 함께 logtb_set_loose_end_chkpt_lsa 가 같이 호출된다. 짝이 되는 logtb_release_tran_index (1139) 가 TDES를 비우고, 트랜잭션이 들고 있던 lock을 해제하고, hint_free_index 를 갱신한다.

trantable 은 TR_TABLE_CS critical section (csect_enter (CSECT_TRAN_TABLE)) 뒤에서 산다. writer (assign / release) 는 write 모드로 진입하고, reader (대부분의 TDES lookup) 는 read 모드로 진입하거나 인덱스를 통한 직접 접근이라 진입을 건너뛴다.

TRAN_STATE (log_comm.h) 가 라이프사이클 enum 이다. 15 개 값을 가진다. 이 숫자가 의미 있는 이유는 분명하다. 트랜잭션 한 건의 진행을 alive / dead / in-progress 셋만으로는 표현할 수 없기 때문이다. postpone, 2PC, unilateral abort 가 각자 자기 중간 상태를 만들어 낸다. analysis 패스가 이 중간 상태들을 구분할 수 있어야 복구가 정확해진다.

// TRAN_STATE — src/transaction/log_comm.h
enum
{
TRAN_RECOVERY, /* system tran for recovery */
TRAN_ACTIVE, /* normal in-flight */
TRAN_UNACTIVE_COMMITTED, /* commit complete */
TRAN_UNACTIVE_WILL_COMMIT, /* commit log written, force pending */
TRAN_UNACTIVE_COMMITTED_WITH_POSTPONE, /* committed, postpones running */
TRAN_UNACTIVE_TOPOPE_COMMITTED_WITH_POSTPONE, /* sysop-postpone variant */
TRAN_UNACTIVE_ABORTED, /* user-initiated abort */
TRAN_UNACTIVE_UNILATERALLY_ABORTED, /* system aborted (crash) */
TRAN_UNACTIVE_2PC_PREPARE, /* prepared, waiting decision */
TRAN_UNACTIVE_2PC_COLLECTING_PARTICIPANT_VOTES, /* coordinator phase 1 */
TRAN_UNACTIVE_2PC_ABORT_DECISION, /* coordinator phase 2 — abort */
TRAN_UNACTIVE_2PC_COMMIT_DECISION, /* coordinator phase 2 — commit */
TRAN_UNACTIVE_COMMITTED_INFORMING_PARTICIPANTS, /* informing after commit */
TRAN_UNACTIVE_ABORTED_INFORMING_PARTICIPANTS, /* informing after abort */
TRAN_UNACTIVE_UNKNOWN
} TRAN_STATE;

log_impl.h:143-183LOG_ISTRAN_* 매크로들이 이 enum 을 나머지 엔진이 던지는 질문 단위로 압축한다. LOG_ISTRAN_ACTIVE 는 재시작된 서버 위에서 정상적으로 in-flight 인지를 묻고, LOG_ISTRAN_COMMITTED 는 다섯 commit 측 상태를 한 묶음으로 본다. LOG_ISTRAN_ABORTED 는 네 abort 측 상태를 묶고, LOG_ISTRAN_2PC_IN_SECOND_PHASE 는 두 번째 phase에 있는 2PC 상태들을 묶는다. 복구 매니저가 “redo가 필요한가, undo가 필요한 가, 2PC를 마저 끝내야 하는가” 를 결정할 때 의지하는 시점이 바로 이 매크로의 압축된 시점이다.

Isolation — 세 개의 수준, access 시점에서 디스패치

섹션 제목: “Isolation — 세 개의 수준, access 시점에서 디스패치”

DB_TRAN_ISOLATION (compat/dbtran_def.h) 은 3비트 필드다.

// DB_TRAN_ISOLATION — src/compat/dbtran_def.h
typedef enum
{
TRAN_UNKNOWN_ISOLATION = 0x00,
TRAN_READ_COMMITTED = 0x04, /* alias TRAN_REP_CLASS_COMMIT_INSTANCE,
TRAN_CURSOR_STABILITY */
TRAN_REPEATABLE_READ = 0x05, /* alias TRAN_REP_READ */
TRAN_SERIALIZABLE = 0x06, /* alias TRAN_NO_PHANTOM_READ */
TRAN_DEFAULT_ISOLATION = TRAN_READ_COMMITTED,
} DB_TRAN_ISOLATION;

값은 TDES별로 (log_tdes::isolation) 보관되며, 클라이언트 측에는 tm_Tran_isolation (transaction_cl.h) 그림자가 따로 있다. 이 값을 읽는 자리가 세 곳이다.

  • 스냅샷 획득 (mvcc_satisfies_snapshot, cubrid-mvcc.md). READ COMMITTED 는 statement 단위로 다시 잡고, REPEATABLE READ 는 트랜잭션 동안 유지하며, SERIALIZABLE 은 스냅샷 측면에서는 REPEATABLE READ 와 같지만 추가로 key-range 락을 잡는다.
  • Lock 획득 (lock manager). SERIALIZABLE 은 스캔 경계에서 key-range 락을 잡고, REPEATABLE READ 는 읽기 안정성을 MVCC 에 맡기고 쓰기 대상에만 lock 을 걸며, READ COMMITTED 는 최소 lock 만 걸고 statement 끝에서 lock 을 푼다.
  • Statement 경계 (xtran_*_query_end_*). cursor stability 의미론을 위해 READ COMMITTED 는 자기 스냅샷을 풀고, 나머지는 유지한다.

별칭이 의도적으로 살아 있는 점도 짚어 둘 만하다 — TRAN_CURSOR_STABILITYTRAN_READ_COMMITTED 와 같은 값이다. 이는 호환성 seam이다. 옛 API는 lock-engine 어휘 (cursor-stability, repeatable-class) 로 격리 수준을 명명했고, 새 코드는 SQL-표준 이름을 쓴다. 둘 다 같은 디스패치 코드로 컴파일된다.

서버 측 commit은 xtran_server_commit (transaction_sr.c:71) 이 처리한다. abort는 xtran_server_abort (128) 다. 둘 다 RPC 래퍼이며, 실제 동작은 log_manager.clog_commit / log_abort 안에서 일어난다.

// xtran_server_commit — src/transaction/transaction_sr.c (condensed)
TRAN_STATE
xtran_server_commit (THREAD_ENTRY *thread_p, bool retain_lock)
{
TRAN_STATE state;
int tran_index = LOG_FIND_THREAD_TRAN_INDEX (thread_p);
/* Guard rails: no in-flight queries, no held mutex stack. */
// ... condensed ...
state = log_commit (thread_p, tran_index, retain_lock);
/* Fire post-commit triggers (replication, CDC supplemental flush). */
// ... condensed ...
return state;
}

log_commit 안의 시퀀스는 cubrid-log-manager.md 에 자세히 나오지만 여기서는 — postpone 레코드가 버퍼에 있다면 LOG_COMMIT_WITH_POSTPONE 을 append하고 그 postpone들을 실행한다. 그 다음 LOG_COMMIT 을 append하고 force-flush하며, TDES 상태를 TRAN_UNACTIVE_COMMITTED 로 전이한다. lock을 해제 하거나 (retain_lock 이면 유지) 한 뒤, logtb_release_tran_index 로 TDES를 비운다. log_abort 는 이 거울이다 — LOG_ABORT 를 append하고, undo를 구동하고, lock을 해제하고, TDES를 비운다.

System op — 서브트랜잭션 단위의 복구

섹션 제목: “System op — 서브트랜잭션 단위의 복구”

여러 페이지를 함께 만지지만 한 그룹으로만 atomic 이어야 하는 연산들 (B+Tree split, heap overflow 할당, 스키마 변경) 은 시스템 op 를 사용한다. 시스템 op 는 log_sysop_start (log_manager.c:3599) 로 열리고, TDES의 topops 스택에 nest 된다. 그 다음 자기 서브 동작을 실행한 뒤 log_sysop_commit (3916) 으로 commit하거나 log_sysop_abort 로 abort한다.

// LOG_TOPOPS_STACK / LOG_TOPOPS_ADDRESSES — src/transaction/log_impl.h
struct log_topops_addresses
{
LOG_LSA lastparent_lsa; /* Where the parent's log range was when this op began */
LOG_LSA posp_lsa; /* First postpone of this op */
};
struct log_topops_stack
{
int max;
int last; /* -1 ⇒ no system op in progress */
LOG_TOPOPS_ADDRESSES *stack;
};

log_sysop_start 는 새 LOG_TOPOPS_ADDRESSES 를 push한다. 그 때의 lastparent_lsa 는 부모의 현재 tail_lsa 다. 시스템 op이 활성화되어 있는 동안 그 op의 로그 레코드들은 로그 위에서 연속된 범위를 이룬다. log_sysop_commit 은 그 범위가 끝났음을 LOG_SYSOP_END_COMMIT 레코드로 표시한다 (lastparent_lsaprv_topresult_lsa 가 사슬을 이룬다). log_sysop_abortLOG_SYSOP_END_ABORT 를 쓰면서 그 범위를 거꾸로 걷어 undo를 적용한다.

log_sysop_end_* 변종들은 LOG_REC_SYSOP_END 의 union arm에 대응한다 (cubrid-log-manager.md 의 §Log record 참조).

  • log_sysop_end_logical_undo — 시스템 op이 자기 logical undo 이미지를 함께 들고 다닌다. 인덱스 split처럼 물리 undo가 어려운 경우에 쓰인다.
  • log_sysop_end_logical_compensate — 되돌려진 시스템 op이 CLR 포인터를 남긴다.
  • log_sysop_end_logical_run_postpone — 시스템 op이 postpone 실행을 구동한다.

복구 측은 이 변종들을 인지한다. analysis 패스가 sysop 범위를 LOG_SYSOP_END_TYPE 으로 분류하고, redo / undo 패스가 변종별로 다른 경로를 호출한다는 뜻이다 (자세한 내용은 cubrid-recovery-manager.md).

Savepoint — 사슬에 매달린 명명된 LSA

섹션 제목: “Savepoint — 사슬에 매달린 명명된 LSA”

savepoint 생성은 log_append_savepoint (log_manager.c, log_manager.h:132 에 선언) 가 처리한다. 이 함수는 savepoint 이름을 담은 LOG_SAVEPOINT 레코드를 발행한다. TDES의 savept_lsa 가 그 새 레코드의 LSA로 갱신되고, 레코드 안의 prv_savept 가 직전 savepoint를 가리키는 포인터 역할을 한다. 사슬이 만들어지는 셈이다. rollback-to-savepoint가 그 사슬을 거슬러 갈 수 있는 것은 이 구조 덕분이다.

// LOG_REC_SAVEPT — src/transaction/log_record.hpp
struct log_rec_savept
{
LOG_LSA prv_savept; /* Previous savepoint record */
int length; /* Savepoint name length follows */
};

savepoint로의 rollback은 다음처럼 진행된다.

log_abort_partial(savepoint_name, savept_lsa)
→ savept_lsa 사슬을 따라 그 이름의 savepoint를 찾는다
→ tail_lsa부터 그 savepoint까지 undo
→ 단계마다 CLR을 발행
→ tail_lsa를 그 savepoint 레코드의 LSA로 되돌린다

savepoint에는 두 종류가 있다 (SAVEPOINT_TYPE, transaction_cl.h:49). USER_SAVEPOINT 는 SQL SAVEPOINT foo 로 명시적으로 만들어진 것이고, SYSTEM_SAVEPOINT 는 엔진이 내부적으로 만든 것이다 (예 — statement 한 단위만 자동으로 abort 하기 위해 statement 경계를 감싸는 용도).

stateDiagram-v2
  [*] --> ACTIVE: logtb_assign_tran_index
  ACTIVE --> WILL_COMMIT: log_commit\n(append LOG_COMMIT)
  WILL_COMMIT --> COMMITTED_W_POSTPONE: postpones queued
  COMMITTED_W_POSTPONE --> COMMITTED: log_do_postpone done
  WILL_COMMIT --> COMMITTED: no postpones
  ACTIVE --> ABORTED: log_abort\n(append LOG_ABORT, drive undo)
  ACTIVE --> UNILATERALLY_ABORTED: crash detected\nin recovery analysis
  ACTIVE --> PREPARED_2PC: log_2pc_prepare
  PREPARED_2PC --> COMMIT_DECISION: coordinator says commit
  PREPARED_2PC --> ABORT_DECISION: coordinator says abort
  COMMIT_DECISION --> INFORMING_PARTICIPANTS_C: phase 2 send
  ABORT_DECISION --> INFORMING_PARTICIPANTS_A: phase 2 send
  INFORMING_PARTICIPANTS_C --> COMMITTED: all acks received
  INFORMING_PARTICIPANTS_A --> ABORTED: all acks received
  COMMITTED --> [*]: logtb_release_tran_index
  ABORTED --> [*]
  UNILATERALLY_ABORTED --> [*]

각 전이는 자기 로그 레코드 발행으로 이름이 붙는다. log_commitLOG_COMMIT_WITH_POSTPONE 또는 LOG_COMMIT 을 발행하고, log_abortLOG_ABORT 를 발행하며, 2PC 경로 (cubrid-2pc.md 에서 다룸) 는 LOG_2PC_* 타입을 발행한다. 복구의 analysis 패스 는 이 로그를 앞으로 걸으며 TDES별 그림을 만들어 가고, LOG_ISTRAN_* 매크로로 각 TDES의 운명을 결정한다.

클라이언트 측 그림자와 API 표면

섹션 제목: “클라이언트 측 그림자와 API 표면”

트랜잭션 모듈의 클라이언트 측은 작다. tm_Tran_index, tm_Tran_isolation, tm_Tran_ID, tm_Tran_async_ws, tm_Tran_wait_msecs 같은 글로벌 변수들과 transaction_cl.htran_* API가 전부다.

// Client-visible API — src/transaction/transaction_cl.h (excerpt)
extern int tran_commit (bool retain_lock);
extern int tran_abort (void);
extern int tran_unilaterally_abort (void);
extern int tran_reset_isolation (TRAN_ISOLATION isolation, bool async_ws);
extern int tran_reset_wait_times (int wait_in_msecs);
extern int tran_savepoint_internal (const char *name, SAVEPOINT_TYPE type);
extern int tran_abort_upto_user_savepoint (const char *name);
extern int tran_abort_upto_system_savepoint (const char *name);
extern int tran_2pc_start (void);
extern int tran_2pc_prepare (void);
extern int tran_set_global_tran_info (int gtrid, void *info, int size);
extern bool tran_has_updated (void);

각 진입점이 하는 일은 작은 로컬 부기 (예 — broker를 위한 tran_end_libcas_function 호출) 와 그 다음 서버 RPC다. 서버 측의 일은 위에서 본 그대로다.

앵커는 심볼명에 둔다. 라인 번호는 개정마다 어긋나므로 위치 힌트 표 안에서만 다룬다.

  • log_tdes (log_impl.h) — descriptor.
  • trantable (log_impl.h) — descriptor 테이블.
  • log_topops_stack, log_topops_addresses (log_impl.h) — system op 스택.
  • log_rcv_tdes (log_impl.h) — 복구 시점에만 채워지는 부가 정보.
  • TRAN_STATE (log_comm.h) — 15개 라이프사이클 enum.
  • DB_TRAN_ISOLATION (compat/dbtran_def.h) — 3-수준 격리 enum.
  • LOG_TOPOP_RANGE (log_manager.h) — (start_lsa, end_lsa) 쌍. nested-top postpone replay에 쓰인다.
  • tx_transient_class_registry (transaction_transient.hpp) — invalidation이 필요한 modified-class 리스트.
  • logtb_assign_tran_index (log_tran_table.c) — 새 트랜잭션 슬롯 할당.
  • logtb_release_tran_index (log_tran_table.c) — 슬롯 반환.
  • logtb_set_current_tran_index (log_tran_table.c) — 스레드 의 현재 인덱스 설정.
  • logtb_complete_mvcc (log_tran_table.c) — commit/abort 시 점에 MVCC 정보 마무리.
  • logtb_grow_* (log_tran_table.c) — 테이블 확장.
  • xtran_server_commit (transaction_sr.c) — 서버 commit.
  • xtran_server_abort (transaction_sr.c) — 서버 abort.
  • xtran_server_savepoint (transaction_sr.c) — savepoint 생성.
  • xtran_server_unilaterally_abort_tran (transaction_sr.c) — 에러 복구 중 강제 abort.
  • log_sysop_start (log_manager.c) — frame push.
  • log_sysop_commit (log_manager.c) — pop with commit, LOG_SYSOP_END_COMMIT 발행.
  • log_sysop_abort (log_manager.c) — pop with abort, LOG_SYSOP_END_ABORT 발행, 거꾸로 undo.
  • log_sysop_start_atomic (log_manager.c) — 복구에 민감한 연산 (파일 할당/해제) 을 위한 atomic 변종.
  • log_sysop_end_logical_undo (log_manager.c) — 자기 logical undo를 가진 시스템 op.
  • log_sysop_end_logical_compensate / log_sysop_end_logical_run_postpone (log_manager.c) — 보상과 postpone replay 변종.
  • log_sysop_attach_to_outer (log_manager.c) — end record를 쓰지 않고 시스템 op의 로그 범위를 부모에 붙이기 (시스템 op이 실질적으로 marker 일 때).
  • tran_commit (transaction_cl.c).
  • tran_abort (transaction_cl.c).
  • tran_savepoint_internal (transaction_cl.c) — USER와 SYSTEM savepoint 모두 여기로 수렴.
  • tran_abort_upto_user_savepoint / tran_abort_upto_system_savepoint (transaction_cl.c).
  • tran_reset_isolation (transaction_cl.c) — tm_Tran_isolation 을 뒤집고 서버로 forward.

이 개정 시점의 위치 힌트 (2026-05-01)

섹션 제목: “이 개정 시점의 위치 힌트 (2026-05-01)”
심볼파일라인
log_tdes (struct)log_impl.h475
log_topops_stacklog_impl.h362
log_topops_addresseslog_impl.h353
log_rcv_tdeslog_impl.h458
trantablelog_impl.h602
LOG_ISTRAN_ACTIVE (macro)log_impl.h143
LOG_ISTRAN_COMMITTED (macro)log_impl.h146
LOG_ISTRAN_ABORTED (macro)log_impl.h153
LOG_ISTRAN_2PC (macro)log_impl.h173
TRAN_STATE enumlog_comm.h36
DB_TRAN_ISOLATION enumdbtran_def.h36
logtb_assign_tran_indexlog_tran_table.c796
logtb_release_tran_indexlog_tran_table.c1139
logtb_complete_mvcclog_tran_table.c4050
logtb_set_current_tran_indexlog_tran_table.c6002
xtran_server_committransaction_sr.c71
xtran_server_aborttransaction_sr.c128
xtran_server_savepointtransaction_sr.c348
log_sysop_startlog_manager.c3599
log_sysop_start_atomiclog_manager.c3665
log_sysop_commit_internallog_manager.c3825
log_sysop_commitlog_manager.c3916
log_commitlog_manager.c5352
log_abortlog_manager.c5461
  • LOG_TDES 는 약 50개 필드를 가진 단일 struct다 — hot/cold 분리가 없다. log_impl.h:475 에서 검증. PostgreSQL은 PROCPGXACT 를 분리해 가시성 스캔이 hot 필드만 읽도록 하지만, CUBRID은 mvccinfo (가시성과 직결) 를 bind_historyquery_timeout 같은 부기 필드 옆에 함께 둔다. 함의 — TDES 테이블을 가시성 스캔할 때 strictly necessary한 캐시 라인보다 더 많은 라인을 읽게 된다.

  • TRAN_STATE 의 15개 값 중 7개는 2PC 상태 머신에 속한다. log_comm.h:36-67 에서 검증. log_impl.h:173-176LOG_ISTRAN_2PC 매크로가 그 중 6개를 “2PC에 있다” 로 묶는다. 15개 enum이 TRAN_RECOVERY 를 별도 2PC 변종으로 포함하지는 않는다 — 이는 복구 worker의 의사-트랜잭션을 위한 가짜 상태다.

  • 기본 격리 수준은 TRAN_READ_COMMITTED (0x04) 다. dbtran_def.h:53 (TRAN_DEFAULT_ISOLATION = TRAN_READ_COMMITTED) 와 dbtran_def.h:54 (MVCC_TRAN_DEFAULT_ISOLATION = TRAN_READ_COMMITTED) 에서 검증. 두 default가 같은 이유는 CUBRID이 전체적으로 MVCC다 — default가 갈릴 만한 비-MVCC 모드가 따로 없다.

  • 격리 수준 enum 값은 의도적으로 별칭이 걸려 있다. TRAN_READ_COMMITTED == TRAN_REP_CLASS_COMMIT_INSTANCE == TRAN_CURSOR_STABILITY == 0x04 다. dbtran_def.h:40-42 에서 확인된다. 별칭은 옛 lock-engine 어휘 이름과의 API 호환을 위한 것이며 같은 디스패치 경로로 컴파일된다.

  • trantable 크기는 boot 시점에 정해지며 트랜잭션마다 동적이지 않다. logtb_assign_tran_index (log_tran_table.c:796) 본문 으로 확인된다. 슬롯은 LOG_ADDR_TDESAREA 연결 리스트가 관리 하는 연속 영역에서 할당된다. 부족하면 영역이 자라지만 줄어들 지는 않는다. 상한은 max_clients 서버 파라미터가 정한다.

  • System op 은 별도 테이블이 아니라 TDES 위 스택으로 nest 된다. log_impl.h:361-367LOG_TOPOPS_STACK 에서 확인된다. 스택의 last 필드가 -1 이면 진행 중인 시스템 op 이 없다는 뜻이고, 정수 인덱스이면 그 깊이라는 뜻이다. 글로벌 시스템-op 테이블이 따로 존재하지 않으며, 모든 TDES 가 자기 스택을 소유 한다.

  • Lock 획득 wait timeout 은 TDES 별이다. log_impl.h:486 (wait_msecs 필드) 와 클라이언트 측 글로벌 tm_Tran_wait_msecs (transaction_cl.h:58) 에서 확인된다. 매크로 TRAN_LOCK_INFINITE_WAIT = -1 (log_comm.h:29) 가 영원히 기다린다는 sentinel 이다.

  • block_global_oldest_active_until_commit 는 자기 vacuum 을 직접 해야 하는 장기 연산을 위해 존재한다. log_impl.h:555 (필드 정의) 와 log_impl.h:585lock_global_oldest_visible_mvccid 멤버 함수에서 확인된다. 큰 데이터를 스캔하는 reorganize-partition / upgrade-domain 같은 코드 경로가 동시 트랜잭션에 의해 자기 MVCC 임계가 앞으로 밀리는 것을 막기 위해 이 플래그를 켠다.

  • LOG_2PC_GTRINFOLOG_2PC_COORDINATOR * 는 TDES 의 inline 필드라 2PC 가 아닌 트랜잭션에도 자리가 있다. log_impl.h:505-508 에서 확인된다. coordinator 가 아닌 사이트에서는 coordNULL 이다. 비용은 TDES 당 포인터 하나이고, 이득은 로컬 트랜 잭션을 2PC 역할로 승격할 때 재할당이 필요 없다는 점이다.

  • LOG_RCV_TDES 는 복구 시점에만 비어 있지 않다. log_impl.h:458 (struct 정의) 와 558 (log_tdes::rcv 인라인 필드) 에서 확인된다. 그 안의 필드들 (sysop_start_postpone_lsa, tran_start_postpone_lsa, atomic_sysop_start_lsa, analysis_last_aborted_sysop_*) 은 analysis 패스가 채우고 redo/undo 패스가 소비한다.

  1. TDES 의 hot / cold 분리. mvccinfobind_history 옆에 놓인 캐시 미스 비용을 측정해 본 사람이 있는가? 다른 엔진 들이 분리하는 데는 이유가 있을 것이다. 추적 경로는 동시성 높은 read 워크로드에서 perf stat -e cache-misses 를 돌리고 가상의 분리 TDES 와 비교하는 것이다.

  2. trantable 확장. 헤더 필드 LOG_ADDR_TDESAREA *area 는 런타임 확장이 지원된다는 인상을 준다. 그러나 트리거와 동기화는 검증되지 않았다. 추적 경로는 log_tran_table.c 에서 area 에 쓰기를 grep 으로 찾고, 확장이 요청 경로에서 일어나는지 quiescent 시점에만 일어나는지 확인하는 것이다.

  3. 경합 상황에서의 hint_free_index 정합성. 여러 thread 가 동시에 logtb_assign_tran_index 를 부를 수 있다. hint 는 단일 값이라, 무엇이 그 값을 보호하는가? 추적 경로는 logtb_assign_tran_index 본문을 읽고 compare-and-swap 이나 mutex 사용을 확인하는 것이다.

  4. System op rmutex_topop 의 동작. TDES 별 reentrant mutex 는 시스템 op 이 같은 thread 위에서 재귀적으로 시작될 수 있음을 시사한다. 그러나 깊이의 상한은 검증되지 않았다. 추적 경로는 log_sysop_start 안의 lock_topop() 호출들을 살피고 reentrance 카운트를 추적하는 것이다.

  5. Postpone 캐시 통합. m_log_postpone_cache 는 TDES 에 inline 된 C++ 클래스 (log_postpone_cache) 다. 필드 코멘트에 따르면 그 목적은 log_do_postpone 에서 replay 할 수 있는 postpone 레코드를 기억해 두는 것이다. 정확한 lifetime (commit 시 비워지는가, abort 시에도 비워지는가, sysop 경계를 넘어 살아 남는가) 은 검증되지 않았다. 추적 경로는 log_postpone_cache.cpplog_manager.clog_do_postpone 을 함께 읽는 것이다.

  6. 클라이언트 측 TDES 그림자와 서버 실제 사이의 정합성. tm_Tran_* 는 클라이언트 측 글로벌이다. 연결 failover 시 서버가 다른 wait_msecs 를 갖고 있다면 어떻게 되는가? 추적 경로는 tran_cache_tran_settings 의 소비자를 따라가고, CAS broker 가 reconnect 시 다시 동기화하는지 확인하는 것이다.

CUBRID 너머 — 비교 설계와 연구 동향

섹션 제목: “CUBRID 너머 — 비교 설계와 연구 동향”

본격적인 비교 분석이 아니라, 후속 문서로 이어질 단서들의 모음이다.

  • PostgreSQL 의 PROC / PGXACT 분리. PG 는 descriptor 를 hot 절반 (PGXACT: xid, xmin, vacuumFlags) 과 cold 절반 (PROC: locktag arrays, myProcLocks) 으로 가른다. 가시성 스캔은 hot 만 읽는다. CUBRID 의 monolithic TDES 와 나란히 두면 캐시 비용이 측정될 만한 비교 자리다.

  • InnoDB 의 trx_t + lock_sys 예약. InnoDB 은 lock 예약을 trx_t::lock 안에 박아 두고 글로벌 lock_sys_t mutex 를 쓴다. CUBRID 은 이를 분리한다. TDES 위의 LK_RES *waiting_for_res 와 lock manager 의 자원별 hash 가 나뉘어 있다. 두 설계를 비교하면 lock 획득 critical path 가 분명해진다.

  • Hekaton 의 in-memory transaction map (Larson 외, VLDB 2011). Hekaton 은 TDES 를 트랜잭션-id 로 키된 lock-free hash 에 저장하며, 버전 데이터는 레코드 안에 inline 저장된다. CUBRID 의 고정 배열 trantable 은 그 반대 방향 설계점이다.

  • PostgreSQL subtransaction 의 partial rollback 사슬. PG 는 SubTransactionId 와 백엔드별 스택을 쓰는데, CUBRID 의 topops 스택과 거의 같은 구조다. PG 의 두 버전 subxact-id 매핑 (subxact 와 parent xid) 이 CUBRID 의 LOG_TOPOPS_ADDRESSES 보다 조금 더 정교하지만, lifecycle 은 구조적으로 동일하다.

  • RDMA 위의 optimistic concurrency control (FaRM, NSDI 2014). FaRM 은 TDES 테이블을 없애고 트랜잭션 상태를 레코드 버전에 직접 인코드한다. CUBRID 의 TDES 가 살아남는 이유는 격리 모드 들이 lock 획득 시 descriptor 를 필요로 하기 때문이라는 점이 이 비교로 분명해진다.

  • JTA XAResource 의미론 (JSR 907). CUBRID 2PC TRAN_STATE 분기는 JTA 의 prepared / commit / rollback 의미론에 부합한다. cubrid-2pc.md 가 이 부합점을 열거하는 자연스러운 후속 문서다.

  • CockroachDB serializable + parallel commits (Taft 외, SIGMOD 2020). Cockroach 는 descriptor 를 분산 KV 위로 밀어 넣고, 단일 intent 레코드를 쓰는 것으로 commit 을 표시한 뒤 그 상태를 나중에 lazy 하게 해소한다. 그 transaction record 가 CUBRID 의 TDES 역할을 하지만 고정 크기 테이블에 묶이지 않는다는 점이 다르다. 두 설계를 비교하면 공유 메모리 엔진이 trantable 상한으로 지불하는 비용과 shared-nothing 엔진이 intent 해소 트래픽으로 지불하는 비용이 함께 보일 것이다.

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

섹션 제목: “원본 분석 (raw/code-analysis/cubrid/storage/transaction/)”
  • Transaction Internals.pdf
  • Transaction Internals.pptx

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

섹션 제목: “교재 챕터 (knowledge/research/dbms-general/)”
  • Database Internals (Petrov), 5장 Transactions and Recovery, §ACID, §Isolation levels.
  • Concurrency Control and Recovery in Database Systems (Bernstein, Hadzilacos, Goodman), 1-4장.

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

섹션 제목: “CUBRID 소스 (/data/hgryoo/references/cubrid/)”
  • src/transaction/log_impl.h — TDES, trantable, sysop 스택.
  • src/transaction/log_tran_table.c — trantable 관리.
  • src/transaction/transaction_cl.{h,c} — 클라이언트 API.
  • src/transaction/transaction_sr.{h,c} — 서버 진입점.
  • src/transaction/transaction_global.hpp — 시스템 tran 상수.
  • src/transaction/transaction_transient.hpp — modified-class 레지스트리, lob locator 사슬.
  • src/transaction/log_comm.hTRAN_STATE enum.
  • src/transaction/log_manager.c — sysop, commit, abort.
  • src/compat/dbtran_def.hDB_TRAN_ISOLATION enum.
  • knowledge/code-analysis/cubrid/cubrid-log-manager.md — TDES가 발행하는 로그 레코드.
  • knowledge/code-analysis/cubrid/cubrid-mvcc.mdlog_tdes::mvccinfo 의 소비자.
  • knowledge/code-analysis/cubrid/cubrid-lock-manager.mdlog_tdes::wait_msecs 의 소비자이자 log_tdes::waiting_for_res 의 producer.
  • knowledge/code-analysis/cubrid/cubrid-recovery-manager.md — analysis 시점에 TDES를 소비. 같은 배치에서 진행 중.
  • knowledge/code-analysis/cubrid/cubrid-2pc.md — 2PC 상태 머신 가지와 coord / gtrinfo 의 소유자. 같은 배치에서 진행 중.