(KO) CUBRID Double Write Buffer — 페이지 버퍼와 데이터 파일 사이의 torn-page 방어선
목차
학술적 배경
섹션 제목: “학술적 배경”Double Write Buffer(DWB)는 WAL 프로토콜만으로는 손볼 수 없는 단 한 가지 실패 시나리오를 막기 위해 자리하고 있다. 그 이름이 torn page다. torn page는 한 페이지의 디스크 이미지가 절반은 새것, 절반은 옛것으로 갈라져 있는 모양을 가리킨다. 여러 섹터에 걸친 쓰기 한복판에서 크래시가 일어나면 home 자리에 그런 괴물 페이지가 남는다. DBMS의 페이지 단위가 OS와 하드웨어가 한 번에 갱신할 수 있는 단위보다 크기 때문이다. 쓰기 도중 크래시는 앞 절반만 새 이미지, 뒤 절반은 옛 이미지(또는 OS가 섹터 쓰기를 어느 순서로 흘려 보냈느냐에 따라 그 거꾸로)인 페이지를 만든다.
근본은 DBMS 페이지 크기와 파일시스템·디바이스의 원자 쓰기 단위 사이의 비대칭에 있다. CUBRID는 데이터 페이지를 IO_PAGESIZE(설정에 따라 4–16 KiB)로 쓴다. 반면 요즘 리눅스 파일시스템이 보장하는 원자 쓰기 단위는 하드웨어 섹터 경계(보통 512 B 또는 4 KiB)까지가 끝이다. 원자 쓰기라는 말 자체도 두 갈래로 갈린다. Database Internals(Petrov) 5장 §Recovery가 그 둘을 따로 적어 둔다.
- 성공 시 원자성.
write(2)가 성공하고 그 뒤 OS가 죽으면, 플래터에 닿은 바이트는 버퍼의 어떤 부분 집합이다.O_DIRECT를 두어도 디바이스가 모든 섹터를 다 끝내기 전에 전원이 끊길 수 있다. - 실패 시 원자성. 일부 파일시스템(ZFS, Btrfs, reflink가 있는 XFS)은 페이지마다 새 블록을 적고 포인터만 바꾸는 식으로 페이지 쓰기를 원자적으로 커밋한다. 그런 자리 위에서는 파일시스템 단계에서 torn page가 나오지 않는다. 다만 DBMS는 자기가 그런 파일시스템 위에 도는 것이라고 가정할 수 없다.
WAL만으로는 모자라는 까닭이 여기서 따라온다. ARIES(Mohan 외, TODS 17.1, 1992)의 redo는 redo 이미지를 다시 얹을 일관된 이전 상태가 있어야 한다. redo 로그는 페이지 상태 S에서 S'를 만들어 낼 만큼의 정보를 담는다. 그러나 디스크 위 페이지가 S도 S'도 아닌 — 일관된 옛것도 일관된 새것도 아닌 — 상태라면, redo가 받을 입력 자체가 정의되지 않는다. Database Internals §Torn Pages가 정리해 두는 세 가지 빠져나갈 길은 다음과 같다.
- Full-page WAL — 체크포인트 직후 페이지가 처음 더티가 될 때 페이지 이미지 통째를 로그로 적는다. PostgreSQL의
full_page_writes = on이 그 길이다. 비용은 로그 쓰기가 부풀어 오른다는 점이다(16 KiB 페이지가 16 KiB 로그 레코드가 된다). - 디바이스나 파일시스템이 끼워 주는 8 KiB / 16 KiB 원자 쓰기 — 아래쪽 스택이 페이지 단위 원자성을 직접 받쳐 주는 길이다. 리눅스 6.x가 실험적 지원을 시작했고, ZFS는 네이티브로, 큰 엔터프라이즈 SAN 배열은 노출하는 기능으로 내준다. 비용은 비호환성이다. DBMS가 이 보장을 제공할 길은 없고, 받아 쓰는 일밖에 못 한다.
- Double-write buffer — 더티 페이지마다 먼저 순차 staging 자리(double write 파일)에 적고, staging 자리를
fsync한 뒤에야 home 자리에 페이지를 쓴다. 크래시 복구에서는 체크섬 검사에서 떨어진 home 페이지를 staging 자리의 사본으로 되돌린다. 비용은 데이터 페이지 한 장당 두 번의 쓰기와 두 번의fsync다. MySQL InnoDB, MariaDB, Percona Server, 그리고 CUBRID가 받아 쓰는 길이다.
CUBRID는 세 번째 길을 골랐다. InnoDB의 기본도 같다. 이 문서의 나머지는 그 선택이 src/storage/double_write_buffer.{hpp,cpp}에서 어떻게 짜여 있고, 페이지 버퍼의 flush 경로, file_io 서브시스템, boot/recovery 드라이버에 어떻게 꿰어지는지를 천천히 들여다본다.
DBMS 공통 설계 패턴
섹션 제목: “DBMS 공통 설계 패턴”torn page를 막기 위해 double-write buffer 길을 고른 엔진은 부품을 거의 같은 모양으로 짠다. 이름은 다르다. InnoDB는 Doublewrite_log / dblwr, MariaDB는 buf_dblwr, Percona는 parallel doublewrite, CUBRID는 dwb_Global이라 부른다. 그러나 모양은 함께 쓴다. 이 절은 그 공통 어휘를 짚어 두고, 다음 절 ## CUBRID의 구현이 한 줄씩 천천히 들어간다.
Slot 배열 — 디스크 위 순차 고정 크기 자리
섹션 제목: “Slot 배열 — 디스크 위 순차 고정 크기 자리”DWB는 디스크 위 정해진 크기의 파일이다(시스템 테이블스페이스의 미리 잡아 둔 extent 자리에 들어가기도 한다). 그 자리는 slot으로 갈라져 있다. slot 한 칸에 DBMS 페이지 한 장이 들어간다. slot에 대한 쓰기는 순차다. 위치 카운터가 한 바퀴 돌며 wrap한다. 그래서 아래쪽 디스크가 보는 모양은 streaming 워크로드다. InnoDB의 옛 모양은 2 × 1 MiB 블록 × 64 slot × 16 KiB / 페이지이며, DWB로 흘려 보내는 fsync는 1 MiB짜리 순차 쓰기 한 번이다.
Block — fsync의 단위
섹션 제목: “Block — fsync의 단위”slot은 block으로 묶인다. block은 home 페이지를 쓰기 전에 DWB를 fsync한다는 동작의 단위다. block 통째를 적고, fsync하고, 그 뒤에야 그 안의 slot을 home 볼륨에 한 장씩 적는다. block 단위로 묶어 두는 까닭은 둘이다. 첫째, fsync 비용을 페이지 여럿에 흩어 둔다. 둘째, block이 가득 찼다는 사실 자체가 DWB writer 스레드에게 이제 흘려 보내도 안전하다는 신호가 된다. block이 가득 차고 나면 producer가 더 적어 둘 자리가 없기 때문이다.
Position-with-flags — 64비트 atomic word 한 칸
섹션 제목: “Position-with-flags — 64비트 atomic word 한 칸”동시 producer(페이지 버퍼 flusher, foreground writer)들은 다음 비어 있는 slot을 lock-free로 잡을 길이 필요하다. 손에 익은 기법이 64비트 atomic word 한 칸에 다음 셋을 함께 packing하는 길이다.
- 지금 slot 위치(낮은 비트들),
- block마다 한 비트씩 들고 있는 write started 비트마스크(높은 비트들),
- 그리고 두어 개의 lifecycle 플래그(
CREATED,MODIFY_STRUCTURE).
이 word 위에서 도는 CAS 루프가 slot 잡기, block 경계의 비트 뒤집기, 구조 바꾸기(create / destroy / resize)를 한꺼번에 손맞춘다.
Producer 측 흐름
섹션 제목: “Producer 측 흐름”home 볼륨으로 더티 페이지를 흘려 보내려는 페이지 버퍼 flusher는 다음을 차례로 한다.
- DWB slot 잡기. 위치 카운터를 atomic CAS로 한 칸 늘려 slot 한 칸을 받아 든다.
- slot에 페이지 바이트 옮겨 적기. slot의
io_page포인터가 닿는 자리에 페이지를memcpy한다. - DWB hash에 등록. hash 키는
VPID = (volid, pageid)다. 같은 페이지를 fix하려는 동시 reader가 home 볼륨(어쩌면 torn 모양일 수 있다)을 다시 읽기보다 DWB 안의 새 메모리 사본을 찾을 수 있게 하기 위함이다. - block이 가득 차면 flush를 청한다. block마다의 페이지 카운트를 한 칸 늘린다.
BLOCK_NUM_PAGES에 닿으면dwb-flush-blockdaemon을 깨운다(standalone 모드라면 그 자리에서 직접 한다). daemon은 block 통째를 DWB 볼륨에 순차 쓰기로 흘려 보내고 fsync한 뒤, slot마다의 내용을 home 볼륨에 적는다.
Recovery 측 흐름
섹션 제목: “Recovery 측 흐름”재시작 자리에서는 redo가 시작되기 전에 엔진이 디스크 위의 옛 DWB 볼륨을 연다. slot마다의 페이지 이미지를 모두 읽어 들이고, slot이 가리키는 (volid, pageid)마다 다음을 한다.
- home 볼륨에서 home 페이지를 읽는다.
- home 페이지의 체크섬과 정합을 본다.
- home 페이지가 망가져 있고 DWB slot의 이미지가 멀쩡하면, DWB slot의 내용을 home 페이지 위에 덮어쓴다.
이 DWB 주도 손보기 패스가 끝나면, DWB 볼륨은 따로 안내 없이 지운 뒤 다시 만든다. 이때 쓰는 파라미터는 지금 설정 값이다. 그 뒤 redo 패스가 도는데, 이 시점의 home 페이지는 모두 일관된 상태(옛 이미지든 새 이미지든, 어쨌든 torn은 아니다)다.
이론과 CUBRID 사이의 이름 짝
섹션 제목: “이론과 CUBRID 사이의 이름 짝”| 이론적 개념 | CUBRID 이름 |
|---|---|
| 디스크 위 DWB 볼륨 | dwb_Volume_name(fileio_make_dwb_name이 만든다) |
| Slot — DBMS 페이지 한 장 크기 칸 | DWB_SLOT(double_write_buffer.hpp) |
| Block — fsync 단위, slot의 묶음 | DWB_BLOCK(double_write_buffer.cpp) |
| Atomic position-with-flags word | dwb_Global.position_with_flags(UINT64) |
| Slot–VPID hash(메모리 안 lookup) | dwb_Global.slots_hashmap(lockfree hashmap) |
| Producer 측 acquire | dwb_acquire_next_slot → dwb_set_data_on_next_slot |
| Producer 측 stage + insert | dwb_add_page → dwb_slots_hash_insert |
| Block writer(DWB 쓰기 + fsync + home 쓰기) | dwb_flush_block → dwb_write_block |
| Background flush daemon | dwb_flush_block_daemon(1 ms tick) |
| File-sync helper daemon | dwb_file_sync_helper_daemon(10 ms tick) |
| Reader 측 hit(동시 fix) | dwb_read_page(pgbuf_fix 페이지 로드 경로에서 부른다) |
| 크래시 복구 스캔 | dwb_load_and_recover_pages(boot_sr.c가 부른다) |
| 페이지마다의 손상 게이트 | dwb_check_data_page_is_sane + fileio_page_check_corruption |
| 미flush DWB 페이지 강제 flush | dwb_flush_force(fileio_synchronize_all이 부른다) |
CUBRID의 구현
섹션 제목: “CUBRID의 구현”DWB는 움직이는 부품이 여섯이다. 디스크 위 볼륨이 staging된 사본을 들고 있고, 메모리 안 block / slot이 그 볼륨 모양을 1대1로 비춘다. position-with-flags atomic이 동시 producer를 손맞추고, slot hash가 DWB를 동시 reader의 캐시로 바꾼다. flush 기계가 가득 찬 block을 디스크로 밀어내고, recovery 스캔이 크래시 뒤에 그 볼륨을 받아 쓴다. 이 차례대로 따라간다.
모양 — 정해진 크기 순차 볼륨과 이어진 block
섹션 제목: “모양 — 정해진 크기 순차 볼륨과 이어진 block”DWB는 데이터베이스 init 시점에 만들어지는 영구 볼륨 한 개다. 이름은 fileio_make_dwb_name(file_io.c:5882)이 만든다.
// fileio_make_dwb_name — src/storage/file_io.cvoidfileio_make_dwb_name (char *dwb_name_p, const char *dwb_path_p, const char *db_name_p){ sprintf (dwb_name_p, "%s%s%s%s", dwb_path_p, FILEIO_PATH_SEPARATOR (dwb_path_p), db_name_p, FILEIO_SUFFIX_DWB);}이 볼륨은 active log 옆자리를 잡고, suffix는 FILEIO_SUFFIX_DWB다. 크기는 두 파라미터로 정해진다.
| 파라미터 | 범위 | CUBRID 심볼 |
|---|---|---|
| 전체 버퍼 크기 | 512 KiB ≤ size ≤ 32 MiB(2의 거듭제곱) | PRM_ID_DWB_SIZE |
| Block 개수 | 1 ≤ blocks ≤ 32(2의 거듭제곱) | PRM_ID_DWB_BLOCKS |
| Block당 페이지 수 | 파생값: total_pages / num_blocks | dwb_Global.num_block_pages |
두 다이얼은 dwb_load_buffer_size / dwb_load_block_count가 읽어 들인다. 자기 범위로 clamp된 뒤 dwb_power2_ceil을 거쳐 2의 거듭제곱으로 올림된다. 둘 가운데 하나라도 0으로 두면 DWB 자체가 꺼진다.
볼륨은 만들 때 fileio_format(boot_db_full_name, dwb_volume_name, LOG_DBDWB_VOLID, num_block_pages, …)로 모양을 잡는다. LOG_DBDWB_VOLID는 DWB만 쓰도록 떼어 둔 영구 볼륨 id다. 다음 재시작에 이미 자리 잡고 있는 DWB 볼륨이 곧 복구 트리거이고, 볼륨이 없다는 사실은 손볼 게 없다는 뜻이다.
Producer 측 자료 구조
섹션 제목: “Producer 측 자료 구조”// DWB_SLOT — src/storage/double_write_buffer.hppstruct double_write_slot{ FILEIO_PAGE *io_page; /* The contained page or NULL. */ VPID vpid; /* The page identifier. */ LOG_LSA lsa; /* The page LSA */ bool ensure_metadata; /* Include metadata when syncing */ unsigned int position_in_block; /* The position in block. */ unsigned int block_no; /* The number of the block where the slot reside. */};
// DWB_BLOCK — src/storage/double_write_buffer.cppstruct double_write_block{ FLUSH_VOLUME_INFO *flush_volumes_info; /* per-block flush bookkeeping */ volatile unsigned int count_flush_volumes_info; unsigned int max_to_flush_vdes;
pthread_mutex_t mutex; /* protects wait_queue */ DWB_WAIT_QUEUE wait_queue; /* threads sleeping on this block's flush */
char *write_buffer; /* contiguous block bytes — written in one fileio_write_pages */ DWB_SLOT *slots; /* slot view onto write_buffer */ volatile unsigned int count_wb_pages; /* current fill level */
unsigned int block_no; volatile UINT64 version; /* incremented after each flush */ volatile bool all_pages_written; /* set when home writes complete */};
// DOUBLE_WRITE_BUFFER — global singletonstruct double_write_buffer{ bool logging_enabled; DWB_BLOCK *blocks; /* num_blocks-sized array */ unsigned int num_blocks; /* power of 2, ≤ DWB_MAX_BLOCKS = 32 */ unsigned int num_pages; /* num_blocks × num_block_pages */ unsigned int num_block_pages; /* power of 2 */ unsigned int log2_num_block_pages; /* used by macros */
volatile unsigned int blocks_flush_counter; volatile unsigned int next_block_to_flush;
pthread_mutex_t mutex; DWB_WAIT_QUEUE wait_queue; /* structure-modification waiters */
UINT64 volatile position_with_flags; /* THE coordinator */
dwb_hashmap_type slots_hashmap; /* VPID → DWB_SLOTS_HASH_ENTRY */ int vdes; /* volume descriptor */
DWB_BLOCK *volatile file_sync_helper_block; /* block being post-flushed */};DWB_BLOCK::write_buffer가 slot을 받쳐 주는 유일한 메모리다. DWB_BLOCK::slots[i].io_page는 그 위에서의 포인터일 뿐이고, 정확히 write_buffer + i × IO_PAGESIZE 자리를 가리킨다. 할당부가 그 의도를 그대로 드러낸다.
// dwb_create_blocks — src/storage/double_write_buffer.cppfor (i = 0; i < num_blocks; i++) { blocks_write_buffer[i] = (char *) malloc (block_buffer_size * sizeof (char)); ... for (j = 0; j < num_block_pages; j++) { io_page = (FILEIO_PAGE *) (blocks_write_buffer[i] + j * IO_PAGESIZE); fileio_initialize_res (thread_p, io_page, IO_PAGESIZE); dwb_initialize_slot (&slots[i][j], io_page, j, i); } dwb_initialize_block (&blocks[i], i, 0, blocks_write_buffer[i], slots[i], flush_volumes_info[i], 0, num_block_pages); }이 배치가 두 가지를 만들어 낸다. (가) block을 디스크로 적을 때 단일 fileio_write_pages 호출 한 번이면 끝난다. 바이트가 이미 볼륨 순서로 놓여 있기 때문이다. (나) slot 한 칸을 손보는 일(producer가 자기 페이지를 slot에 옮기는 일)이 공유 write buffer에 거는 memcpy 한 번이다. 서로 다른 slot에 동시에 적는 producer들은 안전하다. producer마다 position-with-flags atomic을 거쳐 자기 slot 자리를 독점하기 때문이다.
Position-with-flags — 가운데 손맞추는 자리
섹션 제목: “Position-with-flags — 가운데 손맞추는 자리”64비트 atomic dwb_Global.position_with_flags가 producer 측의 단 하나뿐인 직렬화 자리다. 비트 모양은 다음과 같다.
bit 63 62 61 ... 33 32 31 30 29 ... 0 +-----------------------+ +---+ +---+ +-----------------+ | block-write-started | | M | | C | | slot position | | bitmask (one bit per | | S | | R | | (30 bits) | | block, max 32 blocks) | | | | E | | | +-----------------------+ +---+ +---+ +-----------------+
M = MODIFY_STRUCTURE flag (bit 31) C = CREATE flag (bit 30)매크로 접근자가 그 인코딩을 가린다.
// position-with-flags macros — double_write_buffer.cpp#define DWB_GET_POSITION(p) ((p) & 0x000000003fffffffULL) /* low 30 bits */#define DWB_GET_BLOCK_STATUS(p) ((p) & 0xffffffff00000000ULL) /* high 32 bits */#define DWB_MODIFY_STRUCTURE 0x0000000080000000ULL#define DWB_CREATE 0x0000000040000000ULL
/* "block write started" bit — block_no occupies bit (63 - block_no) */#define DWB_IS_BLOCK_WRITE_STARTED(p, block_no) \ (((p) & (1ULL << (63 - (block_no)))) != 0)#define DWB_STARTS_BLOCK_WRITING(p, block_no) ((p) | (1ULL << (63 - (block_no))))#define DWB_ENDS_BLOCK_WRITING(p, block_no) ((p) & ~(1ULL << (63 - (block_no))))
/* "next slot position" — wraps at num_pages */#define DWB_GET_NEXT_POSITION_WITH_FLAGS(p) \ (DWB_GET_POSITION (p) == (DWB_NUM_TOTAL_PAGES - 1) \ ? ((p) & DWB_FLAG_MASK) : ((p) + 1))30비트 위치 필드는 전체 slot 수의 한도를 2³⁰까지 잡아 두지만, 실제로는 DWB_MAX_SIZE / IO_PAGESIZE가 만들어 내는 수천 개 자리에서 막힌다. block-status 비트 필드가 block 개수의 한도를 32(DWB_MAX_BLOCKS)로 못 박아 두는데, 그래서 PRM_ID_DWB_BLOCKS도 그 값으로 clamp된다.
dwb_acquire_next_slot의 한가운데에 자리한 atomic CAS가 이 길을 wait-free hot path로 만든다.
// dwb_acquire_next_slot — src/storage/double_write_buffer.cpp (condensed)STATIC_INLINE intdwb_acquire_next_slot (THREAD_ENTRY *thread_p, bool can_wait, DWB_SLOT **p_dwb_slot){start: current_position_with_flags = ATOMIC_INC_64 (&dwb_Global.position_with_flags, 0ULL);
if (DWB_NOT_CREATED_OR_MODIFYING (current_position_with_flags)) { /* wait or return — see below */ }
current_block_no = DWB_GET_BLOCK_NO_FROM_POSITION (current_position_with_flags); position_in_current_block = DWB_GET_POSITION_IN_BLOCK (current_position_with_flags);
if (position_in_current_block == 0) { /* first write into this block — must wait for previous iteration to flush */ if (DWB_IS_BLOCK_WRITE_STARTED (current_position_with_flags, current_block_no)) { if (!can_wait) return NO_ERROR; dwb_wait_for_block_completion (thread_p, current_block_no); goto start; } current_position_with_block_write_started = DWB_STARTS_BLOCK_WRITING (current_position_with_flags, current_block_no); new_position_with_flags = DWB_GET_NEXT_POSITION_WITH_FLAGS (current_position_with_block_write_started); } else { new_position_with_flags = DWB_GET_NEXT_POSITION_WITH_FLAGS (current_position_with_flags); }
if (!ATOMIC_CAS_64 (&dwb_Global.position_with_flags, current_position_with_flags, new_position_with_flags)) goto start; /* lost race, try again */
block = dwb_Global.blocks + current_block_no; *p_dwb_slot = block->slots + position_in_current_block; VPID_SET_NULL (& (*p_dwb_slot)->vpid); /* invalidate previous occupant */ return NO_ERROR;}block의 첫 producer가 그 block의 “block-write-started 비트를 뒤집는 자리다. 이 비트가 flush daemon과 구조 수정 길이 block N에는 staging된 데이터가 있다, 디스크로 나가야 한다”를 알아채는 손맞춤 손잡이다.
Producer 측 — pgbuf flush 흐름
섹션 제목: “Producer 측 — pgbuf flush 흐름”DWB 쓰기의 가장 큰 producer는 페이지 버퍼의 flush 길이다. pgbuf_bcb_safe_flush_internal_release_mutex(page_buffer.c의 10468 줄 근방)에서 알맞은 토막을 들면 다음과 같다.
// pgbuf flush path — src/storage/page_buffer.cDWB_SLOT *dwb_slot = NULL;bool uses_dwb;
uses_dwb = dwb_is_created () && !is_temp;
start_copy_page: /* ... TDE-encrypt or memcpy bufptr → iopage ... */ if (uses_dwb) { error = dwb_set_data_on_next_slot (thread_p, iopage, false, false, &dwb_slot); if (dwb_slot != NULL) { iopage = NULL; /* the slot's io_page replaces our local */ goto copy_unflushed_lsa; } }
copy_unflushed_lsa: /* ... WAL flush (logpb_flush_log_for_wal) ... */
if (uses_dwb) { error = dwb_add_page (thread_p, iopage, &bufptr->vpid, false, &dwb_slot); /* dwb_add_page enqueues; the actual home write is done later by dwb_flush_block via dwb_write_block */ } else { write_mode = (dwb_is_created () == true ? FILEIO_WRITE_NO_COMPENSATE_WRITE : FILEIO_WRITE_DEFAULT_WRITE); fileio_write (thread_p, ..., iopage, ..., write_mode); }여기에 두 가지 성질이 있다. (가) is_temp가 임시 볼륨의 DWB 길을 짧게 끊어 둔다. 임시 볼륨 위의 torn page는 무해하다. 임시 볼륨은 다음 시작 자리에서 어차피 다시 만들어지기 때문이다. (나) 길이 둘로 갈라져 있다. dwb_set_data_on_next_slot이 slot을 잡고 staging까지 끝낸 뒤, WAL force가 staging과 dwb_add_page 사이에서 일어난다. 이 갈라짐이 DWB 길 안쪽에서도 지켜지는 WAL 불변식이다. 페이지가 어디로 나가든 — 이번 자리에서는 home이 아니라 DWB로 나간다 해도 — 그 변경을 풀어 두는 로그 레코드는 데이터 페이지가 디스크에 닿기 전에 디스크 위에 있어야 한다는 약속이다.
Producer 측 — file_io.c의 비-pgbuf writer
섹션 제목: “Producer 측 — file_io.c의 비-pgbuf writer”또 다른 producer가 fileio_write_or_add_to_dwb(file_io.c:4014)다. 페이지 버퍼를 거치지 않고 fileio_write로 페이지를 곧장 적는 호출자(예: 볼륨 확장 코드)가 받아 쓴다.
// fileio_write_or_add_to_dwb — src/storage/file_io.cvoid *fileio_write_or_add_to_dwb (THREAD_ENTRY *thread_p, int vol_fd, FILEIO_PAGE *io_page_p, PAGEID page_id, size_t page_size, bool ensure_metadata){ bool skip_flush = false; DWB_SLOT *p_dwb_slot = NULL;
skip_flush = dwb_is_created (); if (skip_flush) { arg.vdes = vol_fd; vol_info_p = fileio_traverse_permanent_volume (thread_p, fileio_is_volume_descriptor_equal, &arg); if (vol_info_p) { /* permanent volume — route through DWB */ VPID_SET (&vpid, vol_info_p->volid, page_id); io_page_p->prv.volid = vol_info_p->volid; io_page_p->prv.pageid = page_id; error_code = dwb_add_page (thread_p, io_page_p, &vpid, ensure_metadata, &p_dwb_slot); if (p_dwb_slot != NULL) return io_page_p; /* staged */ } /* not permanent OR DWB disabled meanwhile — fall through to direct write */ }
write_mode = skip_flush ? FILEIO_WRITE_NO_COMPENSATE_WRITE : FILEIO_WRITE_DEFAULT_WRITE; return fileio_write (thread_p, vol_fd, io_page_p, page_id, page_size, write_mode);}FILEIO_WRITE_NO_COMPENSATE_WRITE와 FILEIO_WRITE_DEFAULT_WRITE의 갈라짐이 또 한 자리의 torn-page 다이얼이다. DWB가 켜져 있을 때는 fileio_write에게 partial write를 알아채도 다시 쓰지 말라는 신호가 간다. 그 다시 쓰기는 DWB가 재시작 자리에서 대신 손볼 일이기 때문이다. DWB가 없을 때는 fileio_write가 자기 partial-write 재시도 길로 떨어진다.
Producer 측 흐름 (Mermaid)
섹션 제목: “Producer 측 흐름 (Mermaid)”sequenceDiagram
participant PB as 페이지 버퍼 flush 길
participant DA as dwb_set_data_on_next_slot
participant WL as logpb_flush_log_for_wal (WAL force)
participant DP as dwb_add_page
participant SH as dwb_slots_hashmap (VPID hash)
participant BL as DWB_BLOCK (count_wb_pages++)
participant FD as dwb-flush-block daemon
participant FB as dwb_flush_block
PB->>DA: iopage를 다음 slot에 staging
DA-->>PB: DWB_SLOT* (slot에 페이지가 memcpy됨)
PB->>WL: 페이지 LSA까지 WAL force
WL-->>PB: nxio_lsa >= page.lsa
PB->>DP: dwb_add_page(iopage, vpid, slot)
DP->>SH: insert (vpid → slot)
DP->>BL: ATOMIC_INC_32(&count_wb_pages)
alt block이 가득 찼다면
DP->>FD: 깨움
FD->>FB: dwb_flush_next_block
FB->>FB: fileio_write_pages (block → DWB 볼륨)
FB->>FB: fileio_synchronize (DWB 볼륨)
FB->>FB: dwb_write_block (slot → home 볼륨)
FB->>FB: fileio_synchronize (home 볼륨 — 일부는 helper가)
else block이 부분만 차 있다면
DP-->>PB: NO_ERROR (아직 flush 없음)
end
Slot hash — DWB를 reader 측 캐시로 바꾸기
섹션 제목: “Slot hash — DWB를 reader 측 캐시로 바꾸기”DWB가 페이지를 staging만 해 두고 아직 home 자리에 적지 않은 동안이 있다. 이 동안에 다른 reader 스레드(페이지 테이블 미스가 나서 pgbuf_fix를 도는 스레드)가 home 볼륨에서 옛 이미지를 읽게 될 위험이 자리한다. slot hash가 그 위험을 막는다. 성공한 dwb_add_page 한 번마다 slot이 dwb_Global.slots_hashmap에 VPID 키로 등록된다.
페이지 버퍼의 fix 길은 fileio_read로 떨어지기 전에 hash부터 들여다본다.
// pgbuf_fix path — page_buffer.c:8239if (dwb_read_page (thread_p, vpid, &bufptr->iopage_buffer->iopage, &success) != NO_ERROR) return NULL;else if (success == true) /* nothing to do — page bytes copied from DWB slot */;else if (fileio_read (thread_p, fileio_get_volume_descriptor (vpid->volid), ...) == NULL) /* error path */;dwb_read_page는 hash를 lookup한다. slot의 VPID가 청한 VPID와 그대로 들어맞으면(producer가 그 자리를 덮어쓰는 도중일 수도 있다) slot의 io_page를 호출자의 버퍼로 memcpy한다.
// dwb_read_page — src/storage/double_write_buffer.cpp:3968intdwb_read_page (THREAD_ENTRY *thread_p, const VPID *vpid, void *io_page, bool *success){ *success = false; if (!dwb_is_created ()) return NO_ERROR;
VPID key_vpid = *vpid; slots_hash_entry = dwb_Global.slots_hashmap.find (thread_p, key_vpid); if (slots_hash_entry != NULL) { if (VPID_EQ (&slots_hash_entry->slot->vpid, vpid)) { memcpy ((char *) io_page, (char *) slots_hash_entry->slot->io_page, IO_PAGESIZE); *success = true; } pthread_mutex_unlock (&slots_hash_entry->mutex); } return NO_ERROR;}그러면 의미가 또렷해진다. DWB hit은 reader가 그 페이지의 가장 새 버전을 받는다는 뜻이다. 아직 home 볼륨에 적히지 않은 버전까지 받는다. 이 자리가 DWB가 단지 torn-page 막이 노릇을 넘어 home 볼륨에 대한 write-back 캐시로도 도는, 흔치 않은 설계 결정의 자리다. torn-page만 막는 게 목적이라면 slot hash는 굳이 둘 까닭이 없다. CUBRID가 굳이 더해 둔 까닭은, slot이 어차피 메모리에 자리하고 있어 캐시화의 비용이 사실상 0이기 때문이다.
LSA 순서 hash insert — 다시-fix를 다루기
섹션 제목: “LSA 순서 hash insert — 다시-fix를 다루기”같은 VPID가 한 block-fill 창 안에서 두 번 DWB에 들어올 수 있다. 페이지가 더티가 되었다 flush staging되고, 다시 더티가 되어 또 staging되는 흐름이다. dwb_slots_hash_insert는 reader에게 가장 새 버전을 보이게 두고 옛 slot을 거두는 식으로 그 다툼을 풀어 둔다.
// dwb_slots_hash_insert — src/storage/double_write_buffer.cpp (excerpt)*inserted = dwb_Global.slots_hashmap.find_or_insert (thread_p, *vpid, slots_hash_entry);if (! (*inserted)) { if (LSA_LT (&slot->lsa, &slots_hash_entry->slot->lsa)) { /* The older slot is better than mine — leave it in hash. */ pthread_mutex_unlock (&slots_hash_entry->mutex); return NO_ERROR; } else if (LSA_EQ (&slot->lsa, &slots_hash_entry->slot->lsa)) { /* Same LSA — page modified without logging (rare). Replace, but invalidate old slot if in same block. */ if (slots_hash_entry->slot->block_no == slot->block_no) { VPID_SET_NULL (&slots_hash_entry->slot->vpid); fileio_initialize_res (thread_p, slots_hash_entry->slot->io_page, IO_PAGESIZE); } } slot->ensure_metadata = slot->ensure_metadata || slots_hash_entry->slot->ensure_metadata; }slots_hash_entry->slot = slot;LSA_LT 검사(내가 캐시된 것보다 더 옛것이다)가 hash 다툼에서 이긴 다른 producer의 slot이 자기보다 큰 LSA를 가진 경우에 대한 막이다. LSA_EQ 가지는 logging 없이 손본 길을 다룬다. 임시 볼륨 식의 쓰기, 즉 LSA가 앞으로 가지 않은 쓰기다.
Flush — block 단위 fsync, 그리고 home 쓰기
섹션 제목: “Flush — block 단위 fsync, 그리고 home 쓰기”dwb_flush_block이 내구성 이야기의 심장이다. producer가 block의 마지막 slot을 채웠고 daemon이 자리하고 있지 않을 때는 그 자리에서 inline으로 돌고, daemon이 자리하고 있을 때는 dwb-flush-block daemon에서 돈다. 차례는 다음과 같다.
// dwb_flush_block — src/storage/double_write_buffer.cpp:2191 (condensed)STATIC_INLINE intdwb_flush_block (THREAD_ENTRY *thread_p, DWB_BLOCK *block, bool file_sync_helper_can_flush, UINT64 *cur_pos_w_flags){ ATOMIC_INC_32 (&dwb_Global.blocks_flush_counter, 1);
/* (1) Snapshot the slots in VPID order so the home writes hit each volume contiguously. */ dwb_block_create_ordered_slots (block, &p_dwb_ordered_slots, &ordered_slots_length);
/* (2) De-duplicate: the same VPID may appear twice — keep the newer LSA. */ for (i = 0; i < block->count_wb_pages - 1; i++) { ... }
/* (3) Wait for the previous block's home writes to finish. */ while (dwb_Global.file_sync_helper_block != NULL) { thread_sleep(1) or flush inline; }
/* (4) WRITE THE WHOLE BLOCK TO THE DWB VOLUME — sequential, fast. */ fileio_write_pages (thread_p, dwb_Global.vdes, block->write_buffer, 0, block->count_wb_pages, IO_PAGESIZE, FILEIO_WRITE_NO_COMPENSATE_WRITE);
/* (5) FSYNC THE DWB VOLUME — durability barrier. */ fileio_synchronize (thread_p, dwb_Global.vdes, dwb_Volume_name, false);
/* (6) WRITE THE PAGES TO THEIR HOME VOLUMES (and remove from slot hash). */ dwb_write_block (thread_p, block, p_dwb_ordered_slots, ordered_slots_length, file_sync_helper_can_flush, /* remove_from_hash = */ true);
/* (7) FSYNC THE HOME VOLUMES — small ones inline, big ones offloaded to helper. */ for (i = 0; i < block->count_flush_volumes_info; i++) { if (file_sync_helper_can_flush && num_pages > max_pages_to_sync && dwb_is_file_sync_helper_daemon_available ()) continue; /* let the helper do it */ if (ATOMIC_CAS_32 (&block->flush_volumes_info[i].flushed_status, VOLUME_NOT_FLUSHED, VOLUME_FLUSHED_BY_DWB_FLUSH_THREAD)) fileio_synchronize (thread_p, block->flush_volumes_info[i].vdes, NULL, block->flush_volumes_info[i].metadata); }
block->all_pages_written = true; ATOMIC_TAS_32 (&block->count_wb_pages, 0); ATOMIC_INC_64 (&block->version, 1ULL);
/* (8) Clear the block's "write started" bit; advance next_block_to_flush. */ /* (9) Wake any threads sleeping on this block's wait_queue. */}이 여덟 걸음이 torn-page 막이 약속의 실행 그 자체다. 5단계와 6단계 사이에서 DWB 볼륨은 디스크 위에 내구한 자리로 박힌다. 6단계 어디에서 크래시가 나도, home 쓰기가 torn 모양으로 끝났을 가능성이 있는 모든 페이지를 DWB는 멀쩡한 사본을 들고 있다. 7단계가 끝나면 두 사본이 모두 내구한 자리에 들어가고, 다음 block-fill 사이클에서 DWB slot이 다시 쓰일 수 있다.
helper daemon dwb_file_sync_helper_daemon은 7단계를 나란히 돌리려고 자리하고 있다. home 볼륨이 여럿일 수 있고, 큰 볼륨에 대한 fsync는 느리다. 메인 flush 스레드는 비싼 fsync를 helper에게 넘기고, 자기는 다음 block에 더 많은 데이터를 적으러 빨리 돌아온다. 그 인계는 단일 포인터 atomic dwb_Global.file_sync_helper_block을 거쳐 일어난다.
상태 기계 — DWB slot 한 칸의 한살이
섹션 제목: “상태 기계 — DWB slot 한 칸의 한살이”stateDiagram-v2 [*] --> FREE: 서버 시작 (block 만들어짐) FREE --> STAGED: dwb_set_data_on_next_slot\n(slot 잡고 페이지 memcpy) STAGED --> HASHED: dwb_add_page\n(VPID를 slots_hashmap에 등록) HASHED --> DWB_WRITTEN: dwb_flush_block 4-5걸음\n(write_buffer flush + DWB 볼륨 fsync) DWB_WRITTEN --> HOME_WRITTEN: dwb_write_block\n(slot의 io_page를 home 볼륨에 적기) HOME_WRITTEN --> HOME_SYNCED: fileio_synchronize\n(home 볼륨 fsync — 메인이거나 helper) HOME_SYNCED --> FREE: count_wb_pages 리셋; block->version++ HASHED --> HASHED_INVALIDATED: 같은 VPID가 더 큰 LSA로 다시 stage HASHED_INVALIDATED --> DWB_WRITTEN: 그래도 flush됨 (dwb_write_block에서 NULL VPID skip)
여기에 세 가지 성질이 산다. (가) STAGED와 HASHED 사이에서는 다른 reader가 slot을 찾아낼 수 없다. (어쩌면 옛 이미지가 들어 있는) home 볼륨에서 읽어야 한다. (나) HASHED와 HOME_SYNCED 사이의 동안에는 reader가 dwb_read_page로 slot의 내용을 받아 간다. (다) HOME_SYNCED를 빠져나가는 그 한 번의 옮김이 slot이 다음 block-fill 사이클을 위해 풀려나는 단 하나뿐인 자리다.
크래시 복구 — dwb_load_and_recover_pages
섹션 제목: “크래시 복구 — dwb_load_and_recover_pages”복구 이야기의 바깥 진입점은 한 자리뿐이다. boot_sr.c가 vacuum init을 마친 직후, 로그 복구(analysis / redo / undo) 이전에 dwb_load_and_recover_pages를 부른다.
// boot_sr.c:2403 — server boot, with crash recoveryoid_set_root (&boot_Db_parm->rootclass_oid);
/* Load and recover data pages before log recovery */error_code = dwb_load_and_recover_pages (thread_p, log_path, log_prefix);if (error_code != NO_ERROR) goto error;
#if defined(SERVER_MODE)pgbuf_daemons_init ();dwb_daemons_init ();parallel_query::worker_manager_global::get_manager ().init ();#endif이 자리 잡기가 ARIES 정합에 결정적이다. analysis가 로그를 걷기 시작할 즈음에는 디스크 위 모든 home 페이지가 둘 가운데 하나의 일관된 상태에 있다. 일관된 옛 이미지(DWB에 사본이 없었거나 home 쓰기가 시작/끝나지 않은 경우)이거나 일관된 새 이미지(DWB가 되돌린 경우)다. redo는 알려진 상태 위에 LSN을 다시 얹을 수 있다.
dwb_load_and_recover_pages 본문(double_write_buffer.cpp:3199)은 다섯 걸음으로 풀린다.
- 디스크 위 DWB 볼륨 열기. 자리하고 있지 않으면 손볼 게 없다. 엔진이 갓 만들어졌거나, 직전 세션이 DWB가 destroy될 만큼 깨끗이 끝난 경우다.
num_dwb_pages크기의 메모리 안DWB_BLOCK한 개 잡기 — normal operation 시점의 block 개수와 무관하게 DWB 통째를 단일 block으로 적재한다. 복구 스캔은 block 경계를 알 까닭이 없고, slot 이미지만 있으면 되기 때문이다.- 모든 페이지 읽기.
fileio_read_pages가 DWB 볼륨 통째를 block의 write_buffer로 끌어 들인다. slot마다 VPID와 LSA는 페이지의prv.{volid,pageid,lsa}헤더에서 다시 길어 올린다. - 정렬과 dedup.
dwb_block_create_ordered_slots가(VPID, LSA)로 줄세운다. dedup 루프가 줄세운 배열을 걸으면서 같은 VPID가 두 번 자리하고 있으면 옛 사본을 거둔다(한 block의 flush 도중 크래시가 나서 직전 block의 slot이 진행 중인 block에 일부 덮인 경우 그런 일이 일어난다). - slot마다
dwb_check_data_page_is_sane. home 볼륨의 사본을 읽는다. 멀쩡하면(fileio_page_check_corruption통과) 그대로 둔다. slot의 VPID를 NULL로 두어 복구 쓰기가 그 slot을 건너뛰게 한다. home이 망가져 있고 DWB slot이 멀쩡하면, home 페이지를 갈아 끼울 자리로 표시한다. 둘 다 망가져 있으면 그것은 치명적인 복구 오류다. dwb_write_block으로 home 페이지 덮어쓰기. normal flush에서 쓰는 producer 측 함수를 그대로 다시 쓴다.remove_from_hash = false로 부르는 까닭은 아직 살아 있는 hash가 자리하고 있지 않기 때문이다.- DWB 볼륨 dismount + unformat, 그 뒤
dwb_create로 새로 만든다. 새 DWB는 비어 있고, 크래시 자리에서 staging되어 있던 것은 이제 home 페이지에 커밋된 모양이다.
복구 흐름 (Mermaid)
섹션 제목: “복구 흐름 (Mermaid)”flowchart TB
S0["서버 시작\nboot_sr.c"]
S1["log_initialize\n(active log 헤더 읽기)"]
S2["vacuum_initialize"]
S3["dwb_load_and_recover_pages"]
S4["pgbuf_daemons_init\ndwb_daemons_init"]
S5["log_recovery (analysis / redo / undo)"]
S0 --> S1 --> S2 --> S3 --> S4 --> S5
subgraph DWB_RECOVERY["dwb_load_and_recover_pages"]
R1["fileio_is_volume_exist?"]
R2["fileio_mount + fileio_read_pages"]
R3["dwb_block_create_ordered_slots\n(VPID, LSA로 정렬)"]
R4["중복 dedup\n(같은 VPID 두 번)"]
R5["dwb_check_data_page_is_sane\n(home 페이지 읽기)"]
R6{"home 망가짐 &\nDWB 멀쩡?"}
R7["slot을 갈아 끼울 자리로 표시"]
R8["slot을 건너뛰는 자리로 표시"]
R9["dwb_write_block\n(망가진 home 페이지 갈아 끼우기)"]
R10["fileio_synchronize home 볼륨"]
R11["fileio_dismount + fileio_unformat"]
R12["dwb_create (새로)"]
end
R1 -- "예" --> R2
R1 -- "아니오" --> R12
R2 --> R3 --> R4 --> R5
R5 --> R6
R6 -- "예" --> R7
R6 -- "아니오" --> R8
R7 --> R9
R8 --> R9
R9 --> R10 --> R11 --> R12
핵심 불변식은 단출하다. dwb_create가 돌아올 때, DWB 볼륨은 비어 있고 home 볼륨들은 일관된 상태다. 다음에 redo가 도는 자리에서 보는 home 페이지는 데이터베이스의 일관된 한 스냅샷이다. torn은 아니다.
성능 — write 부풀림과 그것을 견딜 만한 까닭
섹션 제목: “성능 — write 부풀림과 그것을 견딜 만한 까닭”DWB의 가장 나쁜 비용은 데이터 페이지의 2× write 부풀림이다. 더티 페이지마다 먼저 DWB 볼륨에, 그 뒤 home 볼륨에 적힌다. 다만 이 비용을 운영에서 견딜 만한 까닭이 둘이다.
순차 vs. 무작위. DWB 쓰기는 순차다. 한 번에 block 한 개(보통 1 MiB 너머)가 자리 잡힌 파일에 들어간다. 요즘 스토리지는 순차 쓰기를 거의 최고 대역으로 받아 준다. 그래서 처리량 관점에서 DWB 쓰기는 사실상 공짜에 가깝다. 비용을 잡아 두는 것은 전송 시간이 아니라 fsync 지연이다. home 쓰기는 거꾸로 무작위다. 여러 볼륨에서 디스크 곳곳으로 흩어지는 페이지들이다. DWB는 무작위 쓰기를 더하는 게 아니라, 순차 쓰기 한 번과 fsync 한 번을 더할 뿐이다.
Daemon에 묶은 group flush. dwb-flush-block daemon은 한 block의 fsync 비용을 그 안의 모든 slot에 흩어 둔다. num_block_pages = 64인 block은 64장 페이지 쓰기를 두 번 fsync한다(DWB 볼륨에 한 번, home 볼륨에 한 번). 페이지마다의 fsync 비용은 그래서 (2 fsync) / 64 ≈ 1/32짜리 동기 쓰기 한 번에 머문다. PostgreSQL의 full_page_writes = on(체크포인트 직후 더티가 될 때마다 페이지 통째를 로그에 적는다)보다 훨씬 값싸다.
나란히 도는 home 쓰기 파이프. daemon이 home 페이지를 적고 fsync를 기다리는 동안, 다른 producer는 다음 block을 계속 채워 나간다. 파이프가 foreground 페이지 flusher가 DWB fsync에서 멎지 않게 막는다. 큰 home 볼륨의 fsync를 helper daemon이 떠맡으면 메인 flush 스레드는 다음 block을 곧장 시작할 수 있다.
운영자가 손에 쥐는 다이얼이 절충 자리를 손볼 수 있게 해 준다. 작은 PRM_ID_DWB_SIZE는 메모리는 줄이지만 flush 빈도를 늘린다. 큰 PRM_ID_DWB_BLOCKS는 파이프 깊이를 늘리지만 block마다의 slot 수를 줄인다. 어느 쪽을 0으로 두면 DWB가 통째로 꺼진다. 8 KiB / 16 KiB 네이티브 원자 쓰기를 받쳐 주는 스토리지에서는 쓸모 있는 길이다.
소스 코드 가이드
섹션 제목: “소스 코드 가이드”닻은 줄 번호가 아니라 심볼 이름이다.
공개 API (src/storage/double_write_buffer.hpp)
섹션 제목: “공개 API (src/storage/double_write_buffer.hpp)”DWB_SLOT— producer에게 보이는 slot 구조체.io_page,vpid,lsa,position_in_block,block_no를 들고 다닌다.dwb_is_created— DWB가 첫 모양을 잡았는가.dwb_create— DWB 볼륨과 메모리 안 짜임을 만든다. 첫 볼륨 만들기 자리에서boot_sr.c가 부른다.dwb_recreate— 지금 파라미터로 destroy + create(PRM_ID_DWB_SIZE/PRM_ID_DWB_BLOCKS변경 시 쓴다).dwb_load_and_recover_pages— 크래시 복구 진입점.dwb_destroy— finalize.dwb_get_volume_name— 들여다보기용.dwb_flush_force— 미처리 DWB 내용을 모두 drain한다.fileio_synchronize_all이 부른다.dwb_read_page— 페이지 버퍼 fix 길용 slot-hash lookup.dwb_set_data_on_next_slot— producer 측 acquire+stage.dwb_add_page— producer 측 commit(hash에 등록, block 채움 카운트 늘리기, block이 가득 차면 flush 청하기).dwb_synchronize—fileio_synchronize가 단일 볼륨 flush 전에 미처리 DWB 내용을 밀어내는 자리.dwb_daemons_init/dwb_daemons_destroy— 서버 모드 daemon 한살이.
내부 타입 (src/storage/double_write_buffer.cpp)
섹션 제목: “내부 타입 (src/storage/double_write_buffer.cpp)”DWB_WAIT_QUEUE_ENTRY/DWB_WAIT_QUEUE— block마다 + 전역의 단방향 wait queue. block flush 끝나기나 구조 수정 끝나기에 깨어난다.FLUSH_VOLUME_INFO— block × 볼륨 단위로 7단계 fsync에 필요한 장부(descriptor, page count, status flag).DWB_BLOCK— fsync 단위(write_buffer,slots,count_wb_pages,version, wait queue).DWB_SLOTS_HASH_ENTRY— VPID 키 hash 엔트리.DWB_SLOT을 가리킨다.DOUBLE_WRITE_BUFFER(싱글톤dwb_Global) — 전역 상태.
내부 함수
섹션 제목: “내부 함수”dwb_init_wait_queue/dwb_block_add_wait_queue_entry/dwb_block_disconnect_wait_queue_entry/dwb_block_free_wait_queue_entry/dwb_remove_wait_queue_entry/dwb_signal_waiting_threads/dwb_destroy_wait_queue— wait-queue 프리미티브.dwb_signal_waiting_thread— park된 스레드 하나 시그널(THREAD_DWB_QUEUE_RESUMED박기).dwb_set_status_resumed— timeout 정리 뒤 park된 스레드를 resumed로 되돌린다.dwb_wait_for_block_completion— block의 wait_queue에 park(timeout 20 ms).dwb_wait_for_strucure_modification— DWB resize/destroy/create 중 전역 wait_queue에 park(timeout 10 ms).dwb_signal_block_completion/dwb_signal_structure_modificated— 그 큐들에서 wake-all.dwb_starts_structure_modification/dwb_ends_structure_modification—position_with_flags의MODIFY_STRUCTURE플래그 set/clear. set 전에 CAS 루프를 돌고, 진행 중인 flusher가 빠져나가기를 기다린다.dwb_load_buffer_size/dwb_load_block_count/dwb_power2_ceil— 파라미터 파싱, 범위 clamp, 2의 거듭제곱 올림.dwb_initialize_slot/dwb_initialize_block/dwb_create_blocks/dwb_finalize_block— 메모리 짜임 helper.dwb_create_internal/dwb_destroy_internal— 구조 수정 플래그 아래에서 create / destroy.dwb_acquire_next_slot— lock-free producer 진입점.dwb_set_slot_data— 페이지 바이트를memcpy하고 VPID, LSA를 slot에 적어 둔다.dwb_init_slot— slot 필드 리셋(ordered-slot snapshot의 sentinel 끼워 넣기에서 쓰인다).dwb_block_create_ordered_slots— VPID 다음 LSA로qsortsnapshot, sentinel 포함.dwb_compare_slots— 정렬 비교자.dwb_compare_vol_fd— 볼륨 디스크립터 비교자.dwb_add_volume_to_block_flush_area— 7단계 fsync 대상 장부.dwb_get_next_block_for_flush— 다음 가득 찬 block 고르기(daemon이 받아 쓴다).dwb_flush_next_block— daemon 본문.dwb_flush_block— 여덟 걸음 내구성 춤.dwb_write_block— 6단계, slot을 home 볼륨에 적기.dwb_file_sync_helper— 7단계 fsync를 떠맡은 helper daemon 본문.dwb_slots_hash_entry_alloc/_free/_init/_key_copy/_compare_key/_key— hash 콜백.dwb_slots_hash_insert/dwb_slots_hash_delete— hash 연산.dwb_check_data_page_is_sane— 복구 자리에서 slot마다 결정(home 갈아 끼우기 또는 건너뛰기).dwb_debug_check_dwb— 디버그 전용 dup 잡기(복구 block용).dwb_is_flush_block_daemon_available/dwb_is_file_sync_helper_daemon_available/dwb_flush_block_daemon_is_running/dwb_file_sync_helper_daemon_is_running— daemon 보이기 helper.PRM_ID_ENABLE_DWB_FLUSH_THREAD의 게이트.dwb_flush_block_daemon_init/dwb_file_sync_helper_daemon_init— daemon 시동. looper 주기는 1 ms / 10 ms.class dwb_flush_block_daemon_task— flush daemon의 task 클래스.dwb_file_sync_helper_execute— helper daemon의 task 진입점(cubthread::entry_callable_task로 등록된다).
가로 참조
섹션 제목: “가로 참조”pgbuf_bcb_safe_flush_internal_release_mutex(page_buffer.c10468 줄 근방) — 1차 producer.pgbuf_*_safe_flush_internal_release_mutex—dwb_read_page를 들여다보는 fix 길(page_buffer.c:8239).fileio_write_or_add_to_dwb(file_io.c:4014) — 직접 쓰기 길용 2차 producer.fileio_synchronize_volume_and_dwb(file_io.c의 2844, 2912, 3126, 3332 — 호출 자리 여럿) — 볼륨 단위 fsync 전에dwb_synchronize를 불러 미처리 DWB를 drain한다.fileio_synchronize_all(file_io.c:4642) — 볼륨 fsync 일괄 처리 전에dwb_flush_force를 부른다.boot_restart_server(boot_sr.c:2403) — vacuum init과pgbuf_daemons_init사이에서dwb_load_and_recover_pages를 부른다.boot_create_database(boot_sr.c:4908) — 데이터베이스 파라미터 셋업 뒤, 첫 볼륨 모양 잡기 전에dwb_create를 부른다.dwb_initialize_pool은file_io.c:1882의 flush 끄기 길에서 쓰인다(skip_flush = dwb_is_created ()로 두어, 볼륨 만들기 도중의 페이지마다의 fsync를 DWB가 나중에 내구성을 떠맡을 것이므로 건너뛰는 것이다).
위치 힌트 (2026-04-30 기준)
섹션 제목: “위치 힌트 (2026-04-30 기준)”| 심볼 | 파일 | 줄 |
|---|---|---|
DWB_SLOT (struct) | double_write_buffer.hpp | 33 |
dwb_is_created (declaration) | double_write_buffer.hpp | 44 |
dwb_create (declaration) | double_write_buffer.hpp | 45 |
dwb_load_and_recover_pages (declaration) | double_write_buffer.hpp | 47 |
dwb_destroy (declaration) | double_write_buffer.hpp | 48 |
dwb_flush_force (declaration) | double_write_buffer.hpp | 50 |
dwb_read_page (declaration) | double_write_buffer.hpp | 51 |
dwb_set_data_on_next_slot (declaration) | double_write_buffer.hpp | 52 |
dwb_add_page (declaration) | double_write_buffer.hpp | 54 |
dwb_synchronize (declaration) | double_write_buffer.hpp | 57 |
dwb_daemons_init (declaration) | double_write_buffer.hpp | 60 |
DWB_MIN_SIZE / DWB_MAX_SIZE | double_write_buffer.cpp | 51-52 |
DWB_MIN_BLOCKS / DWB_MAX_BLOCKS | double_write_buffer.cpp | 53-54 |
DWB_POSITION_MASK | double_write_buffer.cpp | 70 |
DWB_BLOCKS_STATUS_MASK | double_write_buffer.cpp | 73 |
DWB_MODIFY_STRUCTURE flag | double_write_buffer.cpp | 76 |
DWB_CREATE flag | double_write_buffer.cpp | 79 |
struct double_write_wait_queue_entry | double_write_buffer.cpp | 176 |
struct double_write_wait_queue | double_write_buffer.cpp | 184 |
enum FLUSH_VOLUME_STATUS | double_write_buffer.cpp | 196 |
struct flush_volume_info | double_write_buffer.cpp | 204 |
struct double_write_block | double_write_buffer.cpp | 215 |
struct dwb_slots_hash_entry | double_write_buffer.cpp | 235 |
struct double_write_buffer | double_write_buffer.cpp | 261 |
dwb_Global (singleton) | double_write_buffer.cpp | 306 |
slots_entry_Descriptor | double_write_buffer.cpp | 421 |
dwb_init_wait_queue | double_write_buffer.cpp | 451 |
dwb_signal_waiting_threads | double_write_buffer.cpp | 663 |
dwb_power2_ceil | double_write_buffer.cpp | 732 |
dwb_load_buffer_size | double_write_buffer.cpp | 769 |
dwb_load_block_count | double_write_buffer.cpp | 795 |
dwb_starts_structure_modification | double_write_buffer.cpp | 822 |
dwb_ends_structure_modification | double_write_buffer.cpp | 924 |
dwb_initialize_slot | double_write_buffer.cpp | 949 |
dwb_initialize_block | double_write_buffer.cpp | 977 |
dwb_create_blocks | double_write_buffer.cpp | 1009 |
dwb_finalize_block | double_write_buffer.cpp | 1131 |
dwb_create_internal | double_write_buffer.cpp | 1163 |
dwb_slots_hash_insert | double_write_buffer.cpp | 1380 |
dwb_destroy_internal | double_write_buffer.cpp | 1474 |
dwb_set_status_resumed | double_write_buffer.cpp | 1521 |
dwb_wait_for_block_completion | double_write_buffer.cpp | 1552 |
dwb_signal_waiting_thread | double_write_buffer.cpp | 1643 |
dwb_wait_for_strucure_modification | double_write_buffer.cpp | 1704 |
dwb_compare_slots | double_write_buffer.cpp | 1781 |
dwb_block_create_ordered_slots | double_write_buffer.cpp | 1845 |
dwb_slots_hash_delete | double_write_buffer.cpp | 1883 |
dwb_add_volume_to_block_flush_area | double_write_buffer.cpp | 1960 |
dwb_write_block | double_write_buffer.cpp | 2007 |
dwb_flush_block | double_write_buffer.cpp | 2192 |
dwb_acquire_next_slot | double_write_buffer.cpp | 2468 |
dwb_set_slot_data | double_write_buffer.cpp | 2612 |
dwb_init_slot | double_write_buffer.cpp | 2642 |
dwb_get_next_block_for_flush | double_write_buffer.cpp | 2659 |
dwb_set_data_on_next_slot | double_write_buffer.cpp | 2686 |
dwb_add_page | double_write_buffer.cpp | 2726 |
dwb_synchronize | double_write_buffer.cpp | 2841 |
dwb_is_created | double_write_buffer.cpp | 2909 |
dwb_create | double_write_buffer.cpp | 2925 |
dwb_recreate | double_write_buffer.cpp | 2967 |
dwb_debug_check_dwb | double_write_buffer.cpp | 3013 |
dwb_check_data_page_is_sane | double_write_buffer.cpp | 3091 |
dwb_load_and_recover_pages | double_write_buffer.cpp | 3199 |
dwb_destroy | double_write_buffer.cpp | 3403 |
dwb_get_volume_name | double_write_buffer.cpp | 3440 |
dwb_flush_next_block | double_write_buffer.cpp | 3459 |
dwb_flush_force | double_write_buffer.cpp | 3514 |
dwb_file_sync_helper | double_write_buffer.cpp | 3766 |
dwb_read_page | double_write_buffer.cpp | 3969 |
class dwb_flush_block_daemon_task | double_write_buffer.cpp | 4013 |
dwb_file_sync_helper_execute | double_write_buffer.cpp | 4053 |
dwb_flush_block_daemon_init | double_write_buffer.cpp | 4073 |
dwb_file_sync_helper_daemon_init | double_write_buffer.cpp | 4087 |
dwb_daemons_init | double_write_buffer.cpp | 4099 |
dwb_daemons_destroy | double_write_buffer.cpp | 4109 |
dwb_read_page use | page_buffer.c | 8239 |
pgbuf flush / dwb_set_data_on_next_slot | page_buffer.c | 10548 |
pgbuf flush / dwb_add_page | page_buffer.c | 10597 |
fileio_write_or_add_to_dwb | file_io.c | 4014 |
fileio_synchronize_all → dwb_flush_force | file_io.c | 4642 |
fileio_make_dwb_name | file_io.c | 5882 |
boot_restart_server → dwb_load_and_recover_pages | boot_sr.c | 2403 |
boot_create_database → dwb_create | boot_sr.c | 4908 |
소스 검증
섹션 제목: “소스 검증”DWB에는
raw/안에 남아 있는 raw 분석이 없다. 본 문서는 소스만을 발판으로 한 walkthrough다. 아래 검증 항목은2026-04-30시점의 살아 있는 소스에서 그대로 길어 올린 불변식들과, DWB를 가리키는 가까운 문서들(페이지 버퍼 / 로그 매니저 / 복구 매니저)이 그것들을 어떻게 말하거나 함의하는지를 정리해 둔 것이다.
- 페이지 테이블 미스 자리에서 디스크 읽기 전에 DWB부터 본다.
cubrid-page-buffer-manager.md§Double Write Buffer (DWB) — torn-page 보호가 그것을 짚는다.page_buffer.c:8239에서 검증된다. 미스 시 읽기 차례의 첫 호출이dwb_read_page이고,fileio_read가 fallback이다. - Producer 측 staging이 셋으로 갈라진다. stage → WAL force → commit.
cubrid-page-buffer-manager.md는 이것을 짚지 않지만,page_buffer.c:10548-10597의 소스 길이 그것을 보여 준다. WAL force(logpb_flush_log_for_wal)가dwb_set_data_on_next_slot과dwb_add_page의 사이에 자리한다. slot 자체는 WAL force 전에 잡히지만, reader에게 보이는 자리에 들어가는 시점은(hash 등록과 flush가 모두 끝난 자리는) WAL force 뒤에dwb_add_page가 commit한 이후다. - DWB 복구는 analysis / redo / undo 이전에 일어난다.
cubrid-recovery-manager.md는 세 패스 재시작을 그리지만 analysis 이전에 무엇이 도는지는 적지 않는다. 검증된 사실은 이렇다.boot_restart_server가boot_sr.c:2403에서dwb_load_and_recover_pages를 부른 뒤pgbuf_daemons_init+dwb_daemons_init, 그 다음에야log_recovery를 부른다. redo 패스가 보는 home 페이지는 이미 손상이 손봐진 상태다. - DWB 쓰기는 WAL을 우회하지 않는다. DWB 볼륨이 자체로 내구한 자리에 있고, 본디 redo 로그 없이 replay할 수 있어 보일 수 있지만, CUBRID는 그렇게 받아 쓰지 않는다.
dwb_flush_block은 로그를 들여다보지 않는다. 그 페이지의 로그 레코드가 이미 디스크 위에 있는(WAL 불변식이 producer 측에서 지켜진) 페이지를 staging할 뿐이다. 복구는 DWB로 home 페이지를 다시 짜고, redo가 그 일관된 home 페이지 위에 로그 레코드를 다시 얹는다. DWB는 redo의 갈음이 아니라 redo 이전의 정리 자리다. - Block 단위 flush 차례가 강제된다.
dwb_flush_block이 본문 첫 자리에서 다음을 assert한다.(DWB_GET_PREV_BLOCK (flush_block->block_no)->version > flush_block->version) || .... 즉 지금 block을 시작하기 전에 직전 block이 flush되어 있어야 한다(직전의 version이 더 크다).position_with_flags의 block-write-started 비트가 그것을 받쳐 주는 손맞춤 장치다. daemon은next_block_to_flush를 골라 두고, 그 block만이 자격을 가진다. - 복구 자리의 slot hash는 살아 있는 hash가 아니라 ordered-slot snapshot 위에서만 본다.
dwb_load_and_recover_pages동안에는 복구 block이 새로 만들어지고, normal에서 받아 쓰는slots_hashmap은 아직 자리하고 있지 않다(복구 코드가 스캔의 마지막에dwb_create를 부르며 짠다). 그래서dwb_block_create_ordered_slots가 복구 block 통째를 돌 수 있다. 다툴 producer가 없고, 수천 개 slot 위의qsort는 비싸지 않다. FILEIO_WRITE_NO_COMPENSATE_WRITE가fileio_write위에 찍히는 DWB의 봉인이다. DWB가 켜져 있으면 모든 pgbuf와 file_io 쓰기가 이 모드를 쓴다. 이 모드는fileio_write에게 partial write를 알아채도 페이지마다의 다시 쓰기를 하지 말라고 알린다. 그 다시 쓰기 자체가 또 한 번의 torn-page 위험을 만들어 내기 때문이다. DWB가 없으면FILEIO_WRITE_DEFAULT_WRITE가 쓰이고 다시 쓰기가 돈다. DWB가 부분만 켜질 수 없는 결정적인 까닭이 여기에 있다. 모든 데이터 쓰기가fileio_write의 다시 쓰기를 건너뛰거나(DWB가 손볼 것이므로), 그렇지 않으면 모두 다시 써야 한다.- 두 daemon의 주기가 다르다.
dwb-flush-blockdaemon은 1 ms마다,dwb-file-synchelper는 10 ms마다 tick한다. 1 ms 주기는 flusher의 일(될 수 있는 한 빨리 block을 drain)에 들어맞고, 10 ms 주기는 helper의 일(큰 home 볼륨 fsync)이 어차피 fsync 지연에 묶여 있어 충분하다. 두 daemon 모두PRM_ID_ENABLE_DWB_FLUSH_THREAD를 따른다. 그것을 끄면 block의 마지막 slot을 채운 producer가 그 자리에서 inline flush한다. - DWB 볼륨 id는
LOG_DBDWB_VOLID에 떼어져 있다(log_volids.hpp). disk manager의 영구 볼륨 범위 바깥이며,db_volumes_view에도 비치지 않는다. 영구 볼륨을 걷는 디스크 할당 길로부터 DWB를 떼어 놓기 위함이다.
미해결 질문
섹션 제목: “미해결 질문”- DWB 안의 TDE 암호화 페이지. pgbuf flush 길은 페이지를
tde_encrypt_data_page로 stack 버퍼에 암호화한 뒤dwb_set_data_on_next_slot을 부른다. 그래서 slot은 암호문 바이트를 들고 있다. 복구는 같은 암호문을 DWB 볼륨에서 읽어 home에 적는다. 풀이는 다음pgbuf_fix에서 일어난다. 질문 — 크래시와 재시작 사이에 TDE 마스터 키가 회전된 경우 무슨 일이 일어나는가. slot의 IV / 키 generation은 좇이는가.DWB_SLOT구조체에는 TDE 장부 필드가 없으므로, (가) IV가 페이지 헤더(FILEIO_PAGE::prv) 자체에 적혀 round-trip을 견디거나, (나) 키 회전이 깨끗한 종료(DWB drain)를 먼저 청하는 둘 가운데 하나로 보인다. 추적 —tde_encrypt_data_page와tde_decrypt_data_page를 읽고 페이지 헤더가 IV를 들고 다니는지 본다. - DWB와 parallel redo.
cubrid-recovery-manager.md는log_recovery_redo_parallel.{cpp,hpp}가 VPID 단위 parallel redo를 어떻게 짜는지 그린다. DWB 복구는 그 redo 이전에 단일 스레드로 돈다(복구 block 스캔이 순차다). 그러나 redo가 시작되고 나면 daemon 주도의 DWB 활동과 race가 생길 수 있다. redo 도중 페이지 버퍼가 flush를 시작하면 그 flush가 DWB를 거치기 때문이다.pgbuf_daemons_init과dwb_daemons_init이dwb_load_and_recover_pages와log_recovery사이에서 부르므로, flusher가 redo 도중 살아 있다. 질문 — redo가 만든 더티 페이지도 같은 DWB 규율을 따르는가. 그렇다. redo 길의pgbuf_set_lsa+pgbuf_set_dirty가 결국 flush를 깨우고, 그 flush가 DWB를 거친다. 함의 — redo 도중의 크래시도 normal operation 도중의 크래시와 같은 모양으로 손볼 수 있다. 두 번째 재시작의 DWB 안에는 첫 번째 재시작이 끊긴 redo가 다시 얹어 둔 페이지들이 들어 있다. - block-write-started 비트의 다시 쓰기.
DWB_MAX_BLOCKS = 32에서 block-status 비트마스크는position_with_flags의 32-63비트를 차지한다. 미래에 더 많은 block이 청해지면 모양을 다시 짜야 한다.dwb_starts_structure_modification이 한 번에 한 구조 수정만을 강제하지만 모양 한도 자체에는 손대지 않는다. 질문 — 32라는 숫자가 영원한 설계 한도인가, 아니면 (별도의 block-status 워드 같은) 넓힘 길이 있는가. block-bit 매크로마다assert (block_no < DWB_MAX_BLOCKS)가 박혀 있어, 그것을 풀려면 매크로마다 다시 들여다봐야 한다. - 같은 LSA, 다른 block의 hash 부딪힘.
dwb_slots_hash_insert의LSA_EQ가지에는, 같은 VPID와 같은 LSA의 slot이 다른 block에 떨어졌을 때 옛 block의 version이 엄격히 더 작아야 한다는 디버그 전용 단정이 박혀 있다. 다시 끼우기 차례 버그에 대한 막이지만, 운영 길은 그 자리를 침묵으로 받아들인다. 질문 — 운영 길이 옛 block을 먼저 flush한다는 보장이 있는가(그러면 새 slot이 결국 home 쓰기에서 이긴다), 아니면 옛 block의 느린 flush가 새 staging 뒤에도 home 페이지를 옛 이미지로 둘 수 있는가.next_block_to_flush가 강제하는 block flush 차례가 그것을 막아야 한다. 다만dwb_flush_force의 즉석 block 고르기와의 만남은 좇아 봐야 한다. dwb_synchronize의 의미. 함수 이름은 단일 볼륨과 DWB의 동기화를 시사하지만, 본문은(error = dwb_flush_force (thread_p, &complete))로 모든 미처리 DWB 내용을 drain한다. 이것이 의도인가. 이 볼륨에 닿을 가능성이 있는 모든 것을 flush하는 가장 값싼 안전 길 — 아니면dwb_synchronize가 본디 볼륨마다였던 옛 설계의 잔재인가. 추적 — 함수에 대한 git blame이 필요하다.fileio_fsync_pending짧은 회로.dwb_synchronize는fileio_fsync_pending()이 true면 일찍 빠져나간다. 질문 — 이 깃발은 어느 자리에서 박히고, 이 짧은 회로가 shutdown 자리에 미동기 DWB 내용을 두고 갈 위험은 없는가. shutdown이 어차피 강제 drain을 깨우므로 위험은 낮지만, 주석은 아무 말이 없다.- 런타임 DWB 끄기. 헤더 주석(“Activating/deactivating DWB while the server is alive, needs additional work”)은
DWB_NOT_CREATED_OR_MODIFYING길이 그대로 믿을 수 있는 상태가 아님을 시사한다. 운영에서는 운영자가PRM_ID_DWB_SIZE = 0을 두고 다시 띄운다. 질문 — 라이브 enable/disable의 길이 잡혀 있는가, 그러려면 무엇이 필요한가.pgbuf와file_io의 producer 길이 옮겨감 상태를 다뤄야 하지만, 지금은 그 머리에서 한 번만dwb_is_created를 본다. - copy-database(
migrate) 도구와의 만남.dwb_synchronize는 볼륨 복사 길(file_io.c:2844, 2912, 3126, 3332)에서 부른다. 외부 도구(예:cubrid copydb)도 이것을 불러야 하는가. 사용자의 시각으로는 서버가 살아 있는 동안의 도구 실행이 막혀 있으므로 외부 도구가 살아 있는 DWB를 만나선 안 된다. 그러나dwb_synchronize는 어디서 불려도 막아 주는 모양으로 짜여 있다. 옛 설계의 잔재일 수 있다.
본 문서는 소스만을 발판으로 한다. raw/code-analysis/cubrid/storage/에 DWB를 큐레이트된 raw 분석이 자리하고 있지 않다. 아래의 길잡이는 CUBRID 소스 길과 본 지식 베이스 안의 형제 문서들이다.
CUBRID 소스 (/data/hgryoo/references/cubrid/)
섹션 제목: “CUBRID 소스 (/data/hgryoo/references/cubrid/)”src/storage/double_write_buffer.hpp— 공개 API.src/storage/double_write_buffer.cpp— 구현.src/storage/page_buffer.c— 1차 producer(flush 길)와 1차 reader(fix 길).src/storage/file_io.c— 2차 producer(fileio_write_or_add_to_dwb), 볼륨 이름 짜는 자리(fileio_make_dwb_name), 동기 호출 자리.src/transaction/boot_sr.c— boot 자리에서 잇는 일(redo 이전의dwb_load_and_recover_pages, 데이터베이스 init 자리의dwb_create).src/transaction/log_volids.hpp—LOG_DBDWB_VOLID를 떼어 둔 자리.
형제 문서
섹션 제목: “형제 문서”knowledge/code-analysis/cubrid/cubrid-page-buffer-manager.md— 페이지 버퍼의 flush 길과 미스 자리에서의 DWB 들여다보기.knowledge/code-analysis/cubrid/cubrid-log-manager.md— DWB producer 측이 지키는 WAL 불변식.knowledge/code-analysis/cubrid/cubrid-recovery-manager.md— DWB 손보기 뒤에 도는 세 패스 재시작.
교재 챕터 (knowledge/research/dbms-general/)
섹션 제목: “교재 챕터 (knowledge/research/dbms-general/)”- Database Internals(Petrov), 5장 §Recovery, §Torn Pages — torn-page 문제와 세 가지 빠져나갈 길.
- Mohan 외, ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking and Partial Rollbacks Using Write-Ahead Logging(TODS 17.1, 1992) — DWB가 짝을 이루는 WAL 불변식.
비교 엔진 (설계 자리에서 가리키는 곳)
섹션 제목: “비교 엔진 (설계 자리에서 가리키는 곳)”- MySQL InnoDB
Doublewrite_log/dblwr— CUBRID이 가장 닮은 정통 DWB 설계. - MariaDB / Percona Server parallel doublewrite — 다중 block 병렬 변종.
- PostgreSQL
full_page_writes = on— 갈음하는 full-page-WAL 길. - SQL Server torn-page 알아내기 비트 — 더 가벼운 알아내기 전용 설계로, 실패 자리에서 DWB 주도 손보기가 아니라 미디어 되돌리기를 청한다.