콘텐츠로 이동

(KO) CUBRID Prior List — 트랜잭션별 commit과 로그 writer을 분리하는 락프리 producer 측 WAL 큐

목차

ARIES 논문(Mohan 외, ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging, TODS 17.1, 1992)이 엔진에 강제하는 두 규칙 이 WAL 프로토콜의 알맹이다. 변경을 기술하는 로그 레코드는 그 변경이 들어간 더티 데이터 페이지보다 먼저 안정 저장소에 도달해야 하고(durability), LSN 순서대로 도달해야 한다(ordering). Database Internals(Petrov, 5장 §Recovery)가 이 계약을 다시 풀어 쓰면서 한 가지 공학적 관찰을 덧붙인다. ordering 규칙이 WAL을 동시성 문제로 만든다 — 다수의 스레드가 만들어 내는 레코드가 한 줄로 서기 위한 단일 전체 순서에 합의해야 비로소 무엇이든 쓰여진다.

이 order-then-write 단계의 순진한 구현은 처리량에 치명적이다. 교과서 설계는 모든 appender을 글로벌 로그 latch 위에 한 줄로 세워 두고 copy-and-stamp 작업이 끝날 때까지 잡아 둔다. latch는 레코드 길이만큼의 memcpy 동안, 그리고 cursor 전진 동안 잡혀 있다. Mohan과 DeWitt의 1992년 commit pipe 연구가 구조적 처방을 명명했다 — 파이프라인을 분리하라. producer는 latch 바깥에서 레코드를 staging 구조에 쌓고, 단일 drain 스레드가 latch로 보호되는 작업(권위 있는 로그 페이지로 바이트를 옮기는 일)을 도맡는다.

이 분리에는 세 가지 움직이는 부품이 있다. 첫째, producer에서 drain으로 가는 큐 — 보통 multi-producer single-consumer (MPSC) 구조다. Michael과 Scott의 락프리 FIFO(1996)가 교과서적 참조이며, 실무 엔진은 enqueue-at-tail 한 동작에 짧은 mutex를 두르는 패턴을 자주 쓴다. producer의 다른 작업(레코드 포매팅, MVCC 메타데이터 스탬핑)이 mutex 바깥에서 끝나기 때문에 짧은 mutex로 충분하다. 둘째, producer에서 drain으로 가는 wakeup 채널. 셋째, drain에서 producer에게 돌아가는 ack 채널. 둘과 셋의 정설적 답은 condition variable과 durable LSN 워터마크 한 쌍이다 — 커미터는 목표 LSN으로 CV 위에 잠들고, drain은 진전이 있을 때마다 broadcast한다. Group commit은 여기서 자연스럽게 떨어진다. 한 CV 위에서 잠들어 있던 다수의 커미터가 한 번의 fsync 진전으로 모두 깨어 난다.

drain은 정상 상태에서 producer를 따라잡아야 한다. 안 그러면 큐가 무한정 자란다. 실제 시스템은 soft cap을 둔다. 큐가 임계치를 넘으면 producer가 함께 drain을 돕거나(self-help 패턴) 혹은 backpressure 위에서 잠든다. 어느 쪽이든 처리량은 디스크의 유효 쓰기 대역폭에 의해 묶이므로 둘 사이의 차이는 latency다.

Postgres의 XLogInsert은 latch로 보호되는 참조점이다. appender가 WALInsertLock(현재 버전에서 8개의 stripe lock)을 잡고, WAL 버퍼 페이지에 바이트를 직접 복사한 뒤, 푼다. 별도의 producer 큐도 없고, insert 경로의 별도 drain 스레드도 없다. CUBRID의 prior list 는 의도적으로 그 반대다. appender는 권위 있는 로그 페이지를 절대 건드리지 않는다 — LOG_PRIOR_NODE 레코드를 큐에 넘기고, 그 큐는 로그 플러시 데몬(또는 로그 critical section 안에서 도와 들어오는 자조 경로)만이 페이지로 끌어낸다.

WAL을 쓰는 모든 엔진의 commit pipe는 PostgreSQL, InnoDB, Oracle, SQL Server, CUBRID에 걸쳐 비슷한 한 줌의 패턴을 결합한다. 작업 분할만 다를 뿐이다.

PostgreSQL XLogInsert — 중앙 집중, latch 보호

섹션 제목: “PostgreSQL XLogInsert — 중앙 집중, latch 보호”

XLogInsert(src/backend/access/transam/xlog.c)이 가장 단순한 모델이다. appender가 레코드 크기를 계산하고, WALInsertLock(PG 9.4+의 stripe된 8개 집합, XLogRecPtr로 해시)을 잡고, 방금 청구한 LSN 위치에 WAL 버퍼 페이지로 바이트를 직접 복사한 뒤, 푼다. commit 시점에 XLogFlush(LSN)WALWriteLock을 잡고 WAL 세그먼트에 pg_pwrite / pg_fsync을 호출하고 WriteRqst.Flush을 전진시킨다. 비용은 WALInsertLock이 레코드 크기만큼의 memcpy 동안 잡혀 있다는 점이다. 보상은 단순함 이다 — 별도 producer 큐도, drain 스레드도 없고, LSN 순서가 lock acquisition 순서와 자명하게 일치한다.

MySQL InnoDB — 원형 버퍼 위의 group commit

섹션 제목: “MySQL InnoDB — 원형 버퍼 위의 group commit”

InnoDB은 redo 레코드를 글로벌 log_buf 링 버퍼에 쓴다. 미니 트랜잭션(mtr_t)이 레코드를 로컬에서 모은 뒤 commit 시점에 log_sys->mutex 아래에서 log_buf로 플러시한다. log_writer 스레드가 log_buf을 redo 세그먼트로 끌어내고, log_flusherfsync을 발사한다. group commit은 os_event 대기에서 떨어진다. 모양은 CUBRID에 더 가깝다(producer / drain / flusher가 별개의 단계다). 다만 producer가 권위 있는 버퍼에 쓰기 위해 log_sys->mutex로 동기화해야 한다는 차이가 있다.

각 strand는 자기 인메모리 로그 버퍼와 자기 LGWR 순서를 가진다. LGWR이 쓰기 시점에 strand들을 끼워 넣는다. producer는 strand 별 mutex 경합을 줄이려고 trid 해시로 strand에 분할된다. strand 사이 의 순서는 LGWR 시점에 sequence 번호로 재구성된다. CUBRID은 모든 producer가 한 짧은 mutex 아래에 단일 prior list로 들어오게 해 강을 건넌다 — 그 mutex가 레코드 바이트 이동을 가로지르지 않기 때문에 단일 큐로도 견딘다.

CUBRID — producer 측 batched 큐, single-consumer drain

섹션 제목: “CUBRID — producer 측 batched 큐, single-consumer drain”

CUBRID의 prior list는 LOG_PRIOR_NODE 레코드의 single-consumer 큐다. producer는 다음을 한다.

  1. 글로벌 mutex 바깥에서 새로 malloc한 LOG_PRIOR_NODE 안에 레코드를 빌드한다 — undo/redo 페이로드의 선택적 zlib 압축까지 포함한다(producer 경로에서 가장 비싼 일).
  2. prior_lsa_mutex을 잡고, 노드에 다음 단조 LSN을 부여하고, 꼬리 에 link한 뒤, 푼다. mutex 보유 시간은 LSN 산술과 list-tail 포인터 조작뿐이다.
  3. 선택적으로, list_size이 페이지 버퍼 용량을 넘으면 로그 플러시 데몬을 깨우고 1ms 양보한다 — backpressure 경로다.
  4. commit 시점에 logpb_flush_pages(lsn)을 호출해 force flush을 예약하고, nxio_lsa >= my_commit_lsn이 될 때까지 호출자를 group-commit CV 위에 park한다.

log-flush 데몬이 latch로 보호되는 작업을 한다. LOG_CS_ENTER, logpb_prior_lsa_append_all_list(각 LOG_PRIOR_NODE을 분리해 권위 있는 LOG_PAGE 링 버퍼에 복사), logpb_flush_all_append_pages(디스크에 쓰고 nxio_lsa 전진), group-commit CV broadcast.

flowchart LR
  subgraph PROD["Producers (transaction threads)"]
    direction TB
    T1["TX A\nbuild LOG_PRIOR_NODE,\nzlib-compress payload"]
    T2["TX B\nbuild LOG_PRIOR_NODE"]
    T3["TX C\nbuild LOG_PRIOR_NODE"]
  end

  subgraph QUEUE["Prior list (under prior_lsa_mutex)"]
    direction LR
    H["prior_list_header"] --> N1["NODE\nstart_lsa=L1"]
    N1 --> N2["NODE\nstart_lsa=L2"]
    N2 --> N3["NODE\nstart_lsa=L3"]
    N3 --> Tl["prior_list_tail"]
  end

  subgraph CONS["Consumer (log-flush daemon)"]
    direction TB
    D1["LOG_CS_ENTER"]
    D2["detach prior list\n(swap header/tail to NULL\nunder mutex)"]
    D3["copy each NODE into\nLOG_PAGE ring buffer"]
    D4["fileio_write_pages\n· fsync"]
    D5["advance nxio_lsa\nbroadcast gc_cond"]
    D1 --> D2 --> D3 --> D4 --> D5
  end

  T1 -->|prior_lsa_alloc_and_copy_*\nthen prior_lsa_next_record| H
  T2 --> H
  T3 --> H

  Tl -.->|drained by| D2

  subgraph WAITERS["Commit waiters"]
    W1["TX A wait\nuntil nxio_lsa >= L1"]
    W2["TX B wait\nuntil nxio_lsa >= L2"]
  end

  D5 -. broadcast .-> W1 & W2

CUBRID이 내세우는 식별점은 producer 경로의 유일한 mutex가 prior_lsa_mutex이고 그 보유 시간이 레코드 크기 memcpy가 아니라 O(1) 산술로 지배된다는 점이다. 비싼 바이트는 mutex가 잡히기 전에 prior 노드 안으로 옮겨지고 (prior_lsa_alloc_and_copy_*), 그만큼 비싼 바이트가 mutex가 풀린 뒤에 drain 스레드에 의해 권위 있는 로그 페이지로 복사된다 (LOG_CS_OWN_WRITE_MODE 아래).

prior list에는 여덟 개의 움직이는 부품이 있다. 큐에 들어가는 것이 무엇인지 정의하는 LOG_PRIOR_NODE 모양. 그것을 빌드하는 producer 측 할당기. LSN을 부여하고 꼬리에 link하는 producer 측 attach. mutex가 보호하는 prior list 상태 (prior_list_header, prior_list_tail, list_size, prior_lsa, prev_lsa). producer 측 list을 flush 측 list로 넘기는 drain detach. flush 측 list을 걸어가며 권위 있는 LOG_PAGE 프레임을 채우는 drain copy. 커미터를 nxio_lsa 위에 park하는 commit-waiter 메커니즘. 큐가 페이지 버퍼 용량 너머로 자라면 발화하는 backpressure 경로. 아래에서는 producer→consumer 순서 대로 이들을 걸어 본다.

LOG_PRIOR_NODE — 큐에 들어가는 레코드

섹션 제목: “LOG_PRIOR_NODE — 큐에 들어가는 레코드”

구조체는 log_append.hpp에 산다. 세 개의 바이트 버퍼, 네 개의 길이 필드, 하나의 forward link.

// log_prior_node — src/transaction/log_append.hpp
struct log_prior_node
{
LOG_RECORD_HEADER log_header; /* prev_tranlsa, back_lsa, forw_lsa, trid, type */
LOG_LSA start_lsa; /* assigned at attach time; for assertions */
bool tde_encrypted;
int data_header_length; /* type-specific data header */
char *data_header;
int ulength; char *udata; /* undo data (zlib-compressed if eligible) */
int rlength; char *rdata; /* redo data (zlib-compressed if eligible) */
LOG_PRIOR_NODE *next;
};

노드는 malloc된 free-store 객체다 — pool에서 오지 않는다 — prior_lsa_alloc_and_copy_data / _crumbs(log_append.cpp:273 / :410)에서 할당되고 drain(logpb_append_prior_lsa_listlog_page_buffer.c:3040)에서 free된다. malloc/free 쌍은 prior list mutex 바깥에 있고, mutex로 보호되는 것은 link 조작뿐이다. 노드는 완성된 레코드 헤더를 들고 있다 — prev_tranlsa(트랜잭션별 back-link)와 back_lsa(물리적 이전 레코드) 모두 포함한다. 두 필드는 prior_lsa_start_append이 mutex 아래에서 채운다. tdes->tail_lsaprior_info.prev_lsa에 의존하는 데, attach가 이들을 변형하기 때문이다.

// log_prior_lsa_info — src/transaction/log_append.hpp
struct log_prior_lsa_info
{
LOG_LSA prior_lsa; /* next LSA to assign */
LOG_LSA prev_lsa; /* last attached node's LSA */
LOG_PRIOR_NODE *prior_list_header; /* singly-linked producer queue */
LOG_PRIOR_NODE *prior_list_tail;
INT64 list_size; /* bytes — backpressure trigger */
LOG_PRIOR_NODE *prior_flush_list_header; /* drain-owned hand-off */
std::mutex prior_lsa_mutex;
};

불변식. *prior_lsa*는 다음 LSN이며, attach마다 start_lsa로 복사된 뒤 레코드의 직렬화된 크기만큼 전진한다 (log_prior_lsa_append_advance_when_doesnot_fit, _add_align, prior_lsa_append_data{pageid, offset} 페어를 페이지 경계 너머로 걸어간다). *prev_lsa*는 이전 LSN이며, prior_lsa이 전진 하기 전 새 노드의 back_lsa로 쓰인다. *list_size*는 누적 바이트 카운트다 — `sizeof(LOG_PRIOR_NODE) + data_header_length + ulength

  • rlength을 합산. logpb_get_memsize()보다 커지면 backpressure를 트리거한다. *prior_flush_list_header*는 drain 측이 소유한다. drain CS 바깥에서 NULL이며, drain은 mutex 아래에서 prior_list_header을 분리하고 mutex를 푼 뒤 분리된 list을 prior_flush_list_header에 stage해 두고 walk-and-copy 루프로 들어 간다. log_Gl.prior_info`이 그 싱글톤이다.

Producer 1단계 — mutex 바깥에서 노드 빌드

섹션 제목: “Producer 1단계 — mutex 바깥에서 노드 빌드”

prior_lsa_alloc_and_copy_data(log_append.cpp:273)이 비-UNDOREDO 레코드의 할당기다. 노드를 malloc하고, 필드를 zero-out하고, log_header.type을 세팅한 뒤, rec_type으로 디스패치해 타입별 generator(prior_lsa_gen_postpone_record, prior_lsa_gen_dbout_redo_record, prior_lsa_gen_2pc_prepare_record, prior_lsa_gen_end_chkpt_record, 또는 catch-all prior_lsa_gen_record)으로 넘긴다. 각 generator는 데이터 헤더 구조체(LOG_REC_POSTPONE / LOG_REC_DONETIME 등의 크기로)을 malloc하고, 호출자의 데이터를 prior_lsa_copy_undo_data_to_node / _redo_data_to_node(둘 다 malloc(length) + memcpy)로 복사한다.

이 경로에서는 어떤 mutex도 잡히지 않는다. log_Gl.prior_info을 건드리는 것도 없다. 무거운 짐 — 레코드 포매팅, 가능하다면 압축 — 은 critical section 바깥에서 끝난다.

prior_lsa_alloc_and_copy_crumbs(log_append.cpp:410)이 가장 흔한 종류인 UNDOREDO 모양 레코드를 위한 자매 할당기다. undo와 redo를 LOG_CRUMB { length, data } 배열로 받아 호출자가 분산된 버퍼를 미리 합치지 않고 그대로 넘길 수 있게 하고, prior_lsa_gen_undoredo_record_from_crumbs(:651)로 위임해 diff-encoding(LOG_DIFF_UNDOREDO_DATA), MVCC 스탬핑(LOG_REC_MVCC_UNDOREDO), zlib 압축을 처리한다.

압축은 짐을 떠받치는 디테일이다. log_Zip_min_size_to_compress (기본 255바이트)을 넘는 UNDOREDO 레코드는 스레드별 LOG_ZIP 컨텍스트(log_append_get_zip_undo / _redo:1725 / :1751) 위에서 log_zip(zip_undo, ...) / log_zip(zip_redo, ...)으로 zlib 압축된다. 압축은 CPU 바운드이며 KB 단위 레코드라면 수십 마이크로초까지 걸린다. 압축이 prior_lsa_mutex 아래에서 일어났다면 큐는 가장 느린 압축자에 직렬화되었을 것이다. 압축을 mutex 이전에 두면, CUBRID은 N개의 producer가 병렬로 압축하게 두고 각 producer는 attach 시점에 O(1) 으로 끝낼 수 있는 완성된 LOG_PRIOR_NODE를 손에 쥔 채 들어온다.

Producer 2단계 — mutex 아래에서 LSN 부여 및 attach

섹션 제목: “Producer 2단계 — mutex 아래에서 LSN 부여 및 attach”

attach는 prior_lsa_next_record(log_append.cpp:1553)이고 prior_lsa_next_record_internal(:1357)로 위임된다.

// prior_lsa_next_record_internal — src/transaction/log_append.cpp:1357 (condensed)
static LOG_LSA
prior_lsa_next_record_internal (THREAD_ENTRY *thread_p,
LOG_PRIOR_NODE *node,
LOG_TDES *tdes, int with_lock)
{
if (with_lock == LOG_PRIOR_LSA_WITHOUT_LOCK)
log_Gl.prior_info.prior_lsa_mutex.lock ();
prior_lsa_start_append (thread_p, node, tdes); /* assign start_lsa */
LOG_LSA start_lsa = node->start_lsa;
/* MVCC bookkeeping (chain prev_mvcc_op_log_lsa for vacuum) and
tdes state stamping for SYSOP_START_POSTPONE / SYSOP_END /
COMMIT_WITH_POSTPONE / SYSOP_ATOMIC_START / COMMIT / ABORT */
if (LOG_IS_MVCC_OP_RECORD_TYPE (node->log_header.type) || ...) {
prior_update_header_mvcc_info (start_lsa, mvccid);
}
/* advance prior_lsa past data_header + udata + rdata */
log_prior_lsa_append_advance_when_doesnot_fit (node->data_header_length);
log_prior_lsa_append_add_align (node->data_header_length);
if (node->ulength > 0) prior_lsa_append_data (node->ulength);
if (node->rlength > 0) prior_lsa_append_data (node->rlength);
prior_lsa_end_append (thread_p, node); /* fill forw_lsa */
/* link the node onto the tail */
if (log_Gl.prior_info.prior_list_tail == NULL)
log_Gl.prior_info.prior_list_header = log_Gl.prior_info.prior_list_tail = node;
else {
log_Gl.prior_info.prior_list_tail->next = node;
log_Gl.prior_info.prior_list_tail = node;
}
log_Gl.prior_info.list_size +=
sizeof (LOG_PRIOR_NODE) + node->data_header_length + node->ulength + node->rlength;
if (with_lock == LOG_PRIOR_LSA_WITHOUT_LOCK) {
log_Gl.prior_info.prior_lsa_mutex.unlock ();
/* backpressure: queue too big -> wake daemon and yield, or self-help drain */
if (log_Gl.prior_info.list_size >= (INT64) logpb_get_memsize ()) {
perfmon_inc_stat (thread_p, PSTAT_PRIOR_LSA_LIST_MAXED);
#if defined(SERVER_MODE)
if (!log_is_in_crash_recovery ()) {
log_wakeup_log_flush_daemon (); thread_sleep (1);
} else { LOG_CS_ENTER (thread_p); logpb_prior_lsa_append_all_list (thread_p); LOG_CS_EXIT (thread_p); }
#else
LOG_CS_ENTER (thread_p); logpb_prior_lsa_append_all_list (thread_p); LOG_CS_EXIT (thread_p);
#endif
}
}
tdes->num_log_records_written++;
return start_lsa;
}

함수가 mutex 아래에서 하는 다섯 가지. prior_lsa에서 start_lsa 부여, MVCC 부기 갱신(prev_mvcc_op_log_lsa 체인, vacuum 정보, tdes->rcv.atomic_sysop_start_lsa 리셋, tdes->commit_abort_lsa 스탬프), 레코드 직렬화 크기만큼 prior_lsa 전진, 노드를 꼬리에 link, list_size 증가. 이 가운데 어떤 것도 바이트를 만지지 않는다 — 모두 포인터·정수 산술이다 — 그래서 mutex 보유 시간은 작은 상수 만큼의 캐시라인 액세스로 묶인다.

prior_lsa_start_append(:1593)이 node->start_lsa = prior_lsa과 back-link(prev_tranlsa = tdes->tail_lsa, back_lsa = prior_info.prev_lsa)을 채우고 prior_lsa을 헤더 뒤로 전진시킨다. prior_lsa_end_append(:1652)이 데이터가 prior_lsa을 전진시킨 뒤에 forw_lsa = prior_info.prior_lsa을 채운다. 노드의 헤더는 마치 레코드가 이미 직렬화된 것처럼 올바른 prev/back/forw 포인터를 갖는다.

두 가지 결과. 첫째, 호출자에게 돌려준 LSN은 그 레코드가 디스크에 도달했을 때 갖게 될 바로 그 LSN이다. 호출자는 그것을 데이터 페이지에 스탬프하고(pgbuf_set_lsa) savepoint LSN으로 쓰고 HA replica에 넘길 수 있다. 둘째, prior_lsa_next_record을 마친 producer는 WAL 경로에 더 이상 할 일이 없다. 바이트는 drain이 옮길 것이고, 페이지는 데몬이 플러시할 것이고, durable LSN은 producer의 개입 없이 전진할 것이다.

prior_lsa_next_record_with_lock(:1559)은 호출자가 이미 prior_lsa_mutex을 들고 있다고 가정하는 자매다 — checkpoint 도중 mutex가 LOG_END_CHKPT 스탬핑 전체를 감싸야 시작과 끝 LSA 사이 에 다른 레코드가 끼지 않는다는 사실을 보장하기 위해 쓰인다.

drain 측 — logpb_prior_lsa_append_all_list

섹션 제목: “drain 측 — logpb_prior_lsa_append_all_list”

drain은 글로벌 로그 critical section인 LOG_CS_OWN_WRITE_MODE 아래 에서 돈다. 이리로 도달하는 호출자는 셋이다. 로그 플러시 데몬(logpb_flush_pages_direct 경유), LOG_CS_ENTER을 거쳐 logpb_flush_pages_direct을 직접 부르는 임의 스레드(예: 작은 빌드의 backpressure self-help 경로, 혹은 commit 시점의 log_no_logging 강제 플러시), 그리고 partial-record 플러시 경로 (logpb_append_next_record이 레코드가 버퍼 경계를 가로지르면 logpb_flush_all_append_pages을 부르는 경우).

// logpb_prior_lsa_append_all_list — src/transaction/log_page_buffer.c:3106
int
logpb_prior_lsa_append_all_list (THREAD_ENTRY * thread_p)
{
LOG_PRIOR_NODE *prior_list;
INT64 current_size;
assert (LOG_CS_OWN_WRITE_MODE (thread_p));
log_Gl.prior_info.prior_lsa_mutex.lock ();
current_size = log_Gl.prior_info.list_size;
prior_list = prior_lsa_remove_prior_list (thread_p);
log_Gl.prior_info.prior_lsa_mutex.unlock ();
if (prior_list != NULL) {
perfmon_add_stat (thread_p, PSTAT_PRIOR_LSA_LIST_SIZE,
(unsigned int) current_size / ONE_K);
perfmon_inc_stat (thread_p, PSTAT_PRIOR_LSA_LIST_REMOVED);
logpb_append_prior_lsa_list (thread_p, prior_list);
}
return NO_ERROR;
}

detach는 가장 단순한 형태다. mutex 아래에서 list_size을 스냅샷 하고, prior_list_headerNULL로 swap, prior_list_tailNULL로 swap, list_size을 0으로 만들고, 푼다. mutex는 정확히 세 번의 포인터 store와 한 번의 INT64 store 동안 잡힌다. mutex가 잡힌 동안 어떤 바이트도 옮기지 않는다. 분리된 list은 어떤 다른 스레드도 더 이상 닿을 수 없는 사적인 단방향 연결 list다.

prior_lsa_remove_prior_list(log_page_buffer.c:3084)이 swap을 수행하는 도우미다. logpb_prior_lsa_append_all_list에서만 호출 되며 LOG_CS_OWN_WRITE_MODE을 단언한다.

logpb_append_prior_lsa_list(log_page_buffer.c:3040)이 walk이다.

// logpb_append_prior_lsa_list — src/transaction/log_page_buffer.c:3040
static int
logpb_append_prior_lsa_list (THREAD_ENTRY * thread_p, LOG_PRIOR_NODE * list)
{
LOG_PRIOR_NODE *node;
assert (LOG_CS_OWN_WRITE_MODE (thread_p));
/* stage the detached list at the flush-side header */
assert (log_Gl.prior_info.prior_flush_list_header == NULL);
log_Gl.prior_info.prior_flush_list_header = list;
while (log_Gl.prior_info.prior_flush_list_header != NULL) {
node = log_Gl.prior_info.prior_flush_list_header;
log_Gl.prior_info.prior_flush_list_header = node->next;
logpb_append_next_record (thread_p, node); /* copy bytes into LOG_PAGE */
if (node->data_header) free_and_init (node->data_header);
if (node->udata) free_and_init (node->udata);
if (node->rdata) free_and_init (node->rdata);
free_and_init (node);
}
return NO_ERROR;
}

logpb_append_next_record(log_page_buffer.c:2981)이 실제 바이트가 드디어 권위 있는 페이지를 만나는 자리다. node->start_lsa == log_Gl.hdr.append_lsa(prior list의 LSA가 페이지 측 cursor와 일치 한다는 sanity check — 큐가 순서대로 조립되었다는 검증)을 검증하고, 다음 레코드가 인메모리 로그 버퍼를 넘치게 한다면 미리 플러시하고, 페이지의 tde_encrypted 플래그를 노드에서 스탬프하고, logpb_start_append(레코드 헤더를 쓴다)을 호출하고, {data_header, udata, rdata} 각각마다 logpb_append_data을 호출 하고, logpb_end_append을 호출한다.

flush 측 헤더 포인터는 엄격히 필요하지는 않다 — drain이 로컬 포인터를 똑같이 쓸 수 있다 — 다른 자리들에서 liveness 신호로 검사된다(레코드가 append되는 중이다, 동시 재detach 금지). logpb_append_prior_lsa_list 진입의 assert(prior_flush_list_header == NULL) 단언이 한 번에 하나의 drain만 돈다는 사실을 강제한다.

각 노드의 바이트가 페이지로 복사된 뒤, 노드의 malloc된 페이로드 (data_header, udata, rdata, 그리고 노드 자체)이 free된다. 노드 풀은 없다. 할당 압력은 C 런타임의 malloc 아레나에 떨어진다. CUBRID의 모니터링은 PSTAT_PRIOR_LSA_LIST_SIZE(drain 한 번당 평균 바이트)와 PSTAT_PRIOR_LSA_LIST_REMOVED(drain 횟수)을 노출하므로, 운영자는 큐가 매끄럽게 비워지고 있는지 가늠할 수 있다.

logpb_flush_all_append_pages(log_page_buffer.c:3232)이 디스크에 쓰는 절반이다. 로그 페이지 버퍼의 dirty list을 걸어가며 active log 볼륨에 fileio_write_pages을 발사하고 log_Gl.append.nxio_lsa을 전진시킨다. 함수가 큰 까닭(약 600줄)은 partial-record의 두 단계 플러시 처리 때문이다. 가장 최근 레코드 헤더가 사는 페이지를 빼고 모두 플러시하고, 그 헤더 페이지를 가장 나중에 플러시한다. 그래야 플러시 도중 크래시가 나도 디스크 위에는 옛 end-of-log 마커 또는 새 마커 — 둘 중 하나만 — 남고 forward 포인터가 매달리지 않는다. 두 단계 댄스는 :3355-3380에 인라인 주석으로 적혀 있다.

logpb_force_flush_pages(log_page_buffer.c:4096)이 뼈대만 있는 강제 경로다.

// logpb_force_flush_pages — src/transaction/log_page_buffer.c:4096
void
logpb_force_flush_pages (THREAD_ENTRY * thread_p)
{
LOG_CS_ENTER (thread_p);
logpb_flush_pages_direct (thread_p);
LOG_CS_EXIT (thread_p);
}
// logpb_flush_pages_direct — src/transaction/log_page_buffer.c:3952
void
logpb_flush_pages_direct (THREAD_ENTRY * thread_p)
{
assert (LOG_CS_OWN_WRITE_MODE (thread_p));
logpb_prior_lsa_append_all_list (thread_p); /* drain the prior list */
(void) logpb_flush_all_append_pages (thread_p); /* write pages to disk */
log_Stat.direct_flush_count++;
}

logpb_flush_pages_direct이 “prior list에 있는 것은 모두 디스크에 가게 하라”의 정설적 진입점이다. 먼저 prior list을 페이지 버퍼로 끌어내고, dirty 페이지를 디스크에 써서 fsync한다. 두 단계 호출은 producer/consumer 분리를 그대로 비춘다. drain은 큐→페이지 단계, flush는 페이지→디스크 단계.

log_commitlog_commit_local → … → logpb_flush_pages(commit_lsa)(log_page_buffer.c:3980)이 커미터의 경로다. 함수가 (PRM_ID_LOG_ASYNC_COMMIT, group_commit)에서 명시적인 4-사분면 정책을 구현한다.

asyncgc거동
nonoLFT 깨우고 nxio_lsa >= flush_lsa이 될 때까지 gc_cond 위에서 대기
noyesgc_cond 위에서 대기 (LFT는 log_get_log_group_commit_interval에 한 번씩 tick)
yesnoLFT 깨우고 즉시 반환 (durable 이전에 commit ack)
yesyes즉시 반환, LFT는 알아서 tick

흥미로운 건 동기 + group commit 사례다. 커미터는 데몬을 깨우지 않는다. 데몬의 looper는 timed period을 갖고, 각 데몬 tick은 이전 tick 이래로 gc_cond 위에서 기다리던 모든 커미터를 한 묶음으로 처리한다. wakeup이 적을수록 batch가 커진다. 그것이 group commit의 이득이다.

커미터는 CV 루프에 park된다.

// logpb_flush_pages — log_page_buffer.c:3980 (committer wait, condensed)
nxio_lsa = log_Gl.append.get_nxio_lsa ();
while (LSA_LT (&nxio_lsa, flush_lsa)) {
timespec to = now() + max_wait_time_in_msec; /* 1 second */
pthread_mutex_lock (&gc->gc_mutex);
nxio_lsa = log_Gl.append.get_nxio_lsa ();
if (LSA_GE (&nxio_lsa, flush_lsa)) { pthread_mutex_unlock (&gc->gc_mutex); break; }
if (need_wakeup_LFT) log_wakeup_log_flush_daemon ();
pthread_cond_timedwait (&gc->gc_cond, &gc->gc_mutex, &to);
pthread_mutex_unlock (&gc->gc_mutex);
need_wakeup_LFT = true;
nxio_lsa = log_Gl.append.get_nxio_lsa ();
}

group-commit ack은 데몬의 log_flush_execute(log_manager.c:10377) 이 logpb_flush_pages_direct이 반환한 — 즉 drain과 페이지 flush가 모두 완료되고 nxio_lsa이 전진한 뒤 — 발사하는 pthread_cond_broadcast (&gc_cond)이다.

// log_flush_execute — log_manager.c:10377
static void
log_flush_execute (cubthread::entry & thread_ref)
{
if (!BO_IS_SERVER_RESTARTED () || !log_Flush_has_been_requested) return;
LOG_CS_ENTER (&thread_ref);
logpb_flush_pages_direct (&thread_ref); /* drain + write to disk */
LOG_CS_EXIT (&thread_ref);
log_Stat.gc_flush_count++;
pthread_mutex_lock (&log_Gl.group_commit_info.gc_mutex);
pthread_cond_broadcast (&log_Gl.group_commit_info.gc_cond);
log_Flush_has_been_requested = false;
pthread_mutex_unlock (&log_Gl.group_commit_info.gc_mutex);
}

데몬은 period가 log_get_log_group_commit_intervalcubthread::looper로 등록된다 — period는 라이브 config 값을 가리키는 함수 포인터이므로 파라미터가 바뀌면 재시작 없이 전파 된다.

Backpressure — drain보다 producer가 큐를 더 빨리 키울 때

섹션 제목: “Backpressure — drain보다 producer가 큐를 더 빨리 키울 때”

backpressure 경로는 prior_lsa_next_record_internal 꼬리의 self-help 패턴이다.

// excerpt from prior_lsa_next_record_internal — log_append.cpp:1521
if (log_Gl.prior_info.list_size >= (INT64) logpb_get_memsize ()) {
perfmon_inc_stat (thread_p, PSTAT_PRIOR_LSA_LIST_MAXED);
#if defined(SERVER_MODE)
if (!log_is_in_crash_recovery ()) {
log_wakeup_log_flush_daemon ();
thread_sleep (1); /* 1 millisecond */
} else {
LOG_CS_ENTER (thread_p);
logpb_prior_lsa_append_all_list (thread_p);
LOG_CS_EXIT (thread_p);
}
#else
LOG_CS_ENTER (thread_p);
logpb_prior_lsa_append_all_list (thread_p);
LOG_CS_EXIT (thread_p);
#endif
}

logpb_get_memsize ()은 인메모리 로그 페이지 버퍼 크기(보통 수천 페이지)를 돌려준다. 큐된 바이트가 그것을 넘으면 producer는 drain이 뒤처지는 것을 인지하고 다음 중 하나를 한다.

  • server mode(평상시) — 플러시 데몬을 깨우고 OS 스레드를 1ms 양보한다. 데몬이 drain을 하고, producer는 돌아와 비워진 큐 를 보고 자기 commit wait으로 진행한다.
  • crash recovery(아직 데몬이 없음) 또는 standalone mode (데몬 자체가 없음) — producer가 직접 로그 critical section을 잡고 drain한다. self-help 경로다 — 더 느리다(drain이 병렬이 아니라 producer 스레드 위에서 일어난다) 하지만 정확하다.

PSTAT_PRIOR_LSA_LIST_MAXED 카운터가 backpressure 발화의 운영자 가시 신호다. 정상 상태에서 발화한다는 것은 producer 부하가 데몬의 drain rate를 포화시켰다는 뜻이고, 처방은 빠른 I/O, 큰 로그 페이지 버퍼, 또는 async-commit / group-commit 튜닝 가운데 하나다.

상태 기계 — 한 트랜잭션의 commit

섹션 제목: “상태 기계 — 한 트랜잭션의 commit”
stateDiagram-v2
  [*] --> Active: BEGIN
  Active --> ProducerBuilding: log_append_undoredo_data\n(or any append-API call)
  ProducerBuilding --> ProducerAttaching: prior_lsa_alloc_and_copy_*\n(zlib compress, malloc node)
  ProducerAttaching --> Active: prior_lsa_next_record\n(LSN assigned, node on tail)
  Active --> ProducerBuilding: more log records
  Active --> Committing: log_commit
  Committing --> CommitNodeAttached: append LOG_COMMIT,\nattach to prior list
  CommitNodeAttached --> Waiting: logpb_flush_pages(commit_lsa)
  Waiting --> Waiting: nxio_lsa < commit_lsa\n(timed wait on gc_cond)
  Waiting --> Durable: nxio_lsa >= commit_lsa\n(daemon broadcast)
  Durable --> [*]: log_complete, ack client

  state ProducerBuilding {
    [*] --> Allocating
    Allocating --> Compressing: large UNDOREDO?
    Compressing --> Filling
    Allocating --> Filling: small record
    Filling --> [*]
  }

  state Waiting {
    [*] --> CheckLsn
    CheckLsn --> WakeLFT: need_wakeup_LFT
    WakeLFT --> Sleep
    CheckLsn --> Sleep: !need_wakeup_LFT
    Sleep --> CheckLsn: cond_timedwait wake
  }

다이어그램은 producer 단계가 durable LSN 의존성이 없다는 점을 명시적으로 만든다. 트랜잭션은 commit까지 레코드를 계속 만들어 낼 수 있고, commit 시점에야 Waiting에 들어가며, 그때서야 트랜잭션의 진전이 drain의 진전에 의존하기 시작한다.

비교 Mermaid — Postgres 중앙 latch vs CUBRID prior list

섹션 제목: “비교 Mermaid — Postgres 중앙 latch vs CUBRID prior list”
flowchart TB
  subgraph PG["PostgreSQL XLogInsert (centralised, latch-protected)"]
    direction TB
    PGT1["TX A"]
    PGT2["TX B"]
    PGT3["TX C"]
    PGL["WALInsertLock\n(8 striped locks)"]
    PGB["WAL buffer pages\n(authoritative)"]
    PGW["WAL writer\n(XLogFlush)"]
    PGF["WAL segment file"]

    PGT1 -->|hold lock| PGL
    PGT2 -->|hold lock| PGL
    PGT3 -->|hold lock| PGL
    PGL -->|memcpy under lock| PGB
    PGB --> PGW
    PGW --> PGF
  end

  subgraph CUB["CUBRID prior list (producer-side queued)"]
    direction TB
    CT1["TX A"]
    CT2["TX B"]
    CT3["TX C"]
    CN1["build NODE\n(zlib outside mutex)"]
    CN2["build NODE\n(zlib outside mutex)"]
    CN3["build NODE\n(zlib outside mutex)"]
    CL["prior_lsa_mutex\n(O(1) hold)"]
    CQ["prior_list\n(singly-linked queue)"]
    CD["log-flush daemon\n(LOG_CS_ENTER)"]
    CB["LOG_PAGE buffer\n(authoritative)"]
    CF["active log file"]

    CT1 --> CN1
    CT2 --> CN2
    CT3 --> CN3
    CN1 -->|attach under mutex| CL
    CN2 -->|attach under mutex| CL
    CN3 -->|attach under mutex| CL
    CL --> CQ
    CQ -->|drain| CD
    CD --> CB
    CB --> CF
  end

다이어그램이 짚는 두 구조적 차이. 첫째, Postgres의 lock은 레코드 바이트의 memcpy을 가로질러 잡혀 있고, CUBRID의 mutex는 O(1) link-tail 조작을 가로질러 잡혀 있다. 둘째, Postgres은 appender와 권위 있는 버퍼 사이에 어떤 중간 큐도 두지 않고, CUBRID은 큐를 둔다. CUBRID이 치르는 비용은 두 단계 메모리 트래픽(바이트가 먼저 prior 노드로, 나중에 로그 페이지로 복사됨). Postgres이 치르는 비용은 lock에 직렬화된 경합이다. 저-동시성 CPU 바운드 워크로드에서 는 Postgres이 이기고, 고-동시성 I/O 바운드 워크로드에서는 CUBRID 설계의 헤드룸이 더 크다.

실패 사례 — 비어 있지 않은 prior list로 크래시

섹션 제목: “실패 사례 — 비어 있지 않은 prior list로 크래시”

크래시 시점에 prior list에 있는 레코드는 메인 메모리에만 있다 — 디스크에 없다. 소유 트랜잭션은 commit ack을 받지 못했다(받았다 면 append을 마친 채 gc_cond 위에 park되어 있을 것). 복구 시점 에는

  • active log 파일은 크래시 직전에 durable하게 쓰여진 LSN까지만 존재한다. 그 너머의 LSN은 존재한 적도 없다.
  • 복구의 analysis 패스는 그 트랜잭션에 대한 LOG_COMMIT을 찾지 못하고 크래시 시점에 active로 표시한다. undo가 트랜잭션이 durable하게 로깅하는 데 성공한 만큼을 되돌린다.
  • 데이터 페이지는 WAL 불변식을 따른다. durable한 로그보다 새로울 수 없으므로 undo가 되돌릴 수 있다.

결정적 성질은 다음과 같다. 트랜잭션은 자기 LOG_COMMIT 레코드 가 durable하지 않은 한 commit ack을 절대 보지 않는다. 이유는 log_commitnxio_lsa >= commit_lsa까지 블록하는 logpb_flush_pages(commit_lsa)을 호출하기 때문이다. “크래시 시점 에 prior list가 비어 있지 않다는 것은 구조적으로 진행 중이던 어떤 commit도 잃지 않았다”는 뜻이며, 잃은 것은 commit되지 않은 일이고 commit되지 않은 것은 잃어야 마땅한 것이다. drain되지 못한 malloc된 노드는 프로세스 종료 시점에 그냥 누수된다.

sequenceDiagram
  participant TX as TX thread
  participant API as log_append_*
  participant PA as prior_lsa_alloc_and_copy_*
  participant PN as prior_lsa_next_record
  participant DR as logpb_prior_lsa_append_all_list
  participant FA as logpb_flush_all_append_pages
  participant FD as log_Flush_daemon
  participant DK as Disk

  TX->>API: log_append_undoredo_data(...)
  API->>PA: malloc node, format header,\nzlib-compress payload (outside mutex)
  PA-->>API: LOG_PRIOR_NODE *
  API->>PN: prior_lsa_mutex.lock()
  PN->>PN: assign start_lsa,\nstamp tdes / MVCC,\nlink to prior_list_tail,\nbump list_size
  PN->>PN: prior_lsa_mutex.unlock()
  PN-->>API: LSN
  API-->>TX: return LSN
  Note over TX: business logic continues
  TX->>API: log_commit(...)
  API->>PA: build LOG_COMMIT node
  API->>PN: attach commit node, get commit_lsa
  TX->>FD: logpb_flush_pages(commit_lsa)\n[wake daemon if !group_commit]
  TX->>TX: pthread_cond_timedwait(gc_cond)
  loop daemon iteration
    FD->>FD: LOG_CS_ENTER
    FD->>DR: logpb_prior_lsa_append_all_list
    DR->>DR: detach prior list under mutex
    DR->>DR: walk nodes,\nlogpb_append_next_record\n(copy bytes into LOG_PAGE)
    DR->>DR: free nodes
    FD->>FA: logpb_flush_all_append_pages
    FA->>DK: fileio_write_pages, fsync
    FA->>FA: nxio_lsa = append_lsa
    FD->>FD: LOG_CS_EXIT
    FD->>TX: pthread_cond_broadcast(gc_cond)
  end
  TX->>TX: nxio_lsa >= commit_lsa? yes
  API-->>TX: TRAN_UNACTIVE_COMMITTED

줄 번호가 아니라 심볼 이름에 닻을 내린다. 줄은 흘러간다.

Producer 측 타입과 글로벌 (log_append.hpp)

섹션 제목: “Producer 측 타입과 글로벌 (log_append.hpp)”
  • LOG_PRIOR_NODE — 큐에 들어가는 레코드(헤더, TDE 플래그, 세 바이트 버퍼, forward link).
  • LOG_PRIOR_LSA_INFO — 큐 상태(head/tail, size, prior_lsa, prev_lsa, mutex, flush 측 헤더).
  • log_append_info — 페이지 측 cursor(vdes, atomic nxio_lsa, prev_lsa, log_pgptr, TDE-on-new-page 플래그).
  • LOG_PRIOR_LSA_LOCK — attach API의 with/without-lock enum.
  • LOG_DATA_ADDR — 모든 append 호출이 넘기는 (vfid, page, offset) 트리플. offset의 상위 비트가 부분 갱신 플래그 (LOG_RV_RECORD_INSERT, _DELETE, _UPDATE_ALL, _UPDATE_PARTIAL)을 나른다.

Producer 측 할당기와 attach (log_append.cpp)

섹션 제목: “Producer 측 할당기와 attach (log_append.cpp)”
  • prior_lsa_alloc_and_copy_data — 비-UNDOREDO 할당기. 타입별 generator(prior_lsa_gen_postpone_record / _dbout_redo_record / _2pc_prepare_record / _end_chkpt_record / _record)으로 디스패치.
  • prior_lsa_alloc_and_copy_crumbs — 분산된 LOG_CRUMB 배열을 받는 UNDOREDO 할당기. prior_lsa_gen_undoredo_record_from_crumbs 로 위임해 MVCC 스탬핑, zlib 압축, diff-encoding을 처리.
  • prior_lsa_copy_{undo,redo}_data_to_node / _{undo,redo}_crumbs_to_node — 버퍼 복사 도우미 (malloc + memcpy).
  • prior_lsa_next_record / _with_lock / _internal — attach(LSN 부여, tdes에 MVCC / sysop / commit 상태 스탬프, prior_lsa 전진, 노드 link, overflow 시 backpressure).
  • prior_lsa_start_appendstart_lsa, prev_tranlsa, back_lsa 채움.
  • prior_lsa_end_appendforw_lsa 채움.
  • prior_lsa_append_dataprior_lsa을 길이만큼 전진(페이지 경계 가로지름).
  • log_prior_lsa_append_align / _advance_when_doesnot_fit / _add_align — 정렬·경계 도우미.
  • prior_update_header_mvcc_info — vacuum용 log_Gl.hdr.mvcc_op_log_lsa로 MVCC 연산을 체이닝.
  • prior_set_tde_encrypted / prior_is_tde_encrypted — TDE 플래그.
  • log_append_init_zip / _final_zip / log_append_get_zip_undo / _redo — 스레드별 zlib 컨텍스트 라이프사이클.
  • log_append_realloc_data_ptr / _get_data_ptr — 압축 staging 용 스레드별 스크래치 버퍼.
  • log_prior_has_worker_log_records — drain 측 HA 검사.
  • LOG_RESET_APPEND_LSA / LOG_RESET_PREV_LSA / LOG_APPEND_PTR — 부트·복구 시 글로벌 cursor 조작.
  • logpb_prior_lsa_append_all_listLOG_CS_OWN_WRITE_MODE 아래 진입점. detach + walk.
  • prior_lsa_remove_prior_list — detach(mutex 아래에서 head/tail을 NULL로 swap).
  • logpb_append_prior_lsa_list — walk-and-copy 루프.
  • logpb_append_next_record — 한 노드를 권위 있는 LOG_PAGE로 복사. overflow 시 pre-flush.
  • logpb_next_append_page — 페이지 경계 가로지름. flush-info에 현재 페이지 dirty 표시.
  • logpb_flush_all_append_pages — dirty 페이지 쓰기, partial- record 두 단계 댄스, nxio_lsa 전진.
  • logpb_flush_pages_direct — 기존 CS 안에서 drain + flush.
  • logpb_flush_pages(async, group) 매트릭스를 가진 커미터 경로.
  • logpb_force_flush_pages / _header_and_pages — 강제 플러시 래퍼.
  • log_flush_execute — 데몬 본체. drain + flush + broadcast.
  • log_wakeup_log_flush_daemonlog_Flush_has_been_requested 세팅, wakeup() 호출.
  • log_is_log_flush_daemon_available — 존재 여부 검사.
  • log_flush_daemon_init — looper period log_get_log_group_commit_interval로 데몬 생성.

공개 append API (엔진 나머지가 소비)

섹션 제목: “공개 append API (엔진 나머지가 소비)”

log_append_undoredo_data / _undoredo_crumbs(UNDOREDO), log_append_undo_data, log_append_redo_data, log_append_dboutside_redo, log_append_postpone, log_append_run_postpone, log_append_compensate(CLR), log_append_savepoint, log_append_ha_server_state, log_append_supplemental_*(CDC). 각각이 prior_lsa_alloc_and_copy_*로 노드를 만들고 prior_lsa_next_record로 attach한다.

심볼파일
log_append_infolog_append.hpp73
log_prior_nodelog_append.hpp91
log_prior_lsa_infolog_append.hpp112
log_prior_has_worker_log_recordslog_append.cpp152
log_append_init_zip / _final_ziplog_append.cpp185 / 232
prior_lsa_alloc_and_copy_datalog_append.cpp273
prior_lsa_alloc_and_copy_crumbslog_append.cpp410
prior_lsa_copy_{undo,redo}_data_to_nodelog_append.cpp493 / 524
prior_lsa_copy_{undo,redo}_crumbs_to_nodelog_append.cpp555 / 600
prior_lsa_gen_undoredo_record_from_crumbslog_append.cpp651
prior_lsa_gen_postpone_recordlog_append.cpp1062
prior_lsa_gen_dbout_redo_recordlog_append.cpp1109
prior_lsa_gen_2pc_prepare_recordlog_append.cpp1144
prior_lsa_gen_end_chkpt_recordlog_append.cpp1181
prior_lsa_gen_recordlog_append.cpp1217
prior_update_header_mvcc_infolog_append.cpp1320
prior_lsa_next_record_internallog_append.cpp1357
prior_lsa_next_recordlog_append.cpp1553
prior_lsa_next_record_with_locklog_append.cpp1559
prior_set_tde_encrypted / prior_is_tde_encryptedlog_append.cpp1565 / 1581
prior_lsa_start_appendlog_append.cpp1593
prior_lsa_end_appendlog_append.cpp1652
prior_lsa_append_datalog_append.cpp1660
log_append_get_zip_undo / _redolog_append.cpp1725 / 1751
log_prior_lsa_append_align and friendslog_append.cpp1892-1922
logpb_next_append_pagelog_page_buffer.c2630
logpb_append_next_recordlog_page_buffer.c2981
logpb_append_prior_lsa_listlog_page_buffer.c3040
prior_lsa_remove_prior_listlog_page_buffer.c3084
logpb_prior_lsa_append_all_listlog_page_buffer.c3106
logpb_flush_all_append_pageslog_page_buffer.c3232
logpb_flush_pages_directlog_page_buffer.c3952
logpb_flush_pageslog_page_buffer.c3980
logpb_force_flush_pageslog_page_buffer.c4096
log_commitlog_manager.c5352
log_wakeup_log_flush_daemonlog_manager.c10126
log_flush_executelog_manager.c10377
log_flush_daemon_initlog_manager.c10493

이 절은 자매 문서들의 외부에서 본 prior list 시각이 소스가 실제 로 하는 일과 어떻게 들어맞는지 정리한다.

  • vs cubrid-log-manager.md §“prior list — 페이지 버퍼 앞의 staging”. 로그 매니저 문서가 큐, mutex, drain 함수를 정확히 명명한다. 그러나 줌인하지 않은 부분이 있다 — producer 측에서 “build(mutex 바깥)와 attach”(mutex 아래)이 분리되어 있다는 점, 그리고 zlib 압축이 mutex 보유 시간을 짧게 유지하는 데 어떤 역할을 하는지. 로그 매니저 문서는 “정확한 group-commit 윈도우 정책은 무엇인가”을 풀리지 않은 질문으로 남겨 둔다. 답(위에서 검증함)은 logpb_flush_pages 안의 4-사분면 (async_commit, group_commit) 매트릭스와 데몬의 looper period log_get_log_group_commit_interval이다. 카운트 기반 임계는 없다 — 정책은 순전히 시간 기반이며 on-demand wakeup이 보탬이 된다.

  • vs cubrid-double-write-buffer.md §“producer 측 staging이 분리됨: stage → WAL force → commit”. DWB 문서는 페이지 플러시 순서가 dwb_set_data_on_next_slot → WAL force → dwb_add_page 임을 적어 둔다. WAL force는 logpb_flush_log_for_wal이고, 이것이 logpb_flush_pages(page_lsa) — 여기서 설명한 group-commit parking — 으로 연결된다. prior list는 페이지 플러시 durability 경로 안에 있다. dirty 페이지는 자기 log-LSN이 prior list에서 drain되어 fsync되지 않는 한 home에 쓰일 수 없다.

  • vs cubrid-thread-worker-pool.md §데몬 소비자. 워커 풀 문서는 log-flush을 looper 정책 INF (commit이 깨움)로 등록 한다고 쓴다. INF 주장은 부정확하다 — log_flush_daemon_init 은 함수 period를 가진 looper(WAIT_PERIODIC_FUNC 모드, WAIT_INF 아님)을 등록한다. 데몬은 interval에 tick하면서 wakeup 에도 응답한다. 데몬 본체는 log_Flush_has_been_requestedfalse면 short-circuit하므로 빈 tick은 일을 하지 않는다. 순 거동은 interval당 최대 한 번의 flush + on-demand flush.

  • drain은 글로벌 로그 critical section 안에서 돈다. LOG_CS_ENTERlog_Gl.log_cs(heavyweight csect_t)을 writer 모드로 잡는다. 이것이 바깥쪽 lock이고, prior_lsa_mutex안쪽 lock이다. producer는 안쪽만 잡고, drain은 둘 다 잡는다 — 바깥 먼저, 분리만 잠깐 잡는다고 안쪽. 어떤 경로도 반대 순서로 잡지 않는다.

  • prior list mutex의 보유 시간은 묶여 있지만 형식화되어 있지 않다. mutex 아래에서 prior_lsa_next_record_internal이 레코드 타입별 스탬핑을 한다 (MVCC 체인, sysop 상태, commit 상태, vacuum link, prior_lsa 정렬). 엄격한 의미로는 O(1)이 아니지만 (레코드 타입마다 분기), 작은 상수로 묶여 있다 — 바이트 위의 루프 없음, list 자체 위의 루프 없음, I/O 없음.

  • backpressure는 flow control이 아니다. list_size >= logpb_get_memsize() 검사는 soft cap이다 — 데몬 깨우고, 양보 하고, 계속한다. producer를 막는 hard cap은 없다. 병적인 producer는 데몬을 따돌릴 수 있고, 증상은 OOM이다. 실무에서는 디스크의 유효 대역폭이 데이터 페이지 위의 WAL 불변식으로 producer를 flow control한다.

  1. consumer 스레드 다중화. 단일 데몬 설계는 logpb_append_prior_lsa_list 진입의 assert (prior_flush_list_header == NULL)이 강제한다. 두 데몬 은 도울 수 없다 — 목적지인 LOG_PAGE 버퍼도 LOG_CS 아래 single-writer이기 때문이다. 넓혀야 할 축은 동시 fsync(여러 로그 세그먼트를 PostgreSQL 세그먼트 striping처럼 병렬 fsync) 이지 다중 drain 스레드가 아니다.

  2. NUMA 노드별 prior list. Oracle의 redo strand는 큐를 NUMA 노드에 걸쳐 샤딩한다. CUBRID의 단일 mutex는 고-코어 머신에서 잠재적 병목이다. 샤딩하려면 drain 시점에 LSN merge 단계가 필요하다 — LSN이 attach 시점에 단조이지 않게 된다. 부하 아래 에서 prior_lsa_mutex이 측정 가능한 경합을 보일 때만 가치 있는 조사다.

  3. 노드별 malloc / free 비용. 모든 노드는 producer가 malloc하고 drain이 free한다. 스레드별 노드 풀(각 producer가 작은 free-list을 들고, drain이 노드를 돌려 줌)이 NUMA 친화적 이고, 프로파일링에서 malloc/free가 상위 핫스폿에 보이면 할당기 핫스폿을 제거할 수 있다.

  4. list_size 회계 비용. 모든 attach가 list_size에 한 번의 캐시라인 쓰기다. 레코드 카운트로 바꾸면 정밀도가 떨어진다 — 레코드 크기는 작은 LOG_DUMMY_GENERIC부터 KB 단위 UNDOREDO 까지 자릿수를 가로질러 변하기 때문이다.

  5. prior_lsa_next_record_with_lock 호출자. 외부에서 잡은 mutex을 가정하는 변종은 checkpoint에서 쓰인다. std::mutex은 재귀가 아니므로 호출자는 단일 acquisition을 보장해야 한다 — 모든 호출자가 감사를 받아야 한다.

  6. TDE 암호화된 노드 라이프사이클. tde_encrypted = trueLOG_PRIOR_NODEappending_page_tde_encrypted로 표시된 로그 페이지에 들어간다. 노드 빌드와 drain 사이에 마스터 키가 회전한다면 암호화는 옛 키지만 페이지는 현재 세대를 기록한다. prior list에는 IV 부기가 없다 — IV 메커니즘은 DWB의 풀리지 않은 질문 1과 같은 조사 대상이다.

  7. standalone 모드의 help-out drain. SA_MODE에는 데몬이 없다 — list_size이 overflow하면 producer가 자기 자신을 drain한다. producer는 LOG_CS에 들어가기 전에 prior_lsa_mutex을 풀므로 재귀가 불가능하다. 다만 mutex을 든 채 LOG_CS에 진입하는 미래 의 코드 경로는 데드락에 빠진다.

  8. async commit 가시성. PRM_ID_LOG_ASYNC_COMMIT = true이면 커미터는 자기 commit LSN이 durable해지기 전에 반환한다. log_Stat.async_commit_request_count이 요청을 센다 — “ack 되었지만 아직 durable 아님”의 게이지는 없다.

소스 전용 문서. prior list에 특화된 큐레이트된 raw 분석은 없다.

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

섹션 제목: “CUBRID 소스 (/data/hgryoo/references/cubrid/)”
  • src/transaction/log_append.hpp — 공개 타입 (LOG_PRIOR_NODE, LOG_PRIOR_LSA_INFO, log_append_info).
  • src/transaction/log_append.cpp — 할당기와 attach (prior_lsa_alloc_and_copy_*, prior_lsa_next_record*, prior_lsa_start_append, _end_append, _append_data).
  • src/transaction/log_record.hppLOG_RECORD_HEADER, LOG_RECTYPE, 타입별 레코드 구조체.
  • src/transaction/log_manager.c — 데몬(log_flush_execute, log_wakeup_log_flush_daemon), commit 파이프라인(log_commit).
  • src/transaction/log_page_buffer.c — drain (logpb_prior_lsa_append_all_list, _append_prior_lsa_list, _append_next_record), flush(logpb_flush_all_append_pages, _force_flush_pages, _flush_pages_direct, _flush_pages).
  • src/transaction/log_postpone_cache.{hpp,cpp} — postpone 레코드의 인접 인메모리 버퍼링. prior list의 일부는 아니지만 개념적으로 가깝다.
  • src/storage/page_buffer.clogpb_flush_log_for_wal을 통한 WAL 불변식 소비자.
  • cubrid-log-manager.md — 전체 WAL 규율, LSA 스킴, 로그 레코드 타입, 아카이브 부기.
  • cubrid-double-write-buffer.md — prior list drain에 의존하는 페이지 플러시 durability.
  • cubrid-thread-worker-pool.md — 데몬 프레임워크, cubthread::looper 의미, LOG_CS heavyweight csect_t.
  • cubrid-recovery-manager.md — prior list의 durable 결과물을 걸어가는 복구.
  • cubrid-mvcc.md — prior list가 스탬프하는 LOG_MVCC_* 레코드의 소비자.
  • cubrid-page-buffer-manager.mdpgbuf_flush_check_log_lsa 불변식.
  • Mohan 외, ARIES (TODS 17.1, 1992) — prior list가 떠받치는 WAL 계약.
  • Database Internals(Petrov), 5장 §Recovery / §Write-Ahead Logging — commit-pipe과 group-commit 프레임.
  • Mohan & DeWitt commit-pipe 연구(1992) — appender와 writer 분리 의 구조적 논거.
  • Michael & Scott, Simple, Fast, and Practical Non-Blocking and Blocking Concurrent Queue Algorithms(PODC 1996) — 정설적 MPSC FIFO. CUBRID의 prior list는 그 짧은-mutex 등가물이다.
  • Herlihy & Shavit, The Art of Multiprocessor Programming — 락프리 큐 패턴.
  • PostgreSQL XLogInsert / WALInsertLock — 중앙 집중, latch 보호. 비교 기준점.
  • MySQL InnoDB log_buf + log_writer / log_flusher — 원형 버퍼 위의 group commit.
  • Oracle redo strand — LGWR 시점에 merge하는 다중 strand.
  • SQL Server 로그 버퍼 + WAL writer — InnoDB과 비교 가능.