ข้าม​ไป​ยัง​เนื้อหา

Durability และ crash recovery — WAL, fsync, และ​ลำดับ write→fsync→ack

บท2 เรา​ทำให้​ข้อมูล รอด การ​ปิด​โปรแกรม ด้วย​การ append ทุก set/delete เป็น record ลง append-only log file เดียว แล้ว​เก็บ hash index ใน​หน่วย​ความ​จำ​ที่​ชี้​จาก key → offset ใน file (model Bitcask) แต่​มี​ความ​จริง​หนึ่ง​ข้อ​ที่​บท2 พูด​ค้าง​ไว้​แล้ว​เลื่อน​มา​บท​นี้: “appended” ยัง​ไม่ใช่ “durable” เมื่อ write_all คืน Ok มัน​แค่​แปล​ว่า kernel รับ byte เข้า page cache ของ OS แล้ว — ไม่​ได้​แปล​ว่า byte ถึง​จาน disk จริง ถ้า​ไฟ​ดับ​หรือ​เครื่อง panic ใน​จังหวะ​นั้น byte ที่​ยัง​ค้าง​ใน page cache หาย​เกลี้ยง ทั้ง​ที่​โปรแกรม​เรา​คิด​ว่า​เขียน​สำเร็จ​ไป​แล้ว

บท​นี้​คือ แกน​ความ​ไว้ใจ ของ​ทั้ง​คอร์ส: เรา​จะ​ปิด​ช่อง​นั้น​ด้วย​ลำดับ​ที่​ตายตัว — write_all → fsync → อัปเดต index → ack — แล้ว​ต่อ crash recovery: ตอน boot สแกน log ทั้ง file เพื่อ​สร้าง index ขึ้น​ใหม่ โดย​ออกแบบ​ให้ ทน record ตัว​สุดท้าย​ที่​ถูก​เขียน​ค้าง​ครึ่ง​ทาง (torn tail) ได้

📦 kaen-kvstore

code ลงมือ​ของ​คอร์ส​นี้​อยู่​ใน repo kaen-kvstore (code ตัวอย่าง​กำลัง​จัด​ทำ) — ตลอด 8 บท​เรา​สร้าง key-value store บน​เครือข่าย​ที่​กู้​คืน​จาก crash ได้ หนึ่ง​ตัว ด้วย Rust std ล้วน (ไม่มี async/tokio, ไม่มี serde) บท​นี้​ยก​ระดับ log ของ​บท2 จาก “เขียน​แล้ว” เป็น “durable จริง”: ลำดับ write→fsync→ack + replay-on-boot ที่​ทน torn tail — โครง​เดิม​จาก​บท1–2 (write_frame / record แบบ length-prefixed) ยัง​เป็น​แกน​เดิม เรา​แค่​เพิ่ม fsync คั่น​ก่อน ack และ​เพิ่ม​การ​ทน​ของ replay

toolchain ที่ pin ไว้

ทุก​บท pin ไว้​ที่ Rust stable 1.97.1 (ออก 2026-07-16) และ edition = “2024”[dependencies] ใน Cargo.toml ว่างเปล่า​ตลอด​คอร์ส บท​นี้​แตะ std::fs (File, OpenOptions, File::sync_all/sync_data, set_len, metadata), std::io (Read, Write, Seek, BufReader, read_exact, ErrorKind), และ std::collections (HashMap) เท่านั้น

ภาพ​ใน​หัว​ที่​มือใหม่​มัก​มี​คือ “เรียก write_all แล้ว byte ลง​จาน​ทันที” — ผิด ระบบ file สมัย​ใหม่​ทั้งหมด​มี​ชั้น page cache ของ OS คั่น​อยู่​ตรง​กลาง เมื่อ write_all คืน Ok byte แค่​ถูก copy เข้า page cache ใน RAM แล้ว kernel จะ เขียน​ลง storage จริง “เมื่อ​ว่าง” (ไม่​กี่​วินาที​ถัด​มา หรือ​เมื่อ background flusher ทำงาน) เหตุผล​ที่​ออกแบบ​แบบ​นี้​คือ ความเร็ว — เขียน​ลง RAM แล้ว​คืน​ทันที​เร็ว​กว่า​รอ​จาน disk เป็น​พัน​เท่า แต่​ระหว่าง​ช่วง​ที่ byte ยัง​อยู่​แค่​ใน RAM ถ้า​ไฟ​ดับ ข้อมูล​หาย

นี่​คือ​เหตุผล​ว่า​ทำไม durabilitydurabilityการ​รับประกัน​ว่า​ข้อมูล​ที่ ack แล้ว​รอด crash (ต้อง​ผ่าน fsync ก่อน) — การ​รับประกัน​ว่า​ข้อมูล​ที่​เรา ack (“บอก client ว่า​สำเร็จ”) ไป​แล้ว ต้อง​รอด crashจึง​เป็น​กฎ​เรื่อง​ลำดับ (ordering rule) ไม่ใช่​ตัว API ตัว​เดียว เรา​จะ​บอก​ได้​ว่า write หนึ่ง​ครั้ง durable ก็​ต่อ​เมื่อ​เรา​สั่ง fsyncfsyncบังคับ byte จาก page cache ลง storage จริง (`File::sync_all`/`sync_data`) — บังคับ​ให้ byte จาก page cache ลง storage จริง — แล้ว fsync คืน​ค่า​สำเร็จ เท่านั้น ก่อนหน้า​นั้น​ห้าม​ถือว่า durable เด็ดขาด ตำรา Petrov (ch5) พูดตรงๆ ว่า​กฎ durability คือ “write จะ​ถูก ack ว่า durable ได้​ก็​ต่อ​เมื่อ record ถูก force ลง stable storage ด้วย fsync แล้ว​เท่านั้น”

ก่อน​ไป​ที่ code วาง​กรอบ​แนวคิด​ให้​ตรง​ก่อน สิ่ง​ที่​เรา​กำลัง​ทำ​มีชื่อ​ใน​ตำรา​ฐาน​ข้อมูล​ว่า WALWALwrite-ahead log: บันทึก​ลง log ให้ durable ก่อน​ถือว่า commit (write-ahead log) — หลักการ​คือ บันทึก​การ​เปลี่ยนแปลง​ลง log ให้ durable ก่อน ค่อย​ถือว่า commit จุด​ที่​น่า​สนใจ​ของ storage engine แบบ log-structured อย่าง​ของ​เรา​คือ: append-only data log ของ​เรา คือ WAL อยู่​แล้ว ไม่​ต้อง​มี log แยก​อีก file

เทียบ​กับ B-tree ของ​ฐาน​ข้อมูล​ดั้งเดิม: มัน​เก็บ​ข้อมูล​จริง​เป็น page แล้ว แก้​ทับที่​เดิม (in-place) การ​แก้​ทับ​กลางคัน​ทำให้ page เสียหาย​ได้ มัน​จึง​ต้อง​เขียน WAL แยก​อีก file ก่อน​ไป​แตะ page จริง — 2 write ต่อ​หนึ่ง​การ​เปลี่ยนแปลง แต่ store ของ​เรา​ไม่​เคย​แก้​ทับที่​เดิม​เลย ทุก​อย่าง​คือ append record ใหม่​ต่อ​ท้าย ตัว log จึง​เป็น​ทั้ง​ข้อมูล​จริง และ เป็น recovery log ใน file เดียวกัน — เขียน​ครั้ง​เดียว​จบ นี่​เป็น​ข้อ​ได้​เปรียบ​เชิง​โครงสร้าง​ของ log-structured storage ที่ DDIA (ch3) ชี้​ไว้

หัวใจ​ของ​บท​นี้​อยู่​ที่ set แค่​ห้า​บรรทัด​สำคัญ — และ ลำดับ ของ​มัน​คือ​ทั้งหมด ห้าม​สลับ:

use std::fs::File;
use std::io::{self, Seek, SeekFrom, Write};
/// Durability contract, in order: append -> fsync -> index -> Ok.
/// Ok means "durable", so NOTHING may return before sync_data succeeds.
pub fn set(&mut self, key: &[u8], val: &[u8]) -> io::Result<()> {
let offset = self.writer.seek(SeekFrom::End(0))?;
let mut rec = Vec::with_capacity(8 + key.len() + val.len());
rec.extend_from_slice(&(key.len() as u32).to_le_bytes());
rec.extend_from_slice(&(val.len() as u32).to_le_bytes());
rec.extend_from_slice(key);
rec.extend_from_slice(val);
self.writer.write_all(&rec)?; // 1. เข้า page cache ของ OS (ยัง NOT durable)
self.writer.sync_data()?; // 2. fdatasync บังคับลง storage; block จนกว่าจะ durable
self.index.insert(key.to_vec(), offset); // 3. index จะเห็น record นี้ก็ต่อเมื่อ fsync คืนแล้ว
Ok(()) // 4. ถึงตรงนี้ server ถึงตอบ OK กลับ client ได้
}

อ่านที​ละ​หมายเลข:

  1. write_all สร้าง record ทั้ง​ก้อน (header [key_len u32][val_len u32] + key + val) ใน1 buffer แล้ว​เขียน​ที​เดียว — ตอน​นี้ byte อยู่​ใน page cache เท่านั้น ยัง​ไม่ durable
  2. sync_data() คือ​จุด​ที่​เปลี่ยน​ทุก​อย่าง มัน block ไป​เรื่อยๆ จนกว่า kernel จะ​ยืนยัน​ว่า byte ลง storage จริง​แล้ว — นี่​คือ fsync ที่​ทำให้ write กลาย​เป็น durable
  3. อัปเดต index หลัง fsync คืน​ค่า​เท่านั้น เพราะ index ใน​หน่วย​ความ​จำ​คือ “ความ​จริง” ที่ get เชื่อ ถ้า​เรา​ใส่ index ก่อน fsync แล้ว crash ระหว่าง​นั้น index (ที่​หาย​ไป​พร้อม RAM) กับ log (ที่​ไม่มี record นั้น) จะ​ไม่​ตรง​กัน — แต่​เพราะ​เรา​รี​สร้าง index จาก log ทุก​ครั้ง​ที่ boot ประเด็น​จริง​คือ: ตัว log บน disk ต้อง durable ก่อน​เรา​จะ​ถือว่า write นั้น​เกิด​ขึ้น
  4. Ok(()) = สัญญา​ว่า durable ดังนั้น ห้าม return ก่อน sync_data สำเร็จ ในบท5 เมื่อ server ตอบ ST_OK กลับ​ไป client จะ​เชื่อ​ได้​ว่า​ข้อมูล​รอด crash แล้ว​จริง
อย่า ack ก่อน fsync คืน​ค่า — นี่​คือ bug durability ที่​พบ​บ่อย​ที่สุด

version ดิบ​ที่​ผิด​คือ​สลับ​บรรทัด 2 กับ 4: write_all แล้ว Ok(()) ทันที ค่อย fsync ทีหลัง (หรือ fsync เป็น batch เบื้องหลัง) — code แบบ​นี้ compile ผ่าน​สบาย และ​ดู “เร็ว​กว่า” เพราะ​ไม่​ต้อง​รอ​จาน disk แต่​มัน​โก6 client: บอกว่า​สำเร็จ​ทั้ง​ที่ byte ยัง​อยู่​แค่​ใน RAM ไฟ​ดับ​ตอน​นั้น​ข้อมูล​ที่ ack ไป​แล้ว​หาย — นี่​คือ​การ​ละเมิด durability ที่ compiler จับ​ไม่​ได้ (borrow checker พิสูจน์​ความ​ปลอดภัย​ของ​หน่วย​ความ​จำ ไม่​ได้​พิสูจน์​ความ​ถูกต้อง​ข้าม crash) กฎ​ง่ายๆ: fsync คืน​ค่า​ก่อน ack เสมอ

std ให้ fsync สอง​แบบ เลือก​ให้​ถูก​ช่วย​ประหยัด I/O:

  • File::sync_all map กับ fsync(2) — บังคับ​ลง storage ทั้ง เนื้อ​ข้อมูล และ metadata ทั้งหมด ของ file (เวลา​แก้ไข​ล่าสุด, permission, ฯลฯ)
  • File::sync_data map กับ fdatasync(2) — บังคับ​ลง เนื้อ​ข้อมูล และ เฉพาะ metadata ที่​ผู้​อ่าน​จำเป็น​ต้อง​ใช้ เท่านั้น ซึ่ง​สำหรับ​การ append ก็​คือ ขนาด file ใหม่ ไม่​ต้อง​แตะ timestamp ฯลฯ จึง​มัก​เป็น I/O ที่​ถูก​กว่า

สำหรับ append-only log ของ​เรา sync_data เพียงพอ — เรา​แค่​ต้องการ​ให้ byte ที่​ต่อ​ท้าย​กับ​ขนาด file ใหม่​ลง storage จริง ไม่​สน​ว่า timestamp จะ durable ช้า​กว่า​ไหม เอกสาร std บอกไว้ตรงๆ ด้วย​ว่า “บาง platform อาจ implement [sync_data] เป็น sync_all ไป​เลย” — เรา​จึง​ถือว่า sync_data เป็น “อย่าง​มาก​ก็​เท่า sync_all, อย่าง​น้อย​ก็​เร็ว​กว่า” ใช้ได้​อย่าง​ปลอดภัย

เมื่อ index อยู่​ใน​หน่วย​ความ​จำ​ล้วน มัน​หาย​ทุก​ครั้ง​ที่​โปรแกรม​ปิด crash recoverycrash recoveryการ​กู้​สถานะ​หลัง crash โดย replay log แล้ว rebuild index คือ​การ​สร้าง index ขึ้น​ใหม่​ตอน boot ด้วย​การ replay — สแกน log ตั้งแต่ byte 0 ถึง EOF แล้ว apply ทุก record ตาม​ลำดับ (record ที่มา​ทีหลัง​ทับ key เดิม = last-write-wins ได้​เปล่าๆ จาก​ลำดับ​การ​อ่าน) ประเด็น​ออกแบบ​ที่แท้​จริง​อยู่​ที่ ท้าย log: ถ้า crash เกิด​กลาง​การ append record ตัว​สุดท้าย file จะ​จบ​ด้วย record ที่​ขาด​ครึ่ง (torn tail) — recovery ต้อง ตัด​ทิ้ง record ครึ่งๆ นั้น แล้ว​เก็บ​ทุก​อย่าง​ก่อนหน้า​ไว้​ครบ

กุญแจ​คือ read_exact: ตาม​เอกสาร std มัน​อ่าน​ให้​เต็ม buf.len() พอดี หรือ​คืน error UnexpectedEof ถ้า​สาย​จบ​ก่อน — และ “ตอน error นั้น เนื้อ buffer และ​ตำแหน่ง​ใน stream ไม่​ระบุ” ซึ่ง​พอดี​เป๊ะ​กับ​สิ่ง​ที่​เรา​ต้องการ: เจอ UnexpectedEof เมื่อไร แปล​ว่า​ถึง​จุด​ที่ log สมบูรณ์​สุดท้าย — break ทิ้ง​ส่วน​ที่​เหลือ เรา​เช็ก UnexpectedEof สอง​จุด: ตอน​อ่าน header 8 byte (จับ torn header / EOF สะอาด) และ​ตอน​อ่าน body (จับ torn body):

use std::collections::HashMap;
use std::fs::File;
use std::io::{self, BufReader, Read};
use std::path::Path;
/// Rebuild the index by scanning the log to EOF, discarding a torn final record.
fn load(path: &Path) -> io::Result<HashMap<Vec<u8>, u64>> {
let mut index: HashMap<Vec<u8>, u64> = HashMap::new();
let file = match File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(index), // boot ครั้งแรก ยังไม่มี file
Err(e) => return Err(e),
};
let mut reader = BufReader::new(file);
let mut offset: u64 = 0;
loop {
let mut header = [0u8; 8];
match reader.read_exact(&mut header) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break, // EOF สะอาด หรือ header ขาด
Err(e) => return Err(e),
}
let key_len = u32::from_le_bytes(header[0..4].try_into().unwrap()) as usize;
let val_len = u32::from_le_bytes(header[4..8].try_into().unwrap()) as usize;
let mut body = vec![0u8; key_len + val_len];
match reader.read_exact(&mut body) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break, // body ขาด -> ตัด tail ทิ้ง
Err(e) => return Err(e),
}
index.insert(body[..key_len].to_vec(), offset); // record ทีหลังชนะ
offset += 8 + (key_len + val_len) as u64;
}
Ok(index)
}

load คืน HashMap<Vec<u8>, u64> ที่ map จาก key → offset ของ record บน disk (ไม่ใช่ offset ของ value โดยตรง — get อ่าน header ที่ offset นั้น​ก่อน​แล้ว​ค่อย​ข้าม​ไป​อ่าน value ตาม val_len) NotFound ตอน​เปิด file คือ boot ครั้ง​แรก​ที่​ยัง​ไม่มี log จึง​คืน index ว่าง​แทนที่​จะ error

รวม​ทั้งหมด​เป็น​โปรแกรม​เดียว​ที่​เขียน 3 record (มี overwrite ของ key lang), drop store (จำลอง process ปิด), reopen แล้ว replay, จาก​นั้น จงใจ ทำให้ tail ขาด​สอง​แบบ​เพื่อ​พิสูจน์​ว่า recovery ทน — แต่ละ​ครั้ง​ใช้ dir ชั่วคราว​เฉพาะ​ตัว (temp_dir + pid + nanos + AtomicU64) แล้ว​เก็บกวาด​ด้วย remove_dir_all ที่​เจาะจง​เฉพาะ dir นั้น (ไม่มี glob, ไม่มี rm -rf แบบ​เหวี่ยง​แห) รัน​บน musl ได้​ผล​ตาม​นี้:

clean reopen -> replay rebuilt index: lang=rust (latest wins), os=linux OK
torn BODY tail dropped on replay -> every prior record survives OK
torn HEADER stray bytes treated as EOF -> every prior record survives OK
final log length = 45 bytes (2 live keys + history)
STORE OK

อ่านที​ละ​บรรทัด: (1) reopen สะอาด — replay สร้าง index ใหม่​ครบ ค่า lang ที่ overwrite เป็น rust ชนะ​ค่า​เดิม go; (2) เรา​ต่อ header ที่​อ้าง body 8 byte แต่​เขียน​จริง​แค่ 2 byte (torn body) — reopen แล้ว load เจอ UnexpectedEof ตอน​อ่าน body เลย break ตัด tail ทิ้ง record ก่อนหน้า​รอด​ครบ; (3) เรา set_len ให้​เหลือ stray byte 3 ตัว (น้อย​กว่า header 8 byte) — load เจอ UnexpectedEof ตอน​อ่าน header เลย​มอง​เป็น EOF record ก่อนหน้า​รอด​ครบ​เช่น​กัน นี่​คือ truncation-tolerance: torn tail ไม่​ทำให้​ข้อมูล​ที่ durable แล้ว​เสียหาย

flowchart TD
    A["set(key, val)"] --> B["write_all(record)"]
    B --> C["byte อยู่ใน page cache<br/>ยัง NOT durable"]
    C -. "crash ตรงนี้ = write หายตอน replay" .-> X["ตัด tail ทิ้ง<br/>(UnexpectedEof)"]
    C ==> D{{"sync_data / fsync<br/>— แนวกั้น durability —"}}
    D ==> E["อัปเดต index ในหน่วยความจำ"]
    E ==> F["ack: ตอบ OK กลับ client"]
    F -. "crash หลังจุดนี้ = write รอด replay" .-> G["record ถูกอ่านกลับตอน boot"]

คำ​บรรยาย​ภาพ: ลำดับ write→fsync→ack — จะ ack ได้​ก็​ต่อ​เมื่อ fsync (sync_data) คืน​ค่า​แล้ว​เท่านั้น crash ก่อน แนว​กั้น fsync ทำให้ write หาย​ตอน replay (ถูก​ตัด​ทิ้ง​แบบ torn tail); crash หลัง แนว​กั้น​ทำให้ write รอด​และ​ถูก​อ่าน​กลับ​ตอน boot

durability เป็น​เรื่อง​ที่ “เกือบ​ถูก” อันตราย​กว่า “ผิดชัดๆ” — เรา​จึง​ต้อง​พูด​ขอบเขต​ของ toy ตัว​นี้​ให้​ครบ:

สิ่ง​ที่ store นี้​รับประกัน — และ​สิ่ง​ที่​มัน​รับประกัน​ไม่​ได้

ทน truncation แต่​ไม่​ทน corruption — length prefix + read_exact จับ​ได้​แค่ tail ที่ ขาด (torn) จาก crash กลาง append มัน​จับ ไม่​ได้ ถ้า byte กลาง record ถูก พลิก เงียบๆ (bit rot, สาย storage เพี้ยน) — record ที่​ยาว​ถูกต้อง​แต่​เนื้อ​ผิด​จะ​ผ่าน replay ไป​เป็น​ข้อมูล​เสีย การ​จับ corruption ต้อง​มี CRC ต่อ record (ซึ่ง Bitcask ของ​จริง​มี — record layout ของ​มัน​ขึ้น​ต้น​ด้วย crc) เรา​จงใจ​ไม่​ใส่​เพื่อ​ให้​โฟกัส​อยู่​ที่ ordering

fsync เอง​ก็​โกหก​ได้ — ปี 2018 PostgreSQL เจอ “fsyncgate”: บน Linux ถ้า fsync ล้มเหลว (EIO) kernel บาง​รุ่น​อาจ​รายงาน error แค่ ครั้ง​เดียว แล้ว เคลียร์ สถานะ dirty-page ทิ้ง — เรียก fsync ซ้ำ​ได้ Ok ทั้ง​ที่​ข้อมูล​ไม่​เคย​ลง​จาน นอกจาก​นี้​ไดรฟ์ USB/consumer จำนวน​มาก เลื่อน การ flush จริง​เพื่อ​ความเร็ว และ​บน macOS fsync ปกติ​ไม่ flush ตัว cache ของ​ฮาร์ดแวร์ ต้อง​ใช้ F_FULLFSYNC — ข่าวดี​คือ Rust File::sync_all สั่ง F_FULLFSYNC ให้​แล้ว​บน macOS (rust-lang/rust #55920) การ​พิสูจน์ durability จริงๆ ทำได้​ทาง​เดียว​คือ ดึง​ปลั๊กไฟ​จริง ซึ่ง unit test ทำ​แทน​ไม่​ได้ — นี่​คือ​ขีด​จำกัด​ของ​การ​ทดสอบ​ใน​บท6

อีก​เรื่อง​ที่​ต้อง​พูด​คือ ต้นทุน: fsync หนึ่ง​ครั้ง​ต่อ1 write เป็น​คำ​ตอบ​ที่ ถูกต้อง แต่ จำกัด throughput เพราะ​แต่ละ write ต้อง​รอ​จาน disk ยืนยัน​จริง (วัด​ได้​ราว ~250 ops/s บน​เครื่อง​ทดสอบ​นี้ ตัวเลข​จริง​จะ​โผล่​ใน​บท7) ฐาน​ข้อมูล​จริง​แก้​ด้วย group commit — สะสม​หลาย write แล้ว fsync ที​เดียว​เพื่อ​เฉลี่ย​ต้นทุน โดย​ยัง​รักษา​กฎ “ack หลัง fsync” ไว้​ครบ และ​มี​อีก​กับดัก​ที่​รอ​เรา​อยู่​ใน​บท5: เมื่อ​มี​หลาย client พร้อม​กัน ห้าม​ถือ global Mutex คร่อม​ช่วง fsync เพราะ​จะ serialize ทุก worker ไว้​หลัง fsync ที่​ช้า​ที่สุด — compiler จับ​ให้​ไม่​ได้​เช่น​กัน

บท​นี้​เปลี่ยน log ของ​บท2 จาก “เขียน​แล้ว” เป็น “durable จริง”: durability เป็น กฎ​เรื่อง​ลำดับwrite_all ถึง​แค่ page cache, sync_data/sync_all (fsync) ต้อง​คืน​ค่า​ก่อน​ถึง​จะ ack ได้, index อัปเดต​หลัง fsync; log-structured store มี log เป็น WAL ใน​ตัว ไม่​ต้อง​มี file log แยก; crash recovery คือ replay log ถึง EOF โดย​ใช้ read_exact + UnexpectedEof เป็น​ตัว​จบ loop ที่​ทน torn header และ torn body; และ​เรา​พูดตรงๆ ว่า toy นี้​ทน truncation แต่​ไม่​ทน corruption (ต้อง​มี CRC), ว่า fsync เอง​ก็​โกหก​ได้ (fsyncgate / F_FULLFSYNC), และ​ว่า fsync-ต่อ-write จำกัด throughput (group commit คือ​ทาง​แก้​จริง) ทุก snippet compile และ​รัน​ได้​จริง​บน Rust 1.97.1 / edition 2024 / std ล้วน

บท4 เรา​ทวง​คืน​พื้นที่: log ที่ append อย่าง​เดียว​โต​ไป​เรื่อยๆ ตาม​จำนวน operation ไม่ใช่​จำนวน key ที่​ยัง​มี​ชีวิต บท​หน้า​เรา​จัดการ delete ให้​เป็น tombstone (record มาร์ค​ว่า​ลบ ไม่ใช่​ลบ byte เดิม​ทันที) แล้ว​เขียน compaction ที่​คัด​เฉพาะ record ที่​ยัง​มี​ชีวิต​ลง file ใหม่ พร้อม​สลับ file แบบ crash-safe ด้วย fsync data → atomic rename → fsync directory ที่​ต่อยอด​ตรง​จาก durability primitive ของ​บท​นี้


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

เช็กความเข้าใจ — บทที่ 3

ข้อ 1 / 3

เมื่อ write_all คืน Ok แล้ว ข้อมูล durable (รอด crash) หรือยัง เพราะอะไร?