redo log가 있는데 왜 페이지를 두 번 쓰는가 — InnoDB 더블라이트 버퍼

seonwoo_jung·2일 전

1. 왜 이 글을 쓰게 됐나

"redo log로 크래시 복구가 되는데, InnoDB는 왜 더블라이트(doublewrite)라는 걸 또 두고 같은 페이지를 두 번 쓰는가? 그럼 쓰기 I/O가 두 배 아닌가?" — 이 의문이 오래 남아 있었다. 막연히 "더블라이트 = 쓰기 두 배 = 성능 반토막"이라고만 알고 있었는데, 그렇다면 왜 기본값으로 켜져 있는지 인과가 설명되지 않았다.

결론부터 말하면, 더블라이트는 redo log가 못 막는 종류의 손상을 막는다. 둘은 중복이 아니라 서로 직교하는 계층이다. 이 글에서는 (1) redo log가 못 고치는 "찢어진 페이지(torn page)"란 무엇인지, (2) 두 번 쓰는 순서가 왜 안전을 만드는지, (3) 그런데도 왜 성능이 반토막 나지 않는지를 순서대로 따라가 본다.

2. torn page — redo log가 못 고치는 손상

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는 "무엇을 바꿨는가"만 알지, "온전한 페이지 전체 이미지"를 갖고 있지 않다. 그래서 온전한 페이지 사본을 어딘가에 따로 떠 둬야 한다. 그게 더블라이트다.

3. 왜 "두 번" 쓰면 안전해지는가 — 순서가 핵심

더티 페이지를 flush할 때 InnoDB는 데이터 파일의 최종 위치(흩어진 자리)에 곧바로 쓰지 않는다. 순서가 핵심이다.

flush 대상 더티 페이지 배치 (최대 수십~128개)
        │
        ▼
① 더블라이트 영역(연속 공간)에 배치 전체를 sequential write
        │
        ▼
② fsync   ← 여기까지 끝나야 ③ 시작 (배리어)
        │
        ▼
③ 각 페이지를 데이터 파일의 진짜 최종 위치(random)에 write
        │
        ▼
④ (주기적으로) 최종 위치 fsync

이 순서 덕분에 크래시가 어느 지점에서 나든 둘 중 하나가 항상 보장된다.

  • ③ 도중에 최종 위치 페이지가 찢어졌다 → 더블라이트 영역엔 ①에서 fsync까지 끝낸 온전한 사본이 있다.
  • ① 도중에 더블라이트 영역이 찢어졌다 → 최종 위치의 페이지는 아직 손도 안 댄 옛 온전한 버전이다.

즉 "온전한 원본"과 "온전한 사본"이 동시에 깨지는 창(window)이 없다. 이 불변식이 성립하는 이유가 바로 ②의 fsync 배리어다. fsync가 빠지면 ①에서 쓴 사본이 실제로 디스크에 도달했다는 보장이 사라져, ③에서 최종 위치가 찢어졌을 때 복원할 온전한 이미지가 없어진다.

크래시 시점별로 상태를 표로 정리하면 이렇다. 어느 행에서도 "복원에 쓸 온전한 이미지"가 최소 하나는 존재한다.

크래시 시점더블라이트 영역최종 위치복구 동작
① 쓰기 중torn(무시 가능)옛 온전 버전최종 그대로 + redo 재적용
② 후, ③ 전온전 사본 존재옛 온전 버전아직 안 씀, 그대로
③ 쓰기 중온전 사본 존재torndblwr 사본으로 복원 후 redo
③ 완료 후(다음 배치가 덮어씀)새 온전 버전정상

4. 복구 시 torn page를 어떻게 알아채나

복구할 때 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

5. 왜 I/O가 두 배가 아닌가 — 비용 구조

데이터 은 대략 두 배가 맞다(같은 페이지를 dblwr에 한 번, 최종 위치에 한 번). 하지만 체감 비용은 2배에 한참 못 미친다. 두 가지 이유가 있다.

  • 더블라이트 쓰기는 연속 공간에 대한 sequential write다. 반면 최종 flush는 데이터 파일 곳곳으로 흩어지는 random write다. 스토리지 입장에서 sequential 쪽은 random에 비해 훨씬 싸다.
  • 한 배치(최대 수십~128개 페이지)를 모아 single fsync 뒤로 보낸다. fsync 횟수가 페이지 수만큼 늘어나지 않는다.

즉 비용을 지배하는 것은 원래도 있던 random 최종 flush이고, 더블라이트가 추가하는 sequential + 배치 fsync 분은 상대적으로 저렴하다. 실제 오버헤드는 스토리지에 따라 대개 한 자릿수 %대로 알려져 있다(환경마다 편차가 크므로 수치는 참고용이다).

레이아웃도 이 "저렴함"을 뒷받침한다. 8.0.20 이전에는 시스템 테이블스페이스 안에 두 블록 × 각 64페이지 = 128페이지(16KB 기준 약 2MB) 형태였고, 8.0.20부터는 #ib_16384_0.dblwr 같은 별도 파일로 분리되어 innodb_doublewrite_files, innodb_doublewrite_pages로 조정할 수 있다(공식 문서 기준). 크기가 이렇게 작아도 되는 이유는, 더블라이트가 로그처럼 누적 보관하는 게 아니라 매 배치마다 덮어쓰는 스크래치 공간이기 때문이다. 복구에 필요한 건 "가장 최근에 안전하게 떠 둔 배치 하나"뿐이다.

6. 그럼 언제 꺼도 되나

디바이스가 16KB 원자적 쓰기를 하드웨어로 보장하면(일부 NVMe·Fusion-io, atomic write를 지원하는 파일시스템) torn page가 원천적으로 생기지 않으므로 innodb_doublewrite=OFF로 꺼서 쓰기 증폭을 없앨 수 있다. 뒤집어 말하면, 더블라이트의 본질은 "디바이스가 페이지 단위 원자성을 안 주니 소프트웨어로 흉내 낸다"는 것이다. 하드웨어 원자성 보장을 확신할 수 없다면 켜 두는 게 기본값인 이유가 여기 있다.

7. 정리

  • redo log와 더블라이트는 중복이 아니라 직교하는 계층이다. redo는 커밋됐지만 아직 flush 안 된 논리적 변경을 되살리고, 더블라이트는 페이지의 물리적 온전성을 지킨다. redo는 온전한 base 페이지를 전제하므로, base가 torn이면 더블라이트가 먼저 그 base를 복구해 주어야 redo가 비로소 의미를 가진다.
  • 안전을 만드는 것은 "두 번 쓴다"가 아니라 "dblwr → fsync 배리어 → 최종"이라는 순서다. 이 순서가 "온전한 원본 아니면 온전한 사본이 항상 하나는 남는다"는 불변식을 보장한다.
  • 데이터 양은 2배지만 sequential + 배치 single-fsync 구조라, 지배적인 random flush 비용에 비해 추가분은 작다.

더 파고들 만한 주제로는, PostgreSQL이 같은 torn page 문제를 푸는 방식인 full_page_writes(체크포인트 후 첫 수정 시 WAL에 페이지 전체 이미지를 기록)와의 설계 비교가 있다. 손상 대비 이미지를 로그 안에 넣느냐(PG) vs 별도 스크래치 영역에 두느냐(InnoDB)의 트레이드오프가 흥미롭다.

참고 자료

  • MySQL 8.0 Reference Manual §17.6.4 "Doublewrite Buffer", §17.11.1 "InnoDB Disk I/O"
  • InnoDB 소스 storage/innobase/buf/buf0dblwr.cc (더블라이트 배치 쓰기·복구 경로)
  • MySQL 8.0.20 Release Notes — 더블라이트 별도 파일(.dblwr) 분리

0개의 댓글