Back to blog

Write-Ahead Log, Segmented Log & Low-Water Mark

distributed-systemssystem-designsoftware-architecturebackenddesign-patterns
Write-Ahead Log, Segmented Log & Low-Water Mark

Mọi distributed system — Kafka, PostgreSQL, etcd, CockroachDB — đều lưu trữ data. Và mỗi hệ thống đều phải đối mặt với cùng một câu hỏi đáng sợ:

"Chuyện gì xảy ra khi server crash giữa lúc đang ghi?"

Câu trả lời bắt đầu từ ba pattern tạo nên nền tảng durability của hầu như mọi data system bạn từng dùng:

  • Write-Ahead Log (WAL) — Ghi mọi thay đổi trước khi apply, để có thể recover sau khi crash
  • Segmented Log — Chia log ngày càng phình to thành những phần nhỏ dễ quản lý
  • Low-Water Mark — Biết được log entry nào có thể xóa an toàn

Ba pattern này cơ bản đến mức bạn không thể build một distributed system đáng tin cậy nếu thiếu chúng. Chúng giải quyết bài toán cơ bản nhất: đảm bảo data sống sót qua failures.

Trong bài này, chúng ta sẽ tìm hiểu:

✅ Write-Ahead Log — tại sao "ghi trước, apply sau" ngăn chặn data corruption
✅ Segmented Log — cách Kafka quản lý hàng tỷ messages với log segments
✅ Low-Water Mark — khi nào và cách nào xóa log entries cũ an toàn
✅ Cách cả ba pattern kết hợp thành một câu chuyện durability hoàn chỉnh
✅ Implementation thực tế trong PostgreSQL, Kafka và etcd
✅ Code examples minh họa từng pattern

Series: Patterns of Distributed Systems Roadmap
Next: Post #3 — Leader and Followers, HeartBeat & Generation Clock


Bài Toán Durability

Tưởng tượng bạn đang viết một key-value store. Client gửi SET user:123 "Alice". Server của bạn:

  1. Nhận request
  2. Cập nhật in-memory hash map
  3. Trả "OK" cho client

Có gì sai được? Tất cả mọi thứ.

Nếu server crash sau bước 2 nhưng trước khi persist xuống disk, data mất vĩnh viễn — dù client đã nhận "OK". Đây không phải lo ngại lý thuyết. Nó xảy ra trong production. Mỗi ngày.

Cách fix ngây thơ — "cứ ghi xuống disk mỗi operation" — tạo ra một vấn đề khác:

Nếu crash giữa lúc ghi vào data file, bạn có corrupted file — có thể tệ hơn cả mất một record. Bạn có thể mất toàn bộ database.

Chúng ta cần một pattern đảm bảo atomicity (tất cả hoặc không gì cả) và durability (sống sót qua crashes).


Pattern 1: Write-Ahead Log (WAL)

Ý Tưởng Cốt Lõi

Write-Ahead Log pattern giải quyết bài toán durability bằng một quy tắc:

Trước khi apply bất kỳ thay đổi nào vào state, ghi thay đổi đó vào một append-only log trên disk trước.

Insight quan trọng: append vào file gần như là atomic. Ghi một log record vào cuối file hoặc thành công hoàn toàn hoặc fail mà không corrupt data hiện có. Còn update một data structure phức tạp (B-tree, hash table) in-place thì không atomic — crash giữa chừng sẽ để lại structure ở trạng thái inconsistent.

Nếu server crash trước khi ghi WAL, client không nhận được "OK" — nên không có data loss từ góc nhìn của client. Nếu server crash sau khi ghi WAL nhưng trước khi apply vào state, ta có thể replay log khi restart và recover chính xác state.

Cấu Trúc Của Một WAL Entry

Mỗi WAL entry cần đủ thông tin để replay operation một cách độc lập:

interface WALEntry {
  // Số thứ tự duy nhất, tăng đơn điệu
  logSequenceNumber: number;  // LSN — xác định vị trí trong log
 
  // Chuyện gì đã xảy ra
  operation: 'SET' | 'DELETE' | 'UPDATE';
  key: string;
  value?: string;
  previousValue?: string;  // Để undo khi rollback
 
  // Toàn vẹn dữ liệu
  timestamp: number;
  checksum: number;  // CRC32 để phát hiện corruption
}

Log sequence number (LSN) cực kỳ quan trọng. Nó cung cấp total ordering của tất cả operations. Trong quá trình recovery, bạn replay entries theo thứ tự LSN và nhận được chính xác state — bất kể server crash khi nào hay vì sao.

Implementation: Một Write-Ahead Log Đơn Giản

Đây là WAL implementation tối giản nắm bắt bản chất pattern:

import {
  readFileSync, existsSync, appendFileSync,
  fsyncSync, openSync, closeSync
} from 'fs';
 
interface WALEntry {
  lsn: number;
  operation: 'SET' | 'DELETE';
  key: string;
  value?: string;
  timestamp: number;
}
 
class WriteAheadLog {
  private currentLSN = 0;
  private logPath: string;
  private state: Map<string, string> = new Map();
 
  constructor(logPath: string) {
    this.logPath = logPath;
    this.recover(); // Replay log khi startup
  }
 
  /**
   * Core WAL pattern: ghi vào log TRƯỚC, rồi mới apply vào state
   */
  set(key: string, value: string): void {
    // Bước 1: Tạo log entry
    const entry: WALEntry = {
      lsn: ++this.currentLSN,
      operation: 'SET',
      key,
      value,
      timestamp: Date.now(),
    };
 
    // Bước 2: Persist vào log (với fsync cho durability)
    this.appendToLog(entry);
 
    // Bước 3: Apply vào in-memory state
    this.state.set(key, value);
  }
 
  delete(key: string): void {
    const entry: WALEntry = {
      lsn: ++this.currentLSN,
      operation: 'DELETE',
      key,
      timestamp: Date.now(),
    };
 
    this.appendToLog(entry);
    this.state.delete(key);
  }
 
  get(key: string): string | undefined {
    return this.state.get(key); // Reads đến từ in-memory state
  }
 
  /**
   * Append entry và fsync — đảm bảo durability quan trọng nhất
   */
  private appendToLog(entry: WALEntry): void {
    const line = JSON.stringify(entry) + '\n';
    const fd = openSync(this.logPath, 'a');
    appendFileSync(fd, line);
    fsyncSync(fd); // Ép ghi xuống disk — không chỉ OS buffer
    closeSync(fd);
  }
 
  /**
   * Recovery: replay toàn bộ log để rebuild state
   */
  private recover(): void {
    if (!existsSync(this.logPath)) return;
 
    const content = readFileSync(this.logPath, 'utf-8');
    const lines = content.trim().split('\n').filter(l => l.length > 0);
 
    for (const line of lines) {
      const entry: WALEntry = JSON.parse(line);
      this.replayEntry(entry);
      this.currentLSN = Math.max(this.currentLSN, entry.lsn);
    }
 
    console.log(`Recovered ${lines.length} entries, LSN tại ${this.currentLSN}`);
  }
 
  private replayEntry(entry: WALEntry): void {
    switch (entry.operation) {
      case 'SET':
        this.state.set(entry.key, entry.value!);
        break;
      case 'DELETE':
        this.state.delete(entry.key);
        break;
    }
  }
}

Chú ý chi tiết quan trọng: fsyncSync(fd). Không có fsync, OS có thể buffer write trong memory — và crash sẽ mất nó. Lệnh fsync ép data xuống physical disk, cung cấp đảm bảo durability thực sự.

Trade-off Của fsync

fsync rất tốn kém. Trên SSD thông thường, mỗi fsync mất 0.1-1ms. Ở mức 1ms mỗi fsync, bạn bị giới hạn ~1,000 writes per second. Đó là lý do các hệ thống thực tế batch writes:

class BatchedWAL {
  private pendingEntries: WALEntry[] = [];
  private pendingCallbacks: (() => void)[] = [];
  private flushIntervalMs = 10; // Flush mỗi 10ms
 
  constructor(private logPath: string) {
    // Flush định kỳ — gom nhiều writes vào một fsync
    setInterval(() => this.flush(), this.flushIntervalMs);
  }
 
  append(entry: WALEntry): Promise<void> {
    return new Promise(resolve => {
      this.pendingEntries.push(entry);
      this.pendingCallbacks.push(resolve);
    });
  }
 
  private flush(): void {
    if (this.pendingEntries.length === 0) return;
 
    const entries = this.pendingEntries;
    const callbacks = this.pendingCallbacks;
    this.pendingEntries = [];
    this.pendingCallbacks = [];
 
    // Ghi TẤT CẢ pending entries, rồi MỘT fsync
    const fd = openSync(this.logPath, 'a');
    for (const entry of entries) {
      appendFileSync(fd, JSON.stringify(entry) + '\n');
    }
    fsyncSync(fd); // Một fsync cho cả batch
    closeSync(fd);
 
    // Thông báo cho tất cả waiting clients
    callbacks.forEach(cb => cb());
  }
}

Bằng cách batch 100 writes vào một fsync, bạn tăng từ 1,000 writes/sec lên ~100,000 writes/sec. Đây chính xác là cách PostgreSQL, Kafka và etcd làm.

SystemConfig ParameterDefaultTác dụng
PostgreSQLwal_writer_delay200msFlush WAL mỗi 200ms
Kafkalinger.ms0ms (ngay lập tức)Chờ trước khi gửi batch
etcd--wal-sync-durationPer-writeSync WAL mỗi write

WAL Trong PostgreSQL

WAL của PostgreSQL là một trong những implementation trưởng thành nhất. Mọi thay đổi — INSERT, UPDATE, DELETE, thậm chí index modifications — đều đi qua WAL:

pg_wal/
├── 000000010000000000000001   # WAL segment 16MB
├── 000000010000000000000002   # Segment tiếp theo
├── 000000010000000000000003
└── ...

Mỗi WAL record chứa:

  • LSN (Log Sequence Number) — vị trí trong WAL stream
  • Transaction ID — transaction nào thực hiện thay đổi
  • Resource Manager ID — component nào (heap, B-tree index, etc.)
  • Record data — thay đổi thực tế (before/after images của row)

PostgreSQL dùng WAL cho ba mục đích:

  1. Crash recovery — replay WAL từ checkpoint cuối
  2. Streaming replication — ship WAL records sang replicas
  3. Point-in-time recovery (PITR) — replay WAL đến bất kỳ thời điểm nào
-- Xem vị trí WAL hiện tại
SELECT pg_current_wal_lsn();
-- Kết quả: 0/1A3B4C0
 
-- Xem thống kê WAL
SELECT * FROM pg_stat_wal;

WAL Trong Kafka

Kafka đưa concept WAL đi xa hơn — log chính là database. Thay vì dùng WAL để bảo vệ một data structure khác, data store chính của Kafka là bản thân append-only log:

partition-0/
├── 00000000000000000000.log    # Segment đầu tiên
├── 00000000000000000000.index  # Offset index
├── 00000000000065536000.log    # Segment tiếp (bắt đầu ở offset 65536)
├── 00000000000065536000.index
└── ...

Mọi message publish vào Kafka đều được append vào log của partition. Consumers đọc từ log theo tốc độ riêng. Log không bao giờ bị modified in place — chỉ append vào, và cuối cùng được dọn dẹp.

Đây là insight căn bản: log là source of truth, không phải một kênh phụ cho durability.

WAL Trong MySQL InnoDB

Redo log của MySQL InnoDB hoạt động hơi khác — nó dùng fixed-size circular buffer thay vì log tăng mãi:

ib_logfile0   # 48MB (default)
ib_logfile1   # 48MB (default)

InnoDB ghi redo log entries tuần tự vào các files này, quay vòng khi đến cuối. Ràng buộc quan trọng: InnoDB phải flush các modified ("dirty") pages từ buffer pool xuống data files trước khi redo log quay vòng và ghi đè lên entries chưa được apply.

Điều này tạo ra tension tự nhiên giữa redo log size và write throughput — log lớn hơn cho phép nhiều writes hơn trước khi cần flush, nhưng tăng thời gian crash recovery.

-- Kiểm tra cấu hình InnoDB log
SHOW VARIABLES LIKE 'innodb_log_file_size';
-- Kết quả: 50331648 (48MB)
 
SHOW VARIABLES LIKE 'innodb_log_files_in_group';
-- Kết quả: 2

Pattern 2: Segmented Log

Vấn Đề Với Một File Log Duy Nhất

WAL của chúng ta hoạt động, nhưng nó có một vấn đề ngày càng lớn — theo đúng nghĩa đen. File log phình to mãi mãi:

Ngày 1:   wal.log = 10 MB
Ngày 30:  wal.log = 300 MB
Ngày 365: wal.log = 3.6 GB
Năm 3:   wal.log = 10.8 GB  ← Recovery mất 30 phút

Một file log đơn lẻ phình to mãi tạo ra ba vấn đề:

  1. Thời gian recovery — replay 10 GB log entries khi startup mất vài phút
  2. Giới hạn file system — một số file systems có maximum file sizes
  3. Dọn dẹp — bạn không thể xóa entries cũ ở giữa file mà không phải rewrite nó

Ý Tưởng Cốt Lõi

Segmented Log pattern giải quyết bằng cách chia log thành nhiều files nhỏ hơn gọi là segments:

Khi một log segment đạt kích thước hoặc tuổi được cấu hình, đóng nó lại và bắt đầu segment mới. Chỉ segment mới nhất nhận writes.

Các tính chất quan trọng:

  • Chỉ active segment (segment mới nhất) nhận writes
  • Sealed segments là immutable — chúng không bao giờ thay đổi
  • Sealed segments có thể được xóa riêng lẻ khi không còn cần
  • Mỗi segment có base offset xác định entry đầu tiên nó chứa

Implementation: Segmented Log

import { mkdirSync, readdirSync, statSync, unlinkSync } from 'fs';
import { join } from 'path';
 
interface LogSegment {
  baseOffset: number;     // LSN đầu tiên trong segment
  path: string;           // Đường dẫn file
  size: number;           // Kích thước hiện tại (bytes)
  sealed: boolean;        // Không nhận writes nữa
}
 
class SegmentedLog {
  private segments: LogSegment[] = [];
  private activeSegment: LogSegment | null = null;
  private currentLSN = 0;
  private maxSegmentSize: number;
  private logDir: string;
 
  constructor(logDir: string, maxSegmentSizeBytes = 64 * 1024 * 1024) {
    this.logDir = logDir;
    this.maxSegmentSize = maxSegmentSizeBytes;
    mkdirSync(logDir, { recursive: true });
    this.loadExistingSegments();
  }
 
  append(entry: WALEntry): void {
    // Roll sang segment mới nếu segment hiện tại đầy
    if (this.shouldRoll()) {
      this.rollSegment();
    }
 
    const line = JSON.stringify(entry) + '\n';
    const fd = openSync(this.activeSegment!.path, 'a');
    appendFileSync(fd, line);
    fsyncSync(fd);
    closeSync(fd);
 
    this.activeSegment!.size += Buffer.byteLength(line);
    this.currentLSN = entry.lsn;
  }
 
  /**
   * Roll: seal segment hiện tại, tạo active segment mới
   */
  private rollSegment(): void {
    if (this.activeSegment) {
      this.activeSegment.sealed = true;
      console.log(`Sealed segment tại LSN ${this.currentLSN}`);
    }
 
    const newSegment: LogSegment = {
      baseOffset: this.currentLSN + 1,
      path: join(this.logDir, this.formatName(this.currentLSN + 1)),
      size: 0,
      sealed: false,
    };
 
    this.segments.push(newSegment);
    this.activeSegment = newSegment;
  }
 
  private shouldRoll(): boolean {
    return (
      this.activeSegment === null ||
      this.activeSegment.size >= this.maxSegmentSize
    );
  }
 
  /**
   * Xóa segments có entries hoàn toàn dưới LSN đã cho
   */
  deleteSegmentsBefore(lsn: number): number {
    let deleted = 0;
    this.segments = this.segments.filter(segment => {
      if (segment.sealed && this.getSegmentMaxLSN(segment) < lsn) {
        unlinkSync(segment.path);
        deleted++;
        return false;
      }
      return true;
    });
    return deleted;
  }
 
  /**
   * Đọc tất cả entries qua tất cả segments theo thứ tự
   */
  *readAll(): Generator<WALEntry> {
    for (const segment of this.segments) {
      const content = readFileSync(segment.path, 'utf-8');
      const lines = content.trim().split('\n').filter(l => l.length > 0);
      for (const line of lines) {
        yield JSON.parse(line);
      }
    }
  }
 
  // Tên segment dùng offset zero-padded để sắp xếp lexicographic
  private formatName(offset: number): string {
    return offset.toString().padStart(20, '0') + '.log';
  }
 
  private getSegmentMaxLSN(segment: LogSegment): number {
    const content = readFileSync(segment.path, 'utf-8').trim();
    const lines = content.split('\n');
    const last = JSON.parse(lines[lines.length - 1]);
    return last.lsn;
  }
 
  private loadExistingSegments(): void {
    try {
      const files = readdirSync(this.logDir)
        .filter(f => f.endsWith('.log'))
        .sort();
 
      for (const file of files) {
        const path = join(this.logDir, file);
        const baseOffset = parseInt(file.replace('.log', ''), 10);
        const size = statSync(path).size;
        this.segments.push({ baseOffset, path, size, sealed: true });
      }
 
      // Segment cuối trở thành active
      if (this.segments.length > 0) {
        const last = this.segments[this.segments.length - 1];
        last.sealed = false;
        this.activeSegment = last;
      }
    } catch {
      // Không có segments hiện có
    }
  }
}

Quy Ước Đặt Tên Segment

Các hệ thống thực tế dùng số offset zero-padded làm tên segment để lexicographic sorting bằng offset sorting:

# Kafka segment files (20-digit zero-padded offsets)
00000000000000000000.log    # Bắt đầu tại offset 0
00000000000000065536.log    # Bắt đầu tại offset 65536
00000000000000131072.log    # Bắt đầu tại offset 131072
 
# PostgreSQL WAL segments (timeline + segment number)
000000010000000000000001    # Timeline 1, segment 1
000000010000000000000002    # Timeline 1, segment 2

Scheme đặt tên này cho phép:

  • Tìm nhanh: binary search qua tên segments để tìm offset cụ thể
  • Dọn dẹp đơn giản: xóa files có tên trước cutoff point
  • Thứ tự filesystem: ls hiển thị segments theo thứ tự thời gian

Segmented Log Trong Kafka

Quản lý segment của Kafka rất linh hoạt:

# Kích thước segment — roll sang segment mới khi đạt kích thước
log.segment.bytes=1073741824  # 1GB (default)
 
# Tuổi segment — roll sang segment mới sau thời gian này
log.roll.ms=604800000  # 7 ngày
 
# Mỗi segment có companion index files
# .log       — data messages thực tế
# .index     — sparse offset-to-byte-position index
# .timeindex — timestamp-to-offset index

Mỗi segment trong Kafka gồm ba files:

00000000000000065536.log        # Message data
00000000000000065536.index      # Offset → byte position (sparse)
00000000000000065536.timeindex  # Timestamp → offset (sparse)

File .index là sparse index — nó map mỗi N offsets sang byte position trong file .log. Để tìm offset 65600:

  1. Tìm trong .index offset lớn nhất <= 65600 (giả sử 65580 ở byte 42000)
  2. Scan .log bắt đầu từ byte 42000 cho đến khi tìm thấy offset 65600

Cách này cho O(1) lookup với memory overhead tối thiểu.

Segmented Log Trong etcd

etcd chia WAL thành segments 64MB:

member/
└── wal/
    ├── 0000000000000000-0000000000000000.wal   # Segment đầu tiên
    ├── 0000000000000001-0000000000001000.wal   # Segment thứ hai
    └── ...

Cách đặt tên segment của etcd encode cả sequence number và index của entry đầu tiên — giúp dễ dàng tìm segment nào chứa entry cần tìm.


Pattern 3: Low-Water Mark

Vấn Đề Disk Ngày Càng Đầy

Với WAL và segmented log, chúng ta có durability và file sizes dễ quản lý. Nhưng segments cứ tích lũy:

Tháng 1:  10 segments  (640 MB)
Tháng 6:  60 segments  (3.8 GB)
Tháng 12: 120 segments (7.6 GB)  ← Disk sắp đầy

Chúng ta cần xóa segments cũ. Nhưng segments nào an toàn để xóa? Nếu xóa segment vẫn còn cần cho recovery, hệ thống bị corrupt. Nếu giữ tất cả, hết disk.

Ý Tưởng Cốt Lõi

Low-Water Mark pattern theo dõi log entry cũ nhất vẫn còn cần bởi bất kỳ component nào trong hệ thống:

Low-water mark là điểm trong log mà tất cả entries bên dưới có thể được xóa an toàn. Không có gì trong hệ thống vẫn cần entries trước điểm này.

Low-water mark tiến lên khi:

  • Một snapshot capture toàn bộ state (nên không cần log cho recovery)
  • Tất cả consumers đã đọc qua một điểm nhất định (nên không cần log cho replay)
  • Một time-based retention policy hết hạn data cũ

Hai Cách Tiếp Cận Low-Water Mark

Cách 1: Low-Water Mark Dựa Trên Snapshot

Chụp snapshot định kỳ của toàn bộ state. Sau snapshot, log entries trước snapshot LSN không còn cần cho recovery.

class SnapshotBasedLWM {
  private state: Map<string, string> = new Map();
  private segmentedLog: SegmentedLog;
  private lastSnapshotLSN = 0;
  private snapshotPath: string;
 
  constructor(segmentedLog: SegmentedLog, snapshotPath: string) {
    this.segmentedLog = segmentedLog;
    this.snapshotPath = snapshotPath;
  }
 
  /**
   * Chụp snapshot — điều này đẩy low-water mark lên
   */
  takeSnapshot(currentLSN: number): void {
    // Serialize toàn bộ state xuống disk
    const snapshot = {
      lsn: currentLSN,
      timestamp: Date.now(),
      data: Object.fromEntries(this.state),
    };
 
    writeFileSync(this.snapshotPath, JSON.stringify(snapshot));
    // Đảm bảo snapshot durable trước khi xóa WAL segments
    fsyncSync(openSync(this.snapshotPath, 'r'));
 
    this.lastSnapshotLSN = currentLSN;
 
    // Giờ có thể xóa segments cũ an toàn
    const deleted = this.segmentedLog.deleteSegmentsBefore(currentLSN);
    console.log(`Snapshot tại LSN ${currentLSN}, xóa ${deleted} segments`);
  }
 
  /**
   * Recovery: load snapshot + replay WAL entries sau snapshot
   */
  recover(): void {
    // Bước 1: Load snapshot mới nhất
    if (existsSync(this.snapshotPath)) {
      const snapshot = JSON.parse(readFileSync(this.snapshotPath, 'utf-8'));
      this.state = new Map(Object.entries(snapshot.data));
      this.lastSnapshotLSN = snapshot.lsn;
      console.log(`Loaded snapshot tại LSN ${snapshot.lsn}`);
    }
 
    // Bước 2: Replay WAL entries SAU snapshot
    let replayed = 0;
    for (const entry of this.segmentedLog.readAll()) {
      if (entry.lsn > this.lastSnapshotLSN) {
        this.applyEntry(entry);
        replayed++;
      }
    }
    console.log(`Replayed ${replayed} entries sau snapshot`);
  }
 
  private applyEntry(entry: WALEntry): void {
    if (entry.operation === 'SET') {
      this.state.set(entry.key, entry.value!);
    } else if (entry.operation === 'DELETE') {
      this.state.delete(entry.key);
    }
  }
}

Đây là cách tiếp cận của etcd và ZooKeeper. etcd chụp snapshot định kỳ rồi purge WAL segments cũ.

Cách 2: Low-Water Mark Dựa Trên Consumer

Theo dõi position của mọi consumer (reader). Low-water mark là minimum position qua tất cả consumers:

class ConsumerBasedLWM {
  private consumerOffsets: Map<string, number> = new Map();
 
  /**
   * Cập nhật committed position của consumer
   */
  commitOffset(consumerId: string, offset: number): void {
    this.consumerOffsets.set(consumerId, offset);
  }
 
  /**
   * Low-water mark = minimum offset qua tất cả consumers
   */
  getLowWaterMark(): number {
    if (this.consumerOffsets.size === 0) return 0;
    return Math.min(...this.consumerOffsets.values());
  }
 
  /**
   * Dọn dẹp segments dưới low-water mark
   */
  cleanup(segmentedLog: SegmentedLog): number {
    const lwm = this.getLowWaterMark();
    return segmentedLog.deleteSegmentsBefore(lwm);
  }
}

Đây là cách tiếp cận của Kafka. Mỗi consumer group theo dõi offset của mình, và log cleaner của Kafka dùng các offsets này (cộng retention policies) để xác định cái gì có thể xóa.

Low-Water Mark Trong Kafka

Log cleanup của Kafka kết hợp consumer offsets với retention policies cấu hình được:

# Time-based retention — xóa segments cũ hơn 7 ngày
log.retention.hours=168
 
# Size-based retention — giữ tối đa 100GB mỗi partition
log.retention.bytes=107374182400
 
# Log compaction — chỉ giữ value mới nhất cho mỗi key
log.cleanup.policy=compact
 
# Hoặc kết hợp cả hai: xóa data cũ VÀ compact
log.cleanup.policy=compact,delete

Time-based retention rất đơn giản: xóa segments có message mới nhất cũ hơn retention period. Đây là low-water mark tiến lên theo thời gian.

Log compaction thú vị hơn. Thay vì xóa toàn bộ segments, Kafka chỉ giữ value mới nhất cho mỗi key:

Log compaction cực kỳ quan trọng cho changelog topics — topics đại diện cho state hiện tại của entity. Sau compaction, replay log sẽ cho bạn state mới nhất của mọi key mà không cần đọc toàn bộ history.

Low-Water Mark Trong etcd

etcd dùng cách tiếp cận snapshot-based với snapshot interval cấu hình được:

# Chụp snapshot mỗi 10,000 applied entries
--snapshot-count=10000
 
# Số WAL files tối đa giữ lại
--max-wals=5

Flow dọn dẹp của etcd:

  1. Sau mỗi 10,000 entries, chụp snapshot toàn bộ state
  2. Snapshot bao gồm applied index (giống LSN)
  3. WAL files trước snapshot có thể xóa
  4. Khi recovery: load snapshot, rồi replay WAL entries sau snapshot

Low-Water Mark Trong PostgreSQL

PostgreSQL dùng checkpoints làm cơ chế low-water mark:

-- Cấu hình checkpoint
SHOW checkpoint_timeout;        -- default: 5min
SHOW max_wal_size;              -- default: 1GB

Checkpoint flush tất cả dirty pages (data đã bị modified) từ shared buffers xuống disk. Sau khi checkpoint hoàn tất, WAL segments trước LSN của checkpoint không còn cần cho crash recovery và có thể được recycle.

Setting max_wal_size kiểm soát bao nhiêu WAL tích lũy trước khi PostgreSQL ép checkpoint — hiệu quả là giới hạn recovery phải replay bao xa.


Cách Ba Pattern Hoạt Động Cùng Nhau

Ba pattern này tạo thành một durability stack hoàn chỉnh. Mỗi pattern giải quyết một vấn đề cụ thể, và cùng nhau chúng xử lý toàn bộ lifecycle của persistent data:

Vấn đềPatternGiải pháp
"Server crash giữa lúc ghi thì sao?"Write-Ahead LogAppend thay đổi vào durable log trước khi apply
"File log phình to mãi"Segmented LogChia thành segments cố định, chỉ segment active nhận writes
"Segments cũ lãng phí disk"Low-Water MarkTheo dõi cái gì an toàn để xóa, dọn dẹp segments cũ

Vòng Đời Hoàn Chỉnh

Hãy trace toàn bộ lifecycle với ví dụ cụ thể:

Bước 1: Client gửi SET user:123 "Alice"
  → WAL append entry #4001 vào active segment
 
Bước 2: Active segment đạt 64MB
  → Segmented Log roll: seal segment-4, tạo segment-5
 
Bước 3: System chụp snapshot tại LSN 4000
  → Low-Water Mark tiến lên 4000
 
Bước 4: Segments 1-3 (LSN 1-3000) đều dưới low-water mark
  → Segments 1-3 bị xóa khỏi disk
 
Bước 5: Server crash và restart
  → Load snapshot (state tại LSN 4000)
  → Replay segment-4 entries 4001-4500
  → Replay segment-5 entries 4501-current
  → State recover hoàn toàn

Bản Đồ Pattern Trong Thực Tế

Đây là cách ba pattern được implement trong các hệ thống thực:

SystemWALSegmented LogLow-Water Mark
PostgreSQLpg_wal/ files (16MB segments)WAL segment files, auto-recycledCheckpoint LSN — segments trước checkpoint có thể recycle
KafkaPartition log filesSegment size cấu hình được (1GB default)Consumer offsets + retention policy (time/size/compaction)
etcdWAL files trong member/wal/WAL segments (64MB)Snapshot index — WAL files trước snapshot bị purge
MySQL InnoDBRedo log (ib_logfile0/1)Fixed-size circular log filesCheckpoint LSN — pages flush xuống tablespace
CockroachDBPebble WALSST files (sorted string tables)Compaction — merge và discard versions cũ

Thực Hành: Build Một Durable Key-Value Store

Hãy kết hợp cả ba pattern vào một key-value store hoạt động:

class DurableKVStore {
  private state: Map<string, string> = new Map();
  private currentLSN = 0;
  private segmentedLog: SegmentedLog;
  private snapshotPath: string;
  private lastSnapshotLSN = 0;
  private opsAfterSnapshot = 0;
  private snapshotInterval: number;
 
  constructor(
    dataDir: string,
    options: {
      maxSegmentSize?: number;
      snapshotInterval?: number;
    } = {}
  ) {
    this.segmentedLog = new SegmentedLog(
      join(dataDir, 'wal'),
      options.maxSegmentSize ?? 64 * 1024 * 1024,
    );
    this.snapshotPath = join(dataDir, 'snapshot.json');
    this.snapshotInterval = options.snapshotInterval ?? 10000;
 
    this.recover();
  }
 
  set(key: string, value: string): void {
    const entry: WALEntry = {
      lsn: ++this.currentLSN,
      operation: 'SET',
      key,
      value,
      timestamp: Date.now(),
    };
 
    // WAL: persist trước
    this.segmentedLog.append(entry);
 
    // Apply vào state
    this.state.set(key, value);
 
    // Có thể chụp snapshot (đẩy low-water mark lên)
    this.maybeSnapshot();
  }
 
  delete(key: string): void {
    const entry: WALEntry = {
      lsn: ++this.currentLSN,
      operation: 'DELETE',
      key,
      timestamp: Date.now(),
    };
 
    this.segmentedLog.append(entry);
    this.state.delete(key);
    this.maybeSnapshot();
  }
 
  get(key: string): string | undefined {
    return this.state.get(key);
  }
 
  /**
   * Snapshot-based low-water mark:
   * Sau N operations, snapshot state và dọn dẹp segments cũ
   */
  private maybeSnapshot(): void {
    this.opsAfterSnapshot++;
    if (this.opsAfterSnapshot >= this.snapshotInterval) {
      this.takeSnapshot();
    }
  }
 
  private takeSnapshot(): void {
    const snapshot = {
      lsn: this.currentLSN,
      timestamp: Date.now(),
      entries: Object.fromEntries(this.state),
    };
 
    writeFileSync(this.snapshotPath, JSON.stringify(snapshot));
 
    // Đẩy low-water mark lên và dọn dẹp
    const deleted = this.segmentedLog.deleteSegmentsBefore(this.currentLSN);
    this.lastSnapshotLSN = this.currentLSN;
    this.opsAfterSnapshot = 0;
 
    console.log(
      `Snapshot tại LSN ${this.currentLSN}, xóa ${deleted} segments cũ`
    );
  }
 
  /**
   * Recovery: snapshot + WAL replay
   */
  private recover(): void {
    // Bước 1: Load snapshot nếu có
    if (existsSync(this.snapshotPath)) {
      const snapshot = JSON.parse(readFileSync(this.snapshotPath, 'utf-8'));
      this.state = new Map(Object.entries(snapshot.entries));
      this.lastSnapshotLSN = snapshot.lsn;
      this.currentLSN = snapshot.lsn;
      console.log(
        `Loaded snapshot: ${this.state.size} keys tại LSN ${snapshot.lsn}`
      );
    }
 
    // Bước 2: Replay WAL entries sau snapshot
    let replayed = 0;
    for (const entry of this.segmentedLog.readAll()) {
      if (entry.lsn > this.lastSnapshotLSN) {
        this.applyEntry(entry);
        this.currentLSN = entry.lsn;
        replayed++;
      }
    }
 
    if (replayed > 0) {
      console.log(`Replayed ${replayed} WAL entries sau snapshot`);
    }
  }
 
  private applyEntry(entry: WALEntry): void {
    if (entry.operation === 'SET') {
      this.state.set(entry.key, entry.value!);
    } else if (entry.operation === 'DELETE') {
      this.state.delete(entry.key);
    }
  }
}

Cách Dùng

// Tạo durable KV store
const store = new DurableKVStore('./data', {
  maxSegmentSize: 10 * 1024 * 1024,  // Segments 10MB
  snapshotInterval: 5000,             // Snapshot mỗi 5000 ops
});
 
// Các writes này sống sót qua crashes
store.set('user:1', 'Alice');
store.set('user:2', 'Bob');
store.set('config:theme', 'dark');
 
// Reads đến từ memory — nhanh
console.log(store.get('user:1')); // "Alice"
 
// Nếu process crash và restart, DurableKVStore
// tự động recover từ snapshot + WAL replay

Những Cạm Bẫy Thường Gặp

Cạm Bẫy 1: Quên fsync

// SAI — data có thể nằm trong OS buffer, không trên disk
appendFileSync(logPath, data);
 
// ĐÚNG — ép data xuống physical disk
const fd = openSync(logPath, 'a');
appendFileSync(fd, data);
fsyncSync(fd);
closeSync(fd);

Không có fsync, WAL của bạn không cung cấp đảm bảo durability nào. OS có thể buffer writes và mất chúng khi crash. Setting fsync=off của PostgreSQL ghi rõ: "có thể dẫn đến data corruption không thể khôi phục."

Cạm Bẫy 2: Không Xử Lý Partial Writes

Crash có thể xảy ra giữa lúc ghi, để lại dòng JSON bị cắt:

{"lsn":42,"operation":"SET","key":"user:1","value":"Al

Code recovery phải xử lý được trường hợp này:

private recoverEntry(line: string): WALEntry | null {
  try {
    const entry = JSON.parse(line);
    return entry;
  } catch {
    // Entry bị cắt — write cuối bị gián đoạn
    console.warn(`Bỏ qua entry lỗi: ${line.substring(0, 50)}...`);
    return null; // An toàn: client chưa nhận "OK" cho entry này
  }
}

Điều này an toàn vì: nếu write bị gián đoạn, client chưa nhận "OK", nên không có data loss từ góc nhìn của client.

Cạm Bẫy 3: Xóa Segments Vẫn Còn Cần

// SAI — nếu consumer vẫn đang đọc từ 2 ngày trước thì sao?
deleteSegmentsOlderThan(24 * 60 * 60 * 1000);
 
// ĐÚNG — kiểm tra tất cả consumers trước khi xóa
const lwm = Math.min(...allConsumerOffsets);
deleteSegmentsBefore(lwm);

Luôn đảm bảo không component nào vẫn cần data trước khi xóa segments. Trong hệ thống replicated, điều này bao gồm kiểm tra positions của tất cả followers — bạn không thể xóa segments mà follower chưa replicate.

Cạm Bẫy 4: Snapshot Không Phối Hợp Với WAL

// SAI — snapshot không ghi lại LSN
takeSnapshot(state);
deleteAllWAL(); // Mất entries giữa snapshot và crash!
 
// ĐÚNG — snapshot ghi chính xác LSN
takeSnapshot(state, currentLSN);
deleteWALBefore(currentLSN); // Chỉ xóa phần snapshot đã cover

Snapshot phải ghi chính xác LSN nó đại diện, để recovery biết bắt đầu replay WAL từ đâu.


Khi Nào Dùng Các Pattern Này

Dùng WAL Khi:

  • Bạn cần crash recovery — bất kỳ system nào persist state
  • Bạn cần replication — WAL entries có thể ship sang replicas (PostgreSQL streaming replication)
  • Bạn cần audit logging — WAL là lịch sử đầy đủ của mọi thay đổi
  • Bạn đang build bất kỳ database, message broker, hay coordination service nào

Dùng Segmented Log Khi:

  • WAL phình to không giới hạn — điều luôn xảy ra trong production
  • Bạn cần dọn dẹp data cũ mà không phải rewrite toàn bộ log
  • Bạn muốn giới hạn thời gian recovery — chỉ cần replay segments gần đây
  • Bạn cần parallel reads — consumers khác nhau có thể đọc segments khác nhau đồng thời

Dùng Low-Water Mark Khi:

  • Bạn cần thu hồi disk space từ log segments cũ
  • Bạn có nhiều consumers đọc log ở tốc độ khác nhau
  • Bạn chụp snapshot định kỳ và muốn truncate log phía sau chúng
  • Bạn cần retention policies — giữ 7 ngày, giữ 100GB, hoặc giữ latest-per-key

Không Dùng Các Pattern Này Khi:

  • Data vừa trong memory và không cần sống sót qua restarts
  • Bạn đang dùng database có sẵn đã implement các pattern này
  • Bạn đang build stateless service không có persistent state

Tóm Tắt và Điểm Chính

✅ Write-Ahead Log đảm bảo durability: ghi vào log trước, apply vào state sau — crash không bao giờ corrupt data
✅ fsync là bắt buộc — không có nó, OS có thể mất data "đã persist" khi crash
✅ Batch fsync calls là optimization quan trọng — một fsync cho 100 writes thay vì 100 fsyncs
✅ Segmented Log chia WAL phình to thành files dễ quản lý — chỉ active segment nhận writes
✅ Sealed segments là immutable — khiến chúng an toàn để xóa, copy, hay archive riêng lẻ
✅ Low-Water Mark theo dõi cái gì an toàn để xóa — qua snapshots (etcd) hoặc consumer offsets (Kafka)
✅ Log compaction chỉ giữ value mới nhất mỗi key — thiết yếu cho changelog topics trong Kafka
✅ Ba pattern này compose: WAL cung cấp durability, segments quản lý file sizes, low-water mark cho phép cleanup
✅ Mọi distributed system bắt đầu từ đây — PostgreSQL, Kafka, etcd, MySQL, CockroachDB đều implement các pattern này
✅ Recovery = snapshot + WAL replay — load snapshot cuối, replay WAL entries sau nó


Tiếp Theo

Với nền tảng durability đã vững, câu hỏi tiếp theo là: làm sao replicate log này qua nhiều machines? Điều đó đòi hỏi trả lời:

  • Ai nhận writes? (Leader)
  • Followers sync thế nào? (Replication)
  • Phát hiện leader failure thế nào? (HeartBeat)
  • Ngăn stale leaders thế nào? (Generation Clock)

Đây là các pattern của leader election — chủ đề bài viết tiếp theo.


Series: Patterns of Distributed Systems Roadmap
Next: Post #3 — Leader and Followers, HeartBeat & Generation Clock


Có câu hỏi về durability patterns hay WAL implementations? Hãy liên hệ nhé!

📬 Đăng ký nhận bản tin

Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.

Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.

💬 Comments

Đăng nhập để bình luận

Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.