콘텐츠로 이동

CUBRID OOS (Out-of-Line Storage) — 대형 컬럼 외부 저장 메커니즘

개발 중인 기능. 이 문서는 커밋 fa26eff3a(develop 대비 105 커밋 앞) 시점의 feat/oos 브랜치를 분석한다. 마일스톤 M1은 완료되었으며(CRUD, WAL, HA 복제, 커버드 인덱스), M2는 진행 중(bestspace 재사용, 컴팩션, 테이블 삭제 시 파일 파괴, vacuum 연동), M3–M4는 계획 단계에 있다. 심볼명이 안정적인 앵커이며, 위치 힌트 표의 라인번호와 여기 기술된 API는 브랜치 진행에 따라 변동될 수 있다. 소스에는 아직 abort() 호출과 TODO(... remove before develop merge) 마커가 살아 있다 — 교차 확인 항목 참조. 이 문서는 스냅샷이며 명세가 아니다.

CUBRID의 힙은 각 행의 모든 컬럼을 슬롯 페이지의 단일 레코드에 연속으로 저장한다. 대부분의 질의에서는 이 방식이 효율적이지만, 넓은 행이 존재하면 문제가 된다. 예를 들어 1 MB짜리 VARCHAR를 가진 테이블에서 id 만 조회해도 레코드 전체(오버플로 페이지 포함)가 버퍼 풀에 올라 온다. OOS는 큰 컬럼만 클래스별 별도 파일로 빼내고, 원래 위치에는 고정 크기 포인터만 남기는 방식으로 이 문제를 해결한다.

행 저장 엔진은 튜플을 헤더, 가변 오프셋 테이블, 고정 너비 컬럼, 가변 너비 컬럼 순서의 연속 바이트 열로 페이지에 배치한다. 대부분의 행을 함께 읽는 질의에서는 최적이지만, 개별 속성이 커지면 두 가지 병목이 생긴다.

  1. 읽기 증폭. 페이지에 담기는 행 수는 PAGESIZE / 평균 행 크기다. 컬럼 하나가 거대해지면 평균 행 크기가 불어나고, 페이지당 행 수가 급감한다. 작은 컬럼만 필요한 질의도 절대 읽지 않을 바이트가 가득 찬 페이지를 통째로 가져와야 한다. 인덱스 온리 스캔이 도움이 되지만, 힙을 한 번이라도 건드리면 레코드 전체 비용을 지불한다.
  2. 페이지 크기 상한. 값 하나가 페이지 하나보다 크면 인라인으로는 아예 저장이 불가능하다. 엔진은 어딘가에 넘겨 두고 참조만 남겨야 한다.

표준 해법이 오프 페이지 / 아웃 오브 라인 저장이다. 작은 컬럼은 인라인으로 유지하고, 큰 컬럼은 별도 구조로 이동한 뒤, 레코드에는 작은 고정 크기 포인터(보통 원본 길이 포함)만 남긴다. 이 패턴의 핵심 설계 선택은 세 가지다 — 외부화를 언제 결정하는가, 포인터 형태는 무엇인가, 행이 변경되거나 삭제될 때 오프 페이지 데이터를 어떻게 회수하는가.

그림 1 — 인라인 전용 vs 오프 페이지(OOS): 작은 인라인 레코드를 위해 포인터 역참조를 감수한다

그림 내부 라벨은 영어로 표기한다 — 그림 파일은 EN 문서와 공유되며, 사이트 빌드가 EN/KO 양쪽에 동일 파일을 복제한다.

유사 시스템 비교:

  • PostgreSQL TOAST — 튜플 단위 목표 크기(TOAST_TUPLE_THRESHOLD, 페이지 크기에서 파생)를 초과하는 값은 pglz/lz4로 압축하고, 그래도 크면 ~2 KB 청크 단위로 테이블별 pg_toast_* 릴레이션에 push한다. 인라인 포인터(varatt_external)는 약 18바이트로 toast OID와 원본·저장 길이 두 가지를 기록한다. 청크는 읽을 때 재조합된다.
  • MySQL/InnoDB 오프 페이지VARCHAR/BLOB/TEXT 오버플로는 오프 페이지 익스텐트로 이동한다. 인라인 부분에는 최초 오버플로 페이지를 가리키는 약 20바이트 포인터(space id, page number, offset, length)가 남으며, 큰 값은 체인으로 연결된다.
  • CUBRID 기존 오버플로(OVF) 파일 — 긴 문자열은 이미 FILE_MULTIPAGE_OBJECT_HEAP 오버플로 레코드로 넘어간다. OOS는 OVF와 다른 별개의 메커니즘으로, 컬럼 수준 외부화와 더 작은 인라인 공간을 목표로 하며, OVF를 대체하는 것이 아니라 병존한다.

개념적 공통점: 확실히 작은 인라인 레코드를 얻는 대신 확실히 저렴한 포인터 역참조 비용을 치른다. 공학적 비용은 전부 부기(bookkeeping)에 있다. 외부화 기준 휴리스틱, 포인터 무결성, 그리고 특히 MVCC 환경에서의 회수 — 동시 독자가 볼 수 있는 “오래된” 포인터가 남아 있는 동안은 함부로 삭제할 수 없기 때문이다.

OOS(소스에서는 Out-of-row Storage / Out-of-row Overflow Storage; JIRA 에픽에서는 Out-of-Line Storage로 표기)는 대형 가변 길이 컬럼을 클래스별 OOS 파일(FILE_OOS)에 외부화하고, 힙 레코드에 고정 16바이트 인라인 스텁을 남긴다. 에픽 번호는 CBRD-26357이다.

각 테이블은 힙 파일 1개 + OOS 파일 1개를 가지며, OOS 파일은 지연 생성된다. 힙 헤더(HEAP_HDR_STATS)에 oos_vfid 필드가 추가된다. 처음으로 컬럼을 외부화할 때 heap_oos_find_vfid(..., docreate=true)가 OOS 파일을 생성하고, 클래스의 TDE 알고리즘을 적용한 뒤, 최상위 시스템 오퍼레이션 안에서 VFID를 힙 헤더에 기록한다.

// heap_oos_find_vfid -- src/storage/heap_file.c
if (VFID_ISNULL (&heap_hdr->oos_vfid))
{
if (docreate == true)
{
log_sysop_start (thread_p);
if (oos_create_file (thread_p, *oos_vfid) != NO_ERROR) { /* abort */ }
// ... condensed: apply TDE to the new OOS file ...
log_append_undo_data (thread_p, RVHF_STATS, &addr_hdr, sizeof (*heap_hdr), heap_hdr);
VFID_COPY (&heap_hdr->oos_vfid, oos_vfid);
log_append_redo_data (thread_p, RVHF_STATS, &addr_hdr, sizeof (*heap_hdr), heap_hdr);
log_sysop_commit (thread_p);
}
// else: oos_vfid stays NULL — "no OOS file" is not an error
}

oos_create_file은 열거 가능한 FILE_OOS 파일과 슬롯 0에 OOS_HDR_STATS bestspace 헤더를 보유하는 고정 첫 번째 페이지를 할당한다.

그림 2 — 클래스별 파일 쌍: 힙 파일 1개와 지연 생성되는 OOS 파일 1개

두 플래그: HAS_OOS(행 단위)와 IS_OOS(컬럼 단위)

섹션 제목: “두 플래그: HAS_OOS(행 단위)와 IS_OOS(컬럼 단위)”

OOS는 독립된 두 플래그 비트를 사용한다.

  • OR_MVCC_FLAG_HAS_OOS = 0x08 — MVCC 헤더 플래그의 비트 3. 최소 하나 의 컬럼이 외부화된 힙 레코드에 세팅된다. heap_recdes_contains_oos가 “이 레코드에 OOS 확장이 필요한가?”를 빠르게 판별하는 데 사용된다. (헤더 TODO 주석은 OOS가 MVCC와 무관하므로 이 비트를 결국 다른 곳으로 옮겨야 한다고 명시한다.)

    // OR_MVCC_FLAG_HAS_OOS -- src/base/object_representation_constants.h
    // TODO: OOS is not related to MVCC, move to another place when POC ends
    #define OR_MVCC_FLAG_HAS_OOS 0x08 // 0b00001000
  • OR_VAR_BIT_OOS = 0x1 — 가변 오프셋 테이블(VOT) 각 항목의 하위 비트. VOT는 이미 모든 오프셋의 하위 2비트를 플래그로 쓴다 (OR_VAR_FLAG_MASK = 0x3): 비트 0은 OOS 컬럼, 비트 1은 마지막 요소를 나타낸다. 따라서 VOT 한 번 순회로 OOS 컬럼을 찾는 동시에 끝을 판별할 수 있다.

    // OR_IS_OOS / OR_OOS_INLINE_SIZE -- src/base/object_representation.h
    #define OR_VAR_BIT_OOS 0x1
    #define OR_GET_VAR_OFFSET(length) ((int) (length) & (~OR_VAR_FLAG_MASK))
    #define OR_IS_OOS(length) (OR_GET_VAR_FLAG (length) & OR_VAR_BIT_OOS)
    /* OOS inline size: OOS OID (8 bytes) + OOS length (8 bytes) */
    #define OR_OOS_INLINE_SIZE (OR_OID_SIZE + OR_BIGINT_SIZE)

그림 3 — 두 OOS 플래그: MVCC 헤더의 행 단위 비트와 각 VOT 오프셋에서 훔친 컬럼 단위 비트

컬럼 값이 있던 자리에 OR_OOS_INLINE_SIZE = 16바이트가 들어간다. 앞 8바이트는 헤드 청크 위치를 가리키는 CUBRID OID(헤드 청크 로케이터), 뒤 8바이트는 전체 원본 값 길이를 담는 BIGINT다. OID는 모든 CUBRID OID와 동일하게 volid(2B), pageid(4B), slotid(2B)로 분해된다. 뒤따르는 길이 필드 덕분에 독자는 청크를 먼저 가져오지 않고도 목적지 버퍼 크기를 알 수 있다.

그림 4 — 16바이트 OOS 인라인 스텁(OR_OOS_INLINE_SIZE), 바이트 스케일로 표현

설계 에픽에서 “16바이트 OOS OID”라고 표현할 때는 스텁 전체(OID + 길이)를 가리키는 것이지 OID만이 아니다. OID 부분은 8바이트, 길이 필드가 나머지 8바이트다.

외부화 여부는 heap_attrinfo_determine_disk_layout에서 디스크 레이아웃을 계산할 때 결정된다. 이 개정 시점 기준으로 적용 규칙은 원래 설계 의도에 서술된 /8 + 512B 규칙이 아니라 PostgreSQL TOAST 방식이다(교차 확인 항목 참조).

// heap_attrinfo_determine_disk_layout -- src/storage/heap_file.c
/* push the largest variable column to OOS one by one until the heap record
* fits within DB_PAGESIZE/4 (PG TOAST style) ... */
if (header_size + payload_size + mvcc_extra > DB_PAGESIZE / 4)
{
// collect every variable, non-fixed column whose size > OR_OOS_INLINE_SIZE
// sort candidates by size descending, then:
for (auto & cand : oos_candidates)
{
if (header_size + payload_size + mvcc_extra <= DB_PAGESIZE / 4) break;
(*oos_columns)[cand.second] = true; // mark this column OOS
payload_size -= cand.first; // remove inline value
payload_size += OR_OOS_INLINE_SIZE; // add the 16-byte stub
*has_oos = true;
}
}

조립된 레코드가 DB_PAGESIZE/4를 초과할 경우, 엔진은 가장 큰 가변 컬럼부터 외부화하고 레코드가 기준 이하로 줄어들면 멈춘다. 16바이트 스텁보다 작은 컬럼(외부화해도 레코드가 줄어들지 않는 경우)은 외부화 대상이 아니다.

외부화 대상으로 표시된 각 컬럼은 스크래치 RECDES에 직렬화된 뒤 oos_insert로 OOS 파일에 기록되고, 반환된 헤드 청크 OID가 인라인 스텁에 저장된다. 디스크 변환 과정에서 행 단위 HAS_OOS 비트와 컬럼 단위 IS_OOS 비트가 설정된다.

flowchart TD
  I0["INSERT 행"] --> I1["determine_disk_layout:\n레코드 > DB_PAGESIZE/4 ?"]
  I1 -- 아니오 --> I9["완전 인라인 레코드 기록"]
  I1 -- 예 --> I2["가장 큰 가변 컬럼에 IS_OOS 표시\nHAS_OOS 설정"]
  I2 --> I3["heap_attrinfo_insert_to_oos:\n각 OOS 컬럼에 대해"]
  I3 --> I4["컬럼을 RECDES에 직렬화"]
  I4 --> I5["oos_insert -> 헤드 청크 OID"]
  I5 --> I6["VOT 슬롯에 16B 스텁 OID + 길이 기록"]
  I6 --> I7["복제 로그를 위해 스레드 oos_oids에 OID push"]
  I7 --> I8["spage_insert 힙 레코드"]

oos_insert는 페이로드를 한 페이지 안에 담을 수 있는 최대 청크 크기와 비교해 단일 청크 경로와 멀티 청크 경로를 선택한다.

// oos_insert -- src/storage/oos_file.cpp
if (src_len <= oos_get_max_chunk_size_within_page ())
{
const OOS_RECORD_HEADER header{src_len, 0, OID_INITIALIZER};
err = oos_insert_within_page (thread_p, oos_vfid, src, header, oid);
}
else
{
err = oos_insert_across_pages (thread_p, oos_vfid, src, oid);
}

oos_insert_within_pageOOS_RECORD_HEADER를 앞에 붙이고, bestspace로 대상 페이지를 찾아 spage_insert하고, RVOOS_INSERT를 로깅하며, bestspace 캐시를 갱신한다.

// oos_insert_within_page -- src/storage/oos_file.cpp
auto auto_page_ptr = oos_find_best_page (thread_p, oos_vfid, required_length, vpid);
// ... condensed: oos_prepend_header builds [OOS_RECORD_HEADER | payload] into oos_recdes ...
int sp_status = spage_insert (thread_p, page_ptr, &oos_recdes, &slotid);
// ... condensed: error check ...
oid.pageid = vpid.pageid;
oid.slotid = slotid;
oid.volid = vpid.volid;
oos_log_insert_physical (thread_p, page_ptr, const_cast<VFID *> (&oos_vfid), &oid, &oos_recdes);
int freespace_after = spage_max_space_for_new_record (thread_p, page_ptr);
(void) oos_stats_add_bestspace (thread_p, &oos_vfid, &vpid, freespace_after);

반환되는 oid(방금 기록한 슬롯의 volid/pageid/slotid)는 단일 청크 값이면 힙 스텁에, 멀티 청크 체인이면 청크의 next_chunk_oid로 사용된다. oos_prepend_header가 할당한 데이터 영역은 scope_exit로 해제되고, 실제 바이트는 spage_insert가 페이지로 복사하므로 힙 쪽 스텁이 이 버퍼를 직접 참조하는 일은 없다.

스캔 경로는 OOS를 확장하지 않는다. 실제 값 바이트가 필요한 코드는 heap_get_visible_version_expand_oos를 거치며, 이 함수는 context.expand_oos = true로 설정하고 레코드 페치 시 heap_record_replace_oos_oids를 호출해 OOS가 전혀 없었던 것처럼 보이는 레코드를 재조립한다.

flowchart TD
  S0["가시 버전 페치\nexpand_oos = true"] --> S1["heap_record_replace_oos_oids"]
  S1 --> S2{"HAS_OOS 설정됨?"}
  S2 -- 아니오 --> S9["레코드 그대로 반환"]
  S2 -- 예 --> S3["VOT 파싱, IS_OOS 항목 탐색"]
  S3 --> S4["각 항목: 16B 스텁 읽기\nOID + full_length"]
  S4 --> S5["oos_read OID -> 크기 지정 버퍼"]
  S5 --> S6["확장 레이아웃 계산\nVOT를 4바이트 오프셋으로 재작성"]
  S6 --> S7["레코드 빌드: 인라인 블롭 제자리 배치,\nHAS_OOS 클리어, 오프셋 크기 비트 리셋"]
  S7 --> S8["확장 레코드 반환"]

재조립은 스키마 변경에 안전하다. oos_read와 레코드 위의 VOT만 사용하며 클래스 표현(class representation)에 접근하지 않는다. 출력 VOT는 항상 4바이트 오프셋(BIG_VAR_OFFSET_SIZE)으로 작성되므로 임의 크기의 확장에 대응할 수 있다. 재조립 시 헤더는 그대로 복사한 뒤 HAS_OOS 플래그를 클리어하고 4바이트 오프셋 크기를 강제하여 확장 레코드가 OOS를 한 번도 쓰지 않은 레코드와 구별되지 않게 만든다.

// heap_oos_build_record -- src/storage/heap_oos.cpp
std::memcpy (dst, state->src, state->src_header_size);
unsigned int repid_bits = (unsigned int) OR_GET_INT (dst + OR_REP_OFFSET);
repid_bits &= ~ ((unsigned int) OR_MVCC_FLAG_HAS_OOS << OR_MVCC_FLAG_SHIFT_BITS); // clear HAS_OOS
repid_bits &= ~ (unsigned int) OR_OFFSET_SIZE_FLAG;
repid_bits |= OR_OFFSET_SIZE_4BYTE; // force 4B offsets
OR_PUT_INT (dst + OR_REP_OFFSET, (int) repid_bits);
// ... condensed: rewrite VOT with new cumulative offsets, OOS entries sized to blob length ...
// ... condensed: copy fixed+bitmap unchanged; inline each OOS blob in place of its 16B slot ...

읽기 시 목적지 크기는 스텁에 저장된 길이에서 결정하고, 체인 헤더와 교차 검증한다.

// heap_oos_read_blobs -- src/storage/heap_oos.cpp
OR_BUF buf;
or_init (&buf, (char *) state->src + value_offset, OR_OOS_INLINE_SIZE);
if (or_get_oid (&buf, &oos_oid) != NO_ERROR || OID_ISNULL (&oos_oid)) { /* fail */ }
oos_len = or_get_bigint (&buf, &rc);
// ... condensed: validate oos_len in (0, DB_MAX_STRING_LENGTH] ...
state->oos_blobs[i].resize ((std::size_t) oos_len);
if (oos_read (thread_p, oos_oid, oos_buffer (state->oos_blobs[i].data (), (std::size_t) oos_len)) != NO_ERROR)
{ /* fail */ }

한 OOS 페이지보다 큰 값은 청크 체인으로 분할된다. 각 청크 레코드는 OOS_RECORD_HEADER로 시작한다.

// oos_record_header -- src/storage/oos_file.hpp
struct oos_record_header
{
int total_data_length; /* total user-data length across all chunks (excludes OOS headers) */
int chunk_index; /* 0-based index of this chunk in the chain */
OID next_chunk_oid; /* OID of next chunk, or NULL OID if this is the last */
};

헤드 청크는 index 0이며, 그 OID가 힙 스텁에 저장된다. oos_insert_across_pages는 청크를 역순으로(꼬리 먼저) 삽입하여, 각 청크를 기록할 때 next_chunk_oid가 이미 알려진 상태로 만든다. 모든 청크는 total_data_length를 보유하므로 슬레이브 어플라이어가 전체 값을 재조립 전에 검증할 수 있다.

그림 5 — 멀티 청크 체인: 힙 스텁은 헤드 청크 OID만 저장한다

oos_read_across_pages는 index 1부터 next_chunk_oid를 따라가며, 인덱스가 순증하고 total_data_length가 헤드와 일치하는지 단언하며, 0바이트 청크(순환 체인을 무한 루프하게 만들 수 있는 경우)는 거부한다.

OOS와 MVCC가 맞닿는 지점이다. 핵심 불변식은 다음과 같다.

불변식: OOS OID 하나는 힙 레코드 하나에서만 참조된다 — OOS 레코드는 행 간에 공유되지 않는다. OOS OID는 힙 레코드마다 새로 할당된다. 이 덕분에 vacuum(및 즉시 삭제 경로)은 레퍼런스 카운팅 없이 오래된 OID를 무조건 삭제할 수 있다. 어떤 행 버전도 더 이상 그 OID를 참조하지 않으면 누구도 참조하지 않는다.

  • UPDATE (M1): 변경된 컬럼마다 항상 OOS OID를 할당한다. 이전 OOS 레코드는 살아 있는 MVCC 독자가 오래된 행 버전을 역참조할 수 있도록 유지되다가, 오래된 버전이 죽고 나면 vacuum이 회수한다. (M3에서는 변경되지 않은 OOS 컬럼의 OID 재사용을 계획한다.)
  • DELETE: 힙 레코드는 논리 삭제되고, OOS 레코드는 그대로 남아 나중에 vacuum이 회수한다.
  • 비 MVCC 경로 (!is_mvcc_op — SA_MODE 및 MVCC가 비활성화된 카탈로그 클래스의 SERVER_MODE)에는 독자도 vacuum도 없으므로 정리가 즉시 이루어진다: heap_oos_delete_unreferenced가 이전 레코드에 있던 OOS OID 중 새 레코드에 없는 것들을 삭제한다.
stateDiagram-v2
  [*] --> Live : INSERT, oos_insert
  Live --> Updated : UPDATE M1, 새 oos oid 할당
  Updated --> OldDead : 이전 버전 불가시 상태
  Live --> RowDeleted : DELETE, 힙 미변경
  OldDead --> Reclaimed : vacuum oos_delete
  RowDeleted --> Reclaimed : vacuum oos_delete
  Live --> EagerGone : non-mvcc delete or update, 즉시 oos_delete
  Reclaimed --> [*]
  EagerGone --> [*]

Vacuum은 vacuum_oos.cpp의 두 경로로 회수한다.

  1. REMOVE 경로vacuum_heap_oos_delete_within_sysop이 호출자의 기존 sysop 안에서 vacuum 처리 중인 힙 레코드가 참조하는 OOS를 삭제한다.
  2. 포워드 워크 경로vacuum_forward_walk_reclaim_oosRVHF_UPDATE_NOTIFY_VACUUM / RVHF_DELETE_NEWHOME_NOTIFY_VACUUM 레코드의 undo 이미지를 검사하고, 죽은 오래된 버전만이 참조하는 OOS를 자체 sysop(vacuum_forward_walk_oos_delete_atomic) 안에서 삭제한다.

단일 슬롯 VACUUM_OOS_VFID_MEMO는 가장 최근에 해결된 힙 VFID → OOS VFID 매핑을 캐싱하여, 같은 힙을 연속으로 처리하는 대량 UPDATE에서 반복적인 파일/헤더 조회를 건너뛸 수 있게 한다. 포워드 워크 경로는 전반적으로 방어적이다. 조회 결과를 FOUND / NONE / ERROR 삼중 상태로 구분하고, 오류는 전체 vacuum 블록을 실패시키는 대신 로깅 후 클리어(제한적·로깅 누수)하며, oos_chunk_exists로 삭제를 블록 재시도에도 멱등(idempotent)하게 만든다.

새 OOS 레코드 배치 시 파일 전체를 스캔하지 않기 위해, OOS_HDR_STATS.estimates힙 스타일 다중 항목 bestspace 추정치를 (고정 첫 번째 페이지의 슬롯 0에) 유지하고, 전역 인메모리 해시 캐시(oos_Bestspace)도 함께 운용한다. oos_find_best_page는 힙 관리자를 모방한 3계층 탐색을 수행한다.

flowchart TD
  B0["rec_length에 맞는 페이지 필요"] --> B1["1계층: 전역 해시 캐시\noos_stats_find_page_in_bestspace"]
  B1 -- 히트 --> BOK["페이지 고정, 실제 여유 공간 재확인"]
  B1 -- 미스 --> B2["2계층: 헤더 best[] 배열"]
  B2 -- 히트 --> BOK
  B2 -- 미스 --> B3["3계층: 페이지 동기 스캔\noos_stats_sync_bestspace, 힌트 재채움"]
  B3 -- 여전히 없음 --> B4["oos_file_alloc_new"]
  BOK --> BDONE["페이지 반환"]
  B4 --> BDONE

best[]는 최대 OOS_NUM_BEST_SPACESTATS(10)개의 후보 페이지를 보유하고, second_best[] 링 버퍼는 퇴출되었지만 아직 유효한 페이지를 저장한다. Bestspace 갱신은 비로그(log_skip_logging) 방식이다. 힌트는 재구성 가능하므로 크래시 후에도 정확히 살아남을 필요가 없다. 동기화에는 조건부 래치와 제로 대기 모드를 써서 경쟁을 회피한다.

해시 캐시 조회에서 경쟁 회피 원칙이 잘 드러난다. 후보 페이지를 조건부 쓰기 래치와 제로 대기 모드로 고정하여, 바쁜 페이지는 블록하지 않고 건너뛰며, 캐시된 추정치가 오래됐을 수 있으므로 확정 전에 실제 여유 공간(spage_max_space_for_new_record)을 재확인한다.

// oos_stats_find_page_in_bestspace -- src/storage/oos_file.cpp
old_wait_msecs = xlogtb_reset_wait_msecs (thread_p, LK_FORCE_ZERO_WAIT);
// ... condensed: Phase A scans global hash, Phase B scans best[] for a candidate_vpid ...
*out_pgptr = pgbuf_fix (thread_p, &candidate_vpid, OLD_PAGE,
PGBUF_LATCH_WRITE, PGBUF_CONDITIONAL_LATCH);
if (*out_pgptr == NULL) { /* busy or interrupted: skip / error, continue */ }
int actual_free = spage_max_space_for_new_record (thread_p, *out_pgptr);
if (actual_free >= needed_space) { *out_vpid = candidate_vpid; found = OOS_FINDSPACE_FOUND; }
// ... condensed: else unfix, drop the stale hint, try next ...

각 삽입은 캐시에서 대상을 확정하고 미스 시 추정치를 보정하기 때문에, 파일은 밀도 높은 페이지부터 채워지고 oos_find_best_page는 힌트가 소진될 때만 oos_stats_sync_bestspace(제한된 페이지 스캔)나 oos_file_alloc_new로 에스컬레이션한다. 헤더 best[]는 고정 첫 번째 페이지 레코드에 존재하고, 전역 해시 캐시(oos_Bestspace, 용량 OOS_BESTSPACE_CACHE_CAPACITY = 1000개 엔트리, 모든 OOS 파일 공유)는 프로세스 전역이며 재시작 시 재구성된다.

OOS 물리 연산은 전용 redo 핸들러와 함께 WAL로 기록된다: RVOOS_INSERT(oos_rv_redo_insertspage_insert_for_recovery)와 RVOOS_DELETE(oos_rv_redo_deletespage_delete). oos_deletesysop을 사용하지 않는다: 청크 삭제 하나하나가 완전한 undo 데이터와 함께 개별 로깅되므로, 트랜잭션 중단 또는 크래시 복구 시 체인이 역순으로 복원된다.

HA에서 멀티 청크 삽입은 슬레이브가 동일한 체인을 재조립해야 한다. 마스터는 청크를 역순(꼬리 먼저, 헤드 마지막)으로 로깅하고, 실행을 LOG_DUMMY_OOS_RECORD 경계 마커로 감싸며, 중간 청크의 자동 큐잉을 억제하여 헤드 OID만 복제 큐에 enqueue된다.

// oos_insert_across_pages -- src/storage/oos_file.cpp
track_repl = oos_needs_repl_tracking (thread_p); // master only, replication allowed
if (track_repl)
{
log_append_empty_record (thread_p, LOG_DUMMY_OOS_RECORD, NULL);
LSA_COPY (&dummy_lsa, &tdes->tail_lsa);
tdes->oos_suppress_insert_lsa_queueing = true; // intermediate chunks not enqueued
}
// ... condensed: reverse loop inserts chunk N-1 .. 0; capture tail_chunk_lsa at the tail ...
if (track_repl)
{
tdes->oos_insert_lsa_queue.push (dummy_lsa);
tdes->oos_insert_lsa_queue.push (tail_chunk_lsa);
thread_p->oos_oids.push_back (oid_Null_oid); // caller pushes the real head OID after return
}

복제 경로는 null OID에 대한 RVREPL_DUMMY_OOS_RECORD 하나와 실제 헤드 OID에 대한 RVREPL_OOS_INSERT 하나를 내보내므로, 슬레이브 어플라이어는 물리 청크 수에 무관하게 값 하나당 정확히 하나의 논리적 OOS 삽입을 본다. oos_needs_repl_tracking은 이를 마스터로만 제한한다 (!LOG_CHECK_LOG_APPLIER && log_does_allow_replication()). 슬레이브가 재생 중에 자체 마커를 내보내면 안 된다.

oos_log.hpp별도의 디버그/진단 로깅 시설($CUBRID/log/oos.log)로, WAL과는 무관하다. 헤더 전용 printf 방식 로거이며, oos_error/oos_warn 매크로는 릴리스 빌드에서도 활성화되어 QA 및 복제 진단에 쓰이고, oos_trace/oos_debug/oos_infoNDEBUG 하에서 no-op으로 컴파일된다.

심볼은 서브시스템별로 그룹핑된다. 함수명이 안정적인 앵커이며, 섹션 끝의 표는 이 개정 시점에 관찰한 (파일, 라인) 힌트를 제공한다.

OOS 파일 코어 — src/storage/oos_file.{cpp,hpp}

섹션 제목: “OOS 파일 코어 — src/storage/oos_file.{cpp,hpp}”
  • oos_record_header / OOS_RECORD_HEADER_SIZE — 청크별 체인 헤더(total_data_length, chunk_index, next_chunk_oid).
  • oos_buffercubbase::span<char>의 별칭; oos_insert(읽기)와 oos_read(쓰기)에서 사용하는 호출자 소유 바이트 스팬.
  • oos_create_file / oos_remove_file / oos_remove_page — 파일 생명주기. oos_create_fileFILE_OOS 파일과 고정 헤더 페이지를 만들고, oos_remove_filefile_postpone_destroy로 지연 소멸을 등록한다. oos_remove_page(file_dealloc)는 “OOS vacuum이 구현되면 vacuum이 호출”이라고 주석이 달려 있다.
  • oos_insert — 공개 진입점; 페이지 내 또는 페이지 횡단 경로로 분기한다.
  • oos_insert_within_page — 단일 청크 삽입: oos_prepend_header, oos_find_best_page, spage_insert, oos_log_insert_physical, bestspace 갱신.
  • oos_insert_across_pages — 멀티 청크 삽입(역순), HA 복제 경계 추적 포함.
  • oos_read — 공개 읽기; 헤드 청크 검증(chunk_index == 0), total_data_length와 호출자 버퍼 크기 교차 검증 후 체인 순회.
  • oos_read_within_page / oos_read_across_pagesbyte_span_writer 로 단일 청크 페치; 일관성 단언과 함께 체인 순회.
  • oos_delete / oos_delete_chain — (멀티 청크) 레코드 삭제; 청크별 RVOOS_DELETE undo, sysop 없음.
  • oos_chunk_exists — 멱등성 프로브(페이지 할당 해제 또는 슬롯 제거 모두 NO_ERROR로 “없음”을 보고); vacuum 블록 재시도에 사용.
  • oos_get_length — 헤드 청크 헤더에서 total_data_length 읽기 (테스트용; 운영 환경은 인라인 스텁 길이를 사용).
  • oos_get_max_chunk_size_within_pagespage_max_record_size를 정렬 하향 후 체인 헤더를 뺀 값.
  • oos_rv_redo_insert / oos_rv_redo_delete — WAL redo 핸들러.
  • oos_needs_repl_tracking — 마스터 전용 복제 게이트.
  • oos_push_oos_oid — 복제 로그를 위해 thread_p->oos_oids에 OID 추가.

Bestspace (모두 oos_file.cpp 안):

  • oos_bestspace, oos_hdr_stats, OOS_NUM_BEST_SPACESTATS, OOS_FINDSPACE — 추정치 구조와 탐색 결과 열거형.
  • oos_bestspace_initialize / oos_bestspace_finalize — 전역 해시 캐시(oos_Bestspace, vpid_ht + vfid_ht) 생명주기.
  • oos_stats_add_bestspace / oos_stats_del_bestspace_by_vpid / oos_stats_del_bestspace_by_vfid — 캐시 항목 관리.
  • oos_find_best_page — 3계층 탐색 드라이버.
  • oos_stats_find_page_in_bestspace — 해시 후 best[] 조회, 조건부 래치 + 실제 여유 공간 재확인.
  • oos_stats_sync_bestspace — 힌트 재채움을 위한 페이지 스캔 (부분 또는 전체).
  • oos_stats_update / oos_stats_update_internal — 삭제/삽입 후 힌트 갱신.
  • oos_stats_put_second_best / oos_stats_get_second_bestsecond_best[] 링 버퍼.
  • oos_file_alloc_new / oos_vpid_init_new — 새 OOS 데이터 페이지 할당 및 초기화(PAGE_OOS, 앵커된 슬롯 페이지).
  • xoos_get_stats_by_class_oid / oos_get_stats_by_vfid — 클라이언트 API를 지원하는 서버 측 통계(페이지 수, 라이브 레코드 수, 바디 바이트 합계).

힙 연동 — src/storage/heap_oos.{cpp,hpp} + heap_file.c

섹션 제목: “힙 연동 — src/storage/heap_oos.{cpp,hpp} + heap_file.c”
  • heap_record_replace_oos_oids — SELECT 측 확장 진입점; context.expand_oosheap_recdes_contains_oos로 게이팅.
  • HEAP_OOS_EXPAND_STATE + heap_oos_parse_vot / heap_oos_read_blobs / heap_oos_compute_layout / heap_oos_build_record — 4단계 확장: VOT 파싱, oos_read로 블롭 읽기, 출력 레이아웃 계산(4바이트 오프셋), 확장 레코드 조립 및 HAS_OOS 클리어.
  • heap_oos_delete_unreferenced — 즉시 (비 MVCC) 정리: 이전 레코드에는 있고 새 레코드에는 없는 OOS OID를 삭제 (NULL 새 레코드 = 전부 삭제).
  • heap_oos_find_vfid — 힙의 OOS 파일을 찾거나(docreate로) 생성; TDE 적용.
  • heap_attrinfo_determine_disk_layout — 외부화 기준 휴리스틱 (DB_PAGESIZE/4, 가장 큰 컬럼 먼저).
  • heap_attrinfo_insert_to_oos — 컬럼별 INSERT 측 외부화.
  • heap_recdes_contains_oosOR_MVCC_FLAG_HAS_OOS 검사.
  • heap_recdes_get_oos_oids — VOT 순회 후 모든 OR_IS_OOS OID 수집.
  • heap_get_visible_version_expand_oos — OOS 확장 페치 래퍼 (CBRD-26847에서 호출 지점 감사 필요로 플래깅됨).
  • vacuum_oos_vfid_memo / VACUUM_OOS_VFID_LOOKUP_RESULT — 워커 블록당 단일 항목 메모와 FOUND/NONE/ERROR 삼중 상태.
  • vacuum_oos_vfid_lookup — 힙 VFID → OOS VFID 캐싱 해결; 일시적 오류는 캐싱하지 않음.
  • vacuum_forward_walk_reclaim_oos — undo 이미지 기반 회수 (undo 이미지를 먼저 안정적·정렬된 버퍼로 복사).
  • vacuum_forward_walk_oos_delete_atomic — OID 벡터를 값으로 소유하고 (volid, pageid, slotid)로 정렬 후 자체 sysop 안에서 삭제.
  • vacuum_heap_oos_delete_within_sysop — REMOVE 경로 삭제, 호출자 sysop 안에서 수행.
  • vacuum_oos_find_vfid_for_heap_record — 힙 레코드 vacuum 경로의 최초 조회.
  • OR_MVCC_FLAG_HAS_OOS (object_representation_constants.h) — 행 단위 비트 0x08.
  • OR_VAR_BIT_OOS / OR_IS_OOS / OR_SET_VAR_OOS / OR_OOS_INLINE_SIZE (object_representation.h) — 컬럼 단위 VOT 플래그와 16바이트 스텁 크기.
  • FILE_OOS (file_manager.h) — 새 파일 타입.

클라이언트 통계 — src/compat/db_oos.h

섹션 제목: “클라이언트 통계 — src/compat/db_oos.h”
  • db_oos_stats / db_get_oos_statshas_oos_file, OOS VFID, 사용자 페이지 수, 라이브 레코드 수, 바디 바이트 합계를 노출하는 클라이언트 측 구조체와 호출 (서버 측 oos_stats_info 미러).
  • oos_log::OosLogLevel / oos_log_internal + oos_error, oos_warn, oos_trace, oos_debug, oos_info 매크로 — $CUBRID/log/oos.log로 출력하는 헤더 전용 진단 로거(oos_error/oos_warn은 릴리스 활성, 나머지는 디버그 전용). WAL이 아님.

라인번호는 feat/oos @ fa26eff3a, 2026-06-21 기준 관찰값이다. 경로는 정규 저장소 경로다.

심볼파일라인
oos_record_headersrc/storage/oos_file.hpp26
OOS_RECORD_HEADER_SIZEsrc/storage/oos_file.hpp34
oos_buffer (alias)src/storage/oos_file.hpp43
OOS_NUM_BEST_SPACESTATSsrc/storage/oos_file.hpp45
oos_bestspacesrc/storage/oos_file.hpp53
oos_hdr_statssrc/storage/oos_file.hpp60
OOS_FINDSPACE (enum)src/storage/oos_file.hpp101
oos_bestspace_initializesrc/storage/oos_file.cpp184
oos_stats_find_page_in_bestspacesrc/storage/oos_file.cpp454
oos_stats_sync_bestspacesrc/storage/oos_file.cpp634
oos_create_filesrc/storage/oos_file.cpp910
oos_remove_filesrc/storage/oos_file.cpp996
oos_insertsrc/storage/oos_file.cpp1048
oos_insert_across_pagessrc/storage/oos_file.cpp1109
oos_insert_within_pagesrc/storage/oos_file.cpp1205
oos_readsrc/storage/oos_file.cpp1372
oos_find_best_pagesrc/storage/oos_file.cpp1460
oos_delete_chainsrc/storage/oos_file.cpp1696
oos_chunk_existssrc/storage/oos_file.cpp1779
oos_deletesrc/storage/oos_file.cpp1851
oos_get_max_chunk_size_within_pagesrc/storage/oos_file.cpp1861
oos_rv_redo_insertsrc/storage/oos_file.cpp1911
oos_get_lengthsrc/storage/oos_file.cpp1942
xoos_get_stats_by_class_oidsrc/storage/oos_file.cpp1991
oos_get_stats_by_vfidsrc/storage/oos_file.cpp2043
oos_oid_in_vectorsrc/storage/oos_util.cpp35
heap_recdes_compute_oos_flag_debugsrc/storage/oos_util.cpp83
HEAP_OOS_EXPAND_STATEsrc/storage/heap_oos.cpp48
heap_oos_parse_votsrc/storage/heap_oos.cpp69
heap_oos_read_blobssrc/storage/heap_oos.cpp129
heap_oos_compute_layoutsrc/storage/heap_oos.cpp184
heap_oos_build_recordsrc/storage/heap_oos.cpp242
heap_record_replace_oos_oidssrc/storage/heap_oos.cpp338
heap_oos_delete_unreferencedsrc/storage/heap_oos.cpp425
vacuum_oos_vfid_memosrc/query/vacuum_oos.hpp44
vacuum_oos_vfid_lookupsrc/query/vacuum_oos.cpp86
vacuum_forward_walk_oos_delete_atomicsrc/query/vacuum_oos.cpp154
vacuum_forward_walk_reclaim_oossrc/query/vacuum_oos.cpp223
vacuum_oos_find_vfid_for_heap_recordsrc/query/vacuum_oos.cpp339
vacuum_heap_oos_delete_within_sysopsrc/query/vacuum_oos.cpp400
OR_MVCC_FLAG_HAS_OOSsrc/base/object_representation_constants.h178
OR_VAR_BIT_OOSsrc/base/object_representation.h441
OR_IS_OOSsrc/base/object_representation.h451
OR_OOS_INLINE_SIZEsrc/base/object_representation.h455
FILE_OOSsrc/storage/file_manager.h53
heap_attrinfo_determine_disk_layoutsrc/storage/heap_file.c12183
heap_oos_find_vfidsrc/storage/heap_file.c12259
heap_attrinfo_insert_to_oossrc/storage/heap_file.c12435
heap_get_visible_version_expand_oossrc/storage/heap_file.c26450
heap_recdes_contains_oossrc/storage/heap_file.c27772
heap_recdes_get_oos_oidssrc/storage/heap_file.c27779
db_get_oos_statssrc/compat/db_oos.h47
oos_log_internalsrc/storage/oos_log.hpp97
oos_error (macro)src/storage/oos_log.hpp168

활성 기능 브랜치이므로 설계 의도와 위의 라인번호는 계속 변동된다. fa26eff3a 시점에 관찰한 구체적 불일치와 rough edge들이다.

외부화 기준 휴리스틱이 설계 의도와 다름

섹션 제목: “외부화 기준 휴리스틱이 설계 의도와 다름”

원래 에픽에는 기준이 행 크기 > DB_PAGESIZE/8 (16 KB 페이지에서 2 KB) AND 컬럼 크기 > 512 B로 명시되어 있다. 이 개정 시점의 코드는 다른, 더 단순한 규칙을 사용한다: 행 크기 > DB_PAGESIZE/4 AND 컬럼 크기 > OR_OOS_INLINE_SIZE (16 B), 가장 큰 컬럼부터 외부화하고 레코드가 맞아 들어가면 멈춘다(heap_attrinfo_determine_disk_layout, “PG TOAST style”로 주석). 바로 위에 TODO: change the statistics가 있다. 살아 있는 임계값은 에픽이 아니라 코드를 믿어야 한다.

마일스톤JIRA상태범위
M1CBRD-26584완료 (~2026-02)CRUD, WAL 로깅, HA 복제, 커버드 인덱스. 한계: oos_file_destroy 없음, UPDATE는 항상 새 OID 할당, vacuum 미연동.
M2CBRD-26583진행 중bestspace 재사용(CBRD-26658), spage_compact 컴팩션(CBRD-26536), 테이블 삭제 시 oos_file_destroy(CBRD-26608), vacuum 연동(CBRD-26668), develop 머지 + CI.
M3계획변경되지 않은 UPDATE 컬럼에 대한 OOS OID 재사용, PEEK 모드, 페이지 횡단 컴팩션.
M4미정데드락 방지를 위한 ordered page-fix, 모니터링.
  • **oos_remove_page**에 // TODO: will be called by vacuum when OOS vacuum is implemented 주석 — 빈 페이지 해제는 아직 연결되지 않았다. vacuum이 슬롯을 삭제해도 페이지는 해제하지 않는다.
  • Vacuum의 라이브 abort() 호출. vacuum_oos.cpp는 의도적으로 두 곳에서 abort()를 호출한다 — vacuum_forward_walk_reclaim_oos (VACUUM_OOS_VFID_NONE 분기)와 vacuum_oos_find_vfid_for_heap_record — 각각 TODO(do not review, remove before develop merge): force a hard crash in CI로 표시되어 있다. 이들은 OOS 플래그는 있는데 힙에 OOS 파일이 없는 불변식 위반을 조용히 누출하는 대신 CI에서 하드 크래시로 드러내기 위해 의도적으로 존재한다. 머지 전에 반드시 제거되어야 하며, 그 전까지는 해당 조건에서 브랜치가 하드 크래시한다.
  • vacuum_oos.cpp<cstdlib> include/* abort - TODO: remove before develop merge (temporary CI crash) */ 주석이 달려 있다.
  • heap_get_visible_version_expand_oos 과잉 확장 — CBRD-26847은 많은 호출 지점이 기계적으로 이관되어 불필요하게 OOS를 확장할 수 있다고 지적한다. 감사 작업이 미완이다.

알려진 버그 / rough edge (에픽에서)

섹션 제목: “알려진 버그 / rough edge (에픽에서)”
  • unloaddb가 OOS 있을 때 1.6–1.7배 느림(CBRD-26458).
  • UPDATE 시 oos_read가 세 번 발생(CBRD-26516).
  • CDC flashback이 OOS OID를 resolve하지 못함.
  • locator_add_or_remove_index에서 불필요한 OOS 복제 로그가 내보내짐.
  • RECDES.length가 4바이트 int이므로 확장된 단일 레코드가 최대 ~2 GB. heap_oos_compute_layoutnew_lengthINT_MAX를 넘지 않도록 명시적으로 방어한다.
  • M1에는 페이로드 압축 없음(TOAST의 pglz/lz4와 달리); 압축은 오픈 PR (CBRD-26881 / #7258).

heap_recdes_get_oos_oidsheap_recdes_compute_oos_flag_debugOR_VAR_BIT_LAST_ELEMENT가 없는(구 포맷 VOT) 레코드는 “아직 완전히 지원되지 않는다”고 명시한다. 상한 max_var_count가 패딩/고정 바이트를 포함할 수 있고, 오래된 홀수 오프셋이 OR_IS_OOS를 오탐할 수 있다. 디버그 감사기는 LAST_ELEMENT 센티넬이 없는 VOT에서 false를 반환한다.

2026-03-05 설계 논의에서 미결로 남은 항목:

  • OOS 컬럼 하나당 레코드 하나, 아니면 한 행의 여러 OOS 컬럼을 하나의 OOS 레코드로 병합? 미결. 현재 코드는 컬럼별로 개별 저장한다 (heap_attrinfo_insert_to_oos가 컬럼 단위로 루프).
  • OOS 페이지 래치 경쟁 — 나중에 경쟁 완화를 위해 페이지를 분할할 수 있다(M4 과제; oos_find_best_page는 이미 조건부 제로 대기 래치로 완화 중).
  • OVF + OOS 공존 테스트 케이스가 아직 필요하다.
  • CHAR(뿐 아니라 VARCHAR)도 OOS 대상이 되어야 하는가? 자격 검사가 !last_attrepr->is_fixed이므로 고정 너비 CHAR는 현재 제외된다.

코드에서 발견한 TODO/FIXME/XXX (이 개정 기준):

  • oos_insertTODO: Once the OOS_RECORD_HEADER spec is finalized (first segment header and rest segment header), review whether the segment headers can be generated inside oos_insert_within_page / across_pages (청크 헤더 형식이 아직 변경될 수 있음).
  • oos_get_max_chunk_size_within_pageTODO: fix bug for spage_max_record_size returning incorrect size (OOS 범위 외로 선언), TODO: make it a constant initialized once in oos_boot().
  • oos_delete_chainTODO: concurrency — this function assumes the caller holds a row-level lock (e.g. X_LOCK) to prevent concurrent deletion of the same OOS chain. Verify this assumption when wiring callers.
  • oos_stats_sync_bestspaceTODO: ideally find the index of full_search_vpid; for now start from 1 (재개 지점이 근사값).
  • vacuum_*oos_delete가 OID별로 OOS 페이지를 재고정한다는 TODO(perf) 두 건; OID가 이미 페이지 정렬되어 있으므로 페이지 단위 배치가 미래 최적화 대상.
  • 교차 확인 항목에 나열된 abort() / develop-머지 TODO들.

더 넓은 미결 사항: OOS가 CDC/flashback과 어떻게 공존하는가(현재 OOS OID를 resolve할 수 없음), OR_MVCC_FLAG_HAS_OOS 비트를 MVCC 헤더 밖으로 옮겨야 하는가(상수 자체에 TODO: OOS is not related to MVCC, move ... when POC ends가 붙어 있음).

JIRA

  • 에픽: http://jira.cubrid.org/browse/CBRD-26357 — 마일스톤 CBRD-26584(M1), CBRD-26583(M2); 조사 CBRD-26198. 연결된 rough-edge 이슈: CBRD-26458(unloaddb 지연), CBRD-26516(UPDATE 삼중 읽기), CBRD-26847(expand-OOS 호출 감사), CBRD-26881(페이로드 압축), CBRD-26658(bestspace 재사용), CBRD-26536(spage_compact 컴팩션), CBRD-26608(drop-table 파괴), CBRD-26668(vacuum 연동), CBRD-26628(멀티 청크 삽입 복제).

설계 / 설명 문서

브랜치 / PR

  • 브랜치 feat/oos(develop 대비 105 커밋 앞), 분석 시점 fa26eff3a. 주요 PR: #7097(oos_read/oos_insert → oos_buffer 리팩터), #7158(가장 큰 가변 컬럼을 OOS로), #7296(인라인 OOS 읽기 실패 전파), #7295(OOS 파일에 TDE), #7169 / #7168(vacuum의 OOS 청크 회수), #7258(OOS 페이로드 압축, 오픈), #6864(CI 추적 draft).

코드

  • src/storage/oos_file.cpp, src/storage/oos_file.hpp
  • src/storage/oos_log.hpp
  • src/storage/oos_util.cpp, src/storage/oos_util.hpp
  • src/storage/heap_oos.cpp, src/storage/heap_oos.hpp
  • src/query/vacuum_oos.cpp, src/query/vacuum_oos.hpp
  • src/compat/db_oos.h
  • 접촉 지점: src/base/object_representation_constants.h (OR_MVCC_FLAG_HAS_OOS), src/base/object_representation.h (OR_IS_OOS, OR_OOS_INLINE_SIZE), src/storage/file_manager.h (FILE_OOS), src/storage/heap_file.c(외부화 기준, find_vfid, 삽입, 확장, contains/get OIDs 호출 지점).