(KO) CUBRID 읽기 경로 — 서버 재시작이 어떻게 복구되는가
목차
- 이 문서가 따라가는 경로
- 1단계 — 프로세스 부팅
- 2단계 — DWB 복구
- 3단계 — 마지막 체크포인트 찾기
- 4단계 — Analysis 패스
- 5단계 — Redo 패스
- 6단계 — Undo 패스
- 7단계 — Vacuum 따라잡기
- 8단계 — 복제 재개
- 9단계 — 연결 받기
- 다이어그램 — 전체 재시작 파이프라인
- 다루지 않은 내용
- 출처
이 문서는 종합 문서, 즉 읽기 경로다. 깊이 파고드는 분석은 아니다. 크래시가 난 단일 cub_server가 플러시 도중에 SIGKILL로 죽었다가, CAS 워커가 SELECT 1을 던질 수 있는 상태로 돌아올 때까지 어떤 여정을 거치는지를 추적한다. 단계마다 기술적 세부는 형제 문서로 넘긴다. 이 페이지의 값은 순서와 단계 사이의 인계에 있다. 한눈에 그림이 필요하다면 § 다이어그램이 모든 것을 한 장으로 압축해 놓았다.
이 문서가 따라가는 경로
섹션 제목: “이 문서가 따라가는 경로”미디어 손실 직전까지 갈 수 있는 최악의 장애를 떠올려 보자. 서버는 한창 일하고 있다. 체크포인트 데몬이 DWB를 거쳐 더티 페이지들을 흘려보내는 중이고, prior 리스트에는 아직 디스크에 닿지 못한 로그 레코드가 십수 개 쌓여 있다. 사용자 트랜잭션 두 개는 B+Tree 분할 깊숙이 들어가 있고, 세 번째는 막 커밋 레코드를 적어 놓았는데 postpone은 아직 돌지 못했다. 이 순간 기계가 전원을 잃거나, OOM 킬러가 발사되거나, kill -9가 내려앉는다. 프로세스가 끊긴다.
1밀리초 뒤 디스크에는 세 종류의 상처가 남는다.
- 반쯤 흘러간 페이지. DWB가 슬롯들을 자기 홈 볼륨으로 줄지어 옮기던 중이었다. 어떤 페이지는 홈에 닿았고, 어떤 페이지는 닿지 못했다. DWB 볼륨은 내구적이다(어떤 홈 쓰기든 시작되기 전에 이미
fsync(2)가 끝나 있다). 그러므로 찢어진 홈 페이지마다 깨끗한 사본이 DWB에 그대로 남아 있다. - 로그까지 가지 못한 prior 리스트 항목. 몇몇
LOG_REC_*노드가 로그 플러셔를 기다리며 prior 리스트에 앉아 있었다. 그 레코드들은 디스크 위에 흔적이 없으므로, 그 변경 내용은 사라진 것으로 친다. WAL 불변식이 약속하는 것은, 디스크 위의 데이터 페이지에는 그 로그 레코드가 먼저 디스크 위에 있어야 한다는 점이다. 그러므로 prior 리스트 항목이 사라졌다면 그에 짝지어진 데이터 페이지 쓰기도 아직 흘러나가지 않았다는 뜻이다. 일관성은 깨지지 않는다. - 반쯤 끝난 트랜잭션들. 커밋이 끝난 T_c는
LOG_COMMIT과LOG_COMMIT_WITH_POSTPONE을 적어 놓았고 둘 다 내구적이다. 다만 postpone은 돌지 못했다. T_a와 T_b는 구문 한가운데에 멈춰 있다. 스무 개쯤 거슬러 올라가는 undo 사슬은 있지만 커밋 레코드는 없다. ARIES 용어로 이들은 loser다.
재시작이 떠맡는 일은 디스크를 두 갈래로 옳은 상태로 만드는 것이다. 안쪽으로 일관된 상태(찢어진 페이지가 없고, 모든 커밋된 변경이 그 자리에 있고, 모든 미커밋 변경은 사라져 있다)이자, 바깥쪽으로도 옳은 상태(T_c의 커밋은 내구적이고, T_a와 T_b는 애초에 일어난 적이 없다). 그렇게 만든 다음에야, 그제서야 네트워크 리스너가 소켓을 연다.
파이프라인은 필수 6단계와 그에 딸린 후속 2단계로 짜인다.
- 1단계(프로세스 부팅): 볼륨을 마운트하고 로그를 붙인다.
- 2단계(DWB 복구): 찢어진 페이지를 손본다.
- 3단계(체크포인트 찾기): 로그 헤더를 읽는다.
- 4단계(analysis): TX 테이블과 더티 페이지 테이블을 다시 짠다.
- 5단계(redo): redo-LSA부터 앞으로 다시 돌린다.
- 6단계(undo): loser를 되돌리며 CLR을 발행한다.
- 7단계(vacuum 따라잡기): 백그라운드에서 MVCCID를 거두어들인다.
- 8단계(HA 따라잡기): 복제가 다시 돌기 시작한다.
- 9단계(리스너 열기): 첫 번째 클라이언트를 받는다.
16단계는 차례대로 돌아간다. 78단계는 9단계와 함께 백그라운드로 펼쳐진다. 클라이언트 수락을 막지 않는다.
1단계 — 프로세스 부팅
섹션 제목: “1단계 — 프로세스 부팅”cub_server의 main()(src/executables/server.c)은 얇은 껍데기다. 플래그를 파싱하고, 시그널 핸들러를 걸고, net_server_start(src/communication/network_sr.c)를 부른다. 이 오케스트레이터가 다시 boot_restart_server(src/transaction/boot_sr.c)로 디스패치하면, 그 함수가 위상 정렬된 순서를 따라 서브시스템 초기화 목록을 한 줄씩 걸어간다.
순서가 중요한 이유는 의존성 그래프에 사이클이 있기 때문이다. 복구는 페이지 버퍼를 필요로 하고, 페이지 버퍼는 디스크 매니저를 필요로 한다. 디스크 매니저는 카탈로그 메타데이터를 갖고 싶어 하고, 카탈로그는 복구가 끝나야 한다. CUBRID는 단계를 쪼개 이 사이클을 끊는다. 서브시스템마다 두 번씩 올라온다. 한 번은 복구에 끼는 이른 단계로, 또 한 번은 카탈로그 데이터를 받아 쓰는 늦은 단계로 올라온다. 위상 정렬 순서는 cubrid-boot.md를 참고하라.
복구 이야기에서 부팅 시점에 짊어지는 동작은 셋이다.
- 볼륨 열기. 디스크 매니저가
databases.txt를 읽고, 로그 정보 파일로 볼륨 목록을 받아, 디스크립터로 하나하나 연다. 볼륨은 매달려 있지만 아직 믿을 수는 없다. 플러시 도중 크래시 때문에 페이지가 찢어졌을 가능성이 있기 때문이다. 6단계가 끝나기 전까지 클라이언트가 페이지를 읽거나 쓸 길은 없다. - 로그 붙이기.
log_initialize(log_manager.c)가 활성 로그를 열고, 헤더(페이지 id-9)를 읽고,hdr.is_shutdown을 살핀다.true면 복구는 건너뛴다.false라면(우리 시나리오가 그렇다)log_recovery를 부른다. - 복구 디스패치.
log_recovery는cubrid-recovery-manager.md에 있는 세 패스 드라이버다. 다만 그것이 돌기 전에 DWB가 찢어진 페이지를 손볼 기회를 가져야 한다. 그것이 2단계다.
부팅이 막 시작된 시점에는 클라이언트 연결이 아예 막혀 있다. boot_Server_status = BOOT_SERVER_DOWN이고, 리스너는 돌고 있지 않다. 들어오는 패킷은 OS가 모두 거절한다. 복구가 먼저, 듣기가 그다음. 이 순서가 절반쯤 복구된 상태가 클라이언트에게 새어 나가는 일을 막는다.
2단계 — DWB 복구
섹션 제목: “2단계 — DWB 복구”복구 매니저가 로그를 건드리기 전에, double-write buffer를 먼저 들여다본다. DWB는 단 한 가지 이유로 존재한다. 찢어진 페이지를 막아 주는 것. 16 KB짜리 CUBRID 페이지는 여러 개의 디스크 섹터(512 B 또는 4 KiB 단위로 원자적이다)에 걸쳐 누워 있다. 쓰기 도중에 크래시가 나면 앞쪽 절반은 새 내용, 뒤쪽 절반은 옛 내용인 페이지가 디스크 위에 남을 수 있다. ARIES redo는 이런 페이지를 살릴 수 없다. redo 함수는 멀쩡한 페이지에 델타를 얹는 것이지, 찢어진 페이지에 얹는 것이 아니다. PostgreSQL은 풀 페이지 이미지 WAL을 쓰고, InnoDB와 CUBRID는 doublewrite buffer를 쓴다. SQL Server는 찢어진 페이지를 알아내는 비트를 따로 둔다.
DWB의 런타임 불변식은 이렇다. 더티 페이지가 자기 홈 볼륨으로 흘러가기 전에, 사본 하나가 DWB 볼륨에 먼저 자리를 잡고 그 DWB 볼륨이 fsync된다. 그 뒤에야 홈 쓰기가 진행된다. 만약 그 쓰기가 찢어진다 해도 DWB에는 깨끗한 사본이 한 부 남아 있다.
재시작 시점의 DWB 복구는 dwb_load_and_recover_pages(src/storage/double_write_buffer.cpp)가 맡는다.
- DWB 볼륨(들)을 연다.
- 비어 있지 않은 슬롯마다 체크섬을 계산한다.
- 같은 위치의 홈 볼륨 사본을 읽는다.
- 홈 사본이 멀쩡하면(체크섬이 맞고, LSA가 슬롯의 LSA 이상이라면) 건너뛴다.
- 그렇지 않으면 DWB 슬롯을 홈 페이지 위에 덮어쓰고
fsync한다. - DWB를 깨끗한 상태로 표시한다.
이 청소는 ARIES 이전에 반드시 끝나야 한다. analysis와 redo는 홈 볼륨에서 페이지를 읽기 때문이다. 찢어진 페이지가 그대로 끼어 있으면 파서가 깨지거나, redo가 낡은 데이터에 얹히면서 데이터베이스가 조용히 더러워지는 버그가 생긴다.
DWB 복구는 cubrid-checkpoint.md에 적힌 미묘한 체크포인트 상호작용도 함께 푼다. 체크포인트 프로토콜은 logpb_checkpoint의 7단계에서 더티 페이지를 DWB로 흘려보내므로, 체크포인트 플러시 도중에 크래시가 나면 절반쯤 끝난 홈 쓰기와 깨끗한 사본을 든 DWB 슬롯이 동시에 남는다. DWB 청소는 체크포인트 프로토콜의 도움 없이도 그것들을 손본다. 두 보호 장치는 서로 독립적이다.
슬롯 라이프사이클, 병렬 플러시 워커 풀, 두 번째 블록 설계는 cubrid-double-write-buffer.md를 참고하라.
2단계가 끝나면, 모든 데이터 볼륨의 모든 페이지는 자기 디스크 LSA 기준으로 옳거나, 손본 뒤의 모습이다. 복구 매니저는 찢어진 쓰기가 남긴 자국을 걱정하지 않고 어떤 페이지든 읽을 수 있다. 그 페이지가 최신 상태인지는 다른 이야기다. 그 부분은 5단계가 손본다.
3단계 — 마지막 체크포인트 찾기
섹션 제목: “3단계 — 마지막 체크포인트 찾기”ARIES 복구는 체크포인트가 그 끝을 잡아 준다. 체크포인트가 없다면 analysis는 지금까지 적힌 모든 WAL 레코드를 처음부터 걸어야 할 것이다. 체크포인트가 있으면 가장 최근 체크포인트의 LSA부터 출발한다.
가장 최근 체크포인트를 가리키는 포인터는 활성 로그 헤더(페이지 id -9)의 log_Gl.hdr.chkpt_lsa(log_storage.hpp) 자리에 있다. 체크포인트 데몬은 LOG_START_CHKPT(이 LSA가 다음 chkpt_lsa가 된다)와 LOG_END_CHKPT(활성 TX 스냅샷, 활성 sysop 스냅샷, redo-LSA 힌트를 들고 있다)를 발행한 뒤 헤더를 새로 갱신하고 fsync까지 내려 그 값을 늘 최신으로 유지한다.
체크포인트는 퍼지(fuzzy)다. logpb_checkpoint 안에서 trantable을 훑는 일은 읽기 모드 CS 아래에서 돌아간다. 그래서 시작과 끝 브래킷 레코드 사이에 다른 트랜잭션들이 계속 진척한다. 스냅샷은 일관되지만 정지 상태는 아니다. 체크포인트 도중 커밋한 TX는 스냅샷 안에서 활성처럼 보이지만, 실제 커밋 레코드는 더 뒤쪽 로그에 박혀 있고 analysis가 그것을 보게 된다. ARIES 논문은 analysis가 브래킷 윈도우 안의 레코드들을 end-CHKPT 이후 레코드와 똑같이 다루기만 하면 이 방식이 옳다는 것을 증명한다. cubrid-checkpoint.md가 그 증명을 풀어 둔다.
log_recovery가 가장 먼저 하는 일은 log_Gl.hdr.chkpt_lsa를 로컬 변수 rcv_lsa에 베껴 두는 것이다.
// log_recovery — src/transaction/log_recovery.c (excerpt)LSA_COPY (&rcv_lsa, &log_Gl.hdr.chkpt_lsa);if (ismedia_crash != false) { /* media recovery: per-volume rcv_lsa may predate chkpt_lsa */ (void) fileio_map_mounted (thread_p, (bool (*)(THREAD_ENTRY *, VOLID, void *)) log_rv_find_checkpoint, &rcv_lsa); }우리 크래시 시나리오에서는 ismedia_crash가 false이므로 rcv_lsa는 헤더 포인터 그대로다. log_rv_find_checkpoint 분기는 백업에서 복원하는 경우를 위한 것이다. 마운트된 모든 볼륨에 걸쳐 볼륨별 rcv-LSA의 최솟값을 잡는다. cubrid-backup-restore.md를 참고하라.
chkpt_lsa가 NULL_LSA라면 두 가지 가운데 하나다. 갓 만든 데이터베이스라면 analysis가 로그 첫머리부터 걷는다(느리지만 옳다). 그게 아니라 자리 잡고 돌던 데이터베이스에서 그 값이 비어 있다면 헤더가 깨진 것이다(치명적 — 백업에서 복원해야 한다).
짚어 둘 만한 안쪽 파이프 하나. end 레코드의 redo-LSA 힌트(LOG_REC_CHKPT.redo_lsa)는 체크포인트 시점의 페이지 버퍼 전체에서 가장 작은 oldest_unflush_lsa다. 오래도록 더티 상태로 남은 페이지가 있다면 이 값이 chkpt_lsa보다 앞쪽일 수도 있다. analysis는 항상 chkpt_lsa부터 시작하지만, redo는 chkpt.redo_lsa부터 시작한다. 로그 위 모양은 cubrid-log-manager.md, analysis가 그 값을 받아 쓰는 부분은 cubrid-recovery-manager.md에 있다.
4단계 — Analysis 패스
섹션 제목: “4단계 — Analysis 패스”analysis는 chkpt_lsa부터 앞으로 걷는 순회다. 데이터 페이지는 한 장도 건드리지 않는다. 결과물은 오직 인메모리 상태다. 다시 짜낸 트랜잭션 테이블(TT), 다시 짜낸 더티 페이지 힌트(start_redo_lsa), 그리고 모든 TX의 분류(커밋됨, abort됨, in-doubt, loser)가 그것이다.
진입점은 log_recovery_analysis다. 레코드 단위 디스패처 log_rv_analysis_record가 LOG_RECTYPE을 두고 분기한다. 재시작 이야기와 얽혀 있는 팔(arm)들은 다음과 같다.
LOG_START_CHKPT/LOG_END_CHKPT. 첫 번째 체크포인트 레코드만 자기 스냅샷을 사용한다(may_use_checkpoint게이트가 그렇게 막는다).LOG_INFO_CHKPT_TRANS행 하나하나가logtb_rv_find_allocate_tran_index를 거쳐 TDES를 자리에 앉힌다. 상태는 강제 변환된다.TRAN_ACTIVE/TRAN_UNACTIVE_ABORTED는TRAN_UNACTIVE_UNILATERALLY_ABORTED로 바뀐다(즉 loser).TRAN_2PC_PREPARED는 그대로 둔다(즉 in-doubt). end 레코드의redo_lsa가start_redo_lsa로 자리 잡는다. analysis 윈도우 안에서 뒤이어 등장하는 체크포인트 레코드들은 그냥 지나간다.LOG_UNDOREDO_DATA/LOG_MVCC_UNDOREDO_DATA. TX의tail_lsa를 늘린다. TDES가 없으면TRAN_ACTIVE로 새로 잡아 둔다.LOG_COMMIT. TT →TRAN_UNACTIVE_COMMITTED.LOG_COMMIT_WITH_POSTPONE. TT →TRAN_UNACTIVE_COMMITTED_WITH_POSTPONE. 5단계의 postpone 재실행이 마무리를 짓는다.LOG_ABORT. TT →TRAN_UNACTIVE_ABORTED.LOG_2PC_PREPARE. TT →TRAN_UNACTIVE_2PC_PREPARED. TX는 in-doubt 상태로 남는다. 코디네이터가 결정을 내려 줄 때까지 재시작을 넘어 살아남는다.cubrid-2pc.md를 참고하라.LOG_SYSOP_END. TDES별 sysop 장부(LOG_RCV_TDES표시)를 갱신한다.LOG_END_OF_LOG. 멈춤. 지금 LSA가end_redo_lsa가 된다.
analysis가 끝나면, 크래시 시점에 엔진이 알고 있던 모든 TX가 다시 짠 trantable 안에 TDES 항목으로 자리 잡는다. 다음 패스가 다룰 수 있도록 분류까지 끝나 있다.
analysis는 데이터 페이지를 단 한 장도 건드리지 않는다. 순수한 로그 순회이며, 로그가 온전하기만 하면 결정론적이다. 복구 매니저의 정확성 경계는 analysis와 redo 사이의 인계다. 여기에 적지 않은 LOG_* 팔까지 포함한 전체 디스패치는 cubrid-recovery-manager.md에 있다.
5단계 — Redo 패스
섹션 제목: “5단계 — Redo 패스”redo는 start_redo_lsa부터 end_redo_lsa까지 앞으로 걷는다. 디스크 위 대상 페이지의 LSA가 낡아 있는 모든 레코드를 다시 적용한다. 의미는 교과서적 ARIES의 역사 다시 돌리기다. redo가 끝나면 모든 페이지는 크래시 직전 그 순간의 정확한 모습으로 돌아온다. 끝내 커밋되지 못한 TX의 변경까지 그대로 들어 있다. 그 청소는 6단계의 몫이다.
레코드 단위 디스패처는 log_rv_redo_record_sync<T>(log_recovery_redo.hpp)다. 로그 레코드 페이로드 타입으로 특수화된 템플릿이다. 동기 경로의 흐름은 다음과 같다.
- 다음 로그 레코드를 읽는다.
- 대상 VPID를 정한다. 여러 페이지에 걸친 레코드는 VPID 한 번에 하나씩 디스패치한다.
- 대상 페이지를 fix한다(이미 DWB가 손봐 두었다).
page.lsa >= record.lsa라면 건너뛴다. 이미 디스크에 있다는 뜻이다.- 그렇지 않으면
RV_fun[record.rcvindex].redofun (rcv)를 호출한다. page.lsa = record.lsa로 맞춰 두고 더티로 표시한 뒤 unfix한다.
흔히 오해받는 특수화가 하나 있다. LOG_REC_COMPENSATE(CLR)의 경우, 디스패처는 RV_fun[]에서 redo 함수가 아니라 undo 함수를 끄집어낸다. CLR의 페이로드는 한 번 롤백된 동작의 undo 이미지이기 때문이다. redo 도중 그것을 앞으로 돌린다는 말은 그 undo를 다시 적용한다는 뜻이다. log_rv_get_fun<LOG_REC_COMPENSATE>의 소스 주석은 그래서 yes, undo다. 여기서 헛디디면 이중 장애 복구 도중에 데이터를 잃는다. 엔진이 데이터를 잃는 흔한 길이 바로 이 자리다. cubrid-recovery-manager.md를 참고하라.
지금의 코드 경로는 redo를 VPID별로 병렬로 돌린다. log_recovery_redo_parallel.{cpp,hpp}가 로그를 차례대로 읽는 리더 스레드를 띄운다. 동기 전용 복구 함수는 그 자리에서 인라인으로 적용되고, 그 외는 cublog::redo_job_impl로 묶여 VPID 해시로 갈라진 워커 풀로 흘려보낸다. VPID로 해싱하면 락 없이도 페이지마다 LSA가 단조롭게 늘어나는 성질이 지켜진다. 서로 다른 페이지끼리는 자유롭게 겹쳐 돌 수 있다.
머리에 새겨 둘 두 가지가 있다.
- redo는 loser의 변경도 함께 다시 돌린다. 6단계가 그것들을 되돌린다. redo가 loser의 변경을 먼저 얹어 두지 않으면 undo가 돌아갈 수 없다. undo 함수는 페이지가 그 동작을 마친 뒤의 모습이라고 가정하고 짜여 있기 때문이다.
page.lsa >= record.lsa가 페이지별 종료 조건이다. 크래시 전에 이미 흘러나간 페이지는 자기의 마지막 로그 레코드와 같거나 그보다 큰 LSA를 들고 있다. redo는 그 페이지를 건너뛴다. 크래시 시점에 더티였던 페이지는 LSA가 뒤처져 있다. redo는 그 페이지가 따라잡힐 때까지 적용한다.
redo가 끝나면 데이터베이스는 크래시 일관성을 갖추게 된다. 그 뒤 log_recovery_finish_all_postpone이 TRAN_UNACTIVE_*_COMMITTED_WITH_POSTPONE 상태의 TT 항목을 훑으면서 log_do_postpone으로 각 postpone을 다시 돌린다. CUBRID의 패스 순서(analysis → redo → postpone → undo)는 교과서 ARIES와 어긋난다. postpone이 loser의 undo보다 먼저 끝나야 하기 때문이다. 그렇지 않으면 postpone이 기대고 있는 상태를 undo가 도로 거두어 갈 수 있다.
6단계 — Undo 패스
섹션 제목: “6단계 — Undo 패스”undo는 loser의 변경을 지운다. 5단계 끝에서 데이터베이스는 크래시 순간 그대로의 모습이므로, loser가 남긴 변경도 그 안에 그대로 보인다. undo는 loser TX마다 자기 로그 사슬을 tail_lsa에서 거꾸로 걸어가며, 보상 동작을 적용하고 보상 로그 레코드(CLR)를 찍는다. 사슬이 head_lsa에 닿을 때까지 그 일을 이어간다.
드라이버는 log_recovery_undo다. analysis가 TRAN_UNACTIVE_UNILATERALLY_ABORTED로 두고 간 loser TX마다, prev_tranlsa를 거꾸로 따라가며 log_rv_undo_record를 부른다.
- 물리적 레코드(
LOG_UNDOREDO_DATA,LOG_MVCC_UNDOREDO_DATA): 대상 페이지를 fix하고,RV_fun[rcvindex].undofun을 호출한 뒤, 방금 되돌린 레코드의 직전을 가리키는undo_nxlsa를 박은LOG_COMPENSATE를 발행한다. - 논리적 레코드(
LOG_SYSOP_END_LOGICAL_UNDO등):cubrid-transaction.md의 system-op undo 기계로 넘긴다. 논리적 undo는 CUBRID가 B+Tree 연산을 다루는 방식이다. 분할을 물리적으로 거꾸로 돌리려면 분할 전 페이지 전체를 로깅해 두어야 한다. 비용이 너무 크다. 논리적 방식은 인덱스 I에서 키 K를 지우라는 의도만 적어 두고, undo 함수가 페이지의 지금 모습 위에 그 역동작을 다시 만들어 낸다.
CLR의 undo_nxlsa가 바로 undo 자체를 다시 redo 가능하게 만드는 장치다. ARIES라는 이름이 나온 뿌리다. undo 도중에 또 한 번 크래시가 나도, 다음 재시작의 redo 패스가 절반쯤 그어져 있던 CLR 사슬을 앞으로 다시 돌린다(CLR은 redo 전용이다). 그 뒤 undo_nxlsa를 따라 이미 되돌려 놓은 레코드는 건너뛰며 undo가 다시 이어진다.
우리 크래시 시나리오에서 T_a와 T_b는 B+Tree 분할 도중에 죽은 loser다. 두 사슬은 그 구문이 일으킨 모든 분할과 병합을 거꾸로 걷는다. T_c(committed-with-postpone)는 5단계의 postpone 하위 단계에서 이미 끝마쳤다. in-doubt 2PC TX는 손대지 않는다. 그대로 TRAN_UNACTIVE_2PC_PREPARED 상태로 코디네이터를 기다린다(cubrid-2pc.md).
undo가 끝나면 데이터베이스는 트랜잭션 일관성을 갖춘 상태가 된다. 복구 매니저는 log_Gl.rcv_phase = LOG_RESTARTED를 박아 두고 돌아간다. 마지막 손짓으로 log_recovery가 (void) logpb_checkpoint (thread_p)를 부른다. 다음 재시작이 복구 작업이 이미 들어 있는 깨끗한 경계에서 출발할 수 있게 하기 위함이다. CLR 모양, 세이브포인트, 부분 undo는 cubrid-recovery-manager.md와 cubrid-transaction.md를 참고하라.
7단계 — Vacuum 따라잡기
섹션 제목: “7단계 — Vacuum 따라잡기”CUBRID는 MVCC다. 삭제된 행은 그 자리에서 사라지는 것이 아니다. MVCCID-X에 의해 삭제 표시가 박힌 새 버전이 쓰이고, 옛 버전은 X를 여전히 볼 수 있는 스냅샷에는 그대로 보인다. X가 전역 oldest visible MVCCID 아래로 떨어지는 순간, 옛 버전은 죽은 셈이고 거두어 들일 수 있다. vacuum은 그 일을 돌리는 백그라운드 서브시스템이다.
vacuum이 어디까지 갔는지를 내구적으로 가리키는 손잡이는 로그 헤더의 LOG_HEADER.mvcc_op_log_lsa다. chkpt_lsa 옆자리에 앉아 있다. vacuum 마스터는 재시작 때 그 값을 읽고, 다시 짜낸 TT에서 oldest visible MVCCID를 계산한 다음, mvcc_op_log_lsa부터 지금의 로그 꼬리까지 구간을 블록 단위 회수 작업을 큐에 넣는다.
vacuum은 재시작의 임계 경로 위에 있지 않다. 백그라운드 데몬으로 도는 일이고, LOG_RESTARTED가 박힌 뒤에 비로소 굴러간다. 클라이언트는 vacuum이 다 따라잡기 전이라도 들어와서 일을 볼 수 있다. 그 대가는 heap 공간을 차지하고 있는 죽은 버전들과, 스캔 지연이 살짝 늘어나는 정도다. vacuum이 끝날 때까지 클라이언트 수락을 막아 두면 바쁜 데이터베이스에서 몇 분이 흐를 수도 있고, 그것은 운영 입장에서 받아들일 수 없다.
상호작용 두 가지를 짚어 둔다.
- MVCCID 발급은 게으르다. analysis가 MVCCID를 미리 따 두지 않는다.
LOG_MVCC_*레코드 하나하나가 자기를 적은 TX의 MVCCID를 들고 다니므로, TDES별 MVCCID 상태는 analysis가 그 필드들을 보고 다시 짜낸다. 그래서LOG_INFO_CHKPT_TRANS에 MVCCID 필드가 따로 없는 것이다.cubrid-mvcc.md를 참고하라. - vacuum 워커들은 엔진의
cubthread::manager풀을 함께 쓴다. 마스터가 첫 배치를 던지기 전까지는 살아 있되 노는 상태로 대기한다.
마스터/워커 분리, 블록 작업 스케줄링, OID heap 순회, 인덱스 정리는 cubrid-vacuum.md를 참고하라.
8단계 — 복제 재개
섹션 제목: “8단계 — 복제 재개”홀로 도는 서버라면(HA가 없다면) 이 단계는 그대로 빈 단계다.
HA가 잡혀 있는 경우, 부팅의 HA 초기화 단계가 역할에 따라 갈라진다.
- 슬레이브.
applylogdb는 apply가 성공할 때마다 진행 LSA를 디스크에 박아 둔다(db_ha_apply_info또는 그에 준하는 상태 파일에). 재시작 때 이 LSA를 읽고, 마스터의 WAL 스트림(또는 아카이브)을 열어 거기서부터 다시 돌린다. 박아 둔 LSA부터 마스터 꼬리까지의 레코드들이 순서대로 다시 적용되고, 그 뒤 레코드들은 마스터가 만들어 내는 대로 차례차례 흘러 들어온다. - 마스터. 슬레이브들의
copylogdb피어가 다시 붙는다. 각 피어가 자기가 마지막으로 받은 LSA를 마스터에게 알려 주면, 마스터는 그 자리에서부터 스트리밍을 재개한다. 너무 멀리 뒤처진 슬레이브(자기의 재개 LSA가 이미 마스터의 아카이브 정리 범위 안으로 들어가 버린 경우)는 백업에서 새로 부트스트랩해야 한다.cubrid-backup-restore.md를 참고하라.
역할은 cub_master와 그 옆의 하트비트 데몬이 UDP 하트비트로 정한다(cubrid-heartbeat.md). 결정된 역할은 데이터베이스 헤더 안에 박혀 있어, 서버는 재시작 시점에도 자기 자신과 어긋나지 않는다.
복제 복구는 크래시 복구와 다른 관심사이지만 LSA 손잡이를 함께 쓴다. 크래시 복구(1~6단계)가 이 서버 자신의 로컬 상태를 자기 로그 꼬리까지 끌어올리고 나면, 복제 따라잡기가 그 위에서 마스터를 기준으로 슬레이브 자리를 다시 맞춘다. apply/copy 데몬 구조, 충돌 해결, 와이어 위 포맷은 cubrid-ha-replication.md를 참고하라.
9단계 — 연결 받기
섹션 제목: “9단계 — 연결 받기”재시작 파이프라인이 마지막으로 하는 일은 네트워크 리스너를 띄우는 것이다. 이 시점까지는 들어오는 모든 TCP 패킷이 OS 손에서 거절되고 있었다. listen 상태에 있는 소켓이 없었기 때문이다.
리스너는 cubrid-network-protocol.md에 있는 css_init이 띄운다. boot_restart_server가 돌아온 뒤 net_server_start 안에서 그것을 부른다. css_init은 설정된 포트에 소켓을 바인드하고, 백로그를 두고 listen(2)을 걸고, 루프가 accept(2) → 워커 디스패치인 리스너 스레드 하나를 띄운다.
listen(2)이 걸리는 시점에 boot_Server_status는 이미 BOOT_SERVER_UP이다. 받아들인 첫 번째 연결은 등록 핸드셰이크 xboot_register_client(boot_sr.c)를 돈다.
- 클라이언트가 들고 온 자격증명을 카탈로그에 비추어 검증한다.
- TRANID를 발급하고 TDES를 자리에 앉힌다.
- 페이지 크기, 로그 페이지 크기, 루트 클래스 OID, 디스크 호환 번호, HA 상태, 문자셋, 언어, 세션 키를 묶은
BOOT_SERVER_CREDENTIAL(boot.h)을 돌려준다.
클라이언트 쪽에서는 boot_restart_client(boot_cl.c)가 짝을 이룬다. CAS 프로세스가 자기 초기화 도중에 그것을 부른다. 자격증명이 풀어지고 나면, CAS는 진짜 SQL을 던질 수 있다.
연결은 cub_broker로 들어온다. 논리적 서비스 하나에 별도 프로세스 하나가 붙고, 그 프로세스가 CAS 워커 풀을 안고 있는 구조다. cub_server가 다시 살아나면, 브로커마다 그 사실을 알아챈 뒤 자기 CAS 워커들을 등록 흐름에 태워 다시 붙인다. 풀 관리, sticky 세션 정책, 브로커-서버 페일오버는 cubrid-broker.md에 있다.
조용히 깔려 있는 불변식 하나. 리스너가 열리는 그 순간에 데이터베이스는 이미 끝까지 복구되어 있고, 트랜잭션 일관성을 갖추고 있다. 첫 번째 SELECT 1은 일관된 세계를 본다. 그것이 엔진이 응용에 거는 약속이다. RPC 패킷 포맷, keep-alive, 연결 라이프사이클은 cubrid-network-protocol.md를 참고하라.
다이어그램 — 전체 재시작 파이프라인
섹션 제목: “다이어그램 — 전체 재시작 파이프라인”flowchart TB
subgraph CRASH["크래시 시점 디스크 상태"]
direction TB
HV["홈 볼륨 페이지\n(일부 찢어짐)"]
DV["DWB 볼륨\n(흘러가던 페이지의 깨끗한 사본)"]
LV["활성 로그\n마지막 플러시까지의 레코드"]
LH["로그 헤더\nchkpt_lsa, mvcc_op_log_lsa"]
HA["HA 진행 파일\n마지막으로 적용된 LSA"]
end
subgraph BOOT["1단계 — 프로세스 부팅 (boot_sr.c)"]
direction TB
B1["main → net_server_start"]
B2["boot_restart_server: 위상 정렬 순서로 서브시스템 초기화"]
B3["디스크 매니저: 볼륨 열기"]
B4["페이지 버퍼: 캐시 할당"]
B5["log_initialize: 헤더 읽기, is_shutdown=false 확인"]
end
subgraph DWBPHASE["2단계 — DWB 복구 (dwb_load_and_recover_pages)"]
direction TB
D1["DWB 슬롯 훑기"]
D2["슬롯마다 홈 페이지 읽기"]
D3{"홈이 멀쩡한가?"}
D4["건너뛴다"]
D5["DWB 슬롯을 홈 위에 덮어쓰기, fsync"]
D6["DWB 깨끗 표시"]
end
subgraph CHKPT["3단계 — 체크포인트 찾기 (log_recovery)"]
direction TB
C1["rcv_lsa = log_Gl.hdr.chkpt_lsa"]
C2["미디어 크래시인 경우: 볼륨별 rcv_lsa의 최솟값 잡기"]
end
subgraph ANALYSIS["4단계 — Analysis 패스 (log_recovery_analysis)"]
direction TB
A1["rcv_lsa부터 로그 앞으로 걷기"]
A2["log_rv_analysis_record: LOG_RECTYPE으로 분기"]
A3["LOG_END_CHKPT 스냅샷으로 TT 자리 잡기"]
A4["레코드별 TT/DPT 갱신"]
A5["TX 분류:\n커밋됨 | abort됨 | loser | in-doubt"]
A6["start_redo_lsa, end_redo_lsa"]
end
subgraph REDO["5단계 — Redo 패스 (log_recovery_redo)"]
direction TB
R1["start_redo_lsa부터 로그 앞으로 걷기"]
R2["log_rv_redo_record_sync<T>: 페이로드 타입으로 디스패치"]
R3["페이지 fix; page.lsa < record.lsa이면 RV_fun[idx].redofun 적용"]
R4["redo_parallel을 거쳐 VPID 해시별 병렬 실행"]
R5["log_recovery_finish_all_postpone:\nCOMMIT_WITH_POSTPONE 다시 돌리기"]
end
subgraph UNDO["6단계 — Undo 패스 (log_recovery_undo)"]
direction TB
U1["loser TX마다:"]
U2["tail_lsa부터 prev_tranlsa 거꾸로 걷기"]
U3["log_rv_undo_record:\nRV_fun[idx].undofun + LOG_COMPENSATE 발행"]
U4["CLR.undo_nxlsa = 직전 레코드"]
U5["TX → TRAN_UNACTIVE_UNILATERALLY_ABORTED"]
U6["마지막 logpb_checkpoint() — 깨끗한 경계"]
end
subgraph VACUUM["7단계 — Vacuum 따라잡기 (백그라운드)"]
direction TB
V1["vacuum 마스터가 mvcc_op_log_lsa 읽기"]
V2["oldest_visible_MVCCID 계산"]
V3["블록 회수 작업 큐잉"]
end
subgraph HAPHASE["8단계 — HA 따라잡기 (백그라운드)"]
direction TB
H1{"역할?"}
H2["슬레이브: applylogdb가 마지막 적용 LSA부터 재개"]
H3["마스터: copylogdb 피어 재연결, 피어 LSA부터 스트리밍"]
end
subgraph LISTEN["9단계 — 연결 받기 (css_init)"]
direction TB
L1["바인드 / listen / 리스너 스레드 띄우기"]
L2["boot_Server_status = BOOT_SERVER_UP"]
L3["브로커 → CAS 재연결 → xboot_register_client"]
L4["BOOT_SERVER_CREDENTIAL 클라이언트로 반환"]
L5["첫 SELECT 1"]
end
CRASH --> BOOT
BOOT --> DWBPHASE
HV -.->|read| D2
DV -.->|read| D1
D1 --> D2 --> D3
D3 -- yes --> D4
D3 -- no --> D5
D4 --> D6
D5 --> D6
DWBPHASE --> CHKPT
LH -.->|read| C1
C1 --> C2
CHKPT --> ANALYSIS
LV -.->|walk| A1
A1 --> A2 --> A3 --> A4 --> A5 --> A6
ANALYSIS --> REDO
A6 --> R1
R1 --> R2 --> R3 --> R4 --> R5
REDO --> UNDO
U1 --> U2 --> U3 --> U4 --> U5 --> U6
UNDO --> VACUUM
UNDO --> HAPHASE
UNDO --> LISTEN
V1 --> V2 --> V3
HA -.->|read| H2
H1 -- slave --> H2
H1 -- master --> H3
L1 --> L2 --> L3 --> L4 --> L5
이 다이어그램은 재시작 파이프라인의 모든 인계를 한 장으로 압축한다. 이 정도 해상도에서도 세 가지 성질이 눈에 들어온다.
- 2단계는 4단계보다 먼저여야 한다. ARIES analysis는 페이지 버퍼를 거쳐 페이지를 읽는다. 그 페이지가 찢어져 있으면 analysis 순회가 망가진 페이지 헤더에서 그대로 깨진다. 이를 막아 주는 유일한 보호 장치가 DWB 복구다.
- 7단계와 8단계는 6단계에서 갈라져 나온다. 둘은 9단계를 막지 않는, 나란히 도는 백그라운드 작업이다. 클라이언트는 vacuum이 죽은 버전을 다 거두기 전에도, HA가 다 따라잡기 전에도, 트랜잭션 일관성을 갖춘 데이터베이스를 만난다.
- 6단계에서 9단계로 가는 인계는 곧장 이어진다. 그 사이에 따로 준비할 일이 없다.
LOG_RESTARTED가 박히고 복구 직후 체크포인트가 내구적이 된 순간, 리스너가 열린다.
다루지 않은 내용
섹션 제목: “다루지 않은 내용”이 문서는 파노라마이지 백과사전이 아니다. 더 넓은 복구 이야기에서 갈래를 친 부분들은 의도적으로 각자의 문서에 두었다.
- 백업에서 복구(PITR). 우리 시나리오는 디스크 위 상태가 살릴 만하다고 전제했다. 페이지가 찢어지긴 했지만 볼륨은 무사한 경우다. 그 전제가 깨진다면(디스크 장애, 파일시스템 깨짐, 실수로 친
rm) 복구 경로가 달라진다. 가장 최근 백업으로 되돌린 다음, 아카이브된 WAL을 원하는 시점까지 앞으로 다시 돌린다. 진입점은boot_restart_from_backup이고, WAL 다시 돌리기는 우리가 본 redo 디스패처를 그대로 거치되 닻이chkpt_lsa가 아니라 백업 LSA가 된다.cubrid-backup-restore.md를 참고하라. - 미디어 복구(단일 볼륨 복원). 위의 부분 집합이다. 한 볼륨만 망가지고 나머지는 멀쩡한 경우, 운영자가 그 볼륨만 되돌리고 엔진이 아카이브 WAL을 따라 그 볼륨을 앞으로 굴린다.
log_recovery의log_rv_find_checkpoint분기가 이때 필요한 볼륨별 LSA 순회를 맡는다.cubrid-backup-restore.md를 참고하라. - 병렬 redo 안쪽. VPID별 해싱과 워커 풀은 위에서 짚었지만, 작업 큐 모양, 역압 정책, 버퍼 매니저와의 페이지 fix 조정, 페이지 서버 복제 경로와 같이 쓰는 성능 카운터 발판은 모두
cubrid-recovery-manager.md의 병렬 redo 절에 있다. - 서버 모드 대 standalone 모드 차이.
cub_server(서버 모드)와csql -S/loaddb(standalone 모드)는 부팅 경로에서 어느 서브시스템이 올라오는지가 다르다. standalone 도구들은 복구 매니저는 함께 쓰지만, 네트워크 리스너와 브로커 핸드셰이크는 거치지 않는다.cubrid-boot.md와cubrid-sa-cs-runtime.md를 참고하라. - 재시작 뒤 in-doubt 2PC 마무리. 4단계는 prepare까지 간 2PC TX를
TRAN_UNACTIVE_2PC_PREPARED상태로 두고 간다. 5단계와 6단계는 그것을 건드리지 않는다. 9단계가 끝나고 코디네이터의 결정이 네트워크로 도착하면, in-doubt TX는xtran_2pc_*을 거쳐 커밋되거나 abort된다. 진입점은 고아 TX 타이머와LOG_RECOVERY_FINISH_2PC_PHASEenum이다. 자세한 이야기는cubrid-2pc.md에 있다. - 복구 중 TDE로 암호화된 로그 페이지. 데이터베이스가 투명 데이터 암호화를 쓴다면, 로그 페이지는 디스크 위에서는 암호화된 채로 누워 있고, 복구 매니저가 파싱하기 전에 풀어 놓아야 한다. 풀이는 로그 리더 안쪽에서 일어난다. analysis/redo 디스패처가 레코드를 보기 전에 끝난다.
cubrid-tde.md를 참고하라. - 인증, 세션, 권한. 9단계가 소켓을 연다. 그다음 들어온 연결이 가장 먼저 하는 일이 인증이다. 인증 상태, 역할 결정, 세션별 자격은
cubrid-authentication.md와cubrid-server-session.md가 다룬다. - 카탈로그 다시 채우기와 클래스 캐시 다시 짜기. 부트 모듈의 늦은 단계가 복구가 끝난 뒤에 카탈로그를 다시 연다. locator, 클래스 객체 캐시, 통계 캐시는 첫 참조가 들어오는 그 시점에 게으르게 다시 채워진다.
cubrid-catalog-manager.md,cubrid-class-object.md,cubrid-locator.md를 참고하라.
이 지식 베이스 안의 복구 파이프라인 세부 문서
섹션 제목: “이 지식 베이스 안의 복구 파이프라인 세부 문서”cubrid-boot.md— 위상 정렬된 서브시스템 초기화 순서, create-vs-restart 디스패치, 부팅 상태 플래그, 클라이언트 연결을 위한xboot_register_client.cubrid-double-write-buffer.md— 찢어진 페이지 보호. 슬롯 라이프사이클,dwb_load_and_recover_pages, 병렬 플러시 워커 풀, 두 번째 블록 설계.cubrid-checkpoint.md— 퍼지 체크포인트 프로토콜,LOG_START_CHKPT/LOG_END_CHKPT브래킷, redo-LSA 힌트,chkpt_lsa헤더 필드.cubrid-log-manager.md— WAL 프레임워크. 로그 레코드 모양, prior 리스트 규율, 로그 페이지 레이아웃, 로그 헤더.cubrid-recovery-manager.md— 세 패스 ARIES 드라이버.log_recovery, 레코드 단위 디스패처들,RV_fun[], 템플릿화된 redo, VPID별 병렬 redo, postpone 패스, CLR 계약.cubrid-transaction.md— TDES 모양, TX별 로그 사슬, 시스템 ops, 세이브포인트, 논리적 undo.cubrid-2pc.md—LOG_2PC_PREPARE, in-doubt 복구,LOG_RECOVERY_FINISH_2PC_PHASEenum, 코디네이터 주도 마무리 경로.cubrid-vacuum.md— vacuum 마스터/워커 분리, MVCCID 워터마크, 블록 작업 스케줄링,mvcc_op_log_lsa복구.cubrid-mvcc.md— MVCC 발급, 스냅샷 다시 짜기, 복구 중 가시성 규칙.cubrid-ha-replication.md—applylogdb,copylogdb, 슬레이브/마스터 역할, 마지막 적용 LSA를 디스크에 박아 두는 방식.cubrid-heartbeat.md— UDP 하트비트, 역할 협상,cub_master중재.cubrid-network-protocol.md—css_init, 리스너 루프, 와이어 위 RPC 포맷.cubrid-broker.md—cub_broker, CAS 풀, 재연결 정책, sticky 세션.cubrid-backup-restore.md— 갈라진 복구 경로. PITR, 미디어 복구, 단일 볼륨 복원.cubrid-tde.md— 복구 중 암호화된 로그 풀이.cubrid-page-buffer-manager.md— redo 패스가 기대고 있는 페이지 fix와 더티 추적.cubrid-disk-manager.md— 볼륨 열기와 미디어 복구가 쓰는 볼륨별 디스크 헤더.
들여다본 코드 경로
섹션 제목: “들여다본 코드 경로”src/executables/server.c—cub_server의main().src/communication/network_sr.c—net_server_start,css_init.src/transaction/boot_sr.c—boot_restart_server,xboot_register_client,xboot_initialize_server.src/transaction/log_manager.c—log_initialize, 복구로 흘려보내는is_shutdown게이트.src/transaction/log_recovery.c—log_recovery,log_recovery_analysis,log_recovery_redo,log_recovery_finish_all_postpone,log_recovery_undo,log_rv_find_checkpoint.src/transaction/log_recovery_redo.{cpp,hpp}— 템플릿화된 레코드 단위 redo 디스패처와LOG_REC_COMPENSATE특수화.src/transaction/log_recovery_redo_parallel.{cpp,hpp}— VPID별 워커 풀.src/transaction/recovery.h—RV_fun[],LOG_RCVINDEX,LOG_RCV.src/storage/double_write_buffer.cpp—dwb_load_and_recover_pages.src/storage/page_buffer.c—pgbuf_flush_checkpoint, fix/unfix 규율.src/transaction/log_page_buffer.c—logpb_checkpoint,logpb_flush_header.src/connection/server_support.c— 리스너 스레드, accept 루프.
이론적 참고
섹션 제목: “이론적 참고”- Mohan, Haderle, Lindsay, Pirahesh, Schwarz, ARIES, ACM TODS 17.1, 1992 — CUBRID가 구현한 표준 알고리즘.
- Petrov, Database Internals, 2019, 5장 §Recovery와 §ARIES.
- Bernstein, Hadzilacos, Goodman, Concurrency Control and Recovery in Database Systems, 1987 — 체크포인트, consistent와 fuzzy의 갈래.
- Silberschatz, Korth, Sudarshan, Database System Concepts, 7판, 19장.