Skip to content

Write Durability

Won-Kyu Park edited this page Aug 28, 2026 · 2 revisions

쓰기 내구성과 손상 복구

commit()이 도중에 끊기면 어떻게 되는가, 다른 버전 관리 시스템은 이 문제를 어떻게 푸는가, 우리는 무엇을 해야 하는가에 대한 정리다.

발단은 단순한 질문이었다. 디스크가 꽉 차기 직전에 커밋을 하다가 마지막 부분을 쓰는 중 오류가 나면, 버전 파일을 살릴 수 있나?

살릴 수 있는 경우가 있고 없는 경우가 있는데, 지금 코드는 둘 다 못 살린다. 그리고 살릴 수 있었던 경우가 생각보다 많다는 게 이 문서를 쓴 이유다.

한눈에 보기

  • commit()은 옛 HEAD를 제자리에서 파괴적으로 다시 쓴다. 되돌릴 기록이 없다.
  • write() 한 번은 원자적이지 않다. 코드 주석이 그렇다고 전제하고 있는데 사실이 아니고, 실측으로 반례를 만들었다.
  • 코드 전체에 fsync가 하나도 없다. 다만 이건 RCS와 hg도 마찬가지라 특별히 이상한 건 아니다.
  • 정말 이상한 건 우리만 파괴적 제자리 쓰기를 한다는 점이다. RCS·git·hg 중 어느 것도 살아있는 데이터를 덮어쓰지 않는다.
  • 반대로 비트 플립 관점에서는 우리가 RCS보다 낫다. 커밋할 때 옛 블록을 건드리지 않고, 해시 체인이 손상을 탐지한다.

1. 우리가 실제로 하는 일

commit()의 두 번째 이후 커밋 경로다.

# Single write() call: two separate writes would leave the file with an
# overwritten old HEAD but no new HEAD if the process dies in between.
self.stream.seek(self.head_info["start"])
self.stream.write(old_block_bytes + new_block_bytes)
self.stream.truncate()

옛 HEAD가 있던 자리로 되돌아가서, 그것을 델타로 강등한 바이트와 새 HEAD의 full text를 이어 쓴다. 주석은 write() 한 번이면 중간에 죽어도 안전하다는 취지인데, 그렇지 않다. 버퍼가 부분적으로 flush될 수 있고, 실제로 그렇게 된다.

2. 실측: 어디서 끊기면 어떻게 되는가

RLIMIT_FSIZE로 디스크 부족을 흉내 내서, 300줄 파일에 세 번째 버전을 커밋하다가 잘리는 지점을 옮겨가며 측정했다.

끊긴 지점 commit() checkout 1.2 / 1.1 / 1.0 verify()
델타 ~ 새 HEAD 본문 도중 OSError 전부 오답 (len 0 / 7 / 14) KeyError로 죽음
본문 완료 후 hash 도중 OSError 전부 정상 False (정상 탐지)
완료 정상 전부 정상 True

첫 줄이 최악이다. 세 버전이 동시에 오답이 된다. 역델타 구조라 구버전은 전부 HEAD 기준 체인이고, HEAD 하나가 깨지면 그 이전 전체가 복원 불가가 되기 때문이다.

그리고 손상을 탐지하라고 있는 verify()KeyError로 죽었다. 잘린 블록은 text 필드 없이 파싱되는데 그걸 그대로 인덱싱하고 있었다. 이건 PR #17에서 고쳤다 — 이제 False를 반환한다.

3. 살릴 수 있었던 경우

델타 블록까지는 온전히 쓰였고 새 HEAD를 쓰다가 끊긴 경우를 보자. 우리는 RCS의 ci와 달리 작업 파일을 소비하지 않고 그대로 둔다. 그러니 커밋하려던 내용은 디스크에 살아있다.

그렇다면 온전한 델타를 그 내용에 적용하면 이전 버전이 나온다. 실제로 해봤다.

1.1 block (248B) parses: ver=1.1 is_delta=True has_text=True
recover 1.1 from working file + delta : True
recover 1.0 by chaining further back  : True

완전히 복구된다. 그런데 SimpleRCS는 그 복구를 하지 않고 checkout()이 조용히 빈 문자열을 반환한다. 데이터가 디스크에 멀쩡히 있는데 못 꺼내는 상황이다.

델타를 쓰다가 끊긴 경우는 다르다. 옛 HEAD의 full text가 반쯤 지워졌고 그것을 되살릴 재료가 없다. 이건 진짜 손실이다.

4. 다른 시스템은 어떻게 하는가

소스를 직접 읽고 확인한 것만 적는다.

라이브 데이터 변형 안전장치 fsync
RCS ,v 전체를 temp에 다시 씀 temp + rename(), rename 구간 시그널 차단, lock 파일. 실패하면 temp 위치를 알려줌 없음
CVS RCS ,v를 그대로 씀 RCS 것을 상속 없음
git 없음 — object/pack 불변 lockfile(O_CREAT|O_EXCL) + rename + atexit·시그널 정리 설정 가능, 기본값이 loose object를 제외
hg 없음 — revlog append-only 트랜잭션 저널에 append 직전 offset 기록, 롤백 = truncate 없음
SimpleRCS 제자리 tail-rewrite 없음 없음

넷 중 셋이 fsync를 안 한다. 우리가 안 하는 게 특별한 게 아니라는 뜻이다. 특별한 건 마지막 줄이다.

hg의 저널이 정수 하나인 이유

transaction.py:438이 저널에 쓰는 건 이게 전부다.

self._file.write(b"%s\0%d\n" % (file, offset))

파일 이름과 append 직전의 길이. 롤백은 그 오프셋으로 truncate하면 끝이고 데이터 복사가 0이다. revlog가 append-only라서 가능한 일이다. 모듈 docstring이 설계 의도를 그대로 말한다.

As the whole repository is effectively log-structured, this should amount to simply truncating anything that isn't referenced in the changelog.

git이 loose object를 기본적으로 fsync하지 않는 이유

content-addressed라 찢어진 오브젝트는 자기 해시 검증에 실패해서 스스로 드러난다. 그냥 버리면 되니 fsync할 이유가 없다. 반면 refs와 index는 fsync한다 — 잃으면 도달 가능성이 사라진다.

우리에게도 해시 체인이 있어서 탐지는 된다. 없는 건 복구이고, git과 결정적으로 다른 점은 우리는 찢어진 데이터를 버릴 수가 없다는 것이다. 그게 이전 버전의 유일한 사본이었으니까.

5. 델타 방향이라는 갈림길

RCS는 하이브리드다

흔한 오해가 "RCS = reverse delta"인데, 실제로는 둘 다 쓴다. 브랜치를 만든 ,v를 직접 열어봤다.

1.2      (trunk HEAD)  text @AAA / BBB-v2 / CCC@        <- full text
1.1      (trunk)       text @d2 1 / a2 1 / BBB@         <- reverse delta (1.2→1.1)
1.1.1.1  (branch)      text @d2 1 / a2 1 / BBB-branch
                             a3 1 / DDD@                <- forward delta (1.1→1.1.1.1)

trunk는 reverse, 브랜치는 forward다. 이유가 있다. trunk HEAD는 늘 뜨거운 읽기 대상이라 reverse로 두면 공짜로 O(1)이 되고, 브랜치는 덜 읽히니 forward로 둬도 된다. 대신 RCS의 긴 브랜치는 실제로 느려진다 — forward 체인인데 중간 스냅샷 장치가 없어서다.

hg는 그 forward 방식을 전면 적용했다

revlog.py:517deltaparent()는 항상 앞쪽 리비전을 가리킨다. 새 리비전은 뒤에 붙기만 하고 기존 데이터는 손대지 않는다. 대가는 체인 길이인데, 이걸 중간 스냅샷과 format.maxchainlen으로 막는 구조다.

이론은 그런데 실제로 재보니 흥미로웠다. 300줄 파일에 401번 커밋한 revlog:

revisions  : 401
  snapshot :   1 (0.25%)     <- lvl-0 하나뿐
  deltas   : 400 (99.75%)
revision size : 20806        <- 401개 버전 전체가 20KB
tip chainlen  : 401

sparserevlog가 켜진 저장소인데도 중간 스냅샷이 하나도 안 생겼다. 순차 편집만 한 선형 히스토리라 델타가 작아서 크기 휴리스틱이 "필요 없다"고 판단한 결과다. 즉 tip을 읽으려면 델타 401개를 적용해야 한다.

세 좌표

RCS       : O(파일) 재작성 + 원자성        HEAD O(1)
hg        : append-only + 스냅샷           HEAD 체인 walk (실측 401)
SimpleRCS : O(tail) 재작성 + 안전장치 없음  HEAD O(1)

RCS의 전체 재작성은 낭비가 아니라 원자성의 대가다. 어차피 옛 HEAD를 델타로 강등해야 한다면 — 파일 중간을 건드리는 일이다 — 통째로 새로 쓰고 rename하는 게 가장 단순하고, 크래시 안전성이 따라온다.

SimpleRCS는 그 O(파일)을 O(tail)로 줄인 최적화인데, 줄이면서 안전성도 같이 버렸다. 속도 이득은 진짜지만 공짜가 아니었다.

6. 그런데 축을 바꾸면 순위가 뒤집힌다

지금까지는 크래시 안전성 축이었다. 메모리 오류 축에서는 이야기가 달라진다.

RCS의 donerewrite()는 커밋마다 이걸 한다.

fro_spew (from, frew);   /* 옛 ,v 전체를 새 파일로 복사 */
...
chnamemod (...);         /* rename */

fro_spew_partial은 mmap/메모리 경로에서 전체를 통과시킨다. 그러니:

  1. 커밋 한 번에 전체 이력이 RAM을 통과한다
  2. 비트 하나가 뒤집힌다
  3. 손상된 내용이 temp에 쓰인다
  4. rename()원자적으로 설치하며 멀쩡했던 원본을 지운다
  5. RCS에는 무결성 검사가 없으니 아무도 모른다

integrity 필드가 구조체에 있긴 한데(base.h:587) 파싱해서 그대로 다시 쓸 뿐 검증하는 코드가 없다.

여기서 원자성이 독으로 작용한다. 중간 상태가 없으니 "뭔가 이상하다"는 신호가 없고, 이전 파일은 이미 사라졌다. 게다가 10년 전 리비전도 커밋할 때마다 RAM을 다시 통과하므로 노출이 커밋 수에 비례해 누적된다. 불안정한 머신에서 RCS 파일이 무더기로 깨지는 게 정확히 이 모양이다.

커밋당 RAM 통과 범위 무결성 검사 손상 시
RCS 이력 전체 없음 조용히 영구 손상, 원본 소실
SimpleRCS tail만 해시 체인 나머지 블록 무사, verify()가 탐지
hg 새 델타만 revlog 노드 해시 탐지 가능
git 새 오브젝트만 전면 content-addressed git fsck, 손상분만 폐기

우리 tail-rewrite는 크래시에 약하지만 비트 플립 노출 면적은 RCS의 1/N이다. 오래된 블록은 커밋할 때 읽지도 쓰지도 않는다.

정리하면 보장의 종류를 나눠 봐야 한다.

  • 탐지는 해시가 있으면 가능하다. 이건 이미 갖고 있다.
  • 복구는 중복 없이는 불가능하다. 해시는 "깨졌다"까지만 말한다.
  • 노출 면적 축소는 설계로 가능하다. 손대지 않는 데이터는 깨지지 않는다.

7. 선택지

A. 저널 (권장)

파괴적 쓰기 전에 .srcs.journal[head_start][옛 HEAD 블록 원본 바이트]를 쓰고 flush, 성공하면 unlink. 열 때 저널이 있으면 복원한다.

hg는 append-only라 undo가 정수 하나로 끝나지만 우리는 덮어쓰므로 옛 바이트가 필요하다. 비용은 커밋당 이전 full text 크기의 추가 쓰기 한 번이고, tail-rewrite 설계는 그대로 유지된다.

주의: 저널은 쓰기 양을 늘리므로 §6의 비트 플립 노출도 함께 늘린다. RCS의 전체 재작성보다는 여전히 훨씬 작지만 공짜는 아니다. 저널 자체에 해시를 붙이면 "저널이 깨졌는지"를 판별할 수 있어서, 손상된 저널로 멀쩡한 파일을 덮어쓰는 최악은 막을 수 있다.

B. RCS식 temp + rename

가장 확실하고 구현도 단순하다. 대신 커밋마다 O(파일 크기) I/O에 일시적으로 2배 공간이 필요하다. tail-rewrite가 존재하는 이유를 정면으로 부정하고, 무엇보다 디스크가 꽉 찬 상황에서 제일 먼저 실패한다 — 이 논의의 출발점이 바로 그 상황이었다. §6까지 감안하면 비트 플립 노출도 최악이다.

C. append-only 전환

델타 방향을 forward로 뒤집으면 구조적으로 안전해지고 복구가 truncate로 끝난다. 대신 HEAD 읽기 O(1)을 잃는다. hg 실측이 보여주듯 tip을 읽는 데 체인 전체를 걸어야 할 수 있다.

hg가 이 선택을 한 건 분산 VCS라 임의 리비전 접근과 push/pull 병합을 감당해야 했기 때문이지, 안전성만 보고 고른 게 아니다. 위키처럼 HEAD를 압도적으로 자주 읽는 워크로드에서는 손해다.

commit(snapshot=True)가 이미 옛 HEAD를 full text로 남기는 경로라 재료가 아예 없지는 않다. 다만 그것을 써도 옛 HEAD 블록을 제자리에서 다시 쓰는 건 마찬가지라 append-only가 되지는 않는다.

어느 쪽이든 같이 할 만한 것

  • posix_fallocate 사전 예약. statvfs로 여유를 재는 것과 달리 경합이 없고, ENOSPC를 파일을 건드리기 전에 잡는다. 값싸고 A와 잘 맞는다 — 흔한 실패(디스크 부족)는 시작조차 안 하고, 드문 실패(전원·SIGKILL)는 저널로 복구.
  • 파괴적 쓰기 구간 시그널 차단. RCS가 rename 주변에서 하는 IGNOREINTS/RESTOREINTS와 같은 것이다. signal.pthread_sigmask 몇 줄로 Ctrl-C에 반쯤 쓰인 파일이 나오는 걸 막을 수 있다.
  • 작업 파일 기반 복구 경로. §3에서 보듯 델타가 온전하면 완전 복구가 가능하다. 지금은 그 데이터가 디스크에 있는데도 안 쓰고 있다.

8. 권장

이 절은 뒤집혔다. 실제로 구현된 것은 B(temp + rename)이고, A(저널)와 시그널 차단은 폐기됐다. 결정적 근거는 §5의 표에 빠져 있던 사실 하나다 — RCS가 전체를 재작성하는 것은 HEAD를 파일 에 두기 때문이고, HEAD가 인 우리는 바뀌지 않는 부분이 prefix로 남아 원시 바이트 복사로 끝난다. 그러면 B의 비용 논거가 무너지고(측정: 위키 규모에서 fsync가 비용을 지배), §6의 비트 플립 논거도 함께 무너진다(재직렬화가 없으므로). 전체 논의와 구현 결과는 Plan-Atomic-Commit에 있다.

아래는 그 관찰을 하기 전의 판단이며, 기록으로 남긴다.

A + fallocate + 시그널 차단.

우리 워크로드는 HEAD를 압도적으로 자주 읽는 위키 페이지다. reverse delta와 HEAD O(1)은 그 워크로드에 맞는 선택이므로 유지한다. 대신 RCS가 rename으로 공짜로 얻던 원자성을, 우리는 줄여놓은 쓰기 구간에 저널로 되돌려준다.

C는 사실상 다른 저장 엔진이라 별도 논의가 필요하고, B는 이 논의의 출발점이던 상황에서 가장 먼저 실패한다.

재현

  • 끊김 지점 실측: RLIMIT_FSIZEbase + N으로 걸고 커밋한 뒤 파일 상태를 확인. SIGXFSZ를 무시해야 프로세스가 죽지 않고 OSError를 받는다.
  • RCS 델타 방향: ci/co로 브랜치를 만든 뒤 ,v를 직접 열어본다.
  • hg revlog: 소스 트리에서 PYTHONPATH=<hg소스> python3 <hg소스>/hg로 바로 실행할 수 있다. hg debugrevlog / hg debugdeltachain이 스냅샷 수와 체인 길이를 보여준다.

관련

Clone this wiki locally