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

ตัด​ล็อก​เป็น​ท่อน แล้ว​ทำ​สารบัญ​ที่​ไม่​ครบ

บท​ที่ 1 ทิ้ง log ไว้​เป็น file เดียว​ที่​ยาว​ขึ้น​เรื่อยๆ ซึ่ง​ใช้ได้​จนกว่า​จะ​เจอ​คำถาม​สอง​ข้อ

ข้อ​แรก​คือ​จะ​ลบ​ของ​เก่า​อย่างไร ใน​เมื่อ file เดียว​ลบ​หัว​ไม่​ได้ ข้อ​ที่​สอง​คือ​จะ​กระโดด​ไป​ที่ offset กลาง log อย่างไร โดย​ไม่​ต้อง​อ่าน​มา​ตั้งแต่​ต้น

คำ​ตอบ​ของ​สอง​ข้อ​นี้​เป็น​คนละ​กลไก แต่​ทั้ง​คู่​ตั้ง​อยู่​บน​โครงสร้าง​เดียวกัน คือ​ตัด log ออก​เป็น​ท่อน แล้ว​ทำ​สารบัญ​ประจำ​ท่อน

ข้อ​อ้าง​ของ​บท​นี้​คือ​สารบัญ​นั้น​ไม่ใช่​ความ​จริง มัน​เป็น​ตัว​เร่ง มัน​ไม่​ครบ มัน​ไม่มี checksum และ​ลบ​ทิ้ง​แล้ว​เสีย​แค่​เวลา​ตอน​เปิด สาม​ข้อ​นี้​คือ​เหตุผล​ที่​การ​อ่าน​ต้อง​แบ่ง​เป็น​สอง​จังหวะ

📦 code ของ​บท​นี้

crate ของ​คอร์ส​อยู่​ที่ scripts/log-broker/ และ​ชื่อ binary คือ klog ทั้ง​คอร์ส​เขียน​ด้วย Rust standard library ล้วน ตรวจ​ได้​จาก Cargo.toml ที่​ไม่มี​หัวข้อ [dependencies]

บล็อก code กับ​บล็อก​ผล​รัน​ทุก​อัน​ใน​บท​เรียน​นี้​ถูก​คัด​มา​จาก scripts/log-broker/src/segment.rs และ​จาก scripts/log-broker/expected/02-segment.txt ทีละ byte โดย​มี​เทสต์​เทียบ​ไว้ ถ้า​แก้​ใน​บท​เรียน​อย่าง​เดียว​โดย​ไม่​แก้​ที่​ต้นทาง เทสต์​จะ​แดง​ทันที

ผล​รัน​ทุก​บรรทัด​ใน​บท​นี้​มา​จาก​คำ​สั่ง​เดียว คือ klog build-segments --dir data/orders-0 --segment-bytes 4096 --index-interval 512 --records 40 --lookup 27 --now-ms 1700000000000 ซึ่ง​เทสต์​รัน​ซ้ำ​แล้ว​เทียบ​ผล​ที​ละ byte ทุก​ครั้ง

ค่าที่​ตั้ง​ไว้​ใน​คำ​สั่ง​นั้น​เล็ก​กว่า​ค่า​ปริยาย​ของ Kafka หลาย​เท่า เพื่อ​ให้​เห็น​การ​ตัด​ท่อน และ​เห็น​สารบัญ​ที่​ไม่​ครบ​ภายใน​ผล​รัน​หน้า​เดียว

หัวข้อ​นี้​บอก​ว่า​ท่อน​ของ log ประกอบ​ด้วย file อะไร​บ้าง และ​ทำไม​ชื่อ​ของ​มัน​ถึง​ยาว​ยี่สิบ​หลัก

segmentsegmentท่อน​หนึ่ง​ของ log · ท่อน​ที่​เต็ม​แล้ว​ถูก​แช่​แข็ง (immutable) ส่วน​ท่อน​สุดท้าย​ยัง​รับ​การ​เขียน — เป็น​ของ​ที่ #22 วาง​ไว้​เป็น​สะพาน​แล้ว​ส่ง​ต่อ​มา​ถึง​คอร์ส​นี้ คือ​ท่อน​หนึ่ง​ของ log ท่อน​ที่​เต็ม​แล้ว​ถูก​แช่​แข็ง ส่วน​ท่อน​สุดท้าย​เป็น​ท่อน​เดียว​ที่​ยัง​รับ​การ​เขียน

Kafka ตั้ง​ชื่อ file ของ​แต่ละ​ท่อน​ด้วย​นามสกุล​ที่​ประกาศ​ไว้​เป็น constant ใน LogFileUtils.java ของ​รุ่น 4.0.0 ซึ่ง​มี​มากกว่า​ที่​คน​ส่วน​ใหญ่​จำ​ได้

  • .log — ตัว record จริง เป็น file เดียว​ใน​ชุด​ที่​เป็น​ความ​จริง
  • .index — สารบัญ​จาก offset ไป​หา​ตำแหน่ง byte ซึ่ง​เป็น​ของ​ที่​บท​นี้​กาง
  • .timeindex — สารบัญ​จาก timestamp ไปหา offset
  • .txnindex — ช่วง offset ที่​ถูก​ยกเลิก​ไป​ตาม transaction
  • .snapshot — สถานะ​ของ producer ที่​ถูก​ถ่าย​ไว้​เป็น​ภาพ​นิ่ง
  • .deleted — นามสกุล​ที่​ถูก​เติม​ทับ​ตอน​ท่อน​ถูก​ปลด​ระวาง

kaen-log สร้าง​สอง​ใบ​แรก​เท่านั้น อีก​สี่​ใบ​เป็น​ของ​กลไก​ที่​คอร์ส​นี้​ไม่​ได้​ลงมือ​ทำ และ​หัวข้อ​สุดท้าย​ของ​บท​จะ​ไล่​ส่วน​ต่าง​ที่​เหลือ​ระหว่าง kaen-log กับ Kafka ให้​ดู

base offsetbase offsetoffset ของ record ตัว​แรก​ใน segment · ใช้​เป็น​ชื่อ file และ​เป็น​ฐาน​ที่ offset ภายใน​ถูก​เก็บ​เป็น​ส่วน​ต่าง​จาก​มัน คือ offset ของ record ตัว​แรก​ใน​ท่อน และ​มัน​เป็น​ชื่อ​ของ file ทั้ง​คู่​ไป​พร้อม​กัน

src/segment.rs · ชื่อ file ของ​ท่อน
/// ชื่อ file ของ segment · `{:020}` คือสิ่งที่ทำให้ `read_dir` + sort แบบ string
/// ให้ลำดับเดียวกับการเรียงตาม base offset
pub fn segment_prefix(base_offset: u64) -> String {
format!("{base_offset:020}")
}

เอกสาร Implementation ของ Kafka 4.0 อธิบาย​กติกา​การ​ตั้ง​ชื่อ​ไว้​ตรง​ตัว​ว่า “Each log file is named with the offset of the first message it contains. So the first file created will be 00000000000000000000.log”

filenamePrefixFromOffset ของ Kafka เติม​ศูนย์​ให้​ครบ​ยี่สิบ​หลัก​ด้วย setMinimumIntegerDigits(20) และ​ปิด​การ​คั่น​หลัก​ด้วย setGroupingUsed(false) โดย javadoc ของ​มัน​บอก​เหตุผล​ไว้​เอง​ว่า​ทำ​เพื่อ “pad out the offset number with zeros so that ls sorts the files numerically”

ยี่สิบ​หลัก​คือ​ความ​กว้าง​มาก​ที่สุด​ของ​จำนวนเต็ม​ไม่มี​เครื่องหมาย​ขนาด 64 bit เมื่อ​เขียน​เป็น​เลข​ฐาน​สิบ สิ่ง​ที่​มัน​ซื้อ​มา​คือ​การ​เรียง​ตาม​ตัว​อักษร​ให้​ผล​เดียว​กับ​การ​เรียง​ตาม​ตัวเลข

ผล​ที่​ตาม​มา​มี​ค่า​มากกว่า​ที่​เห็น read_dir แล้ว sort แบบ string ธรรมดา ให้​ลำดับ​ของ​ท่อน​ที่​ถูก​ต้อง​ทันที โดย​ไม่​ต้อง​แปลง​ชื่อ​กลับ​เป็น​ตัวเลข​ก่อน

และ​เพราะ .index ไม่มี​หัว file เลย​สัก​บรรทัด ชื่อ file จึง​เป็น​ที่​เดียว​ที่​บอก base offset ของ​ท่อน​ได้ โดย​ไม่​ต้อง​แกะ .log สัก​ก้อน ตัว​สารบัญ​เอง​ไม่รู้​ด้วย​ซ้ำ​ว่า​มัน​เป็น​สารบัญ​ของ​ท่อน​ไหน

ท่อน​ใหม่​เกิด​ตอน​ไหน · Kafka มี​ห้า​เงื่อนไข kaen-log ลงมือ​สาม​ข้อ

หัวข้อ​ที่​มีชื่อ​ว่า “ท่อน​ใหม่​เกิด​ตอน​ไหน · Kafka มี​ห้า​เงื่อนไข kaen-log ลงมือ​สาม​ข้อ”

หัวข้อ​นี้​ไล่​เงื่อนไข roll ของ Kafka ทั้ง​ห้า​ข้อ แล้ว​บอก​ว่า kaen-log ทำ​ข้อ​ไหน​บ้าง

คน​ส่วน​ใหญ่​จำ​ได้​สอง​ข้อ คือ​ท่อน​เต็ม​กับ​ท่อน​หมด​อายุ ของ​จริง​ใน LogSegment.shouldRoll ของ​รุ่น 4.0.0 เป็นการ || ต่อ​กัน​ห้า​พจน์

  1. size > rollParams.maxSegmentBytes - rollParams.messagesSize — เขียน​ก้อน​นี้​ลง​ไป​แล้ว​จะ​ล้น
  2. (size > 0 && reachedRollMs) — ท่อน​แก่​เกิน segment.ms แล้ว และ​ต้อง​มี​ของ​อยู่​ใน​นั้น​ก่อน
  3. offsetIndex().isFull() — สารบัญ offset เต็ม
  4. timeIndex().isFull() — สารบัญ​เวลา​เต็ม
  5. !canConvertToRelativeOffset(rollParams.maxOffsetInMessages) — offset ห่าง​จาก​หัว​ท่อน​เกิน​กว่า​ที่ 4 byte จะ​รับ​ไหว

สาม​ข้อ​หลัง​คือ​ข้อ​ที่​ทำให้​หัว​ข้อ​ถัดๆ ไป​มี​ความ​หมาย เพราะ​ทั้ง​สาม​ข้อ​พูด​ถึง​สารบัญ ไม่​ได้​พูด​ถึง .log

src/segment.rs · เงื่อนไข roll ที่ kaen-log ลงมือ
/// Kafka มีเงื่อนไข roll **ห้าข้อ** · kaen-log ลงมือสามข้อ:
/// ขนาดเต็ม · หมดอายุตามเวลา · และ offset ห่างจากหัวท่อนเกิน u32
/// อีกสองข้อคือสารบัญเต็ม ซึ่ง kaen-log ไม่มี เพราะสารบัญของมันเป็น `Vec`
/// ที่โตได้เรื่อยๆ และ `.timeindex` ซึ่งคอร์สนี้ไม่ได้สร้าง
pub(crate) fn roll_if_needed(
&mut self,
incoming: u64,
last_offset: u64,
now_ms: i64,
) -> io::Result<()> {
let active = self.active_segment();
let size = active.size();
let base = active.base_offset();
let full = size > 0 && size + incoming > self.config.segment_bytes;
let aged = size > 0 && now_ms - self.active_since_ms > self.config.max_segment_ms;
let too_far = u32::try_from(last_offset - base).is_err();
if !(full || aged || too_far) {
return Ok(());
}
self.active_segment().write_index(&self.dir)?;
self.segments.push(Segment::create(&self.dir, self.next_offset)?);
self.active_since_ms = now_ms;
self.bytes_since_last_index_entry = 0;
Ok(())
}

kaen-log ตัด​ข้อ 3 กับ​ข้อ 4 ทิ้ง เพราะ​สารบัญ​ของ​มัน​เป็น Vec ที่​โต​ได้​เรื่อยๆ จึง​ไม่มี​วัน​เต็ม และ​เพราะ​มัน​ไม่​ได้​สร้าง .timeindex เลย

ข้อ 5 ยัง​อยู่​ครบ และ​มัน​คือ​ราคา​ของ offset สัมพัทธ์ ซึ่ง​อีก​สอง​หัวข้อ​ข้าง​ล่าง​จะ​กาง​ให้​ดู

demo ของ​บท​นี้​ตั้ง​ค่า​ให้​เล็ก​จน​ท่อน​แตก​ได้​ภายใน​ผล​รัน​เดียว

expected/02-segment.txt (ตัด​มา​บาง​ส่วน) · ค่าที่​ตั้ง​ไว้
== ค่าที่ตั้งไว้ ==
segment.bytes 4096
index.interval.bytes 512
segment.ms 604800000
record ที่จะเขียน 40

ค่า​ปริยาย​จริง​ของ Kafka 4.3 อยู่​คนละ​มาตราส่วน segment.bytes เป็น 1073741824 ส่วน index.interval.bytes เป็น 4096 การ​ตั้ง​ค่า​ให้​เล็ก​ลง​จึง​เป็นการ​ย่อ​ภาพ ไม่ใช่​การ​เปลี่ยน​กลไก

expected/02-segment.txt (ตัด​มา​บาง​ส่วน) · ท่อน​ที่​ได้
== ท่อนที่ได้ · หนึ่ง batch ต่อหนึ่ง record ==
file base_offset size entry largest_timestamp
00000000000000000000.log 0 3973 7 1700000000028
00000000000000000029.log 29 1507 2 1700000000039
log end offset 40

record 40 ตัว​ลง​ไป​อยู่​ใน​สอง​ท่อน เพราะ​เงื่อนไข​ข้อ 1 ยิง​ตอน​ที่​ก้อน​ถัด​ไป​จะ​ทำให้​ท่อน​แรก​ล้น 4096 ท่อน​ที่​สอง​จึง​เริ่ม​ที่ base offset 29 ซึ่ง​เป็น​ชื่อ​ของ file มัน​เอง

สังเกต​ว่า log end offset เท่ากับ 40 ซึ่ง​เท่ากับ​จำนวน record ที่​เขียน​ลง​ไป​พอดี มัน​คือ offset ตัว​ถัด​ไป​ที่​จะ​ถูก​แจก ไม่ใช่ offset ของ record ตัว​สุดท้าย และ​คอร์ส​นี้​ใช้​ความ​หมาย​นี้​ทุก​บท

หัวข้อ​นี้​แยก​คำ​ที่​คอร์ส​ที่ 22 กับ​คอร์ส​นี้​ใช้​ต่าง​กัน ก่อน​ที่​สารบัญ​จะ​ทำให้​สอง​คำ​นั้น​มา​อยู่​ใน​ประโยค​เดียว

คอร์ส kaen-kvstore ใช้​คำ​ว่า offset แทน​ตำแหน่ง byte ใน file สารบัญ​ขอ​งมันแม็ป key ไปหา offset แล้ว seek ตรง​ไป​ที่​ตำแหน่ง​นั้น

คอร์ส​นี้​ใช้ offset แทน​ลำดับ​ของ record ใน partition ตาม​ความ​หมาย​ของ Kafka ส่วน​ตำแหน่ง byte ได้​ชื่อ​ของ​ตัว​เอง​ว่า byte positionbyte positionตำแหน่ง byte ใน file ของ segment · นี่​คือ​สิ่ง​ที่ #22 เรียก​ว่า offset และ​ใน​คอร์ส​นี้​มัน​เป็น​ปลาย​ทาง​ของ​ลูก​ศร ไม่ใช่​ต้นทาง

สารบัญ​ของ​บท​นี้​จึง​เป็น​ลูก​ศร​ที่​ปลาย​สอง​ข้าง​มี​ความ​หมาย​คนละ​อย่าง ต้นทาง​คือ offset ปลาย​ทาง​คือ byte position ถ้า​เรียก​ทั้ง​สอง​ข้าง​ว่า offset เหมือน​กัน ประโยค​จะ​อ่าน​ไม่​ได้​ความ

ผู้​อ่าน​ที่​เพิ่ง​จบ​คอร์ส​ที่ 22 มา​จะ​เจอ​คำ​เดิม​ใน​ความ​หมาย​ใหม่​ตั้งแต่​หัวข้อ​ถัด​ไป บท​ที่ 1 เตือน​ไว้​แล้ว และ​นี่​คือ​บท​ที่​มัน​กัด​จริง

entry หนึ่ง​ตัว​ของ​สารบัญ​เก็บ​สอง​ค่า​นั้น​ไว้​ใน​สี่​บวก​สี่ byte

src/segment.rs · ขนาด​ของ entry บน disk
/// entry ในสารบัญกิน **8 byte บน disk** เท่ากับของ Kafka: u32 สองตัว big-endian
/// ไม่มีหัว file และไม่มี checksum
pub const ENTRY_SIZE_ON_DISK: usize = 8;
src/segment.rs · entry หนึ่ง​ตัว​ของ​สารบัญ
/// `position` กว้าง **u64 ในหน่วยความจำ** แต่แคบเหลือ u32 ตอนลง disk
/// kaen-log จึงไม่ได้รับมรดกเพดาน 2 GiB ของ Kafka มาโดยบังเอิญ
/// · ที่เดียวที่เพดานยังกัดคือตอนเขียนกลับ และตัวเขียนปฏิเสธค่าที่ใส่ไม่ลง แทนที่จะตัดทิ้ง
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct IndexEntry {
pub rel_offset: u32,
pub position: u64,
}

ความ​กว้าง​ของ field หลัง​ไม่​เท่า​กัน​ระหว่าง​บน disk กับ​ใน​หน่วย​ความ​จำ และ​นั่น​เป็นการ​ตัดสิน​ใจ ไม่ใช่​ความ​บังเอิญ หัวข้อ​เรื่อง​เพดาน​ข้าง​ล่าง​จะ​บอก​ว่า​มัน​ซื้อ​อะไร​มา

หัวข้อ​นี้​กาง​สารบัญ​ของ​ท่อน​แรก​ออก​มา​ดู แล้ว​นับ​ว่า​มัน​เก็บ​กี่​ตัว​จาก​ทั้งหมด​กี่​ตัว

sparse indexsparse indexสารบัญ​ที่​จงใจ​ไม่​เก็บ​ทุก record เก็บ​เป็น​ระยะ​แทน แลก​ความ​แม่น​กับ​ขนาด​ที่​อยู่​ใน​หน่วย​ความ​จำ​ได้ คือ​สารบัญ​ที่​จงใจ​ไม่​เก็บ​ทุก record มัน​เก็บ​เป็น​ระยะ​แทน และ​คำ​ว่า​จงใจ​มา​จาก​เอกสาร​ของ Kafka เอง ไม่ใช่​คำ​ที่​คอร์ส​นี้​เติม​ให้

javadoc ของ OffsetIndex.java รุ่น 4.0.0 เขียน​ไว้​ว่า “An index that maps offsets to physical file locations for a particular log segment. This index may be sparse: that is it may not hold an entry for all messages in the log.”

demo พิมพ์​สารบัญ​ของ​ท่อน​แรก​ออก​มา​ทั้ง​ใบ

expected/02-segment.txt (ตัด​มา​บาง​ส่วน) · สารบัญ​ของ​ท่อน​แรก
== สารบัญของท่อนแรก · 00000000000000000000.index ==
base offset ของสารบัญ 0 · entry ละ 8 byte บน disk
(u32 rel_offset + u32 position · big-endian · ไม่มีหัว file และไม่มี checksum)
สารบัญว่างเปล่า: ไม่ใช่
rel_offset offset จริง position
4 4 548
8 8 1096
12 12 1644
16 16 2192
20 20 2740
24 24 3288
28 28 3836
record 29 ตัวในท่อนนี้ แต่มี entry แค่ 7 ตัว · สารบัญไม่ครบโดยตั้งใจ

บรรทัด​สุดท้าย​คือ​ข้อ​อ้าง​ของ​ทั้ง​หัวข้อ record 29 ตัว​อยู่​ใน​ท่อน​นั้น แต่​สารบัญ​มี entry แค่ 7 ตัว และ​ไม่มี​อะไร​ผิด​พลาด​เกิด​ขึ้น​เลย

entry ที่​หาย​ไป​ไม่​ได้​หาย​ไป​เฉยๆ มัน​ไม่​เคย​ถูก​สร้าง เพราะ​กฎ​การ​เติม​บอก​ว่า​อย่า​เติม ซึ่ง​เป็น​เรื่อง​ของ​หัวข้อ​ถัด​ไป

ENTRY_SIZE ของ Kafka ประกาศ​ไว้​ว่า private static final int ENTRY_SIZE = 8; และ​ตัว​อ่าน​ของ​มัน​คือ buffer.getInt(n * ENTRY_SIZE) กับ buffer.getInt(n * ENTRY_SIZE + 4) ซึ่ง ByteBuffer ของ Java อ่าน​เป็น big-endian โดย​ปริยาย

kaen-log เขียน​รูป​เดียวกัน​ที​ละ byte แล้ว​ขยาย field หลัง​เป็น u64 ตอน​ถอด​กลับ

src/segment.rs · ถอด​สารบัญ​กลับ​จาก byte
fn decode(base_offset: u64, raw: &[u8]) -> io::Result<OffsetIndex> {
if !raw.len().is_multiple_of(ENTRY_SIZE_ON_DISK) {
return Err(bad(format!("สารบัญยาว {} byte ซึ่งไม่ใช่ผลคูณของ 8", raw.len())));
}
let mut entries = Vec::with_capacity(raw.len() / ENTRY_SIZE_ON_DISK);
for chunk in raw.chunks_exact(ENTRY_SIZE_ON_DISK) {
let mut rel = [0u8; 4];
let mut pos = [0u8; 4];
rel.copy_from_slice(&chunk[0..4]);
pos.copy_from_slice(&chunk[4..8]);
entries.push(IndexEntry {
rel_offset: u32::from_be_bytes(rel),
// แคบ 4 byte บน disk → กว้าง u64 ในหน่วยความจำ ตรงนี้ที่เดียว
position: u64::from(u32::from_be_bytes(pos)),
});
}
Ok(OffsetIndex { base_offset, entries })
}

บรรทัด​ที่​มี​คอม​เมน​ต์​กำกับ​คือ​ที่​เดียว​ใน crate ที่ byte position เปลี่ยน​ไป​ทาง​กว้าง​ขึ้น จึง​ไม่มี​ทาง​เสีย​ค่า ส่วน​ขา​แคบ​ลง​อยู่​ที่​ตอน​เขียน​กลับ​ลง disk ซึ่ง​เป็น​เรื่อง​ของ​หัวข้อ​เพดาน​ข้าง​ล่าง

decode รับ base_offset เข้า​มา​เป็น argument เพราะ file ไม่มี​หัว​ให้​อ่าน ค่าที่​ส่ง​เข้า​มา​นั้น​ได้​มา​จาก​การ​แยก​ชื่อ file ตอน​เปิด partition

กฎ​การ​เติม entry ที่​เกือบ​ทุก​บทความ​บน​เว็บ​เขียน​กลับ​ด้าน

หัวข้อ​ที่​มีชื่อ​ว่า “กฎ​การ​เติม entry ที่​เกือบ​ทุก​บทความ​บน​เว็บ​เขียน​กลับ​ด้าน”

หัวข้อ​นี้​กาง​กฎ​การ​เติม entry ของ Kafka ที​ละ​บรรทัด แล้ว​เอา​ไป​ทาบ​กับ​ตัวเลข​จริง​ใน​สารบัญ​ข้าง​บน

LogSegment.append ของ​รุ่น 4.0.0 เก็บ physicalPosition ไว้​ก่อน​เขียน แล้ว​ตัดสิน​ใจ​ด้วย​สาม​บรรทัด​นี้

if (bytesSinceLastIndexEntry > indexIntervalBytes) { offsetIndex().append(largestOffset, physicalPosition); … bytesSinceLastIndexEntry = 0; } แล้ว​ปิด​ท้าย​นอก​วงเล็บ​ด้วย bytesSinceLastIndexEntry += records.sizeInBytes();

สอง​ข้อ​ที่​คน​เขียน​กลับ​ด้าน​บ่อย​ที่สุด​อยู่​ใน​สาม​บรรทัด​นั้น​พอดี

  • ตัว​นับ​ถูก​บวก หลัง การ​ตรวจ ไม่ใช่​ก่อน และ​เครื่องหมาย​เป็น > แบบ​เข้ม ไม่ใช่ >=
  • entry ผูก offset ตัว​สุดท้าย ของ​ก้อน​ที่​เพิ่ง​เขียน เข้า​กับ​ตำแหน่ง​ที่​ก้อน​นั้น เริ่ม ไม่ใช่​ผูก​กับ​ตำแหน่ง​ที่​ก้อน​นั้น​จบ

kaen-log ลอก​ลำดับ​นี้​มา​ทั้ง​ลำดับ

src/segment.rs · กฎ​การ​เติม entry ตอน​ต่อ​ท้าย​ท่อน
/// ต่อท้ายท่อน · คืนตำแหน่ง byte ที่ batch นี้เริ่ม
///
/// กฎการเติมสารบัญตรงกับ `LogSegment.append` ของ Kafka: ตรวจด้วย `>` แบบเข้ม
/// **ก่อน** บวกตัวนับ และ entry ที่ได้ผูก offset **ตัวสุดท้าย** ของก้อนนี้
/// เข้ากับตำแหน่งที่ก้อนนี้ **เริ่ม** · เกือบทุกบทความบนเว็บเขียนกลับด้าน
pub fn append(
&mut self,
bytes: &[u8],
last_offset: u64,
max_timestamp: i64,
add_index_entry: bool,
) -> io::Result<u64> {
let position = self.size;
self.log.write_all(bytes)?;
if add_index_entry {
let rel = u32::try_from(last_offset - self.base_offset)
.map_err(|_| bad("offset ห่างจากหัว segment เกิน u32"))?;
self.index.append(rel, position)?;
}
self.size += len_to_u64(bytes.len());
self.largest_timestamp = self.largest_timestamp.max(max_timestamp);
Ok(position)
}

ตัว​นับ bytes_since_last_index_entry ไม่​ได้​อยู่​บน Segment แต่​อยู่​บน Log เพราะ​ทาง produce ของ​บท​ที่ 3 เป็น​คน​ขับ​มัน ตัว Segment::append แค่​รับคำ​ตัดสิน​มา​เป็น bool

ทีนี้​เอา​กฎ​นี้​ไป​ทาบ​กับ​สารบัญ​ข้าง​บน แล้ว​ตัวเลข​ทุก​ตัว​จะ​อธิบาย​ตัว​เอง​ได้

หนึ่ง batch ใน​ผล​รัน​นี้​ยาว 137 byte และ index.interval.bytes ตั้ง​ไว้​ที่ 512 ก้อน​แรก​จึง​เจอ​ตัว​นับ​เป็น​ศูนย์ ซึ่ง​ไม่​มากกว่า 512 และ​ไม่​ได้ entry

ตัว​นับ​สะสม​ไป​เรื่อยๆ จนถึง​ก้อน​ที่​ห้า ตอน​นั้น​มัน​มี​ค่า​เท่ากับ​สี่​ก้อน​แรก​รวม​กัน ซึ่ง​มากกว่า 512 เป็น​ครั้ง​แรก entry ตัว​แรก​จึง​เกิด​ที่​ก้อน​นั้น

นั่น​คือ​แถว​แรก​ของ​สารบัญ​พอดี rel_offset 4 คู่​กับ position 548 โดย​เลข 548 คือ​ความ​ยาว​ของ​สี่​ก้อน​แรก​รวม​กัน และ​เลข 4 คือ offset ของ​ก้อน​ที่​ห้า​เอง

ระยะ​ห่าง​ระหว่าง entry สอง​ตัว​ที่​ติด​กัน​จึง​ไม่​เท่ากับ index.interval.bytes เป๊ะ มัน​มากกว่า​เสมอ และ​มากกว่า​ได้​ไม่​เกิน​ความ​ยาว​ของ batch หนึ่ง​ก้อน

เอกสาร Kafka 4.3 เลือก​ใช้​คำ​ว่า roughly ตรง​จุด​นี้​พอดี ว่า “The default setting ensures that we index a message roughly every 4096 bytes” คำ​นั้น​ไม่ใช่​คำ​กำกวม มัน​คือ​ผล​ของ​การ​ตรวจ​ก่อน​บวก

offset สัมพัทธ์​ซื้อ 4 byte มา แล้ว​ขาย​เพดาน​ไป​หนึ่ง​ข้อ

หัวข้อ​ที่​มีชื่อ​ว่า “offset สัมพัทธ์​ซื้อ 4 byte มา แล้ว​ขาย​เพดาน​ไป​หนึ่ง​ข้อ”

หัวข้อ​นี้​บอก​ว่าการ​เก็บ offset เป็น​ส่วน​ต่าง​ประหยัด​อะไร และ​ไป​ติด​เพดาน​ที่​ตรง​ไหน​บ้าง

javadoc ของ OffsetIndex.java อธิบาย​การ​เลือก​นี้​ไว้​เอง​ว่า “The offset stored is relative to the base offset of the index file” และ​ปิด​ท้าย​ย่อหน้า​เดียวกัน​ว่า “Using relative offsets in this way let’s us use only 4 bytes for the offset.”

ตัวอย่าง​ที่ javadoc ยกไว้​เอง​คือ ถ้า​หัว​ท่อน​อยู่​ที่ 50 แล้ว record ที่​ลำดับ 55 จะ​ถูก​เก็บ​ลง​สารบัญ​เป็น 5

ถ้า​เก็บ offset แบบ​เต็ม​ความ​กว้าง entry จะ​กิน 12 byte เพราะ offset เป็น​จำนวนเต็ม​ขนาด 64 bit การ​เก็บ​เป็น​ส่วน​ต่าง​จาก​หัว​ท่อน​ทำให้​เหลือ 8 byte พอดี

ราคา​ของ​มัน​เป็น​เพดาน ไม่ใช่​ความ​เสี่ยง AbstractIndex.toRelative ปฏิเสธ​ค่าที่​ใส่​ไม่​ลง​ตรงๆ ด้วย if (relativeOffset < 0 || relativeOffset > Integer.MAX_VALUE) return OptionalInt.empty();

และ Kafka ไม่​ได้​ปล่อย​ให้​เพดาน​นั้น​กลาย​เป็น error มัน​เอา​ไป​ใส่​เป็น​เงื่อนไข roll ข้อ​ที่​ห้า​แทน ท่อน​ที่​กำลัง​จะ​ทะลุ​เพดาน​จึง​ถูก​ปิด แล้ว​เปิด​ท่อน​ใหม่​ที่​มี base offset ใหม่

เพดาน​อีก​ข้อ​ไม่​ได้​อยู่​ที่ offset แต่​อยู่​ที่​ตำแหน่ง byte และ​มัน​คนละ​เลข​กัน​ใน​สอง​ระบบ

src/segment.rs · ตัว​เขียน​ปฏิเสธ​ตำแหน่ง​ที่​ใส่​ไม่​ลง
/// ตัวเขียนปฏิเสธตำแหน่งที่ใส่ u32 ไม่ลง แทนที่จะตัดให้พอดี
/// · เพดานของ segment เดียวจึงเป็น 4 GiB และมันเป็น **ข้อผิดพลาดที่ดังออกมา**
pub fn append(&mut self, rel_offset: u32, position: u64) -> io::Result<()> {
u32::try_from(position).map_err(|_| {
bad(format!("ตำแหน่ง {position} ใส่ 4 byte ของ entry ไม่ลง · segment เดียวเกิน 4 GiB"))
})?;
self.entries.push(IndexEntry { rel_offset, position });
Ok(())
}

kaen-log เขียน position เป็น u32 ไม่มี​เครื่องหมาย เพดาน​ของ​หนึ่ง​ท่อน​จึง​เป็น 4 GiB และ​ตัว​เขียน​ปฏิเสธ​ค่าที่​ใส่​ไม่​ลง​แทนที่​จะ​ตัด​ให้​พอดี เพดาน​จึง​เป็น​ข้อ​ผิด​พลาด​ที่​ดัง​ออก​มา

แต่​ถ้า​พูด​ถึง​ความ​เข้า​กัน​ได้ที​ละ byte กับ Kafka เลข​คือ 2 GiB เพราะ Kafka อ่าน​ช่อง​นั้น​ด้วย buffer.getInt ซึ่ง​คืน int แบบ​มี​เครื่องหมาย ค่าที่​เกิน​ครึ่ง​บน​ของ u32 จะ​กลาย​เป็น​เลข​ติดลบ​ทันที

สอง​เลข​นี้​ต้อง​พูด​แยก​กัน การ​พูด​เลข​เดียว​จะ​ผิด​ข้าง​ใด​ข้าง​หนึ่ง​เสมอ

เรื่อง​นี้​ยัง​ไม่​จบ​ใน​ฝั่ง Kafka ด้วย ตั๋ว KAFKA-19603 ที่​ขอ​เปลี่ยน log.segment.bytes จาก int เป็น long ยัง​มี​สถานะ Open และ Fix Version เป็น None โดย​คำ​อธิบาย​ของ​มัน​ระบุ​ว่า “.index files store offset-to-position mappings using 4-byte integers for file positions. If segments exceed 2GB, position values would overflow the 4-byte limit.”

อ่าน​สอง​จังหวะ · กระโดด​ด้วย binary search แล้ว​เดิน​ต่อ​แบบ​มี​เพดาน

หัวข้อ​ที่​มีชื่อ​ว่า “อ่าน​สอง​จังหวะ · กระโดด​ด้วย binary search แล้ว​เดิน​ต่อ​แบบ​มี​เพดาน”

หัวข้อ​นี้​ประกอบ​สารบัญ​ที่​ไม่​ครบ​เข้า​กับ​การ​อ่าน แล้ว​ชี้​ว่า​อะไร​เป็น​ตัว​คุม​ความ​ยาว​ของ​จังหวะ​ที่​สอง

two-stage readtwo-stage readการ​อ่าน​สอง​จังหวะ: หา entry ที่​ใกล้​ที่สุด​ใน​สารบัญ​ก่อน แล้ว​สแกน​ต่อ​จาก​จุด​นั้น​จนถึง offset ที่​ขอ คือ​คำ​ตอบ​ของ​คำถาม​ที่​ว่า ถ้า​สารบัญ​ไม่มี entry ของ offset ที่​ขอ จะ​อ่าน​อย่างไร

จังหวะ​ที่ 1 หา entry ตัว​ที่​ใหญ่​ที่สุด​ซึ่ง​ยัง​ไม่​เกิน target javadoc ของ OffsetIndex.lookup เรียก​มัน​ว่า “Find the largest offset less than or equal to the given targetOffset”

src/segment.rs · จังหวะ​ที่ 1
/// จังหวะที่หนึ่ง · หา entry ตัวที่ `rel_offset` มากที่สุดแต่ยังไม่เกิน target
/// แล้วคืนตำแหน่ง byte ที่จะเริ่ม scan
///
/// การส่ง fetch มาที่ segment เก่ากว่า target เป็น **ทางปกติ ไม่ใช่ bug**
/// จึงต้องกันการลบ: `(target - base) as u32` จะ panic ใน build แบบ debug
pub fn lookup(&self, target: u64) -> u64 {
if target <= self.base_offset {
return 0;
}
let Ok(rel) = u32::try_from(target - self.base_offset) else {
return 0;
};
match self.entries.binary_search_by_key(&rel, |e| e.rel_offset) {
Ok(i) => self.entries[i].position,
Err(0) => 0,
Err(i) => self.entries[i - 1].position,
}
}

จังหวะ​ที่ 2 อ่าน​หัว batch ที่​ตำแหน่ง​นั้น แล้ว​เดิน​ไป​ข้าง​หน้า​ที​ละ​ก้อน จนกว่า​จะ​ถึง​ก้อน​ที่​ครอบ target

สอง​จังหวะ​อยู่​ใน function เดียวกัน ชื่อ read_from และ​มัน​คือ​หน้าที่​ที่​ทั้ง​คอร์ส​เรียก​ใช้

src/segment.rs · สอง​จังหวะ​อยู่​ใน read_from ตัว​เดียว
/// จังหวะที่หนึ่ง + จังหวะที่สอง · คืน (base offset ของ batch, ตำแหน่ง byte, ความยาว)
/// `Ok(None)` แปลว่า target เลยท้ายท่อนนี้ไปแล้ว
///
/// จังหวะที่หนึ่งคือ `index.lookup` ที่กระโดดไป entry ตัวที่ใกล้ที่สุดแต่ยังไม่เกิน
/// จังหวะที่สองคือ loop ข้างล่างที่เดินหัว batch ไปข้างหน้าทีละก้อนจนถึงก้อนที่ครอบ
/// target · **ตัวนี้คือหน้าที่ที่ทั้งคอร์สเรียกใช้** และเป็นตัวที่บทที่ 2 ยกไปแสดง
pub fn read_from(&self, target: u64) -> io::Result<Option<(u64, u64, u32)>> {
let mut pos = self.index.lookup(target);
loop {
let Some(header) = self.read_batch_header_at(pos)? else { return Ok(None) };
let last = header.last_offset()?;
let total = header.total_size();
if last >= target {
let len = u32::try_from(total).map_err(|_| bad("batch เดียวยาวเกิน u32"))?;
return Ok(Some((header.base_offset, pos, len)));
}
pos += len_to_u64(total);
}
}

บรรทัด self.index.lookup(target) คือ​จังหวะ​ที่ 1 ทั้ง​จังหวะ มัน​คืน​ตำแหน่ง byte ที่​จะ​เริ่ม​เดิน ส่วน loop ที่​ตาม​มา​คือ​จังหวะ​ที่ 2 มัน​อ่าน​หัว batch ที​ละ​ก้อน​จน​เจอ​ก้อน​ที่ last_offset ถึง target แล้ว​หยุด

Ok(None) เกิด​เมื่อ​เดิน​พ้น​ท้าย​ท่อน​ไป​แล้ว​ยัง​ไม่​เจอ ซึ่ง​แปล​ว่า target อยู่​ใน​ท่อน​ถัด​ไป ไม่ใช่​ท่อน​นี้

demo ของ​บท​นี้​ต้อง​พิมพ์​จำนวน​ก้าว​ของ​จังหวะ​ที่ 2 ออก​มา​ด้วย มัน​จึง​เรียก​ฝาแฝด​ชื่อ read_from_traced ซึ่ง​ห่อ read_from อีก​ชั้น​แล้ว​นับ​ก้าว​ให้ ฝาแฝด​ตัว​นั้น​มี​ไว้​เพื่อ​ให้​เลข​ที่​พิมพ์​เป็น​เลข​ที่​วัด​ได้ ไม่ใช่​เลข​ที่​เล่า และ​ไม่มี​หน้าที่​อื่น​นอกจาก​นั้น

ผล​รัน​ของ target ที่ 27 เดิน​ครบ​ทั้ง​สอง​จังหวะ

expected/02-segment.txt (ตัด​มา​บาง​ส่วน) · อ่าน​สอง​จังหวะ
== อ่านสองจังหวะ · target offset 27 ==
ท่อนที่ครอบ offset นี้: 00000000000000000000.log (base offset 0)
จังหวะที่ 1 · binary search เจอ entry (rel_offset 24, position 3288) → เริ่มที่ byte 3288
จังหวะที่ 2 · scan ไปข้างหน้า 4 batch แล้วหยุด
batch ที่ได้ · base offset 27 · position 3699 · ยาว 137 byte
record offset 27 · timestamp 1700000000027 · key ORD-1069 · value {"event":"OrderPlaced","order_id":"ORD-1069","total":129927}

จังหวะ​ที่ 1 หยุด​ที่ entry ของ rel_offset 24 ซึ่ง​เป็น​ตัว​ใหญ่​ที่สุด​ที่​ยัง​ไม่​เกิน 27 แล้ว​ให้​ตำแหน่ง​เริ่ม​มา​เป็น byte 3288

จังหวะ​ที่ 2 เดิน​ผ่าน 4 batch แล้ว​หยุด ซึ่ง​เท่ากับ​ก้อน​ของ offset 24 ถึง 27 พอดี

เลข 4 นั้น​ไม่​ได้​ขึ้น​กับ​ว่า​ท่อน​ใหญ่​แค่​ไหน มัน​ขึ้น​กับ​ระยะ​ห่าง​ระหว่าง entry สอง​ตัว​ที่​ติด​กัน ซึ่ง​หัวข้อ​ที่​แล้ว​บอก​ไป​แล้ว​ว่า​เท่ากับ index.interval.bytes บวก​ได้​อีก​ไม่​เกิน​หนึ่ง batch

นี่​คือ​เหตุผล​ที่​การ​อ่าน​สอง​จังหวะ​ไม่ใช่​การ​ประนีประนอม ความ​ยาว​ของ​จังหวะ​ที่​สอง​ถูก​คุม​ด้วย​ค่า config ตัว​หนึ่ง ไม่​ได้​ถูก​คุม​ด้วย​ขนาด​ของ​ท่อน คน​ตั้ง​ค่า​จึง​เลือก​เอง​ได้​ว่า​จะ​จ่าย​เป็น​ขนาด​ของ​สารบัญ หรือ​จ่าย​เป็นการ​เดิน

flowchart TD
    T["target offset 27"] --> S1
    subgraph S1["จังหวะที่ 1 · binary search บนสารบัญ"]
        direction LR
        I1["rel 4 → 548"] --- I2["rel 8 → 1096"] --- IX["rel 12 · 16 · 20"] --- I3["rel 24 → 3288"] --- I4["rel 28 → 3836"]
    end
    S1 -->|"entry ตัวใหญ่สุดที่ยังไม่เกิน target"| P["เริ่มอ่านที่ byte 3288"]
    P --> S2
    subgraph S2["จังหวะที่ 2 · เดินหน้าทีละ batch"]
        direction LR
        B1["batch offset 24"] --> B2["batch offset 25"] --> B3["batch offset 26"] --> B4["batch offset 27 · หยุด"]
    end
    S2 --> R["ได้ base offset 27 ที่ byte 3699 ยาว 137 byte"]

คำ​บรรยาย​ภาพ: ตัวเลข​ทุก​ตัว​ใน​ภาพ​มา​จาก​ผล​รัน​ของ​บท​นี้ · จังหวะ​ที่ 1 เป็นการ​กระโดด​ครั้ง​เดียว​ลง​บน​สารบัญ​ที่​มี entry เจ็ด​ตัว ส่วน​จังหวะ​ที่ 2 เป็นการ​เดิน​หน้า​ที​ละ batch ที่​ยาว​ไม่​เกิน​ระยะ​ห่าง​ระหว่าง entry สอง​ตัว ไม่ใช่​ยาว​เท่ากับ​ท่อน

หัวข้อ​นี้​แสดง​ว่า batch ที่​ได้​กลับ​มา​มี base offset ต่ำ​กว่า target ได้ และ​ใคร​เป็น​คน​ทิ้ง​ส่วน​เกิน

หน่วย​ที่ broker อ่าน​และ​ส่ง​ออก​ไป​คือ batch ทั้ง​ก้อน ไม่ใช่ record เดี่ยว ผล​ที่​ตาม​มา​คือ batch ที่​ครอบ target อาจ​เริ่ม​ก่อน target

FileRecords.searchForOffsetWithSize ของ Kafka 4.0.0 พูด​ข้อ​นี้​ด้วย code ของ​มัน​เอง มัน​วน​แล้ว​สั่ง if (offset >= targetOffset) return new LogOffsetPosition(batch.baseOffset(), batch.position(), batch.sizeInBytes()); โดย​ตัว offset ที่​เอา​ไป​เทียบ​คือ batch.lastOffset() แต่​ค่าที่​คืน​กลับ​ไป​คือ batch.baseOffset()

demo เขียน batch ที่​มี 3 record ต่อ​ท้าย​เข้าไป แล้ว​ถาม target ที่​อยู่​กลาง​ก้อน​นั้น

expected/02-segment.txt (ตัด​มา​บาง​ส่วน) · batch ที่​ครอบ target
== batch ที่ครอบ target · base offset ของมันต่ำกว่า target ได้ ==
เขียนอีกหนึ่ง batch ที่มี 3 record · offset 40 ถึง 42
ท่อนที่ครอบ offset นี้: 00000000000000000029.log (base offset 29)
จังหวะที่ 1 · binary search เจอ entry (rel_offset 8, position 1096) → เริ่มที่ byte 1096
จังหวะที่ 2 · scan ไปข้างหน้า 4 batch แล้วหยุด
batch ที่ได้ · base offset 40 · position 1507 · ยาว 289 byte
record offset 40 · timestamp 1700000000040 · key ORD-1082 · value {"event":"OrderPlaced","order_id":"ORD-1082","total":200000}
record offset 41 · timestamp 1700000000041 · key ORD-1083 · value {"event":"OrderPlaced","order_id":"ORD-1083","total":200001}
record offset 42 · timestamp 1700000000042 · key ORD-1084 · value {"event":"OrderPlaced","order_id":"ORD-1084","total":200002}
target คือ 41 แต่ batch ที่ได้เริ่มที่ 40 · จังหวะที่ 2 หยุดที่
**ทั้ง batch** ไม่ใช่ที่ record · ตัวอ่านเป็นคนทิ้ง record ส่วนหน้าเอง

target คือ 41 แต่​สิ่ง​ที่​ได้​กลับ​มา​คือ​ก้อน​ที่​เริ่ม​ที่ offset 40 และ​มี record ยาว​ไป​ถึง 42

ตัว​อ่าน​เป็น​คน​ทิ้ง record ส่วน​หน้า​เอง broker ไม่​ได้​แกะ batch ให้ นั่น​คือ​เหตุผล​ที่ offset ที่​ผู้​อ่าน​ขอ กับ offset ตัว​แรก​ที่​มัน​ได้​รับ ไม่​จำเป็น​ต้อง​เท่า​กัน

ข้อ​นี้​จะ​กลับ​มา​อีก​ครั้ง​ที่​บท​ที่ 4 ตอน​ที่ fetch ต้อง​ตัดสิน​ใจ​ว่า​จะ​ส่ง​อะไร​กลับ​ไป และ​อีก​ครั้ง​ที่​บท​ที่ 6 ตอน​ที่ compaction ทำให้ offset บาง​ตัว​หาย​ไป​จาก log ถาวร

หัวข้อ​นี้​ไล่​สอง​เส้น​ทาง​ที่​หน้าตา​เหมือน​ความ​ผิด​พลาด แต่​เป็น​ทาง​ปกติ​ของ fetch

สารบัญ​ของ​ท่อน​หนึ่ง​ถูก​ถาม​ด้วย offset ที่​ไม่​ได้​อยู่​ใน​ท่อน​นั้น​ได้ และ​มัน​เกิด​ขึ้น​จริง​บ่อย​กว่า​ที่​คิด

expected/02-segment.txt (ตัด​มา​บาง​ส่วน) · ทาง​ที่​ไม่ใช่ bug
== ทางที่ไม่ใช่ bug สองทาง ==
ถาม target 27 กับท่อน 00000000000000000029.log ซึ่ง base offset คือ 29 · lookup คืน position 0
target ที่ต่ำกว่าหัวท่อนคือ fetch ที่วิ่งมาผิดท่อน · เป็นทางปกติ ไม่ใช่ error
ถาม target 45 ซึ่งเลย log end offset · read_from คืน None

ทาง​แรก​คือ target ต่ำ​กว่า​หัว​ท่อน lookup คืน position 0 ซึ่ง​แปล​ว่า​ให้​เริ่ม​อ่าน​จาก​ต้น​ท่อน ไม่ใช่ error และ​ไม่ใช่​ค่าที่​แปล​ว่า​ไม่​พบ

ถ้า​ไม่​กัน​เงื่อนไข​นี้​ไว้ การ​ลบ target - base_offset จะ​ติดลบ และ build แบบ debug ของ Rust จะ panic ตรง​นั้น​ทันที ซึ่ง​เป็น crash ที่มา​จาก​ทาง​ปกติ​ของ​ระบบ

ทาง​ที่​สอง​คือ target ที่​เลย log end offset ไป​แล้ว read_from คืน None เพราะ​จังหวะ​ที่ 2 อ่าน​หัว batch ที่​ตำแหน่ง​นั้น​ไม่​ได้

None ตัว​นั้น​ไม่​ได้​แปล​ว่า offset ไม่มี​อยู่​ใน​โลก มัน​แปล​ว่า​ไม่มี​ใน​ท่อน​นี้ และ​บท​ที่ 4 จะ​เป็น​คน​ตัดสิน​ว่า​คำขอ​นั้น​ควร​ได้​ของ​ว่าง​กลับ​ไป หรือ​ควร​ถูก​จอด​ไว้​รอ

หัวข้อ​นี้​ทดสอบ​ข้อ​อ้าง​หลัก​ของ​บท​ตรงๆ ด้วย​การ​ลบ​สารบัญ​แล้ว​นับ entry ใหม่

สารบัญ​ของ Kafka ไม่มี checksum และ javadoc ของ OffsetIndex.java บอก​เหตุผล​ไว้​ใน​ประโยค​เดียว​ว่า “No attempt is made to checksum the contents of this file, in the event of a crash it is rebuilt.”

ประโยค​นั้น​ตัดสิน​สถานะ​ของ​สารบัญ​ทั้ง​ใบ ของ​ที่​สร้าง​ใหม่​ได้​จาก​ของ​อื่น​ไม่​ต้อง​พิสูจน์​ตัว​เอง เพราะ​วิธี​ตอบ​คำถาม​ว่า​มัน​เสีย​หรือ​ไม่ ที่​ถูก​กว่า​การ​ตรวจ คือ​สร้าง​ใหม่​ทิ้ง​ไป​เลย

kaen-log เขียน​ข้อ​นี้​ไว้​ใน​ตัว Segment::open ถ้า​เปิด .index ไม่​เจอ ก็​กวาด .log แล้ว​สร้าง​ขึ้น​ใหม่

src/segment.rs · ไม่มี .index ก็​สร้าง​ใหม่​จาก​การก​วาด .log
/// เปิด segment ที่มีอยู่แล้ว · ถ้า `.index` หายไป ก็สร้างใหม่จากการกวาด `.log`
/// นั่นคือราคาทั้งหมดของการลบสารบัญทิ้ง: เวลาตอนเปิด ไม่ใช่ข้อมูล
pub fn open(dir: &Path, base_offset: u64, index_interval_bytes: u64) -> io::Result<Segment> {
let prefix = segment_prefix(base_offset);
let log = OpenOptions::new()
.read(true)
.append(true)
.open(dir.join(format!("{prefix}{LOG_FILE_SUFFIX}")))?;
let mut seg = Segment {
base_offset,
index: OffsetIndex::empty(base_offset),
log,
size: 0,
largest_timestamp: i64::MIN,
};
let scan = seg.scan(index_interval_bytes)?;
seg.size = scan.size;
seg.largest_timestamp = scan.largest_timestamp;
let index_path = dir.join(format!("{prefix}{INDEX_FILE_SUFFIX}"));
seg.index = match fs::read(&index_path) {
Ok(raw) => OffsetIndex::decode(base_offset, &raw)?,
Err(e) if e.kind() == io::ErrorKind::NotFound => scan.index,
Err(e) => return Err(e),
};
Ok(seg)
}

scan ที่​มัน​เรียก​คือ​ตัว​เดียว​กับ​ที่​ใช้​หา offset ตัว​สุดท้าย และ​มัน​เดิน​ทั้ง​ท่อน​ที​ละ batch ตาม​กฎ​การ​เติม​ข้อ​เดิม​ทุก​ประการ ผล​ที่​ได้​จึง​ต้อง​เหมือน​สารบัญ​ที่​ถูก​ลบ​ไป

demo ลบ .index ทุก​ใบ​ทิ้ง แล้ว​เปิด partition ขึ้น​ใหม่​ทั้ง​อัน

expected/02-segment.txt (ตัด​มา​บาง​ส่วน) · ลบ​สารบัญ​แล้ว​เปิด​ใหม่
== ลบ .index ทุกใบ แล้วเปิดใหม่ ==
ลบไป 2 ใบ · สร้างใหม่จากการกวาด .log ทั้งหมด
จำนวน entry ก่อนลบ [7, 2]
จำนวน entry หลังเปิด [7, 2]
เหมือนเดิม: ใช่
ราคาของการลบสารบัญคือเวลาตอนเปิด ไม่ใช่ข้อมูล

จำนวน entry ก่อน​ลบ​กับ​หลัง​เปิด​เท่า​กัน​ทุก​ท่อน และ​ไม่มี record หาย​ไป​สัก​ตัว

สิ่ง​ที่​จ่าย​ไป​คือ​เวลา​ตอน​เปิด เพราะ​การ​สร้าง​สารบัญ​ใหม่​ต้อง​อ่าน​หัว batch ของ​ทั้ง​ท่อน ส่วน .log เอง​ถูก​กวาด​อยู่​แล้ว​ตอน​เปิด เพื่อ​หา offset ตัว​สุดท้าย​ที่​อยู่​ใน​นั้น

ตัว​เร่ง​ที่​ลบ​ทิ้ง​ได้ กับ​ความ​จริง​ที่​ลบ​ทิ้ง​ไม่​ได้

.log มี CRC-32C ประจำ​ทุก batch ตาม​ที่​บท​ที่ 1 กาง​ไว้ ส่วน .index ไม่มี​อะไร​เลย ไม่มี​หัว file ไม่มี checksum และ​ไม่มี​ชื่อ​ของ​ตัว​เอง​อยู่​ข้าง​ใน

ความ​ต่าง​นี้​ไม่​ได้​มา​จาก​ความ​ใส่ใจ​ที่​ไม่​เท่า​กัน มัน​มา​จาก​คำถาม​ว่า ถ้า file นี้​เสีย จะ​กู้​จาก​อะไร .index กู้​จาก .log ได้ ส่วน .log ไม่มี​อะไร​ให้​กู้

และ​คอร์ส​นี้​ไม่​ได้​อ้าง​ว่า​จังหวะ​ที่ 2 จะ​จับ entry ที่​ผิด​ได้​เสมอ entry ที่​ชี้​ผิด​ตำแหน่ง​จะ​ทำให้ การ​ถอด​หัว batch ล้ม​เหลว ซึ่ง​เป็นการ​ฟ้อง ไม่ใช่​การ​ตรวจ วิธี​ที่​ถูก​ยัง​เป็นการ​ลบ​สารบัญ​ทิ้ง​แล้ว​สร้าง​ใหม่​อยู่ดี

หัวข้อ​นี้​ประกาศ​ส่วน​ต่าง​ระหว่าง kaen-log กับ Kafka ก่อน​ที่​ผู้​อ่าน​จะ​ไป​เจอ​เอง

  • ไม่มี memory mapping หน้า std::fs::File ของ Rust ไม่มี​คำ​ว่า mmap อยู่​เลย​สัก​ที่ kaen-log จึง​อ่าน​สารบัญ​ทั้ง​ใบ​ขึ้น​หน่วย​ความ​จำ​ตอน​เปิด ซึ่ง​พอ​ไหว​เพราะ​สารบัญ​ไม่​ครบ​อยู่​แล้ว entry จึง​น้อย​กว่า record หลาย​เท่า · ท่อน​แรก​ของ​ผล​รัน​นี้​มี entry 7 ตัว​จาก record 29 ตัว
  • ไม่​จอง file ล่วง​หน้า Kafka จอง .index ไว้​ตาม segment.index.bytes ซึ่ง​ค่า​ปริยาย​ของ​รุ่น 4.3 คือ 10485760 แล้ว​หด​ตอน roll ส่วน kaen-log เขียน​เท่า​ที่​มี​จริง
  • ไม่มี warm section Kafka ค้น​ส่วน​ท้าย​ของ​สารบัญ​ก่อน เพราะ​ส่วน​นั้น​มี​โอกาส​อยู่​ใน page cache ของ OS มากกว่า ซึ่ง​เป็นการ​ปรับ​ที่​จ่าย​ผล​เมื่อ​สารบัญ​ถูก mmap เท่านั้น
  • ไม่มี .timeindex และ​ไม่มี .txnindex การ​ค้น​ด้วย timestamp กับ​เรื่อง transaction จึง​ไม่มี​ใน​คอร์ส​นี้

AbstractIndex.warmEntries ของ Kafka มี​คอม​เมน​ต์​ที่​อ่าน​สนุก​กำกับ​ไว้ ว่าการ page fault ตอน lookup “can cause the at-least-once produce latency to jump to about 1 second from a few ms” อ่าน​มัน​เป็น​ประวัติศาสตร์​ของ design ที่ mmap ไม่ใช่​คำ​แนะนำ​สำหรับ broker ที่​ไม่​ได้ mmap

และ​มี​เส้น​หนึ่ง​ที่​ต้อง​พูด​ตรงๆ crate ตัว​นี้​คอม​ไพล์​ได้​เฉพาะ​บน Unix

Segment อ่าน .log ด้วย read_exact_at ซึ่ง​มา​จาก std::os::unix::fs::FileExt เอกสาร​ของ std ระบุ​ว่า “The offset is relative to the start of the file and thus independent from the current cursor. The current file cursor is not affected by this function.”

คุณสมบัติ​นั้น​คือ​สิ่ง​ที่​ทำให้ thread หลาย​ตัว​อ่าน file เดียวกัน​พร้อม​กัน​ได้ โดย​ไม่​ต้อง Seek และ​ไม่​ต้อง​ล็อก ซึ่ง​บท​ที่ 4 จะ​ใช้​เต็ม​ที่

ราคา​ของ​มัน​คือ​ชื่อ module std::os::unix ที่​บอก​อยู่​แล้ว​ว่า​มัน​ไม่มี​บน Windows CI ของ repo นี้​เป็น Linux และ​เครื่อง​ผู้​เขียน​เป็น macOS จึง​ไม่มี​อะไร​แดง แต่​ผู้​อ่าน​ที่​ใช้ Windows จะ build crate นี้​ไม่​ผ่าน

คอร์ส​นี้​ยัง​ไม่​ได้​วัด​ด้วย​ว่า read_at ดี​กว่า Seek กับ read_exact ตรง​ไหน​ภาย​ใต้​การ​แย่ง​กัน​อ่าน สิ่ง​ที่​มี​คือ​เอกสาร​ที่​รับรอง​ว่า​หัว​อ่าน​ไม่​ขยับ ซึ่ง​เป็น​ข้อ​เท็จ​จริง​เรื่อง​ความ​ถูก​ต้อง ไม่ใช่​ข้อ​อ้าง​เรื่อง​ผล​ได้

บท​ที่ 3 จะ​รับ Segment::append ตัว​นี้​ไป​ต่อ แล้ว​ตอบ​คำถาม​ที่​บท​นี้​เลี่ยง​ไว้​ตลอด คือ​ใคร​เป็น​คน​แจก offset ที่​ประทับ​ลง​หัว batch และ ack ควร​ออก​ไป​ตอน​ไหน


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • OffsetIndex.java tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — ประโยค​ที่​ระบุ​ว่า​สารบัญ sparse โดย​ตั้งใจ ENTRY_SIZE = 8 ตัว​อ่าน​สอง field ด้วย buffer.getInt เหตุผล​ของ offset สัมพัทธ์​พร้อม​ประโยค only 4 bytes javadoc ของ lookup และ​ประโยค​ที่​บอก​ว่า​ไม่มี​การ​ทำ checksum เพราะ​สร้าง​ใหม่​ได้
  • LogSegment.java tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — เงื่อนไข roll ทั้ง​ห้า​พจน์​ใน shouldRoll และ​กฎ​การ​เติม entry ที่​ตรวจ​ด้วย > ก่อน​บวก​ตัว​นับ
  • AbstractIndex.java tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — toRelative ที่​ปฏิเสธ​ค่า​เกิน Integer.MAX_VALUE และ​คอม​เมน​ต์​ของ warmEntries เรื่อง page fault
  • FileRecords.java tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — searchForOffsetWithSize ที่​เทียบ​ด้วย batch.lastOffset() แต่​คืน batch.baseOffset() ออก​มา
  • LogFileUtils.java tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — นามสกุล​ของ file ทั้ง​ชุด และ filenamePrefixFromOffset ที่​เติม​ศูนย์​ให้​ครบ​ยี่สิบ​หลัก​เพื่อ​ให้ ls เรียง​ตาม​ตัวเลข
  • Apache Kafka 4.0 — Implementation › Log (ตรวจ​แล้ว 2026-09-03) — ประโยค​ที่​ระบุ​ว่า file แรก​ชื่อ 00000000000000000000.log และ​ตั้ง​ชื่อ​ตาม offset ของ message ตัว​แรก​ที่​อยู่​ใน​นั้น
  • Apache Kafka 4.3 — topic configs (ตรวจ​แล้ว 2026-09-03) — ค่า​ปริยาย​ของ segment.bytes segment.ms index.interval.bytes และ segment.index.bytes พร้อม​ประโยค roughly every 4096 bytes กับ​ประโยค​เรื่อง​การ​จอง file ล่วง​หน้า​แล้ว​หด​ตอน roll
  • KAFKA-19603 (ตรวจ​แล้ว 2026-09-03) — ตั๋ว​ที่​ขอ​เปลี่ยน​ชนิด​ของ log.segment.bytes เป็น long สถานะ Open และ​ประโยค​ที่​อธิบาย​ว่า​ตำแหน่ง​ใน .index กว้าง 4 byte จึง​ติด​เพดาน​ที่ 2GB
  • Rust std — trait FileExt (ตรวจ​แล้ว 2026-09-03) — read_at ที่ stable ตั้งแต่ 1.15.0 กับ​ประโยค​ที่​ระบุ​ว่า​มัน​ไม่​ขยับ​หัว​อ่าน และ​คำ​เตือน​ว่า write_at บน file ที่​เปิด​แบบ append จะ​ไม่​เคารพ offset ที่​ส่ง​ไป
  • Rust std — struct File (ตรวจ​แล้ว 2026-09-03) — หน้าที่​ไม่มี​คำ​ว่า mmap อยู่​เลย ซึ่ง​เป็น​ที่มา​ของ​การ​อ่าน​สารบัญ​ทั้ง​ใบ​ขึ้น​หน่วย​ความ​จำ

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

ข้อ 1 / 3

สารบัญของท่อนแรกมี entry ตัวแรกผูก rel_offset 4 ไว้กับ position 548 ทั้งที่ record ของ offset 0 ถึง 3 ก็อยู่ในท่อนเดียวกัน ข้อไหนอธิบายได้ถูกต้อง