포스트

WAL과 체크포인트 - 커밋은 언제 디스크에 닿는가

“커밋하면 디스크에 저장된다”는 말은 맞지만, 저장되는 것이 데이터 파일은 아니다. 데이터 페이지는 한참 뒤에 쓰인다. 커밋 시점에 디스크에 닿는 것은 로그이고, 이 순서를 뒤집지 않는 것이 ACID의 D를 만드는 방법이다.

문제: 랜덤 쓰기는 느리다

트랜잭션 하나가 여러 테이블의 여러 페이지를 바꾼다. 그 페이지들은 디스크 여기저기에 흩어져 있다. 커밋할 때마다 전부 제자리에 쓰면, 커밋 하나가 랜덤 쓰기 수십 번이 된다.

그리고 중간에 죽으면 일부만 쓰인 상태가 남는다. 어느 페이지가 반영됐고 어느 것이 안 됐는지 알 방법이 없다.

해법: 로그를 먼저 쓴다

Write-Ahead Logging의 규칙은 하나다. 데이터 페이지를 디스크에 쓰기 전에, 그 변경을 기술한 로그 레코드를 먼저 디스크에 쓴다.

커밋 시점에 하는 일은 이것뿐이다.

  1. 변경 내용을 로그 버퍼에 append
  2. 커밋 레코드까지 로그를 디스크에 fsync
  3. 클라이언트에 성공 응답

데이터 페이지는 메모리(버퍼 풀)에서만 바뀐 채로 남는다(dirty page). 디스크의 데이터 파일은 아직 옛날 상태다.

얻는 것이 크다. 로그는 순차 쓰기이고 파일 하나다. 랜덤 쓰기 수십 번이 순차 쓰기 한 번 + fsync 한 번이 된다.

크래시에서 복구하는 방법도 여기서 나온다. 재시작하면 로그를 읽어 커밋된 것은 다시 적용하고(redo), 커밋 안 된 것은 되돌린다(undo). 로그에 커밋 레코드가 있으면 그 트랜잭션은 성공한 것이고, 데이터 페이지에 반영이 안 됐어도 redo로 복원된다.

그룹 커밋

fsync는 비싸다. 커밋마다 하나씩 하면 디스크 지연이 곧 커밋 지연이다.

그룹 커밋이 이것을 나눈다. 짧은 시간 안에 도착한 여러 트랜잭션의 커밋을 모아 fsync 한 번으로 처리한다. 동시성이 높을수록 커밋당 비용이 줄어든다.

실무적 함의가 하나 있다. “커밋 지연이 N ms”는 단일 수치가 아니라 부하의 함수다. 한가할 때 잰 값과 바쁠 때 잰 값이 다르고, 대개 바쁠 때가 건당으로는 더 싸다.

체크포인트

로그는 무한히 자랄 수 없다. 그리고 복구 시 읽어야 할 로그가 길면 재시작이 오래 걸린다.

체크포인트는 “이 지점까지의 변경은 데이터 파일에 반영됐다”를 기록한다. dirty page들을 디스크에 쓰고, 그 시점을 로그에 남긴다. 복구는 마지막 체크포인트부터 읽으면 된다.

여기에 교환이 있다.

  • 자주 하면: 복구가 빠르고, 평상시 디스크 쓰기가 늘어난다.
  • 드물게 하면: 평상시가 가볍고, 복구가 느리며, 한 번에 쓸 양이 많아 체크포인트 순간에 지연 스파이크가 생긴다.

MySQL에서 innodb_io_capacity와 리두 로그 크기(innodb_redo_log_capacity)를 튜닝하는 것이 이 교환을 조절하는 일이다. 리두 로그가 작으면 체크포인트가 강제로 자주 일어나고(furious flushing), 그때 처리량이 눈에 띄게 떨어진다.

PostgreSQL에서는 checkpoint_timeout, max_wal_size, checkpoint_completion_target이 같은 역할이다. 마지막 값은 체크포인트 쓰기를 시간에 걸쳐 분산시켜 스파이크를 줄인다.

부분 페이지 쓰기

디스크의 원자적 쓰기 단위(보통 512B~4KB)가 DB 페이지 크기(8~16KB)보다 작다. 페이지를 쓰는 도중 전원이 나가면 반만 쓰인 페이지가 남고, 그 페이지는 redo로도 복구할 수 없다. redo는 “정상 페이지에 변경을 적용하는 것”이지 손상 페이지를 고치는 것이 아니기 때문이다.

두 DB가 각각 다르게 푼다.

  • MySQL: 더블라이트 버퍼. 페이지를 먼저 별도 영역에 쓰고, 그 다음 제자리에 쓴다. 손상되면 별도 영역의 복사본으로 복구한다.
  • PostgreSQL: full_page_writes. 체크포인트 후 각 페이지의 첫 변경 시 페이지 전체를 WAL에 기록한다. 그래서 체크포인트 직후 WAL 양이 급증한다.

두 방식 다 쓰기가 두 배가 되는 구간이 있다는 뜻이고, 이것이 “WAL이 왜 이렇게 많이 쌓이지”의 흔한 답이다.

커밋 내구성의 손잡이

설정의미
innodb_flush_log_at_trx_commit=1커밋마다 fsync. 기본이자 가장 안전
=2커밋 시 OS에 쓰기만, fsync는 1초마다. OS 크래시에 최근 1초 유실
=01초마다 쓰기와 fsync. 프로세스 크래시에도 유실
synchronous_commit=on커밋 시 WAL fsync (PostgreSQL 기본)
=off비동기. 최근 커밋 유실 가능, 데이터 손상은 없음
=remote_apply동기 복제본이 적용할 때까지 대기

PostgreSQL의 synchronous_commit=off가 특이하다. 유실은 가능하지만 일관성은 깨지지 않는다. “최근 몇 건이 없었던 일이 될 수 있다”이지 “데이터가 망가진다”가 아니다. 이 구분이 설정을 고를 때 중요하다.

이 설명이 깨지는 곳

  • fsync가 거짓말할 수 있다. 쓰기 캐시가 켜진 디스크는 캐시에 담고 성공을 돌려준다(페이지 캐시와 fsync).
  • 복제가 있으면 내구성의 정의가 바뀐다. 로컬 fsync보다 “과반 복제본이 받았는가”가 더 강한 보장일 수 있다.
  • WAL은 복제와 PITR의 재료이기도 하다. 스트리밍 복제, 논리 복제, 시점 복구, CDC가 전부 이 로그를 읽는다(CDC의 원리와 한계).
  • 긴 트랜잭션은 WAL을 쌓는다. 커밋되지 않은 트랜잭션이 있으면 그 이전 로그를 지울 수 없다.

무엇을 재면 확인되는가

  1. innodb_flush_log_at_trx_commit이나 synchronous_commit을 바꿔 가며 커밋 지연과 처리량을 재고, 동시성을 올려 그룹 커밋 효과를 본다.
  2. 리두 로그 크기를 줄여 체크포인트를 강제로 자주 일으키고 처리량 저하를 관찰한다.
  3. 체크포인트 직후의 WAL 생성량 급증(full_page_writes)을 실제로 본다.

ParityPay 2편에서 잠금 보유 17ms 중 마지막 0.761ms가 “COMMIT — 잠금 해제”였다. 그 0.761ms 안에 있는 것이 이 글의 내용이고, 설정에 따라 그 값이 달라진다.

정리

  • 커밋 시점에 디스크에 닿는 것은 데이터 페이지가 아니라 로그다. 순차 쓰기 한 번으로 랜덤 쓰기 수십 번을 대신한다.
  • 규칙은 “데이터보다 로그가 먼저”다. 복구는 redo와 undo로 한다.
  • 그룹 커밋 때문에 커밋 지연은 단일 수치가 아니라 부하의 함수다.
  • 체크포인트는 복구 시간과 평상시 부하의 교환이고, 드물게 하면 그 순간 스파이크가 온다.
  • 부분 페이지 쓰기 방어(더블라이트, full_page_writes) 때문에 쓰기가 두 배가 되는 구간이 있다.
  • synchronous_commit=off는 유실을 허용하지만 손상을 허용하지는 않는다.

참고

데이터베이스 내부와 트랜잭션
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다