ลืมทั้งท่อน หรือเก็บค่าล่าสุดต่อ key
log ที่ต่อท้ายอย่างเดียวมีปัญหาข้อเดียวที่หลีกไม่พ้น คือมันโตไปเรื่อยๆ
kaen-log แก้ปัญหานั้นด้วยสองกลไกที่ลืมคนละแบบ และผู้อ่านส่วนใหญ่จำสองอย่างนี้สลับกัน
retentionretentionกติกาการลืมทั้ง segment ตามอายุหรือขนาดรวม · broker ลืมเป็นท่อน ไม่ได้ลืมเป็น record ลบทีละท่อน ไม่เคยเลือกลบ record ตัวใดตัวหนึ่ง ส่วน compactioncompactionเขียนเฉพาะ record ที่ยังมีชีวิตลง file ใหม่เพื่อทวงคืนพื้นที่ · ของ broker สัญญาแค่ว่าค่าสุดท้ายของแต่ละ key ยังอยู่ ไม่ได้สัญญาว่าค่าเก่าหายหมด และห้ามขยับ offset เขียนท่อนเก่าขึ้นใหม่โดยรับรองแค่ว่าค่าสุดท้ายของแต่ละ key ยังอยู่
ประโยคหลังเป็นข้ออ้างเรื่อง การมีอยู่ ไม่ใช่ข้ออ้างเรื่อง ความมีเพียงหนึ่งเดียว ค่าซ้ำของ key เดียวกันอยู่ต่อได้ และไม่มีบรรทัดไหนในเอกสารของ Kafka สัญญาว่ามันจะหายไป
crate ของคอร์สอยู่ที่ scripts/log-broker/ และชื่อ binary คือ klog
บทนี้เป็นเจ้าของ scripts/log-broker/src/clean.rs เพียง file เดียว
บล็อก code กับบล็อกผลรันทุกอันในบทเรียนนี้ถูกคัดมาจาก src/clean.rs
และจาก scripts/log-broker/expected/06-clean.txt ทีละ byte โดยมีเทสต์เทียบไว้
ถ้าแก้ในบทเรียนอย่างเดียวโดยไม่แก้ที่ต้นทาง เทสต์จะแดงทันที
ผลรันทุกบรรทัดในบทนี้มาจากคำสั่งเดียว คือ
klog clean-demo --dir data/orders-0 --now-ms 1700000000000 --retention-ms 604800000 --delete-retention-ms 86400000 --passes 2
ซึ่งเทสต์รันซ้ำแล้วเทียบผลทีละ byte ทุกครั้ง
คอร์สที่ 22 ลืมได้ทันที บทนี้ลืมไม่ได้
หัวข้อที่มีชื่อว่า “คอร์สที่ 22 ลืมได้ทันที บทนี้ลืมไม่ได้”หัวข้อนี้วางเส้นเปรียบเทียบกับคอร์สก่อนหน้าไว้ก่อน แล้วบอกว่าอะไรบังคับให้ kaen-log ทำต่างออกไป
คอร์ส kaen-kvstore สอน compaction ไปแล้ว แต่เป็นคนละตัวกับบทนี้ มันอ่านเซตที่ยังมีชีวิตจากสารบัญในหน่วยความจำ เขียน log ทั้งก้อนขึ้นใหม่ แจก offset ชุดใหม่ให้ทุก record แล้วทิ้ง tombstone ทันทีในรอบเดียวกัน
kaen-log ทำสามข้อนั้นไม่ได้สักข้อ และเหตุผลรวบได้เป็นประโยคเดียว KV store เป็นเจ้าของผู้อ่านรายเดียวของตัวเอง มันจึงลืมเมื่อไหร่ก็ได้ ส่วนผู้อ่านของ broker เป็นคนแปลกหน้าที่ถือนาฬิกาของตัวเอง การลืมจึงต้องนัดเวลา
ผลที่ตามมาสี่ข้อคือโครงของทั้งบทนี้
- offset ต้องอยู่ที่เดิม เพราะมีคนถือเลขนั้นอยู่และจะกลับมาขอต่อจากมัน
- ช่องว่างที่เกิดขึ้นต้องอยู่ต่อ ไม่มีการรูดเลขให้ชิดกัน
- ท่อนที่กำลังเขียนอยู่ห้ามแตะ
- tombstone ต้องอยู่ให้พ้นเส้นตายก่อน ไม่ใช่หายไปในรอบที่เห็นมันครั้งแรก
cleanup.policy เป็น list ไม่ใช่ enum
หัวข้อที่มีชื่อว่า “cleanup.policy เป็น list ไม่ใช่ enum”หัวข้อนี้กางค่าที่
cleanup.policyรับได้ทั้งสี่แบบ แล้วบอกว่า demo ของบทนี้ตั้งค่าไหน
cleanup.policy ไม่ได้บังคับให้เลือกอย่างใดอย่างหนึ่ง เอกสาร topic config ของ Kafka 4.1
เขียนไว้ว่า “It is also possible to specify both policies in a comma-separated list”
แล้วยกตัวอย่างของ list นั้นไว้เป็น delete,compact ส่วนค่าปริยายคือ delete ตัวเดียว
LogConfig ของ kaen-log จึงเก็บมันเป็น bool สองตัวที่แยกจากกัน ซึ่งเป็นรูปเดียวกับที่
LogConfig.java ของ Kafka 4.1 ใช้ คือ public final boolean compact; กับ
public final boolean delete;
== cleanup.policy เป็น list ไม่ใช่ enum == ค่า ความหมาย (ว่าง) ไม่ลบและไม่ compact เลย delete ลบทั้งท่อนตามเวลาและตามขนาด · **นี่คือค่าปริยาย** compact เก็บอย่างน้อยค่าสุดท้ายต่อ key delete,compact ท่อนเก่าถูกลบตามเวลา · ท่อนที่เหลือถูก compactdemo นี้ตั้ง orders เป็น delete,compact เพื่อให้เห็นทั้งสองกลไกบน partition เดียวdemo ตั้ง orders ให้เป็น delete,compact เพราะต้องการให้ทั้งสองกลไกทำงานบน partition
เดียวกัน แล้วดูว่าอะไรเป็นของใคร
cleanup.policy ของ orders = delete,compactretention.ms = 604800000 · delete.retention.ms = 86400000segment.bytes = 512 (เล็กเกินกว่าที่ Kafka จริงยอมรับ ซึ่งต่ำสุด 1048576)ค่า segment.bytes ของ demo เล็กกว่าที่ Kafka จริงยอมรับ หน้า topic config ระบุช่วงที่รับได้ไว้ว่า
[1048576,...] ท่อนขนาดนี้จึงมีอยู่ได้เฉพาะในของเล่น มันมีไว้ให้เห็นหลายท่อนในหน้าจอเดียว
retention ลบทีละท่อน และตัดสินด้วย timestamp ที่มากที่สุด
หัวข้อที่มีชื่อว่า “retention ลบทีละท่อน และตัดสินด้วย timestamp ที่มากที่สุด”หัวข้อนี้แสดงเงื่อนไขการลบท่อน แล้วชี้ว่าทำไม record สดตัวเดียวถึงยกทั้งท่อนให้รอด
เอกสาร Implementation › Log ของ Kafka 4.1 เขียนกลไกนี้ไว้ตรงตัว “Data is deleted one log segment at a time” และบอกด้วยว่าเวลาที่ใช้ตัดสินมาจากไหน “with the largest timestamp in a segment file (order of records is not relevant) defining the retention time for the entire segment”
คำว่า largest ในประโยคนั้นคือทั้งหมดของกลไก record ที่สดที่สุดในท่อนเป็นคนตัดสินชะตาของทั้งท่อน record เก่าที่นั่งอยู่ในท่อนเดียวกับมันรอดไปด้วยกันหมด
deletable_segments ของ kaen-log ลอกรูปมาจาก UnifiedLog.java ของ Kafka 4.1 ตรงๆ
/// เดินจากท่อนเก่าสุด แล้ว **หยุดที่ท่อนแรกที่รอด** · ไม่เคยเอื้อมเข้าไปกลาง log////// เงื่อนไขอ่าน timestamp ที่ **มากที่สุด** ของท่อน · record สดตัวเดียว/// จึงทำให้ทั้งท่อนที่เหลือรอดไปด้วยกันหมดpub fn deletable_segments(log: &Log, now_ms: i64) -> Vec<u64> { let mut out = Vec::new(); let mut it = log.segments().iter().peekable(); while let Some(seg) = it.next() { let next = it.peek(); let upper_bound = next.map_or(log.log_end_offset(), |s| s.base_offset()); let is_last_and_empty = next.is_none() && seg.size() == 0; let breached = now_ms - seg.largest_timestamp() > log.config.retention_ms; if log.high_watermark() >= upper_bound && breached && !is_last_and_empty { out.push(seg.base_offset()); } else { break; } } out}break ในกิ่ง else คือบรรทัดที่ทำให้มันไม่ใช่ filter ตัวนี้ หยุดที่ท่อนแรกที่รอด
มันจึงไม่มีทางเอื้อมข้ามท่อนที่รอดไปลบท่อนที่อยู่ถัดไป
อีกสองเงื่อนไขในบรรทัด if ก็ลอกมาจาก Kafka เหมือนกัน
log.high_watermark() >= upper_bound— คอมเมนต์ในUnifiedLog.javaให้เหตุผลไว้ว่า “We don’t delete segments with offsets at or beyond the high watermark to ensure that the log start offset can never exceed it”!is_last_and_empty— ท่อนสุดท้ายที่ว่างเปล่าไม่ถูกลบ ไม่งั้น log จะไม่เหลือท่อนให้เขียนต่อ
demo เขียน record เก่าหกตัวลงไปก่อน แล้วตามด้วย record สด จนได้เจ็ดท่อน
== retention ลบทีละท่อน ไม่เคยลบทีละ record == file base size largest_timestamp อายุ (ms) หมดอายุ 00000000000000000000.log 0 402 1699395040002 604959998 ใช่ 00000000000000000003.log 3 402 1699395040005 604959995 ใช่ 00000000000000000006.log 6 402 1699999940008 59992 ไม่ใช่ 00000000000000000009.log 9 402 1699999940011 59989 ไม่ใช่ 00000000000000000012.log 12 402 1699999940014 59986 ไม่ใช่ 00000000000000000015.log 15 402 1699999940017 59983 ไม่ใช่ 00000000000000000018.log 18 402 1699999940020 59980 ไม่ใช่ท่อนที่หมดอายุ [0, 3]ลบไป 2 ท่อน · record ที่หายไปด้วย 6 ตัวlog start offset 0 → 6log end offset 21 → 21 (ไม่ขยับ)record ที่หายไปเป็นจำนวนเต็มท่อนเสมอ · ไม่มีทางลบ record เดี่ยวๆ ได้timestamp ที่ใช้ตัดสินคือตัวที่ **มากที่สุด** ของท่อน · record สดตัวเดียวยกทั้งท่อนให้รอดสองบรรทัดสุดท้ายของบล็อกคือข้ออ้างของหัวข้อนี้ ตัวเลขที่หายไปเป็นจำนวนเต็มท่อนเสมอ และ log end offset ไม่ขยับเลย เพราะ retention แตะแต่หัว log ไม่เคยแตะหาง
Kafka จริงมีสองค่าที่คั่นระหว่างสองเหตุการณ์นี้ log.retention.check.interval.ms เป็นรอบที่มันเดินมาตรวจ
และ file.delete.delay.ms เป็นเวลาที่มันรอก่อนจะเอา file ออกจาก disk จริงๆ
โดย file ถูก rename ให้ลงท้ายด้วย .deleted ไว้ก่อน
kaen-log ไม่มีทั้งสองค่า มันลบทันทีในบรรทัดที่เรียก เพราะ demo ถูกขับทีละก้าว ไม่มี thread เบื้องหลัง ที่จะเดินมาตรวจตามรอบ ข้อนี้เป็นสิ่งที่ของเล่นตัวนี้ตัดทิ้ง ไม่ใช่สิ่งที่ Kafka ทำเกินจำเป็น
เพดานสามชั้นที่ตัวทำความสะอาดข้ามไม่ได้
หัวข้อที่มีชื่อว่า “เพดานสามชั้นที่ตัวทำความสะอาดข้ามไม่ได้”หัวข้อนี้บอกว่า compaction เริ่มทำได้ถึง offset ไหน และทำไมท่อนที่กำลังเขียนอยู่ถึงไม่มีวันถูกแตะ
รอบทำความสะอาดหนึ่งรอบไม่ได้กวาดทั้ง log มันกวาดได้ถึงแค่ offset หนึ่งแล้วหยุด ทุกอย่างที่ต่ำกว่าเส้นนั้นคือ หัว log ซึ่งเป็นช่วงที่มันสรุปเป็นตารางก่อนคัดผู้รอด เส้นนั้นคือ offset แรกที่ทำความสะอาดไม่ได้ และมันคือค่าที่น้อยที่สุดในสามค่า
LogCleanerManager.java ของ Kafka 4.1 เขียนสามค่านั้นไว้เป็นคอมเมนต์ของตัวเอง
“We cannot clean past: 1. The active segment 2. The last stable offset (including the high
watermark) 3. Any segments closer to the head of the log than the minimum compaction lag time”
/// เพดานสามชั้นตามคอมเมนต์ของ Kafka เอง: ท่อนที่กำลังเขียนอยู่ · เส้น committed/// · และท่อนที่ยังใหม่กว่า `min.compaction.lag.ms`pub fn first_uncleanable_offset(log: &Log, now_ms: i64) -> u64 { let mut bounds = vec![log.high_watermark(), log.active_segment().base_offset()]; if log.config.min_compaction_lag_ms > 0 { for seg in log.segments() { if seg.largest_timestamp() > now_ms - log.config.min_compaction_lag_ms { bounds.push(seg.base_offset()); break; } } } bounds.into_iter().min().expect("มีอย่างน้อยสองค่าเสมอ")}ชั้นที่หนึ่งคือท่อนที่กำลังเขียนอยู่ และเหตุผลของมันเป็นเรื่องกลไกล้วนๆ รอบทำความสะอาดหนึ่งรอบคือการ เขียนท่อนใหม่ให้ครบก่อน แล้วค่อยสลับเข้าไปแทนของเดิม สลับ file ที่ยังมีคนต่อท้ายอยู่ไม่ได้ เพราะ byte ที่เขียนเข้ามาระหว่างนั้นจะหายไปกับ file เก่า
เอกสาร Design ของ Kafka 4.1 ยืนยันว่ากติกานี้ไม่มีข้อยกเว้น “The active segment will not be compacted even if all of its messages are older than the minimum compaction time lag”
ชั้นที่สองในของจริงคือ last stable offset ซึ่งเป็นเส้นของ transaction kaen-log ยังไม่มี transaction บทนี้จึงใช้ high watermark แทน และเรียกชื่อชั้นนี้ไว้เฉยๆ บทที่ 7 จะกางว่า last stable offset ต้องมีอะไรมารองรับบ้าง แล้วประกาศว่าคอร์สนี้ไม่ได้สร้างมัน ส่วนบทที่ 8 เป็นบทที่ทำให้ high watermark ซึ่งยืนแทนอยู่ตรงนี้ มีค่าของตัวเองจริงๆ
ชั้นที่สามคือ min.compaction.lag.ms ซึ่งค่าปริยายของ Kafka เป็นศูนย์
if ใน code จึงข้ามทั้งกิ่งไปเมื่อไม่มีใครตั้งค่า
compaction สัญญาว่าอย่างน้อย ไม่ได้สัญญาว่าเพียงหนึ่งเดียว
หัวข้อที่มีชื่อว่า “compaction สัญญาว่าอย่างน้อย ไม่ได้สัญญาว่าเพียงหนึ่งเดียว”หัวข้อนี้ยกข้อรับประกันสี่ข้อของ Kafka มาดูข้อที่โดนเข้าใจผิดบ่อยที่สุด แล้วชี้ว่ากลไกที่ทำให้มันเป็นแบบนั้นอยู่ตรงไหนจริงๆ
เอกสาร Design ของ Kafka 4.1 เขียนข้อที่สี่ไว้ว่า “Any consumer progressing from the start of the log will see at least the final state of all records in the order they were written”
คำว่า at least ในประโยคนั้นไม่ใช่การเขียนเผื่อ มันคือขอบเขตของข้ออ้าง compaction บอกว่าค่าสุดท้ายของแต่ละ key ยังอยู่ มันไม่ได้บอกว่าค่าเก่าของ key นั้นหายไปหมดแล้ว
ที่ที่คนมักชี้คือตัวเปรียบเทียบใน should_retain แต่ตัวจริงไม่ได้อยู่ที่บรรทัดนั้น
/// ค่าซ้ำของ key เดียวกันรอด compaction ได้จริง · แต่ตัวที่ปล่อยให้รอดคือ **เพดาน/// `first_uncleanable_offset`** ไม่ใช่เครื่องหมาย `>=` ข้างล่างนี้ · `compact` ข้าม/// ทุกท่อนที่ `base_offset >= first_uncleanable` และท่อนที่กำลังเขียนอยู่เป็นหนึ่งใน/// เพดานนั้นเสมอ ค่าเก่าของ key ที่ยังอยู่เหนือเส้นจึงไม่เคยถูกแตะ · นั่นคือเหตุผล/// ที่ข้ออ้างของ compaction เป็น "อย่างน้อยค่าสุดท้าย" ไม่ใช่ "ค่าสุดท้ายเพียงตัวเดียว"////// `>=` ลอกมาจาก `Cleaner.shouldRetainRecord` ของ Kafka ตรงตัว และของจริงมันมีงานทำ/// เพราะ `SkimpyOffsetMap` เก็บ key เป็นค่า MD5 แล้วเทียบกันที่ค่านั้น · key คนละตัว/// จึงชนช่องเดียวกันได้ และช่องที่ชนอาจถือ offset ที่สูงกว่า record ที่กำลังถูกตรวจ/// `>=` เก็บ record ตัวนั้นไว้แทนที่จะทิ้ง · ตารางของ kaen-log เก็บ byte ของ key/// ตรงๆ จึงชนไม่ได้ และทุก record ที่มาถึงบรรทัดนั้น `>=` กับ `==` ให้ผลเท่ากันหมดpub fn should_retain(map: &OffsetMap, batch: &Batch, rec: &Record, now_ms: i64) -> bool { if rec.offset > map.latest_offset() { return true; } let Some(key) = rec.key.as_deref() else { return false }; let latest_for_key = match map.get(key) { Some(latest) => rec.offset >= latest, None => true, }; let retain_deletes = match delete_horizon_ms(batch.header.attributes, batch.header.base_timestamp) { // ยังไม่เคยถูกประทับเส้นตาย · tombstone จึงรอดรอบทำความสะอาดนี้ไปแน่นอน None => true, Some(horizon) => now_ms < horizon, }; latest_for_key && (rec.value.is_some() || retain_deletes)}สิ่งที่ปล่อยให้ค่าซ้ำรอดคือ เพดาน first_uncleanable_offset รอบทำความสะอาดข้ามทุกท่อน
ที่ base offset ถึงเพดานแล้ว และท่อนที่กำลังเขียนอยู่เป็นหนึ่งในค่าที่ประกอบเป็นเพดานนั้นเสมอ
ค่าเก่าของ key ที่บังเอิญตกอยู่เหนือเส้น จึงไม่เคยถูกแตะสักรอบเดียว
ตารางในผลรันของหัวข้อ compaction ข้างล่างพิมพ์เพดานนั้นออกมาเป็น 25 ขณะที่ log end offset เป็น 28 record สามตัวท้ายสุดจึงอยู่นอกมือของ compaction ทั้งที่ key ของมันซ้ำกับของที่เพิ่งถูกบีบไป
แล้ว >= มีไว้ทำอะไร มันลอกมาจาก Cleaner.shouldRetainRecord ของ Kafka ตรงตัว
และในของจริงมันมีงานทำ เพราะ SkimpyOffsetMap ไม่ได้เก็บ key มันเก็บค่า MD5 ของ key
แล้วเทียบกันที่ค่านั้น
key คนละตัวจึงลงช่องเดียวกันได้ และช่องที่ชนอาจถือ offset ที่สูงกว่า record ที่กำลังถูกตรวจอยู่
>= เก็บ record ตัวนั้นไว้แทนที่จะทิ้ง เพราะยอมให้มีค่าซ้ำ ดีกว่ายอมให้ค่าล่าสุดหายไป
ตารางของ kaen-log เก็บ byte ของ key ตรงๆ ใน HashMap มันจึงชนไม่ได้เลย
และทุก record ที่เดินมาถึงบรรทัดนั้น >= กับ == ให้ผลเท่ากันหมด
เปลี่ยนเครื่องหมายเป็น == แล้วผลรันของบทนี้ไม่ขยับสัก byte
Kafka ไม่ได้ทำแบบนี้เพราะเขียนหลวม เอกสาร topic config อธิบาย min.cleanable.dirty.ratio ไว้ว่า
“This ratio bounds the maximum space wasted in the log by duplicates (at 50% at most 50% of the
log could be duplicates)” นั่นคือการประกาศงบของค่าซ้ำออกมาตรงๆ ไม่ใช่การสัญญาว่าไม่มีค่าซ้ำ
สองการตรวจแรกใน function ก็มีความหมายของตัวเอง record ที่ offset สูงกว่าหัวที่สรุปไว้จะรอดทุกตัว เพราะมันอยู่นอกช่วงที่ตารางสรุปครอบถึง และ record ที่ไม่มี key ถูกทิ้งเสมอ
ข้อหลังตรงกับที่ Kafka ปฏิเสธตั้งแต่ตอน produce เลย LogValidator.java ของ 4.1
คืน error พร้อมข้อความ “Compacted topic cannot accept message without key in topic partition”
เพราะ record ที่ไม่มี key ไม่มีทางถูกอ้างถึงในตารางสรุป มันจึงไม่มีนิยามของคำว่าค่าล่าสุด
สิ่งที่ Kafka รับรองคือข้อที่สอง “Compaction will never re-order messages, just remove some” กับข้อที่สาม “The offset for a message never changes”
ทั้งสองประโยคพูดถึงลำดับที่ยังอยู่ ไม่ได้พูดถึงความสัมพันธ์ระหว่าง key สองตัวหลังบางตัวหายไป ไม่มีแหล่งต้นทางไหนรับรองข้อนั้น และประโยคทำนองว่า “record ของ key A กับ key B ยังเรียงเหมือนเดิมหลัง compact” เป็นประโยคที่ต้องไม่เขียน ต่อให้มันดูจริงแค่ไหนก็ตาม
ผู้อ่านที่เริ่มจากต้น log อาจเห็นค่ากลางทางของ key หนึ่ง หรืออาจไม่เห็น ขึ้นกับว่ารอบทำความสะอาด เดินไปถึงไหนตอนที่มันอ่าน ข้ออ้างที่ยืนได้มีข้อเดียวคือค่าสุดท้ายยังอยู่
หนึ่งรอบทำความสะอาดของ kaen-log จึงเดินตามลำดับที่ตายตัว
flowchart TB
U["หาเพดาน · offset แรกที่ทำความสะอาดไม่ได้"] --> M["สรุปหัว log เป็นตาราง key ไปหา offset ล่าสุด"]
M --> F["คัดผู้รอดออกจากทุกท่อนที่ต่ำกว่าเพดาน"]
F --> W["เขียนผู้รอดลง file ที่ลงท้ายด้วย .cleaned"]
W --> S["fsync แล้ว rename แล้ว fsync ที่ folder"]
S --> R["log ใหม่ · offset เดิมทุกตัว มีช่องว่าง"]
A["ท่อนที่กำลังเขียนอยู่"] -.->|"อยู่เหนือเพดาน ไม่เข้ากระบวนการ"| R
คำบรรยายภาพ: ภาพนี้เป็นลำดับขั้นตอน ไม่ใช่ผลรัน · ท่อนที่กำลังเขียนอยู่ไม่ได้เข้ากระบวนการนี้เลย มันเดินลัดไปอยู่ใน log ใหม่ตามเดิม และนั่นคือเหตุผลที่ค่าล่าสุดของ key หนึ่งมักไม่ได้อยู่ในท่อนที่ถูกทำความสะอาด
ขั้นตอนที่สลับ file เข้าไปแทนของเดิมใช้ลำดับที่คอร์สที่ 22 สอนไว้แล้ว
for (base, bytes) in &rewritten { let prefix = segment_prefix(*base); let cleaned = dir.join(format!("{prefix}{CLEANED_FILE_SUFFIX}")); std::fs::write(&cleaned, bytes)?; std::fs::File::open(&cleaned)?.sync_all()?; std::fs::rename(&cleaned, dir.join(format!("{prefix}{LOG_FILE_SUFFIX}")))?; // สารบัญเป็นของที่คำนวณใหม่ได้ · ลบทิ้งแล้วให้ตอนเปิดสร้างใหม่ ตามบทที่ 2 let index = dir.join(format!("{prefix}{INDEX_FILE_SUFFIX}")); if index.exists() { std::fs::remove_file(index)?; } }สารบัญของท่อนถูกลบทิ้งไปเลย ไม่ได้เขียนใหม่ เพราะมันคำนวณกลับมาได้ทั้งหมดตอนเปิด log ครั้งถัดไป ซึ่งเป็นข้อที่บทที่ 2 วางไว้
tombstone คือ key ที่มี value เป็น null
หัวข้อที่มีชื่อว่า “tombstone คือ key ที่มี value เป็น null”หัวข้อนี้บอกว่า tombstone หน้าตาเป็นอย่างไรบน wire แล้วโยงกลับไปที่ค่าลบหนึ่งของบทที่ 1
tombstonetombstonerecord พิเศษที่มาร์ค key ว่าถูกลบ ไม่ได้ลบ byte เดิมทันที · ใน broker คือ record ที่ความยาวของ value เป็น -1 ไม่ใช่ record ชนิดใหม่ และไม่ใช่ธงในหัว batch เอกสาร Design ของ Kafka 4.1 นิยามไว้สั้นๆ ว่า “A message with a key and a null payload will be treated as a delete from the log”
บทที่ 1 กางรูปนี้ไว้แล้ว ความยาว -1 ในช่อง value_len แปลว่า null
ซึ่งไม่ใช่ค่าเดียวกับ value ที่ยาวศูนย์ byte
== tombstone · key มีอยู่ แต่ value เป็น null ==เขียน OrderCancelled ของ ORD-1043 เป็น tombstone ที่ offset 21value_len บน wire เป็น -1 ซึ่งไม่เหมือน value ที่ยาวศูนย์ byteเขียนต่ออีก 6 record เพื่อดัน tombstone ให้พ้นท่อนที่กำลังเขียนอยู่log end offset 28 · จำนวนท่อน 7key ที่ยังมีชีวิตตอนนี้ ["ORD-1042", "ORD-1044"]demo เขียน OrderCancelled ของ ORD-1043 เป็น tombstone แล้วเขียนต่ออีกชุดหนึ่ง
เพื่อดัน tombstone ให้พ้นท่อนที่กำลังเขียนอยู่ ไม่งั้นมันจะอยู่เหนือเพดานและไม่มีรอบไหนได้แตะมันเลย
เส้นตายเริ่มเดินตอนถูกกวาดรอบแรก ไม่ใช่ตอนถูกเขียน
หัวข้อที่มีชื่อว่า “เส้นตายเริ่มเดินตอนถูกกวาดรอบแรก ไม่ใช่ตอนถูกเขียน”หัวข้อนี้อธิบายว่าทำไม tombstone ถึงหายไปทันทีไม่ได้ และนาฬิกาที่นับถอยหลังของมันเริ่มเดินเมื่อไหร่
ถ้า tombstone หายไปในรอบที่เห็นมันครั้งแรก ผู้อ่านที่ตามช้าจะไม่มีทางรู้เลยว่า key นั้นถูกลบ มันจะเห็นแค่ค่าเก่าค่าหนึ่งแล้วเชื่อว่านั่นคือค่าปัจจุบัน
เอกสาร topic config ของ Kafka 4.1 จึงเรียก delete.retention.ms ว่าเป็นเส้นของความถูกต้อง
ไม่ใช่เส้นของพื้นที่ “This setting also gives a bound on the time in which a consumer must
complete a read if they begin from offset 0 to ensure that they get a valid snapshot of the
final stage” โดยค่าปริยายของมันคือ 86400000
delete horizondelete horizonเส้นเวลาที่ tombstone ต้องอยู่ให้พ้นก่อนถึงจะถูกเก็บกวาดได้ มีไว้ให้ผู้อ่านที่ตามช้าได้เห็นการลบ คือเวลาที่ tombstone ในก้อนนั้นถูกทิ้งได้ และมันถูกเก็บไว้ใน batch เอง
/// bit 6 ของ `attributes` · เมื่อมันถูกตั้ง `base_timestamp` ไม่ใช่ timestamp ของ record/// ตัวแรกอีกต่อไป แต่เป็น **เวลาที่ tombstone ในก้อนนี้ถูกลบได้**pub const DELETE_HORIZON_FLAG_MASK: i16 = 0x0040;ค่านี้ตรงกับ DefaultRecordBatch.java ของ Kafka 4.1 ที่ประกาศไว้ว่า
private static final byte DELETE_HORIZON_FLAG_MASK = 0x40;
pub fn delete_horizon_ms(attributes: i16, base_timestamp: i64) -> Option<i64> { if attributes & DELETE_HORIZON_FLAG_MASK != 0 { Some(base_timestamp) } else { None }}จุดที่คนพลาดกันมากที่สุดคือ นาฬิกาของเส้นตายไม่ได้เริ่มเดินตอนที่ tombstone ถูกเขียน
MemoryRecords.java ของ Kafka 4.1 ตั้งค่ามันตอนกวาด ด้วยบรรทัด
deleteHorizonMs = filter.currentTime + filter.deleteRetentionMs; และตั้งเฉพาะก้อนที่ยังไม่เคยถูกตั้ง
kaen-log ทำท่าเดียวกันในรอบที่เห็น tombstone ก้อนนั้นเป็นครั้งแรก
let mut opts = BatchOptions::from_header(&batch.header); // เข็มนาฬิกาของเส้นตายเริ่มเดินที่ **รอบทำความสะอาดรอบแรกที่เห็น tombstone** // ไม่ใช่ตอนที่ tombstone ถูกเขียน · tombstone จึงรอดครบอย่างน้อยหนึ่งรอบเสมอ if has_tombstone && !already_stamped { opts.attributes |= DELETE_HORIZON_FLAG_MASK; opts.base_timestamp = Some(now_ms + log.config.delete_retention_ms); stats.horizons_stamped += 1; } out.extend_from_slice(&Batch::build_with(survivors, &opts)?.raw);ผลที่ตามมาคือ tombstone รอดครบอย่างน้อยหนึ่งรอบเสมอ เพราะรอบที่ประทับเส้นตายให้มัน
คือรอบที่มันยังต้องอยู่ กิ่ง None ใน should_retain เป็นที่ที่ข้อนี้ถูกบังคับ
อายุจริงของ tombstone จึงเป็นผลบวกของสามช่วง ไม่ใช่ delete.retention.ms เฉยๆ
- เวลาที่รอจนกว่ามันจะตกลงมาต่ำกว่าเพดาน คือพ้นท่อนที่กำลังเขียนอยู่
delete.retention.msนับจากรอบแรกที่กวาดเจอมัน- เวลาที่รอจนรอบทำความสะอาดรอบถัดไปเดินมาถึง
ก่อนจะมีธงในหัว batch Kafka ประมาณเวลาของ tombstone จากเวลาแก้ไขล่าสุดของ file ท่อนสุดท้าย ซึ่งเดาสูงไปก็ได้ เดาต่ำไปก็ได้
รายการ Improvement ของ release notes รุ่น 3.1.0 มีบรรทัด KAFKA-8522 ชื่อว่า “Tombstones can survive forever” อยู่ด้วย นั่นคือทางที่มันพังด้านหนึ่ง ส่วนอีกด้านคือ tombstone หายเร็วเกินไปจนแอปพลาดการลบ
ข้อนี้เป็นหลักฐานว่า “เก็บ tombstone ไว้สักพัก” ไม่ใช่รายละเอียดปลีกย่อย มันต้องมีที่เก็บเวลาที่แน่นอนอยู่ในรูปแบบบน disk ถึงจะทำได้ถูก
เส้นตายขี่มาใน base_timestamp และ delta ต้องขยับตาม
หัวข้อที่มีชื่อว่า “เส้นตายขี่มาใน base_timestamp และ delta ต้องขยับตาม”หัวข้อนี้ชี้ผลข้างเคียงของการยืมช่องเดิมมาเก็บเส้นตาย และเทสต์ที่กันไม่ให้ timestamp เพี้ยน
DELETE_HORIZON_FLAG_MASK ไม่ได้พาช่องใหม่มาด้วย มันเปลี่ยนความหมายของช่องที่มีอยู่แล้ว
คอมเมนต์ของ DefaultRecordBatch.java เขียนไว้ว่า “If the delete horizon flag is set to 1,
the BaseTimestamp will be set to the time at which tombstone records and aborted transaction
markers in the batch should be removed”
ที่อันตรายคือ base_timestamp ไม่ได้ทำหน้าที่เก็บเวลาเฉยๆ มันเป็น ฐานที่ timestamp ของทุก record
ในก้อนถูกเก็บเป็นส่วนต่างจากมัน ย้ายฐานโดยไม่คิด delta ใหม่ เท่ากับเลื่อนเวลาของทุก record ในก้อน
Kafka เขียนข้อนี้ไว้เป็นคอมเมนต์ใน MemoryRecords.java เหมือนกัน ว่า
“in which case, we need to reset the base timestamp and overwrite the timestamp deltas”
kaen-log ไม่ได้ copy ธงแล้วจบ มันส่ง record ที่รอดกลับเข้า Batch::build_with ซึ่งคิด delta ใหม่
จากฐานใหม่ให้ทั้งก้อน และมีเทสต์ยืนยันว่าค่าที่ decode กลับออกมายังตรง
/// เส้นตายขี่มาใน `base_timestamp` · timestamp ของ record ต้องยัง decode กลับถูก #[test] fn stamping_a_horizon_does_not_corrupt_record_timestamps() { let dead = Record::tombstone(5, 1_700_000_000_000, "ORD-1"); let opts = BatchOptions { attributes: DELETE_HORIZON_FLAG_MASK, base_timestamp: Some(1_800_000_000_000), ..BatchOptions::producer_defaults() }; let batch = Batch::build_with(vec![dead.clone()], &opts).unwrap(); let back = Batch::decode(&batch.raw).unwrap(); assert_eq!(back.header.base_timestamp, 1_800_000_000_000); assert_eq!(back.records[0].timestamp_ms, 1_700_000_000_000); assert_eq!( delete_horizon_ms(back.header.attributes, back.header.base_timestamp), Some(1_800_000_000_000) ); }เทสต์ตัวนี้ตรวจสองอย่างพร้อมกัน base_timestamp ที่อ่านกลับมาต้องเป็นเส้นตาย
ส่วน timestamp_ms ของ record ต้องเป็นเวลาเดิมของ record ไม่ใช่เส้นตาย
รอบที่สองไม่เปลี่ยนอะไร นั่นคือ idempotence
หัวข้อที่มีชื่อว่า “รอบที่สองไม่เปลี่ยนอะไร นั่นคือ idempotence”หัวข้อนี้อ่านตารางของสองรอบ แล้วบอกว่าตัวเลขนั้นพิสูจน์อะไร และไม่ได้พิสูจน์อะไร
--passes 2 สั่งให้ demo กวาดสองรอบด้วย --now-ms ค่าเดิม
== compaction · 2 รอบด้วย now-ms เดิม == รอบ uncleanable key ในหัว ท่อนที่ทำ record ก่อน → หลัง byte ก่อน → หลัง tombstone เก็บ/ทิ้ง ประทับเส้นตาย 1 25 3 6 19 → 3 2488 → 347 1/0 1 2 25 3 6 3 → 3 347 → 347 1/0 0รอบที่สองขึ้นไปไม่เปลี่ยนอะไรเลย · เพราะ now-ms ถูกฉีดเข้ามา ไม่ได้อ่านจากเครื่องkey ที่ยังมีชีวิตหลัง compact ["ORD-1042", "ORD-1044"]tombstone ยังอยู่ เพราะ now-ms ยังไม่ถึงเส้นตายที่ประทับไว้ในรอบแรกรอบแรกลด record จาก 19 เหลือ 3 และลด byte จาก 2488 เหลือ 347 รอบที่สองอ่านค่าเดิมทุกช่อง
สิ่งที่ตัวเลขคู่นี้พิสูจน์คือ idempotence กวาดซ้ำบน log ที่กวาดไปแล้วต้องไม่เปลี่ยนอะไร ถ้ามันยังเปลี่ยนอยู่ แปลว่ากติกาการคัดผู้รอดขึ้นกับสถานะของ file ไม่ใช่ขึ้นกับข้อมูล
สิ่งที่มันไม่ได้พิสูจน์คือความนิ่งข้ามการรัน สองอย่างนี้คนละข้ออ้าง ความนิ่งข้ามการรันเป็นงานของเทสต์ที่รันคำสั่งเดิมซ้ำหลายรอบแล้วเทียบผลทีละ byte ไม่ใช่งานของธงนี้
ช่อง tombstone เก็บ/ทิ้ง เป็น 1/0 ทั้งสองรอบ tombstone จึงยังอยู่หลังกวาดสองรอบ
เพราะ --now-ms ยังไม่ถึงเส้นตายที่ประทับไว้ในรอบแรก
demo เดินนาฬิกาข้ามเส้นตายด้วยการเรียกรอบที่สามด้วย now_ms ที่บวก delete.retention.ms ไปแล้ว
== เดินนาฬิกาข้ามเส้นตาย · now-ms + delete.retention.ms + 1 ==tombstone ที่เก็บไว้ 0 · ที่ทิ้ง 1อายุจริงของ tombstone = เวลาที่รอจนถูกทำความสะอาดได้ + delete.retention.ms+ เวลาที่รอรอบถัดไป · ไม่ใช่ delete.retention.ms เฉยๆcompact ตัวเดียวกัน log ก้อนเดียวกัน ต่างกันแค่ค่านาฬิกาที่ฉีดเข้าไป
นี่คือเหตุผลที่ทั้ง crate ห้ามอ่านนาฬิกาของเครื่อง ถ้า should_retain เรียกนาฬิกาเอง
ผลรันของบทนี้จะต่างกันทุกครั้งและไม่มีอะไรให้เทียบ
offset ไม่ขยับ และช่องว่างอยู่ถาวร
หัวข้อที่มีชื่อว่า “offset ไม่ขยับ และช่องว่างอยู่ถาวร”หัวข้อนี้แสดงว่าอ่านที่ offset ที่ถูก compact ไปแล้วได้อะไร และข้อจำกัดนี้ย้อนไปถึงบทที่ 4 อย่างไร
เอกสาร Design ของ Kafka 4.1 เขียนข้อนี้ไว้ยาว และทุกประโยคมีผลกับ code “the messages in the tail of the log retain the original offset assigned when they were first written—that never changes” ตามด้วย “all offsets remain valid positions in the log, even if the message with that offset has been compacted away”
ประโยคหลังคือส่วนที่คนมองข้าม ช่องว่างไม่ใช่ error offset ที่ record หายไปแล้วยังเป็นตำแหน่ง ที่ขอได้ และสิ่งที่ได้กลับมาคือ record ตัวถัดไปที่ยังอยู่
== offset ไม่ขยับ ช่องว่างยังอยู่ ==offset ที่ยังมี record อยู่จริงในท่อนที่เสิร์ฟคำขอนี้ [23, 24]log end offset ยังเป็น 28 เท่าเดิม · compaction ไม่เคยแจก offset ใหม่อ่านที่ offset 6 ซึ่งถูก compact ไปแล้ว → ได้ record ตัวถัดไปที่ Some(23)offset ทุกตัวยังเป็นตำแหน่งที่ถูกต้อง แม้ record ตรงนั้นจะไม่มีแล้วห้ามอ้างลำดับระหว่าง key สองตัวที่ต่างกัน · ไม่มีแหล่งต้นทางไหนรับรองข้อนั้นสิ่งที่ Kafka รับรองคือ compaction ไม่เคยสลับลำดับ แค่เอาบางตัวออกdemo เลือก offset ที่หายไปจริงมาขอ แล้วพิมพ์ว่าได้อะไรกลับมา ถ้ามันคืนค่าว่าง บรรทัดนั้นจะเป็นข้ออ้างที่ผลรันหักล้างเอง demo จึงหยุดพร้อม error ถ้าไม่มีช่องว่างเลย
ข้อนี้ย้อนกลับไปผูกกับบทที่ 4 โดยตรง การอ่านของ kaen-log ไม่ได้แม็ป offset ไปหา record ตัวหนึ่ง แบบหนึ่งต่อหนึ่ง มันแม็ป offset ไปหา ตำแหน่งเริ่มอ่าน แล้วเดินไปข้างหน้า โครงแบบนั้นรองรับช่องว่างอยู่แล้วตั้งแต่ต้น โดยไม่ต้องเพิ่มอะไรในบทนี้เลย
compaction ต้องคืน producer id กับ sequence ให้บทที่ 7
หัวข้อที่มีชื่อว่า “compaction ต้องคืน producer id กับ sequence ให้บทที่ 7”หัวข้อนี้ชี้ field ในหัว batch ที่การเขียนใหม่ต้องคงไว้ และบอกว่าบทไหนเป็นคนอ่านมันกลับ
การเขียนท่อนขึ้นใหม่คือการสร้าง batch ขึ้นใหม่ และตรงนั้นมีกับดักที่ไม่ฟ้องตอน compile
Batch::build ตั้ง producer id, producer epoch และ base sequence กลับเป็นลบหนึ่ง เพราะมันคือ
ทางที่ producer ธรรมดาใช้ ส่วนตัวทำความสะอาดต้องใช้ Batch::build_with แล้วส่งค่าเดิมกลับเข้าไป
/// compaction ต้องคง first/last sequence ไว้ ไม่งั้นสถานะ producer ของบทที่ 7 พัง #[test] fn rewriting_a_batch_preserves_the_producer_fields() { let opts = BatchOptions { producer_id: 1000, producer_epoch: 3, base_sequence: 7, partition_leader_epoch: 2, ..BatchOptions::producer_defaults() }; let batch = Batch::build_with(vec![Record::new(0, 1, "ORD-1", b"v".to_vec())], &opts).unwrap(); let again = Batch::build_with(batch.records.clone(), &BatchOptions::from_header(&batch.header)) .unwrap(); assert_eq!(again.header.producer_id, 1000); assert_eq!(again.header.producer_epoch, 3); assert_eq!(again.header.base_sequence, 7); assert_eq!(again.header.partition_leader_epoch, 2); }สาม field นั้นคือสิ่งที่บทที่ 7 อ่านกลับตอนสร้างสถานะของ producer ขึ้นใหม่หลัง restart ถ้า compaction ล้างมันทิ้ง หน้าต่างกันซ้ำจะว่างเปล่าและ producer เดิมจะส่งซ้ำเข้ามาได้อีก
Kafka บอกข้อเดียวกันนี้ในเอกสารรูปแบบของมันเอง ว่ารุ่น v2 ขึ้นไปคง first และ last offset กับ sequence ของ batch เดิมไว้หลังกวาด และอาจเก็บหัว batch ที่ไม่เหลือ record ไว้ทั้งก้อนเพื่อรักษาข้อมูลลำดับของ producer
นี่คือรอยต่อที่พังตอนรัน ไม่ใช่ตอนอ่าน ทั้ง build และ build_with คืน Batch เหมือนกัน
เทสต์ตัวข้างบนจึงมีไว้ตรึงข้อนี้ไว้ที่บทนี้ ไม่ใช่ปล่อยให้บทที่ 7 ไปเจอเอง
__offsets ถูกกวาดด้วยตัวเดียวกัน ไม่มีกรณีพิเศษ
หัวข้อที่มีชื่อว่า “__offsets ถูกกวาดด้วยตัวเดียวกัน ไม่มีกรณีพิเศษ”หัวข้อนี้เอา partition ภายในของบทที่ 5 มาเข้าเครื่องเดียวกัน แล้วดูว่าอะไรเกิดขึ้น
บทที่ 5 สร้าง partition ภายในชื่อ __offsets ไว้เก็บ offset ที่แต่ละ group commit แล้ว
มันตั้ง cleanup.policy เป็น compact และนั่นคือทุกอย่างที่มันต้องทำ
== __offsets ถูกทำความสะอาดด้วยตัวเดียวกัน ไม่มีกรณีพิเศษ ==record ที่เขียนไปทั้งหมด 12 · key ที่ยังมีชีวิต 3ในท่อนที่ทำความสะอาดได้ · record 8 → 2 · key ที่ยังมีชีวิต 3 → 3log end offset 12 (ไม่ขยับ)ตัวทำความสะอาดไม่รู้เลยว่านี่คือ partition ภายใน · มันเห็นแค่ topic ที่ตั้ง compactบรรทัดกลางอ่านว่า “ในท่อนที่ทำความสะอาดได้” ด้วยเหตุผล ค่าล่าสุดของ key หนึ่งมักนั่งอยู่ใน ท่อนที่กำลังเขียนอยู่ ซึ่งไม่มีวันถูกกวาด ตัวเลขที่ลดลงจึงเป็นตัวเลขของท่อนเก่าเท่านั้น ไม่ใช่ของทั้ง partition
จำนวน key ที่ยังมีชีวิตเท่าเดิมก่อนและหลัง และ demo หยุดพร้อม error ถ้ามันไม่เท่า เพราะ compaction ที่ทำ key หายคือ bug ไม่ใช่การประหยัดพื้นที่
ประโยคสุดท้ายของบล็อกคือประเด็นของหัวข้อนี้ ตัวทำความสะอาดไม่มี if ที่ถามว่าชื่อ topic
ขึ้นต้นด้วยขีดล่างสองตัวหรือไม่ มันเห็นแค่ LogConfig ที่ตั้ง cleanup_compact ไว้
ของเล่นตัวนี้เก่งกว่าและง่ายกว่า Kafka คนละที่
หัวข้อที่มีชื่อว่า “ของเล่นตัวนี้เก่งกว่าและง่ายกว่า Kafka คนละที่”หัวข้อนี้แยกสิ่งที่ kaen-log ทำได้ดีกว่า ออกจากสิ่งที่มันตัดทิ้ง
ตารางสรุปหัว log ของ kaen-log เก็บ key เต็มๆ ใน HashMap ส่วน Kafka เก็บ hash ของ key
ที่ถูกย่อแล้ว โดยเอกสาร Design ระบุว่ามันใช้พื้นที่ 24 byte ต่อ entry
โครงแบบของ Kafka ชนกันได้ และเมื่อชน record ที่ยังมีชีวิตอาจหลุดหายไป kaen-log จึง ถูกต้องกว่าตรงนี้ ไม่ใช่ง่ายกว่า และเหตุผลก็ตรงไปตรงมา ของเล่นไม่ต้องกวาด log ที่ใหญ่เกินหน่วยความจำ
ส่วนที่ kaen-log ตัดทิ้งจริงๆ มีสี่ข้อ
- ไม่มี
min.cleanable.dirty.ratioของจริงใช้อัตราส่วนนี้เลือกว่าจะกวาด partition ไหนก่อน โดยตัวทำความสะอาดเป็นตัวกลางที่ดูทุก partition พร้อมกัน ส่วน demo นี้สั่งกวาดตรงๆ - ไม่มีการจำกัดอัตรา Kafka หรี่ปริมาณ I/O ของการกวาดได้ เพื่อไม่ให้ไปแย่งทางของ producer
- ไม่มี
max.compaction.lag.msค่านี้มีไว้บังคับให้ของที่ต้องลบตามกฎหมายถูกกวาดในเวลาที่กำหนด ต่อให้อัตราส่วนยังไม่ถึงเกณฑ์ก็ตาม - ไม่ได้ทำตามประโยค “Cleaning does not block reads” ของเอกสาร Design เพราะ
compactรับ&mut Logผู้เรียกจึงถือ lock ของ log ไว้ทั้งรอบ และคำขอ fetch ที่ต้องใช้ lock เดียวกัน ตอนวางแผนอ่านก็ค้างรอไปด้วย
ที่ compact เขียน .cleaned ให้ครบก่อนแล้วค่อยสลับ เป็นเรื่องของความปลอดภัยตอนเครื่องดับ
ตามลำดับ fsync → rename → fsync ที่ folder ซึ่งคอร์สที่ 22 วางไว้ ไม่ใช่เรื่องของเวลาที่ถือ lock
บทที่ 7 จะรับ field ของ producer ที่บทนี้อุตส่าห์รักษาไว้ ไปสร้างหน้าต่างกันซ้ำ แล้วตอบคำถามว่า exactly-once เป็นข้ออ้างที่จริงในขอบเขตแค่ไหน
- Apache Kafka 4.1 — Design (ตรวจแล้ว 2026-09-03) — ข้อรับประกันสี่ข้อของ compaction รวมทั้งประโยค at least the final state ประโยคที่ว่า offset ไม่เคยเปลี่ยนและช่องว่างยังเป็นตำแหน่งที่ถูกต้อง นิยามของ tombstone ประโยคที่ยืนยันว่าท่อนที่กำลังเขียนอยู่ไม่ถูก compact ประโยค Cleaning does not block reads และตัวเลข 24 byte ต่อ entry ของตารางสรุปหัว log ที่ Kafka ใช้
- Apache Kafka 4.1 — Implementation › Log (ตรวจแล้ว 2026-09-03) — ประโยค Data is deleted one log segment at a time และประโยคที่ระบุว่า timestamp ที่มากที่สุดของท่อนเป็นตัวกำหนดอายุของทั้งท่อน
- Apache Kafka 4.1 — Topic Configs
(ตรวจแล้ว 2026-09-03) — คำอธิบายของ
cleanup.policyที่ระบุว่ารับได้เป็น list และค่าปริยายคือ delete ·retention.msปริยาย 604800000 ·delete.retention.msปริยาย 86400000 พร้อมประโยคที่บอกว่ามันเป็นขอบของความถูกต้อง ·segment.bytesที่รับค่าได้ตั้งแต่ 1048576 ขึ้นไป · และประโยคเรื่องงบของค่าซ้ำในmin.cleanable.dirty.ratio - UnifiedLog.java branch 4.1
(ตรวจแล้ว 2026-09-03) — เงื่อนไข
startMs - segment.largestTimestamp() > retentionMsและคอมเมนต์เรื่อง high watermark ที่กันไม่ให้ log start offset แซงมันไป - LogCleanerManager.java branch 4.1 (ตรวจแล้ว 2026-09-03) — คอมเมนต์ We cannot clean past ที่ระบุเพดานสามชั้น และการเอาค่าที่น้อยที่สุดในสามค่านั้น
- DefaultRecordBatch.java branch 4.1
(ตรวจแล้ว 2026-09-03) —
DELETE_HORIZON_FLAG_MASK = 0x40และคอมเมนต์ที่ระบุว่าเมื่อธงนี้ถูกตั้ง BaseTimestamp จะกลายเป็นเวลาที่ tombstone ในก้อนนั้นถูกลบได้ · และคอมเมนต์ On Compaction ที่ระบุว่า magic v2 ขึ้นไปคง first และ last offset กับ sequence ของ batch เดิมไว้หลังกวาด และอาจเก็บหัว batch ที่ไม่เหลือ record ไว้เพื่อรักษาข้อมูลลำดับของ producer - MemoryRecords.java branch 4.1
(ตรวจแล้ว 2026-09-03) — บรรทัดที่ตั้งเส้นตายเป็น
filter.currentTime + filter.deleteRetentionMsเฉพาะก้อนที่ยังไม่เคยถูกตั้ง และคอมเมนต์ที่บอกว่าต้องเขียน timestamp delta ใหม่ทั้งก้อนตอนย้ายฐาน - LogConfig.java branch 4.1
(ตรวจแล้ว 2026-09-03) — สอง field
public final boolean compact;กับpublic final boolean delete;ที่ยืนยันว่านโยบายสองข้อนี้แยกจากกันจริง - LogValidator.java branch 4.1 (ตรวจแล้ว 2026-09-03) — จุดที่ Kafka ปฏิเสธ record ที่ไม่มี key บน topic ที่ตั้ง compact ตั้งแต่ตอน produce
- ServerLogConfigs.java branch 4.1
(ตรวจแล้ว 2026-09-03) —
log.retention.check.interval.msที่เป็นรอบเดินมาตรวจ (LOG_CLEANUP_INTERVAL_MS_DEFAULT = 5 * 60 * 1000L) กับfile.delete.delay.msที่เป็นเวลารอก่อนเอา file ออกจาก disk จริง - LogFileUtils.java branch 4.1
(ตรวจแล้ว 2026-09-03) —
DELETED_FILE_SUFFIX = ".deleted"ซึ่งเป็นชื่อที่ท่อนถูก rename ไปก่อน - Cleaner.java branch 4.1
(ตรวจแล้ว 2026-09-03) — วิธีเก่าที่ประมาณเส้นตายจากเวลาแก้ไขล่าสุดของท่อนสุดท้าย
segments.get(segments.size() - 1).lastModified() - log.config().deleteRetentionMsซึ่งยังอยู่สำหรับ batch รุ่นก่อน v2 - KIP-354 · Add a Maximum Log Compaction Lag
(ตรวจแล้ว 2026-09-03) — เหตุผลของ
max.compaction.lag.msที่เขียนไว้ว่า “Ability to delete a record through compaction in a timely manner has become an important requirement in some use cases (e.g., GDPR)” - Apache Kafka 3.1.0 — Release Notes (ตรวจแล้ว 2026-09-03) — รายการ Improvement ที่มี KAFKA-8522 ชื่อ Tombstones can survive forever ซึ่งเป็นรุ่นที่กลไกเส้นตายแบบเก็บไว้ในหัว batch มาถึง
เช็กความเข้าใจ — บทที่ 6
ข้อ 1 / 3topic หนึ่งตั้ง cleanup.policy เป็น compact แล้วกวาดจนนิ่ง ข้อไหนเป็นข้ออ้างที่ยืนได้