ล็อกคือความจริง ไม่ใช่บันทึกเหตุการณ์
broker ที่คอร์สนี้จะสร้างไม่มีคิว ไม่มีตารางสถานะ และไม่มีที่พักของข้อความที่ยังไม่มีใครอ่าน
มันมีของอยู่สองอย่าง คือ file ที่ต่อท้ายได้อย่างเดียว กับจำนวนเต็มหนึ่งตัวที่บอกว่าเขียนไปถึงลำดับไหนแล้ว
กลไกที่เหลือของ broker ทั้งหมด ทั้งสารบัญ การจอดคำขอ การลืม การกันซ้ำ และการทำสำเนา เป็นผลที่ตามมาจากการยอมรับข้อนั้นข้อเดียว ไม่ใช่ feature ที่เติมเข้ามาทีหลัง
crate ของคอร์สอยู่ที่ scripts/log-broker/ และชื่อ binary คือ klog
ทั้งคอร์สเขียนด้วย Rust standard library ล้วน ตรวจได้จาก Cargo.toml ที่ไม่มีหัวข้อ
[dependencies] และ Cargo.lock ที่มี package ตัวเดียวคือตัวมันเอง
บล็อก code กับบล็อกผลรันทุกอันในบทเรียนนี้ถูกคัดมาจาก scripts/log-broker/src/record.rs
และจาก scripts/log-broker/expected/01-record.txt ทีละ byte
โดยมีเทสต์เทียบไว้ ถ้าแก้ในบทเรียนอย่างเดียวโดยไม่แก้ที่ต้นทาง เทสต์จะแดงทันที
ผลรันทุกบรรทัดในบทนี้มาจากคำสั่งเดียว คือ
klog dump-record --hex --key ORD-1042 --now-ms 1700000000000
ซึ่งเทสต์รันซ้ำแล้วเทียบผลทีละ byte ทุกครั้ง
คำว่าล็อกในหัวคุณ กับ log ที่ broker เก็บ
หัวข้อที่มีชื่อว่า “คำว่าล็อกในหัวคุณ กับ log ที่ broker เก็บ”หัวข้อนี้แยกคำสองความหมายออกจากกัน แล้วบอกว่าคอร์สนี้ใช้คำไหน
ในวงสนทนาของ developer ไทย คำว่าล็อกมักหมายถึงบรรทัดที่ logger พิมพ์ออกมา เป็นข้อความที่คนอ่าน เขียนไว้เผื่อวันที่ระบบพัง
ของแบบนั้นมีผู้อ่านเป็นมนุษย์ รูปแบบของมันจึงยืดหยุ่นได้ ตกหล่นได้ และไม่มีใครสร้างสถานะของระบบขึ้นใหม่จากมัน
log ที่ broker เก็บเป็นคนละอย่าง มันคือ append-only logappend-only logfile ที่เขียนต่อท้ายอย่างเดียว ไม่แก้ที่เดิม · ใน broker มันไม่ใช่ชั้นรองของโครงสร้างอื่น มันคือโครงสร้างข้อมูลหลักที่ผู้อ่านทุกรายเดินอยู่ ที่โปรแกรมอ่าน รูปแบบของมันตายตัวทีละ byte และมันเป็นแหล่งเดียวที่ผู้อ่านทุกรายใช้สร้างสถานะของตัวเอง
ชื่อบทจึงเขียนว่าล็อกคือความจริง ไม่ใช่บันทึกเหตุการณ์ ประโยคนี้ไม่ได้แปลว่ามันไม่ได้เก็บเหตุการณ์ มันแปลว่าเหตุการณ์ไม่ได้เกิดที่อื่นแล้วค่อยถูกจดตามมา การเขียนลง log คือตัวเหตุการณ์เอง
ตั้งแต่บรรทัดนี้ไป คอร์สใช้คำว่า log ในรูปอังกฤษเสมอ เพื่อไม่ให้ปนกับความหมายแรก
ผู้อ่านสามรายบน log เดียวกัน คนละตำแหน่ง
หัวข้อที่มีชื่อว่า “ผู้อ่านสามรายบน log เดียวกัน คนละตำแหน่ง”หัวข้อนี้แสดงว่าทำไม log ถึงไม่ใช่คิว โดยดูจากสิ่งที่ผู้อ่านแต่ละรายถืออยู่
คิวมีสมบัติข้อหนึ่งที่ทุกคนคุ้น คือการอ่านทำให้ของหายไป ผู้อ่านรายที่สองจะไม่เห็นสิ่งที่รายแรกหยิบไปแล้ว
log ไม่มีสมบัติข้อนั้นเลย การอ่านไม่ได้ลบอะไรทั้งสิ้น ผู้อ่านแต่ละรายถือจำนวนเต็มของตัวเองไว้ว่าอ่านถึงไหนแล้ว แล้วเดินต่อจากตรงนั้น
topic orders ของคอร์สนี้มีผู้อ่านสามราย และทั้งสามรายอ่าน log ก้อนเดียวกัน
kitchen— ครัวรับออเดอร์ไปทำ จึงตามหลังปลาย log อยู่ไม่กี่ลำดับcourier— คนส่งของตามช้ากว่าครัว เพราะงานของมันเริ่มหลังครัวทำเสร็จanalytics— ทีมข้อมูลเริ่มอ่านจากต้น log และย้อนกลับไปอ่านซ้ำได้ทุกเมื่อ
เอกสารออกแบบของ Kafka พูดข้อเดียวกันนี้ด้วยคำของโครงการเอง ว่า topic ถูกแบ่งเป็น “totally ordered partitions” และตำแหน่งของผู้อ่านในแต่ละ partition คือ “just a single integer, the offset of the next message to consume”
เอกสารหน้าเดียวกันยอมรับตรงไปตรงมาด้วยว่า การที่ผู้อ่านย้อนกลับไปอ่านของเก่าได้นั้น “violates the common contract of a queue”
flowchart LR
P["producer · ส่ง batch เข้ามา"] --> B["broker · แจกลำดับแล้วต่อท้าย"]
B --> L
subgraph L["log ของ orders-0 · เขียนต่อท้ายอย่างเดียว"]
direction LR
N0["0"] --> N1["1"] --> N2["2"] --> N3["3"] --> N4["4"] --> N5["5"] --> N6["6"] --> N7["7"]
end
A["analytics · อ่านอยู่ที่ 0"] -.-> N0
C["courier · อ่านอยู่ที่ 4"] -.-> N4
K["kitchen · อ่านอยู่ที่ 7"] -.-> N7
คำบรรยายภาพ: ภาพนี้เป็นตัวอย่าง ไม่ใช่ผลรัน · หัวเขียนมีหัวเดียวและอยู่ที่ปลาย log เสมอ ส่วนหัวอ่านมีกี่หัวก็ได้ ยืนคนละที่ได้ และการอ่านของหัวหนึ่งไม่ทำให้ของหายไปจากอีกหัวหนึ่ง
offset คือนาฬิกา และ broker เป็นคนเดินเข็ม
หัวข้อที่มีชื่อว่า “offset คือนาฬิกา และ broker เป็นคนเดินเข็ม”หัวข้อนี้บอกว่าลำดับของ record ทำหน้าที่แทนเวลา และใครเป็นคนกำหนดลำดับนั้น
offsetoffsetลำดับของ record ใน partition ที่ broker เป็นคนแจก เริ่มที่ 0 และเพิ่มทีละ 1 · ต่างจาก #22 ที่ใช้คำนี้แทนตำแหน่ง byte ใน file — ความหมายนั้นอยู่ที่ key `byte position` ของคอร์สนี้ คือลำดับของ record ใน partition เริ่มที่ศูนย์แล้วเพิ่มทีละหนึ่ง มันไม่ใช่ตัวแทนของเวลา มันคือเวลาของ log ก้อนนั้น
ผลที่ตามมาคือสถานะทั้งก้อนของผู้อ่านหนึ่งราย ย่อลงเหลือจำนวนเต็มหนึ่งตัว ไม่ต้องมีตารางว่า record ไหนถูกอ่านไปแล้วบ้าง
ข้อได้เปรียบที่แท้จริงของนาฬิกาแบบนี้คือมันไม่ผูกกับนาฬิกาของเครื่องไหนเลย สองเครื่องที่นาฬิกาเดินไม่ตรงกันยังตกลงกันได้ว่าอะไรมาก่อน เพราะลำดับไม่ได้มาจากนาฬิกาของใคร
คอร์ส Distributed Systems สอนนาฬิกาแบบ vector clock กับ Lamport clock ไว้แล้ว ทั้งสองตัวมีไว้เรียงเหตุการณ์ที่เกิดคนละที่และไม่มีใครเป็นคนกลาง
kaen-log ไม่มีของสองอย่างนั้นเลย และไม่ต้องมี เพราะมันมีคนกลางอยู่แล้วคือ broker ที่เป็นผู้เขียนรายเดียวของ partition นั้น ลำดับจึงมาจากการเขียน ไม่ได้มาจากการเทียบนาฬิกา
และคนที่แจกเลขนั้นคือ broker ไม่ใช่ producer producer ประกอบ batch แล้วส่งเข้ามาโดยที่ช่อง
base_offset ยังเป็นศูนย์ ตัว broker เป็นคนเขียนทับช่องนั้นตอน append
Kafka ทำแบบเดียวกัน LogValidator.assignOffsetsNonCompressed เรียก
batch.setLastOffset(offsetCounter.value - 1) และตัว setLastOffset เองมีบรรทัดเดียว คือ
buffer.putLong(BASE_OFFSET_OFFSET, offset - lastOffsetDelta())
เขียนทับแล้วจบ ไม่มีการคิด checksum ใหม่ เหตุผลว่าทำไมถึงทำแบบนั้นได้อยู่ที่ตำแหน่งของ checksum ซึ่งอีกสามหัวข้อข้างล่างจะกางให้เห็น
offset ในคอร์สที่ 22 กับในคอร์สนี้ ไม่ใช่ของอย่างเดียวกัน
หัวข้อที่มีชื่อว่า “offset ในคอร์สที่ 22 กับในคอร์สนี้ ไม่ใช่ของอย่างเดียวกัน”หัวข้อนี้เตือนคำเดียวที่เปลี่ยนความหมายระหว่างสองคอร์ส
คอร์ส kaen-kvstore ใช้คำว่า offset แทนตำแหน่ง byte ใน file สารบัญของมันแม็ป key ไปหา offset แล้ว seek ตรงไปที่ตำแหน่งนั้นแล้วอ่าน
คอร์สนี้ใช้คำเดียวกันแทนลำดับของ record ตำแหน่ง byte ยังอยู่ครบ แต่มันมีชื่อของตัวเองแล้วว่า byte position
ความต่างข้อนี้จะสำคัญตั้งแต่บทที่ 2 เพราะสารบัญของ kaen-log แม็ป offset ไปหา byte position ถ้าใช้คำเดียวกันทั้งสองฝั่งของลูกศร ประโยคจะอ่านไม่ได้ความ
ความกว้างของจำนวนเต็มก็ถูกตรึงไว้ตั้งแต่บทนี้ offset ในหน่วยความจำเป็น u64 เสมอ
ส่วน i64 โผล่ได้ที่ขอบของการ encode ลง wire เท่านั้น เพราะ field ของ Kafka เป็น signed ทุกตัว
/// u64 ในหน่วยความจำ → i64 บน wire · field ของ Kafka เป็น signed ทั้งหมดpub fn wire_i64(o: u64) -> i64 { o.cast_signed()}
/// i64 บน wire → u64 ในหน่วยความจำ · offset ติดลบไม่ใช่สิ่งที่ kaen-log ถือได้pub fn from_wire_i64(v: i64) -> io::Result<u64> { u64::try_from(v).map_err(|_| bad(format!("offset บน wire ติดลบ: {v}")))}สองตัวนี้คือที่เดียวใน crate ที่ความกว้างของ offset เปลี่ยน และฝั่งขาเข้าปฏิเสธค่าติดลบทันที เพราะ offset ติดลบไม่ใช่สิ่งที่ kaen-log ถือได้
คอร์สนี้ไม่สอนสามข้อนี้ซ้ำ ถ้ายังไม่เคยเจอ ให้ย้อนไปอ่านที่ คอร์ส kaen-kvstore ก่อน
- การหุ้ม frame ด้วยความยาวนำหน้า แล้วอ่านกลับด้วย
read_exactบนstd::net write_allไปได้แค่ page cache ไม่ได้แปลว่า byte ถึง storage แล้ว- ลำดับ write แล้ว fsync แล้วค่อย ack ซึ่งบทที่ 3 ของคอร์สนี้จะรับมาทั้งลำดับ แล้วเถียงเรื่องเหตุผลของมันใหม่
เอกสารของ Kafka พูดเรื่อง offset ไว้สองอย่าง
หัวข้อที่มีชื่อว่า “เอกสารของ Kafka พูดเรื่อง offset ไว้สองอย่าง”หัวข้อนี้ยกสองประโยคที่ขัดกันจากหน้าเดียวกัน แล้วตัดสินด้วย code ที่ broker ใช้แจกเลขนั้น
หน้า Implementation › Log ของเอกสาร Kafka 4.0 เขียนไว้ว่า “Each message is uniquely identified by a 64-bit integer offset giving the byte position of the start of this message in the stream of all messages ever sent to that topic on that partition”
ย่อหน้าถัดลงไปในหน้าเดียวกันเขียนว่า โครงการ “decided to use a simple per-partition atomic counter”
สองประโยคนี้อยู่คนละความหมาย ประโยคแรกบอกว่า offset คือตำแหน่ง byte ประโยคหลังบอกว่ามันคือตัวนับที่เพิ่มทีละหนึ่ง
ประโยคหลังคือของจริง และตัดสินได้จาก code ที่แจกเลขนั้น ไม่ต้องเชื่อประโยคไหนเป็นพิเศษ
หน้า Message Format กำหนดให้ batch เก็บ baseOffset ไว้ค่าเดียวที่หัว
แล้วให้ record แต่ละตัวเก็บ offsetDelta ต่อจากนั้น
รูปนี้เข้าได้กับทั้งสองความหมาย เพราะ delta เป็น varint ที่เก็บระยะห่างเท่าไรก็ได้
มันจึงไม่ใช่ตัวตัดสิน
ตัวตัดสินอยู่ที่ LogValidator.assignOffsetsNonCompressed มันวน record ทีละตัวในหนึ่ง batch
แล้วสั่ง offsetCounter.value++ หนึ่งครั้งต่อหนึ่ง record
จากนั้นค่อยประทับ setLastOffset(offsetCounter.value - 1) ลงหัว batch
เลขที่เพิ่มทีละหนึ่งต่อหนึ่ง record คือตัวนับ ไม่ใช่ตำแหน่ง byte เพราะตำแหน่ง byte ต้องเพิ่มตามความยาวของ record ที่เพิ่งผ่านไป ซึ่งไม่เท่ากับหนึ่ง
ผลที่ตามมาคือ last_offset_delta ในหัว batch เท่ากับจำนวน record ในนั้นลบหนึ่งเสมอ
delta ของ record จึงเรียงติดกันโดยไม่มีช่องว่าง ส่วนตำแหน่ง byte ไม่มีทางเรียงแบบนั้น
ประโยคแรกเป็นซากที่ค้างมาจากยุคแรกของโครงการ ตอนนั้น offset เป็นตำแหน่ง byte จริง และเลขของ record จึงเพิ่มขึ้นแบบไม่ต่อเนื่อง
สองประโยคนั้นอยู่ห่างกันไม่ถึงหน้าจอเดียว และหน้านั้นยังเผยแพร่อยู่ในเอกสารของรุ่น 4.0
วิธีตัดสินไม่ใช่การดูว่าประโยคไหนเขียนก่อน และไม่ใช่การเปิดรูปบน disk ด้วย เพราะรูปแบบ base บวก delta เข้าได้กับทั้งสองความหมาย
ตัวที่ตัดสินคือ code ที่แจกเลขนั้น มันต้อง compile และต้องรันได้ ส่วนย่อหน้าอธิบายไม่ต้อง
หัว batch 61 byte และเส้นที่ CRC ไม่ข้าม
หัวข้อที่มีชื่อว่า “หัว batch 61 byte และเส้นที่ CRC ไม่ข้าม”หัวข้อนี้กางความกว้างของแต่ละ field ในหัว batch แล้วชี้ว่า checksum ครอบตั้งแต่ตรงไหน
หน่วยที่ producer ส่งและ broker เขียนลง disk ไม่ใช่ record เดี่ยว แต่เป็น record batchrecord batchหน่วยที่ producer ส่งและ broker เขียนลง log จริง หนึ่ง batch ห่อหลาย record ไว้ใต้ header กับ checksum เดียวกัน ซึ่งห่อ record หลายตัวไว้ใต้ header เดียวกันและ checksum เดียวกัน
/// หัว batch แบบ v2 ยาว 61 byte พอดี — เท่ากับ `RECORD_BATCH_OVERHEAD` ของ Kafka 4.0.0pub const BATCH_HEADER_LEN: usize = 61;/// CRC ครอบตั้งแต่ `attributes` ไปจนจบ batch · `base_offset` กับ `partition_leader_epoch`/// อยู่ **นอก** ช่วงนี้ เพื่อให้ broker ประทับค่าทับได้โดยไม่ต้องคิด CRC ใหม่pub const CRC_COVERED_FROM: usize = 21;/// `batch_length` ไม่ได้นับทั้ง batch · ขนาดจริงบน disk = `batch_length + 12`/// (`OFFSET_LENGTH` 8 + `SIZE_LENGTH` 4 ของ Kafka)pub const LOG_OVERHEAD: usize = 12;BATCH_HEADER_LEN เท่ากับ RECORD_BATCH_OVERHEAD ของ Kafka 4.0.0
ซึ่งไม่ใช่ตัวเลขที่คัดมาจากย่อหน้าในเอกสาร แต่บวกขึ้นมาจาก constant ที่ต่อกันเป็นลูกโซ่ใน
DefaultRecordBatch.java โดยแต่ละตัวเขียนว่าตำแหน่งของตัวเองคือตำแหน่งก่อนหน้าบวกความยาวก่อนหน้า
const BASE_OFFSET_AT: usize = 0;const BATCH_LENGTH_AT: usize = 8;const LEADER_EPOCH_AT: usize = 12;const MAGIC_AT: usize = 16;const CRC_AT: usize = 17;const ATTRIBUTES_AT: usize = 21;const LAST_OFFSET_DELTA_AT: usize = 23;const BASE_TIMESTAMP_AT: usize = 27;const MAX_TIMESTAMP_AT: usize = 35;const PRODUCER_ID_AT: usize = 43;const PRODUCER_EPOCH_AT: usize = 51;const BASE_SEQUENCE_AT: usize = 53;const RECORDS_COUNT_AT: usize = 57;ตัวเลขทั้งชุดข้างบนคือผลบวกนั้น ไม่ใช่ค่าที่ถอดออกมาจากตารางในเอกสาร
batch_length มีกับดักของตัวเองอยู่หนึ่งข้อ มันไม่ได้นับทั้ง batch
ต้องบวก LOG_OVERHEAD กลับเข้าไปถึงจะได้ขนาดจริงบน disk
ตัว klog พิมพ์ตารางนี้ออกมาเองจากหัว batch ที่มันเพิ่งสร้าง ไม่ใช่ตารางที่พิมพ์ใส่ไว้ในบทเรียน
== หัว batch 61 byte · big-endian == byte field ค่า 00..08 base_offset 0 08..12 batch_length 125 12..16 partition_leader_epoch -1 16..17 magic 2 17..21 crc 0x98867f12 21..23 attributes 0 23..27 last_offset_delta 0 27..35 base_timestamp 1700000000000 35..43 max_timestamp 1700000000000 43..51 producer_id -1 51..53 producer_epoch -1 53..57 base_sequence -1 57..61 records_count 1 61.. recordsขนาดจริงบน disk = batch_length + 12 = 137 byteCRC ครอบ byte 21..137 · ไม่ครอบ base_offset และไม่ครอบpartition_leader_epoch — สอง field นี้เป็นของ broker ไม่ใช่ของ producerสองบรรทัดสุดท้ายคือหัวใจของทั้งบท CRC ครอบตั้งแต่ attributes ไปจนจบ batch
ส่วน base_offset กับ partition_leader_epoch ยืนอยู่ก่อนหน้านั้น จึงอยู่นอกช่วงที่ CRC ครอบ
เอกสาร Message Format ของ Kafka บอกเหตุผลไว้เอง ว่า “The partition leader epoch field is not included in the CRC computation to avoid the need to recompute the CRC when this field is assigned for every batch that is received by the broker”
อ่านประโยคนั้นกลับด้านจะได้ข้ออ้างของบทนี้ รูปแบบถูกออกแบบมาให้ broker เขียนทับช่องที่เป็นของ broker ได้ โดยไม่ต้องคิด checksum ใหม่ ตำแหน่งของ field ในหัว batch จึงไม่ใช่รสนิยม แต่เป็นผลของ คำถามว่าใครเป็นเจ้าของช่องไหน
ธง --hex สั่งให้ klog พิมพ์ byte ทั้งก้อนออกมาให้อ่านเทียบกับตารางข้างบนได้ทีละช่อง
== hex dump · ทั้ง batch 137 byte ==00000000 00 00 00 00 00 00 00 00 00 00 00 7d ff ff ff ff |...........}....|00000010 02 98 86 7f 12 00 00 00 00 00 00 00 00 01 8b cf |................|00000020 e5 68 00 00 00 01 8b cf e5 68 00 ff ff ff ff ff |.h.......h......|00000030 ff ff ff ff ff ff ff ff ff 00 00 00 01 94 01 00 |................|00000040 00 00 10 4f 52 44 2d 31 30 34 32 78 7b 22 65 76 |...ORD-1042x{"ev|00000050 65 6e 74 22 3a 22 4f 72 64 65 72 50 6c 61 63 65 |ent":"OrderPlace|00000060 64 22 2c 22 6f 72 64 65 72 5f 69 64 22 3a 22 4f |d","order_id":"O|00000070 52 44 2d 31 30 34 32 22 2c 22 74 6f 74 61 6c 22 |RD-1042","total"|00000080 3a 31 32 39 39 30 30 7d 00 |:129900}.|แปดตัวแรกของแถวบนคือ base_offset ที่ยังเป็นศูนย์ ต่อด้วยสี่ตัวที่เป็น batch_length
แล้ว ff ff ff ff คือ partition_leader_epoch ที่ยังไม่มีค่า ซึ่งเขียนแทนด้วยลบหนึ่ง
std ไม่มี checksum สักตัว จึงต้องกางตารางเอง
หัวข้อที่มีชื่อว่า “std ไม่มี checksum สักตัว จึงต้องกางตารางเอง”หัวข้อนี้บอกว่าทำไม crate ถึงต้องเขียน CRC ขึ้นเอง และเทียบกับอะไรถึงจะรู้ว่าตารางถูก
CRC-32CCRC-32Cchecksum แบบ Castagnoli ที่ Kafka ใช้ตรวจว่า batch เสียหายหรือไม่ · คนละ polynomial กับของ zlib ที่ Kafka ใช้ไม่ใช่ CRC ตัวเดียวกับที่ zlib ใช้ เอกสาร Message Format ระบุไว้ตรงตัวว่า “The CRC-32C (Castagnoli) polynomial is used for the computation”
standard library ของ Rust ไม่มี checksum สักตัวใน API สาธารณะ
และ DefaultHasher ใช้แทนไม่ได้ เพราะเอกสารของมันเขียนไว้เองว่า algorithm ข้างในไม่ได้ระบุไว้
ค่าที่มันคืนจึงเชื่อถือข้ามรุ่นของ std ไม่ได้
ค่าที่ไม่รับประกันความเสถียร ห้ามเขียนลงรูปแบบของ file เด็ดขาด
crate จึงกางตาราง 256 ช่องขึ้นเองตอน compile
/// polynomial ของ Castagnoli ในรูปกลับด้าน · **ไม่ใช่** 0xEDB88320 ของ zlib/// ถ้าใช้ตัวหลัง byte ที่ออกมาจะไม่ใช่ byte ของ Kafka และไม่มีอะไรเตือนconst CRC32C_POLY_REVERSED: u32 = 0x82F6_3B78;
const fn crc32c_table() -> [u32; 256] { let mut table = [0u32; 256]; let mut i = 0usize; while i < 256 { let mut crc = i as u32; let mut bit = 0; while bit < 8 { crc = if crc & 1 != 0 { (crc >> 1) ^ CRC32C_POLY_REVERSED } else { crc >> 1 }; bit += 1; } table[i] = crc; i += 1; } table}
static CRC32C_TABLE: [u32; 256] = crc32c_table();กับดักอยู่ตรงบรรทัดของ polynomial ถ้าหยิบ 0xedb88320 ของ zlib มาใส่แทน
ตารางจะยังคำนวณได้ปกติทุกครั้ง ไม่มี error ไม่มีคำเตือน แต่ byte ที่ออกมาไม่ใช่ byte ของ Kafka
ทางเดียวที่จะรู้ คือเทียบกับค่าตรวจที่มีคนประกาศไว้แล้ว
แคตตาล็อก CRC ของ Greg Cook ระบุค่าตรวจของ CRC-32C สำหรับ input 123456789 ไว้ที่ 0xe3069283
/// ค่าตรวจมาตรฐานของ CRC-32C สำหรับ input `123456789` · ถ้าตารางผิด เทสต์ตัวนี้แดงpub const CRC32C_CHECK_VALUE: u32 = 0xE306_9283;
pub fn crc32c(data: &[u8]) -> u32 { let mut crc = 0xFFFF_FFFFu32; for &b in data { let idx = usize::from((crc as u8) ^ b); crc = (crc >> 8) ^ CRC32C_TABLE[idx]; } !crc}== CRC-32C · std ไม่มี checksum สักตัว จึงต้องกางตาราง 256 ช่องเอง ==polynomial 0x82f63b78 (Castagnoli แบบกลับด้าน)ของ zlib 0xedb88320 (คนละตัว · ใช้ผิดแล้ว byte ไม่ใช่ของ Kafka)ค่าตรวจของ input `123456789` ที่ตารางนี้คิดได้ 0xe3069283 ค่าที่ประกาศไว้ 0xe3069283 ตรงกัน ใช่บรรทัดสุดท้ายคือคำตอบ ตารางที่ crate กางเองให้ค่าตรงกับค่าที่ประกาศไว้
เทสต์ที่บอกแค่ว่าค่าที่คิดได้เท่ากับค่าที่ประกาศไว้ จะเขียวทันทีถ้ามีคนแก้ค่าที่ประกาศไว้ให้ตรงกับ ตารางที่ผิด
src/record.rs จึงมีเทสต์อีกตัวที่ยืนยันด้านกลับ ว่าค่าที่ได้ต้อง ไม่ใช่ 0xcbf43926
ซึ่งเป็นค่าตรวจของ CRC-32 แบบ zlib สำหรับ input เดียวกัน
ตารางที่ใช้ polynomial ผิดจะผ่านด่านแรกไม่ได้และตกด่านที่สองพร้อมกัน
ทุกความยาวใน batch เป็น zigzag varint และลบหนึ่งแปลว่า null
หัวข้อที่มีชื่อว่า “ทุกความยาวใน batch เป็น zigzag varint และลบหนึ่งแปลว่า null”หัวข้อนี้อธิบายการ encode ที่ทุกความยาวใน batch ใช้ แล้วชี้ค่าลบหนึ่งที่บทที่ 6 จะสร้าง tombstone จากมัน
หัว batch ตรึงความกว้างไว้ทุกช่อง แต่ข้างใน batch ตรงกันข้ามโดยสิ้นเชิง ทุกความยาวและทุก delta ของ record เป็น zigzag varintzigzag varintการ encode จำนวนเต็มด้วยความยาวไม่คงที่ โดยแม็ปเลขติดลบสลับกับเลขบวกก่อน ค่าที่เล็กจึงกินที่น้อย
varint เก็บ 7 bit ต่อหนึ่ง byte แล้วใช้ bit สูงสุดบอกว่ายังมี byte ถัดไปหรือไม่ ค่าที่เล็กจึงกินที่น้อย และค่าที่ใหญ่ค่อยกินเพิ่ม
ปัญหาคือ varint เป็นค่าที่ไม่มีเครื่องหมาย เลขติดลบในรูป two’s complement จะมี bit สูงสุดติดหมด และกินครบสิบ byte ทุกครั้งที่ใช้
zigzag แก้ข้อนั้นด้วยการสลับเลขบวกกับเลขลบเข้าหากันก่อนจะ encode
เอกสารของ Protobuf เขียนสูตรไว้ว่า (n << 1) ^ (n >> 31) สำหรับ sint32
และ (n << 1) ^ (n >> 63) สำหรับรุ่น 64 bit
/// `(n << 1) ^ (n >> 31)` · 0→0 · -1→1 · 1→2 · -2→3pub fn zigzag_i32(n: i32) -> u32 { (n.cast_unsigned() << 1) ^ (n >> 31).cast_unsigned()}
pub fn unzigzag_i32(v: u32) -> i32 { (v >> 1).cast_signed() ^ -(v & 1).cast_signed()}
/// `(n << 1) ^ (n >> 63)` · รูปเดียวกับ `sint64` ของ Protobufpub fn zigzag_i64(n: i64) -> u64 { (n.cast_unsigned() << 1) ^ (n >> 63).cast_unsigned()}
pub fn unzigzag_i64(v: u64) -> i64 { (v >> 1).cast_signed() ^ -(v & 1).cast_signed()}รูปนี้ไม่ใช่รสนิยมของ kaen-log เหมือนกัน เอกสาร Message Format ของ Kafka เขียนไว้เองว่า “We use the same varint encoding as Protobuf”
== zigzag varint · รูปที่ทุกความยาวใน batch ใช้ == n zigzag varint 0 0 00 -1 1 01 1 2 02 -2 3 03 64 128 80 01 -64 127 7f 129900 259800 d8 ed 0fแถวของ 0 กับแถวของ -1 คือแถวที่บอกราคาของ zigzag ทั้งสองค่ากินหนึ่ง byte
ถ้าช่องความยาวเป็น i32 ความกว้างคงที่ ทั้งสองค่าจะกินสี่ byte เท่ากัน
สิ่งที่ zigzag varint ซื้อมาจึงเป็นขนาด
ความหมายของลบหนึ่งเป็นคนละเรื่องกับขนาด ลบหนึ่งในช่อง value_len เป็น sentinel
มันแปลว่า null ไม่ใช่ค่าที่ยาวศูนย์
/// ความยาว `-1` บน wire แปลว่า **null** ไม่ใช่ slice ว่าง · นี่คือ tombstone ของบทที่ 6pub const NULL_LEN: i32 = -1;ตัวถอดรหัสจึงต้องแยกสองกรณีนี้ออกจากกันตั้งแต่ก่อนจะอ่าน byte ของค่า
fn read_nullable(cur: &mut Cursor<'_>) -> io::Result<Option<Vec<u8>>> { let len = cur.varint_i32()?; if len == NULL_LEN { return Ok(None); } let len = usize::try_from(len).map_err(|_| bad(format!("ความยาว {len} ไม่ถูกต้อง")))?; Ok(Some(cur.take(len)?.to_vec()))}klog สร้าง batch สองก้อนมาเทียบให้ดู ก้อนหนึ่งเป็น tombstone อีกก้อนมี value ที่ยาวศูนย์
== null ไม่ใช่ slice ว่าง == value_len ขนาด batchtombstone -1 76value ว่างเปล่า 0 76สอง batch ยาวเท่ากันเพราะ zigzag ให้ -1 กับ 0 กินหนึ่ง byte เท่ากันแต่ decode ออกมาคนละอย่าง · tombstone None · value ว่างเปล่า Some([])สอง batch ยาวเท่ากันเป๊ะ แต่ถอดกลับออกมาได้คนละอย่าง
ก้อนแรกได้ None ก้อนหลังได้ Some ที่ห่อ slice ว่างไว้
ความเท่ากันนี้ไม่ได้มาจาก varint ถ้าช่องความยาวเป็นความกว้างคงที่ สอง batch ก็ยังยาวเท่ากันอยู่ดี เพราะ tombstone เป็นค่าหนึ่งในช่องความยาวเอง ไม่ใช่ช่องใหม่ที่ต้องเก็บเพิ่ม
นั่นคือข้อที่บทที่ 6 จะยืนอยู่บนมัน และมันไม่ผูกกับการ encode เลย
สิ่งที่บังคับให้ทั้ง crate ใช้ varint เป็นคนละข้อ คือการรักษารูป byte ของ Kafka ไว้ให้ตรง บทที่ 3 บทที่ 6 และบทที่ 7 อ่าน field ออกจากรูปนั้น ถ้าแปดบทเข้ารหัสไม่ตรงกัน มันจะขัดกันตอนรัน ไม่ใช่ตอนอ่าน
broker ประทับลำดับลงไปโดยไม่แตะ checksum
หัวข้อที่มีชื่อว่า “broker ประทับลำดับลงไปโดยไม่แตะ checksum”หัวข้อนี้แสดงว่าการแจก offset เป็นการเขียนทับช่วงเดียวในหัว batch แล้วจบ
ทุกอย่างข้างบนมารวมกันที่ function สั้นๆ ตัวนี้
/// broker ประทับ offset ทับที่เดิม 8 byte แล้วจบ · **ไม่ต้องคิด CRC ใหม่** /// เพราะ `base_offset` อยู่นอกช่วงที่ CRC ครอบ pub fn stamp_last_offset(buf: &mut [u8], last: u64, last_delta: i32) -> io::Result<()> { let delta = u32::try_from(last_delta) .map_err(|_| bad(format!("last_offset_delta ติดลบ: {last_delta}")))?; let base = last .checked_sub(u64::from(delta)) .ok_or_else(|| bad(format!("offset {last} เล็กกว่า delta {delta}")))?; buf[BASE_OFFSET_AT..BASE_OFFSET_AT + 8].copy_from_slice(&wire_i64(base).to_be_bytes()); Ok(()) }มันไม่เรียก crc32c สักครั้ง เพราะช่องที่มันเขียนทับอยู่นอกช่วงที่ CRC ครอบ
partition_leader_epoch เป็นช่องที่สองที่เป็นของ broker และมันมี stamp_leader_epoch
ของตัวเองที่เขียนทับ 4 byte ด้วยเหตุผลเดียวกัน demo ของบทนี้จึงเรียกสองตัวนี้ต่อกัน
เหมือนที่ LogValidator ของ Kafka เรียก setPartitionLeaderEpoch ต่อจาก setLastOffset ทันที
== broker ประทับ offset ทับที่เดิม == base_offset partition_leader_epoch crcก่อน 0 -1 0x98867f12หลัง 41 0 0x98867f12crc เท่าเดิม และ decode ผ่าน · การแจก offset จึงเป็นการเขียนทับ 8 byte เฉยๆก่อนประทับ base_offset เป็นศูนย์และ leader epoch ยังไม่มีค่า
หลังประทับ base_offset กลายเป็น offset 41 และ leader epoch กลายเป็นศูนย์ ส่วน crc เท่าเดิมทุก bit
และ batch ที่ประทับแล้วยัง decode ผ่าน ซึ่งพิสูจน์ว่า checksum ยังตรงจริง ไม่ใช่แค่ตัวเลขเดิมค้างอยู่ในช่อง
นี่คือเหตุผลที่ producer ไม่ต้องรู้เลยว่า record ของมันจะไปนั่งอยู่ลำดับไหน มันส่ง batch ที่ checksum ถูกต้องมาให้ แล้ว broker เป็นคนตัดสินลำดับตอน append
ของเล่นตัวนี้ไม่ได้ทำอะไรบ้าง
หัวข้อที่มีชื่อว่า “ของเล่นตัวนี้ไม่ได้ทำอะไรบ้าง”หัวข้อนี้ประกาศเส้นขอบเขตของ kaen-log ไว้ล่วงหน้า แทนที่จะให้ไปเจอเอาตอนกลางคอร์ส
- ไม่มี compression เพราะ standard library ไม่มี codec ให้ Kafka บีบอัดทั้ง batch แล้วเก็บของที่บีบแล้วลง log ส่วน kaen-log เก็บของดิบเสมอ
- ไม่มี TLS และไม่มีการยืนยันตัวตน ทุกอย่างที่เข้ามาถึงถือว่าเชื่อได้ ซึ่งไม่จริงบนเครือข่ายจริง
- รูปแบบทรง Kafka แต่ไม่ได้เข้ากันได้ทีละ byte ทุกช่อง ลำดับของ field ความกว้าง และขอบของ CRC ลอกมาตรง แต่ client ของจริงยังคุยกับ kaen-log ไม่ได้
- log ก้อนนี้เป็น partition เดียวบนเครื่องเดียว Kafka จริงกระจาย topic
ordersออกเป็นหลาย partition และรับประกันลำดับ ต่อ partition เท่านั้น
ข้อสุดท้ายมีผลกับโดเมนของคอร์สโดยตรง การใช้ order_id เป็น key ซื้อลำดับต่อหนึ่งออเดอร์มาได้
แต่ไม่ได้ซื้อลำดับรวมของทั้ง topic และไม่มีบทไหนในคอร์สนี้อ้างว่ามี
บทที่ 2 จะรับ log ก้อนเดียวนี้ไปตัดเป็นท่อน แล้วสร้างสารบัญที่จงใจไม่เก็บทุก record ตรงนั้นคือจุดที่ offset กับ byte position จะยืนคนละฝั่งของลูกศรจริงๆ
- Apache Kafka 4.0 — Design (ตรวจแล้ว 2026-09-03) — ที่มาของ “totally ordered partitions” ของประโยคที่ว่าตำแหน่งผู้อ่านคือ จำนวนเต็มตัวเดียว และของประโยคที่ยอมรับว่าการอ่านย้อนหลังผิดสัญญาของคิว
- Apache Kafka 4.0 — Implementation › Log (ตรวจแล้ว 2026-09-03) — หน้าที่พูดเรื่อง offset ไว้สองอย่างในหน้าเดียวกัน ทั้งประโยคตำแหน่ง byte และประโยคตัวนับต่อ partition
- Apache Kafka 4.0 — Implementation › Message Format (ตรวจแล้ว 2026-09-03) — ขอบของ CRC เหตุผลที่ leader epoch อยู่นอกขอบนั้น ประโยคที่ระบุว่าใช้ polynomial แบบ Castagnoli และประโยคที่ระบุว่า varint เป็นรูปเดียวกับ Protobuf
- DefaultRecordBatch.java tag 4.0.0
(ตรวจแล้ว 2026-09-03) — ลูกโซ่ของ constant ที่บวกกันได้
RECORD_BATCH_OVERHEADและตัวsetLastOffsetที่เขียนทับช่องเดียวแล้วจบ - LogValidator.java tag 4.0.0
(ตรวจแล้ว 2026-09-03) — จุดที่ broker ฝั่ง Kafka เรียก
setLastOffsetตอนแจกลำดับ และเรียกsetPartitionLeaderEpochต่อจากนั้นทันที - Protocol Buffers — Encoding (ตรวจแล้ว 2026-09-03) — สูตร zigzag ทั้งสองความกว้าง และกติกาของ bit บอกว่ายังมีต่อ
- CRC RevEng catalogue — CRC-32/ISCSI
(ตรวจแล้ว 2026-09-03) — ค่าตรวจ
0xe3069283ของ CRC-32C และรายชื่อชื่อพ้องที่รวม CRC-32/CASTAGNOLI ไว้ด้วย - Rust std 1.97.1 — primitive i64
(ตรวจแล้ว 2026-09-03) —
to_be_bytesที่คืน byte แบบ big-endian ให้โดยไม่ต้องพึ่ง crate ภายนอก ตัว method นี้ stable มาตั้งแต่ 1.32.0 - Rust std 1.97.1 — struct DefaultHasher (ตรวจแล้ว 2026-09-03) — ประโยค “The internal algorithm is not specified, and so it and its hashes should not be relied upon over releases” ที่ทำให้มันใช้แทน checksum ในรูปแบบบน disk ไม่ได้
เช็กความเข้าใจ — บทที่ 1
ข้อ 1 / 3producer ประกอบ batch เสร็จแล้วส่งเข้ามา broker ต้องแจกลำดับให้มัน ทำไม broker ถึงเขียนทับช่อง base_offset ได้โดยไม่ต้องคิด CRC ใหม่