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

Persistence — append-only log + hash index ใน​หน่วย​ความ​จำ (model Bitcask)

บท1 เรา​ต่อ​โครง​บน​เครือข่าย​เสร็จ — wire protocol แบบ length-prefixed, server/client TCP, และ store ที่​เป็น HashMap ใน​หน่วย​ความ​จำ​ล้วนๆ แต่​มี​จุด​ตาย​อยู่​หนึ่ง​จุด: HashMap นั้น ตาย​พร้อม process ปิด​โปรแกรม​ที​ข้อมูล​หาย​เกลี้ยง บท​นี้​เรา​ลง disk — ทำให้​ทุก set/delete รอด การ​ปิด​โปรแกรม ด้วย​ท่า​เดียว​ที่​ตรง​ไป​ตรง​มา​ที่สุด: append ทุก​คำ​สั่ง​เป็น record ต่อ​ท้าย file log file เดียว แล้ว​เก็บ index ใน​หน่วย​ความ​จำ​ที่​ชี้​จาก key ไป​ยัง​ตำแหน่ง​ของ record ล่าสุด​ใน file นั้น

นี่​ไม่ใช่​ท่า​ที่​เรา​คิด​ขึ้น​เอง — มัน​คือ model Bitcask ที่​ฐาน​ข้อมูล​จริง (Riak) ใช้ และ​เป็น​แกน​ของ log-structured storage ที่​ตำรา​ฐาน​ข้อมูล​พูดถึงตรงๆ (Petrov ch7, DDIA ch3) เรา​แค่​ย่อ​มัน​ลง​มาระดับ toy เพื่อ เข้าใจ ว่า​มัน​ทำงาน​อย่างไร

📦 kaen-kvstore

code ลงมือ​ของ​คอร์ส​นี้​อยู่​ใน repo kaen-kvstore (code ตัวอย่าง​กำลัง​จัด​ทำ) — ตลอด 8 บท​เรา​สร้าง key-value store บน​เครือข่าย​ที่​กู้​คืน​จาก crash ได้ หนึ่ง​ตัว ด้วย Rust std ล้วน (ไม่มี async/tokio, ไม่มี serde — serialization เขียน​เอง​ด้วย byte แบบ length-prefixed) บท​นี้​เปลี่ยน store จาก HashMap ใน​หน่วย​ความ​จำ ให้​เป็น append-only log บน disk + hash index ใน​หน่วย​ความ​จำ (model Bitcask) — record framing ตัว​เดียว​กับ​บท1 ย้าย​จาก socket มา​เขียน​ลง File ส่วน durability จริง (fsync) ยัง​ไม่​มา​จน​บท3

ฐาน​ข้อมูล​ทุก​ตัว​ต้อง​ตอบ​คำถาม​เดียวกัน: จะ​เก็บ​ข้อมูล​ลง storage ที่ รอด การ​ปิด​เครื่อง​ได้​อย่างไร​ให้​เร็ว การ​เขียน​ทับที่​เดิม (in-place update) ใน file ฟัง​ดู​ตรง​ไป​ตรง​มา แต่​มัน​ช้า (ต้อง seek ไปมา) และ​อันตราย (crash กลาง​การ​เขียน​ทับ = record เดิม​พัง) ทาง​เลือก​ที่ log-structured storage เลือก​คือ​กลับ​ด้าน: ไม่​แก้​ที่​เดิม​เลย เขียน​ต่อ​ท้าย​อย่าง​เดียว

append-only logappend-only logfile ที่​เขียน​ต่อ​ท้าย​อย่าง​เดียว ไม่​แก้​ที่​เดิม เป็น​แกน​ของ log-structured storage คือ file ที่​เรา​เขียน ต่อ​ท้าย อย่าง​เดียว ไม่​เคย​ย้อน​ไป​แก้ byte ที่​เขียน​ไป​แล้ว ทุก set คือ append record ใหม่​หนึ่ง​อัน ทุก delete ก็ append record พิเศษ (tombstone) หนึ่ง​อัน — file จึง​เป็น ประวัติการ​เขียน​ทั้งหมด เรียง​ตาม​เวลา ไม่ใช่ ภาพ​สถานะ​ปัจจุบัน ข้อดี​ที่​ได้​มา​ฟรี​มี​สอง​ชั้น: การ​เขียน​กลาย​เป็น sequential write ล้วน (เร็ว​ทั้ง​บน HDD และ SSD) และ crash กลาง append ทำได้​แค่​ทำให้ record ตัว​สุดท้าย ขาด​หาย ไม่มี​ทาง​ไป​ทำลาย record เก่า​ที่​เขียน​ครบ​ไป​แล้ว แนวคิด​นี้​ตรง​กับ WAL / commit log ใน​ตำรา — เพียง​แต่​ใน​ฐาน​ข้อมูล log-structured ตัว log คือ ข้อมูล​เอง ไม่ใช่ log แยก​ที่​เขียน​ก่อน​แก้​หน้า​จริง (เส้น​นี้​เรา​จะ​ขีด​ชัด​ใน​บท3)

แต่ log ล้วนๆ มี​ปัญหา​หนึ่ง: จะ get key ให้​ไว โดย​ไม่​ต้อง​อ่าน​ทั้ง file ได้​อย่างไร คำ​ตอบ​คือ index

hash indexhash indexแผนที่​ใน​หน่วย​ความ​จำ​จาก key → offset ใน file log อ่าน​ได้​เกือบ 1 I/O คือ HashMap ใน​หน่วย​ความ​จำ​ที่ map จาก​ทุก key ไป​ยัง ตำแหน่ง byte ใน file log ของ record ล่าสุด​ของ key นั้น อ่าน​ค่า​หนึ่ง​ครั้ง = หา key ใน hash map (เกือบ O(1)) ได้ offsetoffsetตำแหน่ง byte ใน file ที่ record หรือ value เริ่มต้น แล้ว seek ไป​ตรง​นั้น​ใน file แล้ว​อ่าน​ค่า​ออก​มา — 1 hash lookup + 1 seek + 1 read เท่านั้น นี่​คือ​หัวใจ​ของ BitcaskBitcaskmodel storage engine: append-only log + hash index ทุก key อยู่​ใน RAM: ทุก key ต้อง​อยู่​ใน RAM ครบ (จึง​จำกัด​ที่​จำนวน key ไม่ใช่​ขนาด​ค่า) แต่​ค่า​จริง​อยู่​บน disk ตาม DDIA ch3 อธิบาย​ไว้​ตรง​ตัว

index ของ​เรา​เก็บ​มากกว่า​แค่ offset นิดหน่อย — เก็บ​ทั้ง​ตำแหน่ง​เริ่ม​ของ ค่า และ​ความ​ยาว​ของ​ค่า เพื่อ​จะ​อ่าน​ได้​พอดี​ใน​ที​เดียว:

// index เก็บตำแหน่งของ value payload (ไม่ใช่ต้น record) + ความยาว
#[derive(Clone, Copy, Debug)]
struct ValuePos {
value_offset: u64, // byte offset ใน file ที่ค่าเริ่มต้น
value_len: u32, // อ่านค่าออกมากี่ byte
}

ก่อน append ได้ ต้อง​ตกลง​หน้าตา record บน disk ก่อน เรา​ใช้ format length-prefixed ตัว​เดียว​กับ frame ในบท1 เป๊ะ เพียง​ย้าย​ปลายทาง​จาก socket มา​เป็น File:

[ key_len: u32 LE ][ val_len: u32 LE ][ key bytes ][ val bytes ]

8 byte แรก​คือ header (ความ​ยาว key และ​ความ​ยาว value แบบ little-endian) แล้ว​ตาม​ด้วย byte ของ key และ value ตาม​จำนวน​นั้น​พอดี delete คือ tombstone: record ที่ val_len == u32::MAX (ค่า sentinel) และ ไม่มี byte ของ value ตาม​มา​เลย ค่า u32::MAX ปลอดภัย​เป็น sentinel เพราะ​ไม่มี​ทาง​เป็น​ความ​ยาว​ค่า​จริง (จะ​เท่ากับ​ค่า​ขนาด 4 GiB) เรา​สร้าง record ทั้ง​ก้อน​ไว้​ใน Vec<u8> เดียว​ก่อน แล้ว​ค่อย write_all ครั้ง​เดียว — หนึ่ง write_all = 1 append ทำให้ crash ตัด​ได้​แค่​หาง​เดียว ไม่​ได้​ตัด​กลาง record จน​โครงสร้าง​เพี้ยน

to_le_bytes แปลง​ความ​ยาว​เป็น byte แบบ little-endian คงที่​ทุก​สถาปัตยกรรม (เหตุผล​เดียว​กับ​บท1) และ​เพราะ key/value เป็น byte ดิบ (Vec<u8>) ทั้ง​คู่ ไม่ใช่ String — store จึง​รับ​ค่า binary อะไร​ก็ได้ ตรง​ข้าม​กับ version ดิบ ที่​เก็บ​ที​ละ​บรรทัด​ด้วย \n คั่น ซึ่ง​พัง​ทันที​เมื่อ value มี newline หรือ​ไม่ใช่ UTF-8

Store ถือ3 state: handle สำหรับ เขียน (เปิด​ด้วย append(true)), handle สำหรับ อ่าน แบบ random-access, และ hash index กับ offset ปลาย file:

use std::collections::HashMap;
use std::fs::{File, OpenOptions};
use std::io::{self, BufReader, Read, Seek, SeekFrom, Write};
use std::path::Path;
const TOMBSTONE: u32 = u32::MAX; // val_len == นี้ = คำสั่ง delete
struct Store {
writer: File, // append handle: ทุก write ลงท้าย file
reader: File, // read handle: cursor อิสระสำหรับ get()
index: HashMap<Vec<u8>, ValuePos>, // key -> ตำแหน่งค่าล่าสุด
tail: u64, // offset ปลาย file (append ถัดไปลงตรงนี้)
}
impl Store {
fn open(path: &Path) -> io::Result<Store> {
// append(true): ทุก write() ลง EOF เสมอ ไม่ว่า cursor อยู่ตรงไหน (O_APPEND)
let writer = OpenOptions::new().create(true).append(true).open(path)?;
let reader = OpenOptions::new().read(true).open(path)?;
let mut store = Store { writer, reader, index: HashMap::new(), tail: 0 };
store.rebuild_index()?; // สแกน log ที่มีอยู่เพื่อสร้าง index
Ok(store)
}
}

การ​เปิด​ด้วย OpenOptions::append(true) ให้ semantics แบบ O_APPEND: ทุก write ลงท้าย file เสมอ ไม่​ว่า cursor จะ​อยู่​ตรง​ไหน — จึง​มี handle แยก​สำหรับ​อ่าน (ที่ seek ไป​มา​ได้​อิสระ​ใน get) โดย​ไม่​รบกวน​ตำแหน่ง​ที่ append ลง สอง​มุมมอง​บน file เดียวกัน ไม่​ชน​กัน

set สร้าง record ทั้ง​ก้อน​แล้ว write_all ที​เดียว จาก​นั้น​ชี้ index ไป​ที่ ค่า (ไม่ใช่​ต้น record) — คือ tail + 8 + key_len เพราะ​ค่า​เริ่ม​หลัง header 8 byte และ key:

impl Store {
fn set(&mut self, key: &[u8], value: &[u8]) -> io::Result<()> {
let key_len = key.len() as u32;
let val_len = value.len() as u32;
// สร้าง buffer เดียว แล้ว write() ครั้งเดียว -> 1 append
let mut rec = Vec::with_capacity(8 + key.len() + value.len());
rec.extend_from_slice(&key_len.to_le_bytes());
rec.extend_from_slice(&val_len.to_le_bytes());
rec.extend_from_slice(key);
rec.extend_from_slice(value);
self.writer.write_all(&rec)?;
let value_offset = self.tail + 8 + key_len as u64; // ชี้ที่ค่า ไม่ใช่ header
self.index.insert(key.to_vec(), ValuePos { value_offset, value_len: val_len });
self.tail += rec.len() as u64;
Ok(())
}
fn delete(&mut self, key: &[u8]) -> io::Result<()> {
let key_len = key.len() as u32;
// record tombstone: val_len = TOMBSTONE, ไม่มี byte ของค่า
let mut rec = Vec::with_capacity(8 + key.len());
rec.extend_from_slice(&key_len.to_le_bytes());
rec.extend_from_slice(&TOMBSTONE.to_le_bytes());
rec.extend_from_slice(key);
self.writer.write_all(&rec)?;
self.index.remove(key); // ลบออกจาก index แต่ record ยังคาอยู่ใน file
self.tail += rec.len() as u64;
Ok(())
}
}

สังเกต​ว่า delete ไม่​ได้ ลบ byte ใน file — มัน​แค่ append tombstone แล้ว​เอา key ออก​จาก index เท่านั้น record เก่า​ของ key ยัง​นอน​อยู่​ใน file (นี่​คือ​เหตุ​ที่ log โต​ไม่​หยุด — บท4 จะ​ทวง​คืน​ด้วย compaction) การ​คำนวณ value_offset ที่ tail + 8 + key_len เป็น​จุด​ที่ ต้อง​ตรง​กับ การ​คำนวณ​ตอน replay เป๊ะๆ ไม่​งั้น index เพี้ยน

get คือ​หัวใจ Bitcask ที่​จ่าย​ผล​ตอบแทน: หา key ใน index ได้ ValuePos แล้ว seek ไป​ที่​ค่าตรงๆ อ่าน​ออก​มา​พอดี value_len byte ด้วย read_exact:

impl Store {
fn get(&mut self, key: &[u8]) -> io::Result<Option<Vec<u8>>> {
let pos = match self.index.get(key) {
Some(p) => *p,
None => return Ok(None), // ไม่มีใน index = ไม่มีค่า
};
self.reader.seek(SeekFrom::Start(pos.value_offset))?;
let mut buf = vec![0u8; pos.value_len as usize];
self.reader.read_exact(&mut buf)?; // อ่านให้ครบ value_len พอดี
Ok(Some(buf))
}
}

ไม่​ต้อง​สแกน file ไม่​ต้อง​อ่าน header ซ้ำ​ตอน get — index บอก​ตำแหน่ง​และ​ความ​ยาว​มา​ให้​แล้ว 1 seek + หนึ่ง read_exact จบ

ตอน​เปิด store เรา​ต้อง​สร้าง index ขึ้น​ใหม่​จาก file ที่​มี​อยู่ วิธี​คือ​สแกน​ตั้งแต่​ต้น​จน​จบ ตาม​ลำดับ​ที่​เขียน แล้ว apply ทีละ record — SET ก็ insert, tombstone ก็ remove เพราะ​เรา​สแกน​ตาม​เวลา record ที่มา​ทีหลัง​ของ key เดิม​จะ insert ทับ​ตัว​ก่อน​โดย​อัตโนมัติ last-write-wins จึง​ตก​มา​ฟรี ไม่​ต้อง​เขียน logic เปรียบเทียบ​เวลา​อะไร​เลย:

impl Store {
fn rebuild_index(&mut self) -> io::Result<()> {
let mut r = BufReader::new(&self.reader);
r.seek(SeekFrom::Start(0))?;
let mut offset: u64 = 0;
loop {
// อ่าน header 8 byte; EOF สะอาดตรงนี้ = จบ log
let mut header = [0u8; 8];
match r.read_exact(&mut header) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => break,
Err(e) => return Err(e),
}
let key_len = u32::from_le_bytes([header[0], header[1], header[2], header[3]]);
let val_len = u32::from_le_bytes([header[4], header[5], header[6], header[7]]);
let mut key = vec![0u8; key_len as usize];
r.read_exact(&mut key)?;
if val_len == TOMBSTONE {
self.index.remove(&key); // delete: ไม่มี byte ค่าตามมา
offset += 8 + key_len as u64;
} else {
let value_offset = offset + 8 + key_len as u64; // ตรงกับสูตรใน set()
self.index.insert(key, ValuePos { value_offset, value_len: val_len });
r.seek(SeekFrom::Current(val_len as i64))?; // ข้าม byte ของค่า
offset = value_offset + val_len as u64;
}
}
self.tail = offset;
Ok(())
}
}

จุด​สำคัญ​คือ UnexpectedEof ตอน​อ่าน header คือ ตัว​จบ loop ปกติ — ไม่ใช่ error เมื่อ​สแกน​ถึง​ปลาย file read_exact จะ​ได้ UnexpectedEof เรา break เงียบๆ (ในบท3 การ match แบบ​เดียวกัน​นี้​จะ​กลาย​เป็น​เครื่องมือ ทน torn tail จาก crash ด้วย) และ value_offset = offset + 8 + key_len ตรง​นี้​ต้อง​ตรง​กับ​สูตร​ใน set เป๊ะ — ถ้า​คลาด​กัน​แม้ byte เดียว get หลัง reopen จะ​อ่าน​ผิด​ตำแหน่ง

flowchart LR
    subgraph IDX["hash index ในหน่วยความจำ"]
        K1["key: lang"]
        K2["key: db"]
    end
    subgraph LOG["append-only log บน disk (เรียงตามเวลา)"]
        R1["rec เก่า: lang = rust"]
        R2["rec: db = kaen-kvstore"]
        R3["rec ล่าสุด: lang = rust-2024"]
    end
    K1 -->|value_offset| R3
    K2 -->|value_offset| R2
    R1 -.->|ถูกทับ ไม่มีใครชี้| R1

คำ​บรรยาย​ภาพ: hash index ใน​หน่วย​ความ​จำ​ชี้​ไป​ยัง offset ของ record ล่าสุด ของ​แต่ละ key ใน append-only log — record เก่า​ของ lang ยัง​นอน​อยู่​ใน file แต่​ไม่มี​ใคร​ใน index ชี้​หา (พื้นที่​ตาย​ที่ compaction ในบท4 จะ​ทวง​คืน)

โปรแกรม​ทดสอบ: session แรก​เขียน+ลบ+อ่าน​กลับ, ปิด, แล้ว session สอง​เปิด​ใหม่​ให้ rebuild_index สแกน log สร้าง index ขึ้น​มา​เอง แล้ว​ยืนยัน​ว่า​ได้​สถานะ​เดิม​เป๊ะ (รวม​ถึง tombstone ที่​ต้อง​รอด replay):

fn main() -> io::Result<()> {
let dir = std::env::temp_dir().join("kaen_kvstore_verify");
std::fs::create_dir_all(&dir)?;
let path = dir.join("data.log");
let _ = std::fs::remove_file(&path);
// session 1: เขียน ลบ อ่านกลับ
{
let mut store = Store::open(&path)?;
store.set(b"lang", b"rust")?;
store.set(b"edition", b"2024")?;
store.set(b"lang", b"rust-2024")?; // overwrite: append ใหม่ชนะ
store.delete(b"edition")?;
assert_eq!(store.get(b"lang")?, Some(b"rust-2024".to_vec()));
assert_eq!(store.get(b"edition")?, None);
assert_eq!(store.get(b"missing")?, None);
}
// session 2: reopen -> rebuild_index สแกน log ใหม่ทั้ง file
{
let mut store = Store::open(&path)?;
assert_eq!(store.get(b"lang")?, Some(b"rust-2024".to_vec()));
assert_eq!(store.get(b"edition")?, None, "tombstone ต้องรอด replay");
}
println!("all assertions passed; log at {}", path.display());
Ok(())
}

รัน​บน musl (Rust 1.97.1, std ล้วน) ได้​ผล:

all assertions passed; log at /tmp/kaen_kvstore_verify/data.log

และ hexdump ของ file log ยืนยัน​ว่า byte บน disk ตรง​กับ wire format เป๊ะ — รวม​ถึง tombstone (ff ff ff ff = u32::MAX) ที่​มี key edition แต่​ไม่มี byte ของ​ค่า​ตาม​มา:

00000000: 0400 0000 0400 0000 6c61 6e67 7275 7374 ........langrust
00000010: 0700 0000 0400 0000 6564 6974 696f 6e32 ........edition2
00000020: 3032 3404 0000 0009 0000 006c 616e 6772 024........langr
00000030: 7573 742d 3230 3234 0700 0000 ffff ffff ust-2024........
00000040: 6564 6974 696f 6e edition

อ่านที​ละ record: lang=rust (4+4 header, 0x04/0x04), edition=2024, lang=rust-2024 (overwrite — val_len=0x09), แล้ว tombstone ของ edition (val_len = ff ff ff ff, ตาม​ด้วย key เปล่าๆ ไม่มี​ค่า) — index หลัง replay จึง​เห็น lang → rust-2024 และ edition หาย​ไป ตรง​กับ​ที่ assertion ยืนยัน

ขีด​จำกัด​สอง​ข้อ​ของ store ใน​บท​นี้ — และ​เรา​ชี้​ล่วงหน้า​ว่า​จะ​แก้​บท​ไหน

1. “append แล้ว” ยัง​ไม่​เท่ากับ “durable”write_all คืน Ok แปล​ว่า byte ถึง page cache ของ OS แล้ว​เท่านั้น ยัง​ไม่ ถึง​จาน disk จริง ถ้า​ไฟ​ดับ​ตอน​นี้ byte ที่​ยัง​ค้าง​ใน page cache หาย​ได้ Durability ที่แท้​จริง​ต้อง​บังคับ byte ลง storage ด้วย fsync (File::sync_all/sync_data) ก่อน​ถือว่า commit — เรื่อง​นี้​ทั้ง​เรื่อง​คือ บท3 ตอน​นี้ store เรา​แค่ persistent ข้าม​การ​ปิด​โปรแกรม​ปกติ ยัง​ไม่ crash-safe

2. log โต​ไม่​หยุด — ทุก set/overwrite/delete append อย่าง​เดียว ไม่​เคย​ลบ byte เดิม เขียน​ทับ key เดิม 1000 ครั้ง​ก็ได้ 1000 record ทั้ง​ที่​มี key มี​ชีวิต​แค่​ตัว​เดียว file จึง​โต​ตาม​จำนวน operation ไม่ใช่​จำนวน key ที่​มี​ชีวิต การ​ทวง​คืน​พื้นที่ (เขียน​เฉพาะ record ที่​ยัง​มี​ชีวิต​ลง file ใหม่) คือ compaction ในบท4

บท​นี้​เปลี่ยน store จาก HashMap ที่​ตาย​พร้อม process เป็น model Bitcask: append-only log บน disk เป็น​แหล่ง​ความ​จริง เขียน​ต่อ​ท้าย​อย่าง​เดียว (sequential + crash ตัด​ได้​แค่​หาง); hash index ใน​หน่วย​ความ​จำ map key → ValuePos (offset ของ​ค่า + ความ​ยาว) ทำให้ get เป็น1 lookup + seek + read_exact; record layout [key_len][val_len][key][val] แบบ little-endian เขียน​เอง​ด้วย to_le_bytes/write_all ไม่​พึ่ง serde; delete เป็น tombstone (val_len == u32::MAX); และ rebuild_index สแกน log ตั้งแต่​ต้น​จน​จบ​ให้ last-write-wins ตก​มา​ฟรี ทุก snippet compile และ​รัน​ได้​จริง

บท3 เรา​ปิด​ช่อง​ความ​จริง​ข้อ​แรก: ทำให้ “append แล้ว” กลาย​เป็น “durable” จริง ด้วย​ลำดับ write_all → fsync → update index → ack ที่​ห้าม​สลับ — พร้อม​พูดตรงๆ ว่า fsync เอง​ก็​โกหก​ได้ (fsyncgate) และ toy ของ​เรา ทน truncation แต่ ไม่​ทน corruption


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

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

  • Database Internals — ch7 “Log-Structured Storage” (และ ch3 “File Formats”) — Alex Petrov, 2019 (เข้าถึง 2026-07-24) — append-only / log-structured storage เปลี่ยน update เป็น sequential write และ​มอง​ข้อมูล​บน disk เป็น immutable; append ที่​ถูก​ขัดจังหวะ​ทำได้​แค่​ทิ้ง​หาง​ขาด ไม่​ทำลาย record เดิม
  • Designing Data-Intensive Applications — ch3 “Storage and Retrieval” — Martin Kleppmann, 2017 (เข้าถึง 2026-07-24) — hash index แบบ log-structured (Bitcask): map ทุก key → byte offset ของ record ล่าสุด, อ่าน = 1 hash lookup + seek + read, และ ทุก key ต้อง​อยู่​ใน RAM
  • Bitcask: A Log-Structured Hash Table for Fast Key/Value Data — Sheehy & Smith (Basho), 2010 (เข้าถึง 2026-07-24) — model ต้นฉบับ: append-only log + in-memory keydir; record layout จริง​มี crc | tstamp | ksz | value_sz | key | value (toy ของ​เรา​ตัด crc/tstamp ออก)
  • std fs::OpenOptions (เข้าถึง 2026-07-24) — append(true) ให้ semantics O_APPEND: ทุก write ลง EOF เสมอ​ไม่​ว่า cursor อยู่​ไหน
  • std Read::read_exact (เข้าถึง 2026-07-24) — อ่าน​ให้​เต็ม buf.len() พอดี หรือ​คืน UnexpectedEof เมื่อ​สาย​จบ​ก่อน — ตัว​จบ loop ของ replay
  • std u32::to_le_bytes (เข้าถึง 2026-07-24) — byte little-endian คงที่ 4 byte เสถียร​ตั้งแต่ 1.32.0

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

ข้อ 1 / 3

hash index ในหน่วยความจำของ model Bitcask เก็บอะไรไว้ต่อ1 key?