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) ได้
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
ทุกบท 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 ไปได้แค่ page cache — ยังไม่ถึง disk
หัวข้อที่มีชื่อว่า “write_all ไปได้แค่ page cache — ยังไม่ถึง disk”ภาพในหัวที่มือใหม่มักมีคือ “เรียก 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 แล้วเท่านั้น”
log ของเราคือ WAL อยู่แล้ว
หัวข้อที่มีชื่อว่า “log ของเราคือ WAL อยู่แล้ว”ก่อนไปที่ 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) ชี้ไว้
ลำดับที่ load-bearing: write → fsync → index → ack
หัวข้อที่มีชื่อว่า “ลำดับที่ load-bearing: write → fsync → index → ack”หัวใจของบทนี้อยู่ที่ 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 ได้}อ่านทีละหมายเลข:
write_allสร้าง record ทั้งก้อน (header[key_len u32][val_len u32]+ key + val) ใน1 buffer แล้วเขียนทีเดียว — ตอนนี้ byte อยู่ใน page cache เท่านั้น ยังไม่ durablesync_data()คือจุดที่เปลี่ยนทุกอย่าง มัน block ไปเรื่อยๆ จนกว่า kernel จะยืนยันว่า byte ลง storage จริงแล้ว — นี่คือ fsync ที่ทำให้ write กลายเป็น durable- อัปเดต index หลัง fsync คืนค่าเท่านั้น เพราะ index ในหน่วยความจำคือ “ความจริง” ที่
getเชื่อ ถ้าเราใส่ index ก่อน fsync แล้ว crash ระหว่างนั้น index (ที่หายไปพร้อม RAM) กับ log (ที่ไม่มี record นั้น) จะไม่ตรงกัน — แต่เพราะเรารีสร้าง index จาก log ทุกครั้งที่ boot ประเด็นจริงคือ: ตัว log บน disk ต้อง durable ก่อนเราจะถือว่า write นั้นเกิดขึ้น Ok(())= สัญญาว่า durable ดังนั้น ห้าม return ก่อนsync_dataสำเร็จ ในบท5 เมื่อ server ตอบST_OKกลับไป client จะเชื่อได้ว่าข้อมูลรอด crash แล้วจริง
version ดิบที่ผิดคือสลับบรรทัด 2 กับ 4: write_all แล้ว Ok(()) ทันที ค่อย fsync ทีหลัง (หรือ fsync เป็น batch เบื้องหลัง) — code แบบนี้ compile ผ่านสบาย และดู “เร็วกว่า” เพราะไม่ต้องรอจาน disk แต่มันโก6 client: บอกว่าสำเร็จทั้งที่ byte ยังอยู่แค่ใน RAM ไฟดับตอนนั้นข้อมูลที่ ack ไปแล้วหาย — นี่คือการละเมิด durability ที่ compiler จับไม่ได้ (borrow checker พิสูจน์ความปลอดภัยของหน่วยความจำ ไม่ได้พิสูจน์ความถูกต้องข้าม crash) กฎง่ายๆ: fsync คืนค่าก่อน ack เสมอ
sync_all กับ sync_data ต่างกันตรงไหน
หัวข้อที่มีชื่อว่า “sync_all กับ sync_data ต่างกันตรงไหน”std ให้ fsync สองแบบ เลือกให้ถูกช่วยประหยัด I/O:
File::sync_allmap กับfsync(2)— บังคับลง storage ทั้ง เนื้อข้อมูล และ metadata ทั้งหมด ของ file (เวลาแก้ไขล่าสุด, permission, ฯลฯ)File::sync_datamap กับ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, อย่างน้อยก็เร็วกว่า” ใช้ได้อย่างปลอดภัย
crash recovery: replay log ถึง EOF แล้วทน torn tail
หัวข้อที่มีชื่อว่า “crash recovery: replay log ถึง EOF แล้วทน torn tail”เมื่อ 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
รันจริง: write → drop → reopen → torn tail สองแบบ
หัวข้อที่มีชื่อว่า “รันจริง: write → drop → reopen → torn tail สองแบบ”รวมทั้งหมดเป็นโปรแกรมเดียวที่เขียน 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 OKtorn BODY tail dropped on replay -> every prior record survives OKtorn HEADER stray bytes treated as EOF -> every prior record survives OKfinal 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 ตัวนี้ให้ครบ:
ทน 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 ของบทนี้
บทนี้อิงต้นทางที่ลงวันที่กำกับ อ่านต่อได้โดยตรง:
- Database Internals — ch5 Transaction Processing & Recovery (WAL, force policy, group commit) โดย Alex Petrov (2019) — กฎ durability เป็น ordering rule: ack ว่า durable ได้หลัง force ลง stable storage ด้วย fsync แล้วเท่านั้น; group commit เพื่อเฉลี่ยต้นทุน fsync
- Designing Data-Intensive Applications — ch3 Storage & Retrieval (log-structured storage, crash recovery) โดย Martin Kleppmann (2017 / 2e 2026) — log เป็นทั้งข้อมูลและ recovery log; replay เพื่อ rebuild index หลัง crash
- std
fs::File::sync_all/sync_data(เข้าถึง 2026-07-24) —sync_all= fsync (data + metadata ทั้งหมด);sync_data= fdatasync (data + เฉพาะ metadata ที่ผู้อ่านต้องใช้); บาง platform implementsync_dataเป็นsync_all - std
Read::read_exact(เข้าถึง 2026-07-24) — อ่านให้เต็มbuf.len()พอดี หรือคืนUnexpectedEof; ตอน error เนื้อ buffer/ตำแหน่งใน stream ไม่ระบุ — จึงเหมาะกับการจับ torn tail - “PostgreSQL’s fsync() surprise” (fsyncgate) โดย Jonathan Corbet, LWN.net (2018) — บน Linux
fsyncที่ล้มเหลวอาจรายงาน error ครั้งเดียวแล้วเคลียร์ dirty-page state ทิ้ง - rust-lang/rust #55920 —
sync_allusesF_FULLFSYNCon macOS (2018) — บน macOSFile::sync_allสั่งF_FULLFSYNCเพื่อ flush hardware cache จริง
เช็กความเข้าใจ — บทที่ 3
ข้อ 1 / 3เมื่อ write_all คืน Ok แล้ว ข้อมูล durable (รอด crash) หรือยัง เพราะอะไร?