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

Tombstones และ compaction — ทวง​คืน​พื้นที่​โดย​ไม่​ทำลาย​ความ​ถูกต้อง

บท2 ทำให้​ข้อมูล​รอด​การ​ปิด​โปรแกรม​ด้วย append-only log + hash index (model Bitcask) และ​บท3 ทำให้​มัน ทน crash ด้วย​ลำดับ write → fsync → ack แต่​ทั้ง​สอง​บท​ค้าง​ประเด็นหนึ่งไว้ตรงๆ: file log โต​ไม่มี​ที่​สิ้นสุด เพราะ​เรา​เขียน​ต่อ​ท้าย​อย่าง​เดียว ไม่​เคย​แก้​ที่​เดิม ทุก overwrite และ​ทุก delete แค่ append record ใหม่ ทิ้ง​ของ​เก่า​ให้​ตาย​คา file บท​นี้​แก้​สอง​เรื่อง​ที่​พัน​กัน: DELETE จะบันทึก​อย่างไร​ใน​โลก​ที่​ห้าม​ลบ และ​เรา​จะ​เก็บกวาด dead record ทิ้ง​อย่างไร​โดย ไม่​ทำลาย​ความ​ถูกต้อง แม้ crash กลาง​การ​เก็บกวาด

แนวคิด​ทั้ง​สอง​นี้​ไม่ใช่​ของ​ที่​เรา​คิด​ขึ้น​เอง — มัน map กับ storage engine จริงตรงๆ: การ​มาร์ค​ลบ​ด้วย record พิเศษ​คือ delete marker และ​การ​เขียน​เฉพาะ​ของ​ที่​ยัง​มี​ชีวิต​ลง file ใหม่​คือ segment merge & compaction ของ LSM/SSTable ที่ Petrov และ Kleppmann อธิบาย​ไว้

📦 kaen-kvstore

code ลงมือ​ของ​คอร์ส​นี้​อยู่​ใน repo kaen-kvstore (code ตัวอย่าง​กำลัง​จัด​ทำ) — repo ยืน​เดี่ยว​ของ​คอร์ส​นี้​เอง บท​นี้​ต่อยอด store จาก​บท3: เพิ่ม tombstone record สำหรับ DELETE และ method compact() ที่​ทวง​คืน​พื้นที่​แบบ crash-safe ทุก snippet ด้าน​ล่าง​ตัด​มา​จาก​โปรแกรม​เดียว​ที่ compile ผ่าน (cargo check) และ​รัน​จริง​บน musl (Rust 1.97.1 / edition 2024 / std ล้วน) — ตัวเลข​ที่​ยก​มา​เป็น​ผล​รัน​จริง ไม่ใช่​ตัวเลข​สมมติ

ทวน​โครง​จาก​บท2–3: store เก็บ index: HashMap<String, u64> จาก key → offset ของ record ล่าสุด​ใน file ทุก set append record [key_len u32][val_len u32][key][val] ต่อ​ท้าย แล้ว​ขยับ index ให้​ชี้ offset ใหม่ ผล​ที่​ตาม​มา​คือ​ถ้า​เรา​เขียน​ทับ key เดิม 1000 ครั้ง file จะมี record ของ key นั้น 1000 อัน แต่​มี​เพียง​อัน​สุดท้าย​ที่ index ชี้​อยู่ อีก 999 อัน​คือ dead record — กิน​พื้นที่ disk ถ่วง​เวลา replay ตอน boot แต่​ไม่มี​ใคร​อ่าน​ถึง​อีก​แล้ว

พูด​ให้​ตรง: log โต​ตาม​จำนวน write operation ไม่ใช่​จำนวน key ที่​ยัง​มี​ชีวิต นี่​เป็น​ราคา​ที่ append-only storage ทุก​ตัว​ต้อง​จ่าย — คุณ​แลก​ความ​เรียบ​ง่าย​กับ​ความ​ทนทาน (เขียน​แบบ sequential, crash ได้​แค่ tail ขาด) ด้วย​การ​ยอม​ให้ file บวม แล้ว​ค่อย​มี​กลไก​เก็บกวาด​ตาม​หลัง กลไก​นั้น​คือ compaction

คำถาม​แรก​ที่​คม​กว่า​ที่​คิด: ใน file ที่ append อย่าง​เดียว เรา​จะ “ลบ” key ได้​อย่างไร?

version ดิบ​ที่​ผุด​ขึ้น​ใน​หัว​ก่อน​คือ seek ไป​ที่ record เดิม​แล้ว​เขียน​ทับ byte ให้​เป็น​ศูนย์ หรือ​ย้าย record ท้ายๆ มา​ถม​ช่องว่าง — ผิด​ทั้ง​คู่ มัน​ทำลาย invariant ของ append-only log (การ​เขียน​ต้อง sequential และ record ที่​เขียน​แล้ว​ต้อง immutable) และ​เปิด​ช่อง​ให้ crash กลาง​การ​แก้​ทำ record ที่ ยัง​มี​ชีวิต พัง​ไป​ด้วย ทั้ง​ที่​ทั้ง​บท​เรา​กัน​ไม่​ให้ crash แตะ record เก่า​ได้​เลย

คำ​ตอบ​ที่​ถูก​คือ tombstonetombstonerecord พิเศษ​ที่​มาร์ค key ว่า​ถูกลบ ไม่​ได้​ลบ byte เดิม​ทันที — record พิเศษ​ที่ append ต่อ​ท้าย log เพื่อ มาร์ค ว่า key นี้​ถูกลบ ไม่ใช่​การ​ลบ byte เดิม​ทันที เรา​ใช้​ค่า sentinel ใน​ช่อง val_len: ถ้า val_len == u32::MAX แปล​ว่า record นี้​เป็น tombstone และ ไม่มี value bytes ตาม​หลัง (มี​แต่ key หลัง header) — ต่าง​จาก Set record ปกติ​ที่​มี value bytes เสมอ

const TOMBSTONE: u32 = u32::MAX; // val_len sentinel = a delete
enum Command {
Set { key: String, value: String },
Delete { key: String },
}
// [key_len u32 LE][val_len u32 LE][key bytes][val bytes]
// val_len == u32::MAX => tombstone (no value bytes follow).
fn write_record<W: Write>(w: &mut W, cmd: &Command) -> io::Result<()> {
match cmd {
Command::Set { key, value } => {
let kb = key.as_bytes();
let vb = value.as_bytes();
w.write_all(&(kb.len() as u32).to_le_bytes())?;
w.write_all(&(vb.len() as u32).to_le_bytes())?;
w.write_all(kb)?;
w.write_all(vb)?;
}
Command::Delete { key } => {
let kb = key.as_bytes();
w.write_all(&(kb.len() as u32).to_le_bytes())?;
w.write_all(&TOMBSTONE.to_le_bytes())?; // val_len == u32::MAX marks the tombstone
w.write_all(kb)?; // NO value bytes
}
}
Ok(())
}

delete() จึง​หน้าตา​เหมือน set() เป๊ะ — สร้าง record, append, sync_data() ให้ durable ตาม​ลำดับ​ของ​บท3, แล้ว​ค่อย index.remove() การ​ลบ​เป็น การ​เขียน อีก​ครั้ง​หนึ่ง ไม่ใช่​การ​แก้​ที่​เดิม:

fn delete(&mut self, key: &str) -> io::Result<()> {
let mut buf = Vec::new();
write_record(&mut buf, &Command::Delete { key: key.to_string() })?; // append a tombstone
self.file.seek(SeekFrom::Start(self.write_pos))?;
self.file.write_all(&buf)?;
self.file.sync_data()?;
self.index.remove(key); // replay will do the same from the tombstone
self.write_pos += buf.len() as u64;
Ok(())
}

ตรง​นี้​คือ​กิ่ง​ที่ ใหม่ เทียบ​กับ replay ในบท2–3 ซึ่ง​เห็น​แต่ Set record ตอน boot เรา​สแกน log จาก​ต้น​จน​จบ ทีละ record: Set สั่ง index.insert (record ที่มา​ทีหลัง​ชนะ​โดย​อัตโนมัติ​เพราะ insert ทับ key เดิม) ส่วน Delete/tombstone สั่ง index.remove — พอ​ถึง EOF (read_record คืน None เมื่อ read_exact เจอ UnexpectedEof) ก็จบ สถานะ​สุดท้าย​ใน HashMap คือ “ค่า​ล่าสุด​ของ​ทุก key ที่​ยัง​ไม่​ถูก tombstone ตาม​มา​ปิด”

// Tombstone-aware replay: SET => insert (latest wins); DELETE => remove.
fn build_index(path: &Path) -> io::Result<(HashMap<String, u64>, u64)> {
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false) // NEVER truncate the log we mean to replay
.open(path)?;
let mut bytes = Vec::new();
file.seek(SeekFrom::Start(0))?;
Read::read_to_end(&mut file, &mut bytes)?;
let mut cursor = Cursor::new(&bytes);
let mut index: HashMap<String, u64> = HashMap::new();
loop {
let start = cursor.position();
match read_record(&mut cursor)? {
Some(Command::Set { key, .. }) => {
index.insert(key, start);
}
Some(Command::Delete { key }) => {
index.remove(&key);
}
None => break,
}
}
let write_pos = cursor.position();
Ok((index, write_pos))
}
ทำไม​ต้อง .truncate(false) คู่​กับ .create(true)

ตั้งแต่ Rust 1.97 lint clippy::suspicious_open_options จะ​เตือน​เมื่อ​ใช้ .create(true) โดย​ไม่​ระบุ .truncate(...) ให้​ชัด — และ​ตรง​นี้ ต้อง เป็น false เพราะ​เรา​กำลัง​จะ​เปิด file log ที่​ตั้งใจ replay การ truncate จะ​ล้าง log ทิ้ง​ทั้ง file (ข้อมูล​หาย​เกลี้ยง) ส่วน file temp ของ compaction ด้าน​ล่าง​เป็น​อีก​กรณี — มัน​ใช้ .truncate(true) เพราะ​เรา​ตั้งใจ​เริ่ม​จาก file ว่างจริงๆ

ทีนี้​ถึง​การ​เก็บกวาด compactioncompactionเขียน​เฉพาะ record ที่​ยัง​มี​ชีวิต​ลง file ใหม่ เพื่อ​ทวง​คืน​พื้นที่ คือ​การ​เขียน​เฉพาะ record ที่​ยัง​มี​ชีวิต (ค่า​ล่าสุด​ต่อ key ที่​ยัง​ไม่​ถูกลบ) ลง file ใหม่ แล้ว​สลับ​มา​ใช้ file นั้น​แทน — ทวง​คืน​พื้นที่​ที่ dead record ยึด​ไว้ นี่​คือ​สิ่ง​เดียว​กับ segment merge & compaction ใน​เครื่องยนต์ LSM/SSTable ตาม​ที่ DDIA ch3 นิยาม​ว่า “โยน key ที่​ซ้ำ​ทิ้ง เก็บ​ไว้​แต่​ค่า update ล่าสุด​ของ​แต่ละ key”

จุด​ที่​สวย​ของ model Bitcask คือ​เรา ไม่​ต้อง​สแกน​หา live setindex ชี้​ไป​ยัง record ล่าสุด​ของ​ทุก key ที่​ยัง​มี​ชีวิต​อยู่​แล้ว (tombstone ถอด key ออก​จาก index ไป​แล้ว) เพราะ​ฉะนั้น live set = self.index.keys() เป๊ะๆ เรา​แค่​วน​อ่าน record ตาม offset ใน​นั้น​แล้ว​เขียน​ลง file temp:

fn compact(&mut self) -> io::Result<()> {
let tmp_path = self.dir.join("kvstore.compact"); // SAME dir => same fs => atomic rename
let mut tmp = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&tmp_path)?;
let live_keys: Vec<String> = self.index.keys().cloned().collect(); // avoids borrow conflict
let mut new_index = HashMap::new();
let mut new_pos = 0u64;
for key in live_keys {
let offset = self.index[&key];
self.file.seek(SeekFrom::Start(offset))?;
let value = match read_record(&mut self.file)? {
Some(Command::Set { value, .. }) => value,
_ => continue,
};
let mut buf = Vec::new();
write_record(&mut buf, &Command::Set { key: key.clone(), value })?;
tmp.write_all(&buf)?;
new_index.insert(key, new_pos);
new_pos += buf.len() as u64;
}
tmp.sync_all()?; // 1. fsync the NEW file's data
fs::rename(&tmp_path, &self.path)?; // 2. atomic swap (POSIX rename(2))
File::open(&self.dir)?.sync_all()?; // 3. fsync the DIRECTORY (Unix; makes the rename durable)
self.file = tmp; // 4. adopt compacted fd + fresh offsets (drops old inode)
self.index = new_index;
self.write_pos = new_pos;
Ok(())
}

ลำดับ​สี่​ขั้น​ท้าย block คือ หัวใจ ของ crash-safety และ​มัน​นำ durability primitive ของ​บท3 มา​ใช้​ซ้ำ​ทั้งหมด — ต้อง​เรียง​ตาม​นี้​เท่านั้น:

  • tmp.sync_all() — บังคับ byte ของ file ใหม่​ลง storage จริง​ก่อน ถ้า​ยัง​ไม่ durable แล้ว​เรา​ไป​สลับ file ใหม่​ที่​ยัง​ค้าง​อยู่​ใน page cache อาจ​หาย​ตอน crash
  • fs::rename(tmp, log) — สลับ​ชื่อ​แบบ atomic ตาม POSIX rename(2) ระบบ file รับประกัน​ว่า ณ ทุก​จังหวะ path kvstore.log ชี้​ไป​ที่ file เก่า​ทั้ง​อัน หรือ file ใหม่​ทั้ง​อัน ไม่มี​สถานะครึ่งๆ กลางๆ ให้​เห็น
  • File::open(&self.dir)?.sync_all() — fsync ตัว directory เอง เพราะ​บน Unix การ rename เปลี่ยน directory entry ตัว entry ใหม่​นี้​ก็​ต้อง durable ด้วย ไม่​งั้น crash หลัง rename แต่​ก่อน metadata ของ directory ลง disk อาจ​กลับ​ไป​เห็น​ชื่อ​เดิม
  • self.file = tmp; ... = new_index; — รับ fd ใหม่​พร้อม offset ชุด​ใหม่​เข้า​มา การ drop self.file เก่า​จะ​ปลด reference สุดท้าย​ของ inode เดิม ระบบ file ก็ unlink มัน​ทิ้ง — พื้นที่​ถูก​ทวง​คืน​ตรง​นี้​เอง
temp file ต้อง​อยู่ directory เดียว​กับ log — ไม่ใช่​เรื่อง​ความ​สะดวก แต่​เป็น​เงื่อนไข​ความ​ถูกต้อง

std::fs::rename map กับ Unix rename(2) ซึ่ง atomic เฉพาะ​เมื่อ source กับ destination อยู่​บน filesystem เดียวกัน ถ้า file temp ไป​อยู่​คนละ mount (เช่น /tmp เป็น tmpfs แยก) rename จะ​ล้ม​ด้วย EXDEV — ไม่ใช่​การ copy ข้าม​ให้​เงียบๆ นี่​คือ​เหตุผล​ที่ tmp_path ต้อง self.dir.join(...) เสมอ อยู่ directory เดียว​กับ log เป้าหมาย การ​วาง​ผิด​ที่​ทำลาย atomicity ทั้งหมด​ที่​เรา​อุตส่าห์​สร้าง

โปรแกรม​ทดสอบ​ทำ​แบบ​นี้: เปิด store ใน directory เฉพาะ​กิจ (ประกอบ​จาก temp_dir + pid + nanos + AtomicU64 แล้ว​เก็บกวาด​ด้วย remove_dir_all เจาะจง​เฉพาะ dir ตัวเอง ไม่มี wildcard) เขียน​ทับ key counter 1000 ครั้ง, เพิ่ม key name อีก​ตัว, แล้ว set-แล้ว-delete key temp (ทิ้ง tombstone หนึ่ง​อัน) จาก​นั้น​วัด​ขนาด → compact() → วัด​ใหม่ → ยืนยัน​ความ​ถูกต้อง → reopen store ใหม่​ทั้ง​อัน​เพื่อ​พิสูจน์​ว่า compaction รอด boot:

before compact: log = 17945 bytes, live keys = 2
after compact: log = 42 bytes, live keys = 2
post-compaction write: counter -> Some("1000")
after reopen: counter=Some("1000"), name=Some("kaen-kvstore"), temp=None
all assertions passed

อ่าน​ผล: 1000 overwrites + tombstone พอง file เป็น 17,945 byte สำหรับ key ที่​ยัง​มี​ชีวิต​เพียง 2 ตัว — หลัง compact() เหลือ 42 byte (แค่ 2 live record) หด​ลง​กว่า 400 เท่า โดย​ทุก get ยัง​คืน byte เดิม​เป๊ะ, key ที่​ถูก tombstone (temp) ยัง​เป็น None, write หลัง compaction (counter -> "1000") อ่าน​กลับ​ได้​ปกติ (offset ชุด​ใหม่​สอดคล้อง​กัน) และ​เมื่อ reopen จาก file ที่ compact แล้ว สถานะ​เหมือน​เดิม​ทุก​ประการ — tombstone ยัง​ถูก​เคารพ​ข้าม​การ reboot

flowchart LR
    subgraph OLD["log เดิม (dead + live ปน)"]
        d1["counter=0 (dead)"]
        d2["... 998 dead ..."]
        d3["counter=999 (live)"]
        d4["name=... (live)"]
        d5["temp=... (dead)"]
        d6["tombstone temp"]
    end
    OLD -->|"อ่าน live set จาก index"| FILTER["เก็บเฉพาะ record ที่ยังมีชีวิต"]
    FILTER --> TMP["kvstore.compact\n(tmp.sync_all → fsync data)"]
    TMP -->|"fs::rename (atomic)\n+ fsync directory"| NEW["log ใหม่ (42 byte)"]
    OLD -.->|"crash ก่อน rename → log เดิมยังครบทั้งอัน"| SAFE["สถานะเดิมปลอดภัย"]

คำ​บรรยาย​ภาพ: compaction อ่าน​เฉพาะ record ที่​ยัง​มี​ชีวิต (ตาม offset ใน hash index) เขียน​ลง file temp ใน directory เดียวกัน fsync ให้ durable แล้ว​สลับ​ด้วย atomic rename + fsync directory — ถ้า crash ก่อน rename file log เดิม​ยัง​อยู่​ครบ​ทั้ง​อัน ไม่มี​สถานะ​กลางคัน

ความ​จริง​ที่​ต้อง​พูด: การ​แข่ง​กับ write และ​ทางออก​จริง​คือ segment

หัวข้อ​ที่​มีชื่อ​ว่า “ความ​จริง​ที่​ต้อง​พูด: การ​แข่ง​กับ write และ​ทางออก​จริง​คือ segment”
compact() ตัว​นี้ race-free เพราะ​มัน​เป็น single-threaded — ของ​จริง​ไม่ใช่​แบบ​นี้

compact() ข้าง​บน​ถูกต้อง​เพราะ​ทั้ง store บท​นี้​ยัง single-threaded — ไม่มี write ตัว​ไหน​แทรก​ระหว่าง​ที่​เรา​อ่าน live set, เขียน temp, แล้ว rename ได้​เลย แต่​พอ​ถึง​บท5 ที่​เรา share store ให้​หลาย client ผ่าน Arc<Mutex>/Arc<RwLock> compaction จะ​แข่ง​กับ write ที่​วิ่ง​พร้อม​กัน ทันที — write ที่ append หลัง​จาก​เรา​อ่าน index.keys() ไป​แล้ว​จะ​ตกหล่น​จาก file ใหม่ ถ้า​เรา​ถือ lock ทั้ง​ก้อน​คร่อม compact() ที่​ช้า ก็​เท่ากับ​หยุด​รับ write ทั้ง server ระหว่าง​เก็บกวาด

ทางออก​ที่​ฐาน​ข้อมูล​จริง​ใช้​คือ segmentsegmentfile log ที่​ถูก​แช่แข็ง (immutable) เป็น​สะพาน​สู่ replication ใน #23 — แช่แข็ง log ปัจจุบัน​ให้ immutable เปิด file active ใหม่​ให้ write ตัว​ใหม่​ไป​ลง แล้ว merge เฉพาะ segment ที่​แช่แข็ง​แล้ว (ทำใน background thread ได้ ขณะ​ที่ read/write ยัง​ทำงาน​ผ่าน segment เก่า/ใหม่​ต่อ​ไป) เรา​จะ ตั้ง​ชื่อ กลไก​นี้​ไว้​ที่​นี่ ส่วน​การ​ลงมือ​ทำ​จริง​เลื่อน​ไป​ตอน​คอร์ส​แตะ concurrency (บท5) และ replication (#23) — สำหรับ toy บท​นี้ single-threaded คือ​ความ​ถูกต้อง​ที่​พิสูจน์​ได้​ง่าย​ที่สุด

และ​เช่น​เดียว​กับ​บท3: tombstone + length prefix กัน truncation (tail ขาด​จาก crash) ได้ แต่​ไม่​กัน bit-flip เงียบๆ — ของ​จริง (Bitcask) ใส่ CRC ต่อ record; และ fsync เอง​ก็​โกหก​ได้ (fsyncgate 2018) เรา​ไม่ over-promise

บท​นี้​ปิด​วงจร​ชีวิต​ของ​ข้อมูล​ใน log-structured store: DELETE ไม่ใช่​การ​ลบ byte เดิม แต่​คือ​การ append tombstone record (val_len == u32::MAX ไม่มี value bytes) ที่ replay ตีความ​ว่า index.remove — การ​ลบ​เป็นการ​เขียน​อีก​ครั้ง ไม่ใช่​การ​แก้​ที่​เดิม จึง​ไม่​แตะ append-only invariant; และ​เมื่อ dead record สะสม​จน log บวม compaction เขียน​เฉพาะ live set (ซึ่ง index ชี้​ให้​อยู่​แล้ว) ลง file ใหม่ สลับ​ด้วย​ลำดับ crash-safe fsync data → atomic rename → fsync directory → adopt fd โดย temp file ต้อง​อยู่ directory เดียวกัน​เพื่อ​ให้ rename atomic จริง วัด​จริง​ได้ log หด​จาก 17,945 เหลือ 42 byte โดย​ความ​ถูกต้อง​คง​เดิม​ทุก​จุด​แม้ reopen ทั้งหมด​เป็น Rust std ล้วน compile และ​รัน​ได้​จริง​บน 1.97.1 / edition 2024

บท5 เรา​เปิด​รับ​หลาย client พร้อม​กัน: server single-threaded จาก​บท1 serialize client ที​ละ​ราย — บท​หน้า​เรา​แทน​มัน​ด้วย ThreadPool (แบบ​บท 21 ของ Rust Book) ที่ share store ผ่าน Arc<Mutex>/Arc<RwLock> และ​ตรง​นั้น​เอง​ที่​คำ​เตือน​เรื่อง compaction แข่ง​กับ write จะ​กลาย​เป็น​โจทย์​จริง​ที่​ต้อง​ออกแบบ lock scope ให้​ถูก


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

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

  • Designing Data-Intensive Applications — ch3 “Storage and Retrieval” โดย Martin Kleppmann, 2017 (เข้าถึง 2026-07-24) — compaction คือ “โยน key ที่​ซ้ำ​ทิ้ง เก็บ​ไว้​แต่​ค่า update ล่าสุด​ของ​แต่ละ key”; tombstone คือ deletion record ที่​บอก merge process ให้​ทิ้ง​ค่า​เก่า; และ merge/compaction ทำใน background thread ได้​ขณะ​ยัง​บริการ read/write ผ่าน segment เดิม
  • Database Internals — ch7 “Log-Structured Storage” โดย Alex Petrov, 2019 (เข้าถึง 2026-07-24) — log-structured storage มอง​ข้อมูล​บน disk เป็น immutable segment; การ update/delete กลาย​เป็นการ append; compaction ทวง​คืน​พื้นที่​โดย​รวม segment
  • std fs::rename (เข้าถึง 2026-07-24) — map กับ Unix rename(2); atomic replace เฉพาะ​เมื่อ source/destination อยู่​บน filesystem เดียวกัน (ข้าม mount ล้ม​ด้วย EXDEV)
  • std fs::File::sync_all / sync_data (เข้าถึง 2026-07-24) — sync_all = fsync(2) (data + metadata); ใช้ fsync ตัว file temp และ fsync ตัว directory เพื่อ​ทำ rename ให้ durable

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

ข้อ 1 / 3

tombstone ใน kaen-kvstore คืออะไร และทำงานอย่างไร?