Workspace IndexAlgorithms › Day 70

WAL, Group Commit, and the Cost of fsync TODO

Algorithms · Day 70 / 100 · E. Data & Storage Engines (Day 69-81)

Concept

A write-ahead log (WAL) records changes sequentially to a log file before modifying the actual data pages, guaranteeing durability and crash recovery — and as a side effect, it turns random writes into sequential ones. For a commit to be truly durable, its log record has to actually reach storage, which requires an fsync (or fdatasync) call to force the OS page cache and device cache to flush — and that call is the dominant cost in commit latency. Group commit batches the log records of several transactions that arrive within a short time window and durably writes them with a single fsync, so the number of fsyncs scales with time rather than with transaction count. The result: a slight increase in any single transaction's latency, but a large gain in overall throughput — a classic latency-for-throughput batching tradeoff. Conversely, an asynchronous-commit setting skips waiting on fsync entirely for speed, at the cost of accepting the loss of some recent commits on crash.

When a database suddenly slows down, the culprit is often fsync-per-commit rather than the query plan — and conversely, a setting that looks fast might actually have quietly traded away durability.

Code & Formula

# WAL·그룹 커밋·fsync 비용 — 변경을 로그에 먼저 순차 기록하고, 여러 트랜잭션을 모아 fsync 한 번으로 묶어 내구화한다.
# 트랜잭션마다 fsync하는 방식과, 짧은 창 안의 여러 트랜잭션을 묶어 fsync 한 번으로 처리하는 그룹 커밋을 비교한다.

class WAL:
    def __init__(self):
        self.log = []
        self.fsync_calls = 0

    def append(self, record):
        self.log.append(record)   # 순차 기록 (아직 장치까지 내구화되지는 않음)

    def flush(self):
        self.fsync_calls += 1     # fsync: 실제 장치까지 강제로 내려보내는, 비용이 큰 호출

txns = [f"txn-{i}: UPDATE balance SET ..." for i in range(12)]

# 방식 1: 트랜잭션마다 즉시 fsync
wal1 = WAL()
for t in txns:
    wal1.append(t)
    wal1.flush()
print(f"개별 커밋: fsync 호출 {wal1.fsync_calls}회 (트랜잭션 수만큼)")

# 방식 2: 그룹 커밋 — 4개씩 모아 fsync 한 번
wal2 = WAL()
GROUP_SIZE = 4
for i in range(0, len(txns), GROUP_SIZE):
    for t in txns[i:i + GROUP_SIZE]:
        wal2.append(t)
    wal2.flush()                 # 그룹 전체를 한 번의 fsync로 내구화
print(f"그룹 커밋(그룹 크기 {GROUP_SIZE}): fsync 호출 {wal2.fsync_calls}회")

def replay(wal):
    return list(wal.log)   # 크래시 복구: 로그를 처음부터 재생해 마지막 커밋 상태를 되살린다

print(f"\n복구 재생 결과 (마지막 3건): {replay(wal2)[-3:]}")

Exercise

Run the same insert workload against local Postgres with synchronous_commit on and off, compare TPS and p99 latency, and also measure the numbers when transactions are batched together.

Practical Connection

When an indexer writes thousands of events per block, batching commits at the block level instead of committing per event is exactly the same throughput trick, for exactly the same reason.

If you study this on a given day, add a note link and a ✅ to this line in the source curriculum (docs/knowledge/dev-100-curriculum.md) and this spot will lead straight to the note body. You can also write directly on this page — but regenerating overwrites it, so it's safer to keep anything you want to save as markdown under docs/algorithms/.


한국어

WAL·그룹 커밋·fsync 비용 TODO

Algorithms · Day 70 / 100 · E. 데이터·스토리지 엔진 (Day 69–81)

개념

WAL은 데이터 페이지를 수정하기 전에 변경 내역을 로그 파일에 먼저 순차 기록해 내구성과 크래시 복구를 보장하는 기법이며, 무작위 쓰기를 순차 쓰기로 바꾸는 부수 효과도 크다. 커밋이 진짜 내구적이 되려면 로그 레코드가 저장장치에 도달해야 하고, 이를 위해 fsync 또는 fdatasync로 OS 페이지 캐시와 장치 캐시를 강제로 비워야 하는데 이 호출이 커밋 지연의 지배적 비용이다. 그룹 커밋은 짧은 시간 창 안에 도착한 여러 트랜잭션의 로그를 모아 한 번의 fsync로 함께 내구화해, fsync 횟수를 트랜잭션 수가 아니라 시간 단위에 비례하게 만든다. 그 결과 개별 트랜잭션의 지연은 조금 늘지만 전체 처리량은 크게 오르며, 이는 지연과 처리량을 맞바꾸는 전형적인 배칭이다. 반대로 비동기 커밋 설정은 fsync를 기다리지 않아 빠르지만, 크래시 시 최근 커밋 일부의 손실을 감수하는 선택이다.

DB가 갑자기 느려질 때 원인이 쿼리 플랜이 아니라 커밋당 fsync인 경우가 많고, 반대로 빨라 보이는 설정이 사실은 내구성을 포기한 것일 수도 있다.

코드 · 수식

# WAL·그룹 커밋·fsync 비용 — 변경을 로그에 먼저 순차 기록하고, 여러 트랜잭션을 모아 fsync 한 번으로 묶어 내구화한다.
# 트랜잭션마다 fsync하는 방식과, 짧은 창 안의 여러 트랜잭션을 묶어 fsync 한 번으로 처리하는 그룹 커밋을 비교한다.

class WAL:
    def __init__(self):
        self.log = []
        self.fsync_calls = 0

    def append(self, record):
        self.log.append(record)   # 순차 기록 (아직 장치까지 내구화되지는 않음)

    def flush(self):
        self.fsync_calls += 1     # fsync: 실제 장치까지 강제로 내려보내는, 비용이 큰 호출

txns = [f"txn-{i}: UPDATE balance SET ..." for i in range(12)]

# 방식 1: 트랜잭션마다 즉시 fsync
wal1 = WAL()
for t in txns:
    wal1.append(t)
    wal1.flush()
print(f"개별 커밋: fsync 호출 {wal1.fsync_calls}회 (트랜잭션 수만큼)")

# 방식 2: 그룹 커밋 — 4개씩 모아 fsync 한 번
wal2 = WAL()
GROUP_SIZE = 4
for i in range(0, len(txns), GROUP_SIZE):
    for t in txns[i:i + GROUP_SIZE]:
        wal2.append(t)
    wal2.flush()                 # 그룹 전체를 한 번의 fsync로 내구화
print(f"그룹 커밋(그룹 크기 {GROUP_SIZE}): fsync 호출 {wal2.fsync_calls}회")

def replay(wal):
    return list(wal.log)   # 크래시 복구: 로그를 처음부터 재생해 마지막 커밋 상태를 되살린다

print(f"\n복구 재생 결과 (마지막 3건): {replay(wal2)[-3:]}")

연습

로컬 Postgres에서 synchronous_commit을 켠 상태와 끈 상태로 동일한 삽입 부하를 돌려 TPS와 p99 지연을 비교하고, 트랜잭션을 배치로 묶었을 때의 수치도 함께 재라.

실무 · Verex 연결

인덱서가 블록마다 수천 건의 이벤트를 기록할 때 이벤트 단위 트랜잭션 대신 블록 단위 배치 커밋으로 묶는 것이 정확히 같은 원리의 처리량 개선이다.

공부한 날 원본 커리큘럼(docs/knowledge/dev-100-curriculum.md)의 이 줄에 노트 링크와 ✅ 를 붙이면, 이 자리는 노트 본문으로 바로 이어집니다. 노트 없이 이 페이지에 바로 적어도 됩니다 — 다만 다시 생성하면 덮어쓰이므로, 남길 글은 docs/algorithms/ 의 마크다운으로 쓰는 편이 안전합니다.

← 69. MVCC 내부와 스냅샷 격리의 이상현상(write skew)71. LSM 트리 튜닝 →