"redo log로 크래시 복구가 되는데, InnoDB는 왜 더블라이트(doublewrite)라는 걸 또 두고 같은 페이지를 두 번 쓰는가? 그럼 쓰기 I/O가 두 배 아닌가?" — 이 의문이 오래 남아 있었다. 막연히 "더블라이트 = 쓰기 두 배 = 성능 반토막"이라고만 알고 있었는데, 그렇다면 왜 기본값으로 켜져 있는지 인과가 설명되지 않았다.
결론부터 말하면, 더블라이트는 redo log가 못 막는 종류의 손상을 막는다. 둘은 중복이 아니라 서로 직교하는 계층이다. 이 글에서는 (1) redo log가 못 고치는 "찢어진 페이지(torn page)"란 무엇인지, (2) 두 번 쓰는 순서가 왜 안전을 만드는지, (3) 그런데도 왜 성능이 반토막 나지 않는지를 순서대로 따라가 본다.
InnoDB의 논리적 페이지는 기본 16KB다. 그런데 그 16KB를 디스크로 내려보내는 하위 계층(OS 페이지 캐시, 파일시스템, 블록 디바이스)의 원자적 쓰기 단위는 보통 4KB, 더 아래로 가면 512B 섹터다. 즉 16KB 페이지 하나를 flush하면 실제로는 4KB짜리 물리 쓰기 여러 개로 쪼개져 나간다.
이 여러 물리 쓰기 도중에 전원이 나가거나 커널이 패닉하면, 페이지의 앞 8KB는 새 내용, 뒤 8KB는 옛 내용인 반쯤 쓰인 페이지가 디스크에 남을 수 있다. 이것이 torn page(partial page write)다. InnoDB의 버그가 아니라, 페이지 크기 > 디바이스 원자 쓰기 단위라는 구조에서 필연적으로 생기는 현상이다.
그럼 redo log로 복구하면 되지 않나? 여기서 막힌다. InnoDB의 redo는 순수 물리 로그가 아니라 physiological(페이지 단위는 물리, 페이지 내부는 논리) 로그다. 복구할 때 redo 레코드는 "이 페이지의 이 오프셋에 이 연산을 적용하라"는 식으로, 디스크에 있는 기존 페이지를 읽어와 그 위에 delta를 재적용한다.
redo 적용은 그 아래에 깔릴 base 페이지가 내부적으로 온전하다는 것을 전제한다. 그런데 torn page는 헤더 절반과 데이터 절반이 서로 다른 버전이라, 얹을 base 자체가 깨져 있다.
레코드 디렉터리·슬롯·다음 레코드 포인터 같은 페이지 내부 구조가 성해야 그 위에 논리 연산을 적용할 수 있는데, torn page는 그 구조가 무너진 상태다. redo log는 "무엇을 바꿨는가"만 알지, "온전한 페이지 전체 이미지"를 갖고 있지 않다. 그래서 온전한 페이지 사본을 어딘가에 따로 떠 둬야 한다. 그게 더블라이트다.
더티 페이지를 flush할 때 InnoDB는 데이터 파일의 최종 위치(흩어진 자리)에 곧바로 쓰지 않는다. 순서가 핵심이다.
flush 대상 더티 페이지 배치 (최대 수십~128개)
│
▼
① 더블라이트 영역(연속 공간)에 배치 전체를 sequential write
│
▼
② fsync ← 여기까지 끝나야 ③ 시작 (배리어)
│
▼
③ 각 페이지를 데이터 파일의 진짜 최종 위치(random)에 write
│
▼
④ (주기적으로) 최종 위치 fsync
이 순서 덕분에 크래시가 어느 지점에서 나든 둘 중 하나가 항상 보장된다.
즉 "온전한 원본"과 "온전한 사본"이 동시에 깨지는 창(window)이 없다. 이 불변식이 성립하는 이유가 바로 ②의 fsync 배리어다. fsync가 빠지면 ①에서 쓴 사본이 실제로 디스크에 도달했다는 보장이 사라져, ③에서 최종 위치가 찢어졌을 때 복원할 온전한 이미지가 없어진다.
크래시 시점별로 상태를 표로 정리하면 이렇다. 어느 행에서도 "복원에 쓸 온전한 이미지"가 최소 하나는 존재한다.
| 크래시 시점 | 더블라이트 영역 | 최종 위치 | 복구 동작 |
|---|---|---|---|
| ① 쓰기 중 | torn(무시 가능) | 옛 온전 버전 | 최종 그대로 + redo 재적용 |
| ② 후, ③ 전 | 온전 사본 존재 | 옛 온전 버전 | 아직 안 씀, 그대로 |
| ③ 쓰기 중 | 온전 사본 존재 | torn | dblwr 사본으로 복원 후 redo |
| ③ 완료 후 | (다음 배치가 덮어씀) | 새 온전 버전 | 정상 |
복구할 때 InnoDB는 각 페이지의 체크섬과 LSN 정합성을 검사한다. 전통적으로 페이지 헤더의 FIL_PAGE_LSN과 페이지 트레일러(끝 8바이트에 담긴 LSN 하위 32비트)가 일치해야 하고, 페이지 체크섬도 맞아야 한다. 앞뒤 LSN이 어긋나거나 체크섬이 불일치하면 = 쓰기가 중간에 끊긴 torn page로 판정한다.
torn page가 감지되면 더블라이트 영역을 훑어 해당 page id의 사본을 찾고, 그 사본의 체크섬이 정상이면 최종 위치에 복원한다. 그런 다음에야 redo를 정상 재적용한다. 더블라이트는 "복구 진입점에서 base 페이지를 온전하게 만들어 주는 전처리 단계"인 셈이다.
복구 초입의 흐름을 의사 코드로 옮기면 이렇다.
for each page P referenced by redo before applying:
if checksum(P_on_disk) valid and head_LSN == tail_LSN:
base = P_on_disk # 온전 → 그대로 사용
else: # torn 감지
copy = find_in_doublewrite(page_id(P))
if copy and checksum(copy) valid:
write copy -> P_on_disk # 사본으로 복원
base = copy
# 사본도 깨졌다면 그건 ① 단계 크래시 → 최종 위치가 원래 온전
apply redo records on top of base
데이터 양은 대략 두 배가 맞다(같은 페이지를 dblwr에 한 번, 최종 위치에 한 번). 하지만 체감 비용은 2배에 한참 못 미친다. 두 가지 이유가 있다.
즉 비용을 지배하는 것은 원래도 있던 random 최종 flush이고, 더블라이트가 추가하는 sequential + 배치 fsync 분은 상대적으로 저렴하다. 실제 오버헤드는 스토리지에 따라 대개 한 자릿수 %대로 알려져 있다(환경마다 편차가 크므로 수치는 참고용이다).
레이아웃도 이 "저렴함"을 뒷받침한다. 8.0.20 이전에는 시스템 테이블스페이스 안에 두 블록 × 각 64페이지 = 128페이지(16KB 기준 약 2MB) 형태였고, 8.0.20부터는 #ib_16384_0.dblwr 같은 별도 파일로 분리되어 innodb_doublewrite_files, innodb_doublewrite_pages로 조정할 수 있다(공식 문서 기준). 크기가 이렇게 작아도 되는 이유는, 더블라이트가 로그처럼 누적 보관하는 게 아니라 매 배치마다 덮어쓰는 스크래치 공간이기 때문이다. 복구에 필요한 건 "가장 최근에 안전하게 떠 둔 배치 하나"뿐이다.
디바이스가 16KB 원자적 쓰기를 하드웨어로 보장하면(일부 NVMe·Fusion-io, atomic write를 지원하는 파일시스템) torn page가 원천적으로 생기지 않으므로 innodb_doublewrite=OFF로 꺼서 쓰기 증폭을 없앨 수 있다. 뒤집어 말하면, 더블라이트의 본질은 "디바이스가 페이지 단위 원자성을 안 주니 소프트웨어로 흉내 낸다"는 것이다. 하드웨어 원자성 보장을 확신할 수 없다면 켜 두는 게 기본값인 이유가 여기 있다.
더 파고들 만한 주제로는, PostgreSQL이 같은 torn page 문제를 푸는 방식인 full_page_writes(체크포인트 후 첫 수정 시 WAL에 페이지 전체 이미지를 기록)와의 설계 비교가 있다. 손상 대비 이미지를 로그 안에 넣느냐(PG) vs 별도 스크래치 영역에 두느냐(InnoDB)의 트레이드오프가 흥미롭다.
storage/innobase/buf/buf0dblwr.cc (더블라이트 배치 쓰기·복구 경로).dblwr) 분리