ตัดล็อกเป็นท่อน แล้วทำสารบัญที่ไม่ครบ
บทที่ 1 ทิ้ง log ไว้เป็น file เดียวที่ยาวขึ้นเรื่อยๆ ซึ่งใช้ได้จนกว่าจะเจอคำถามสองข้อ
ข้อแรกคือจะลบของเก่าอย่างไร ในเมื่อ file เดียวลบหัวไม่ได้ ข้อที่สองคือจะกระโดดไปที่ offset กลาง log อย่างไร โดยไม่ต้องอ่านมาตั้งแต่ต้น
คำตอบของสองข้อนี้เป็นคนละกลไก แต่ทั้งคู่ตั้งอยู่บนโครงสร้างเดียวกัน คือตัด log ออกเป็นท่อน แล้วทำสารบัญประจำท่อน
ข้ออ้างของบทนี้คือสารบัญนั้นไม่ใช่ความจริง มันเป็นตัวเร่ง มันไม่ครบ มันไม่มี checksum และลบทิ้งแล้วเสียแค่เวลาตอนเปิด สามข้อนี้คือเหตุผลที่การอ่านต้องแบ่งเป็นสองจังหวะ
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 หลายเท่า เพื่อให้เห็นการตัดท่อน และเห็นสารบัญที่ไม่ครบภายในผลรันหน้าเดียว
partition คือ folder และท่อนหนึ่งคือ file คู่หนึ่ง
หัวข้อที่มีชื่อว่า “partition คือ folder และท่อนหนึ่งคือ file คู่หนึ่ง”หัวข้อนี้บอกว่าท่อนของ 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 ทั้งคู่ไปพร้อมกัน
/// ชื่อ file ของ segment · `{:020}` คือสิ่งที่ทำให้ `read_dir` + sort แบบ string/// ให้ลำดับเดียวกับการเรียงตาม base offsetpub 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
เป็นการ || ต่อกันห้าพจน์
size > rollParams.maxSegmentBytes - rollParams.messagesSize— เขียนก้อนนี้ลงไปแล้วจะล้น(size > 0 && reachedRollMs)— ท่อนแก่เกินsegment.msแล้ว และต้องมีของอยู่ในนั้นก่อนoffsetIndex().isFull()— สารบัญ offset เต็มtimeIndex().isFull()— สารบัญเวลาเต็ม!canConvertToRelativeOffset(rollParams.maxOffsetInMessages)— offset ห่างจากหัวท่อนเกินกว่าที่ 4 byte จะรับไหว
สามข้อหลังคือข้อที่ทำให้หัวข้อถัดๆ ไปมีความหมาย เพราะทั้งสามข้อพูดถึงสารบัญ ไม่ได้พูดถึง .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 ของบทนี้ตั้งค่าให้เล็กจนท่อนแตกได้ภายในผลรันเดียว
== ค่าที่ตั้งไว้ ==segment.bytes 4096index.interval.bytes 512segment.ms 604800000record ที่จะเขียน 40ค่าปริยายจริงของ Kafka 4.3 อยู่คนละมาตราส่วน segment.bytes เป็น 1073741824
ส่วน index.interval.bytes เป็น 4096 การตั้งค่าให้เล็กลงจึงเป็นการย่อภาพ ไม่ใช่การเปลี่ยนกลไก
== ท่อนที่ได้ · หนึ่ง batch ต่อหนึ่ง record == file base_offset size entry largest_timestamp 00000000000000000000.log 0 3973 7 1700000000028 00000000000000000029.log 29 1507 2 1700000000039log end offset 40record 40 ตัวลงไปอยู่ในสองท่อน เพราะเงื่อนไขข้อ 1 ยิงตอนที่ก้อนถัดไปจะทำให้ท่อนแรกล้น 4096 ท่อนที่สองจึงเริ่มที่ base offset 29 ซึ่งเป็นชื่อของ file มันเอง
สังเกตว่า log end offset เท่ากับ 40 ซึ่งเท่ากับจำนวน record ที่เขียนลงไปพอดี
มันคือ offset ตัวถัดไปที่จะถูกแจก ไม่ใช่ offset ของ record ตัวสุดท้าย และคอร์สนี้ใช้ความหมายนี้ทุกบท
offset กับ byte position ยืนคนละฝั่งของลูกศร
หัวข้อที่มีชื่อว่า “offset กับ byte position ยืนคนละฝั่งของลูกศร”หัวข้อนี้แยกคำที่คอร์สที่ 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
/// entry ในสารบัญกิน **8 byte บน disk** เท่ากับของ Kafka: u32 สองตัว big-endian/// ไม่มีหัว file และไม่มี checksumpub const ENTRY_SIZE_ON_DISK: usize = 8;/// `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 กับในหน่วยความจำ และนั่นเป็นการตัดสินใจ ไม่ใช่ความบังเอิญ หัวข้อเรื่องเพดานข้างล่างจะบอกว่ามันซื้ออะไรมา
สารบัญที่จงใจไม่เก็บทุก record
หัวข้อที่มีชื่อว่า “สารบัญที่จงใจไม่เก็บทุก record”หัวข้อนี้กางสารบัญของท่อนแรกออกมาดู แล้วนับว่ามันเก็บกี่ตัวจากทั้งหมดกี่ตัว
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 พิมพ์สารบัญของท่อนแรกออกมาทั้งใบ
== สารบัญของท่อนแรก · 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 3836record 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 ตอนถอดกลับ
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 ลอกลำดับนี้มาทั้งลำดับ
/// ต่อท้ายท่อน · คืนตำแหน่ง 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 และมันคนละเลขกันในสองระบบ
/// ตัวเขียนปฏิเสธตำแหน่งที่ใส่ 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”
/// จังหวะที่หนึ่ง · หา 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 และมันคือหน้าที่ที่ทั้งคอร์สเรียกใช้
/// จังหวะที่หนึ่ง + จังหวะที่สอง · คืน (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 เดินครบทั้งสองจังหวะ
== อ่านสองจังหวะ · 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 ไม่ใช่ที่ record
หัวข้อที่มีชื่อว่า “จังหวะที่สองหยุดที่ทั้ง batch ไม่ใช่ที่ record”หัวข้อนี้แสดงว่า 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 ที่อยู่กลางก้อนนั้น
== 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 ถาวร
ทางที่ไม่ใช่ bug สองทาง
หัวข้อที่มีชื่อว่า “ทางที่ไม่ใช่ bug สองทาง”หัวข้อนี้ไล่สองเส้นทางที่หน้าตาเหมือนความผิดพลาด แต่เป็นทางปกติของ fetch
สารบัญของท่อนหนึ่งถูกถามด้วย offset ที่ไม่ได้อยู่ในท่อนนั้นได้ และมันเกิดขึ้นจริงบ่อยกว่าที่คิด
== ทางที่ไม่ใช่ bug สองทาง ==ถาม target 27 กับท่อน 00000000000000000029.log ซึ่ง base offset คือ 29 · lookup คืน position 0target ที่ต่ำกว่าหัวท่อนคือ 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 แล้วสร้างขึ้นใหม่
/// เปิด 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 ขึ้นใหม่ทั้งอัน
== ลบ .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 ไม่ได้ทำ และเส้นที่ read_at ขีดไว้
หัวข้อที่มีชื่อว่า “ของที่ kaen-log ไม่ได้ทำ และเส้นที่ read_at ขีดไว้”หัวข้อนี้ประกาศส่วนต่างระหว่าง 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.bytessegment.msindex.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 ก็อยู่ในท่อนเดียวกัน ข้อไหนอธิบายได้ถูกต้อง