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

เขียน · fsync · แล้ว​ค่อย​ตอบ — และ​เหตุผล​ที่ Kafka ไม่​ทำ​อย่าง​นั้น

คำขอ​ที่​วิ่ง​เข้า​มา​หา broker เป็น byte เรียง​ต่อ​กัน​เฉยๆ ไม่มี​ขอบ ไม่มี​ชนิด และ​ไม่มี​ชื่อ​ติด​มา​ด้วย

งาน​แรก​ของ broker จึง​ไม่ใช่​การ​เขียน​อะไร​ลง disk แต่​เป็นการ​ตัดสิน​ว่า byte ก้อน​นี้​จบ​ตรง​ไหน และ​ใคร​ควร​รับ​ไป​ทำ​ต่อ

บท​นี้​เดิน​ตาม​คำขอ produce หนึ่ง​อัน​ตั้งแต่ byte แรก​จนถึง​คำ​ตอบ แล้วไป​หยุด​ที่​คำถาม​ข้อ​เดียว คือ broker ควร​รอ​อะไร​ก่อน​จะ​บอก​ว่า​เขียน​แล้ว

ข้อ​อ้าง​เดียว​ที่​ต้อง​ได้​กลับ​ไป​คือ broker แจก offset แล้ว​เขียน​ลง page cache ก่อน​เสมอ จาก​นั้น​จึง​ค่อย​ตัดสิน​ว่า​จะ​รอ​อะไร​ต่อ acks เปลี่ยน​เวลา​ที่​ตอบ ไม่​เคย​เปลี่ยน​สิ่ง​ที่​เขียน

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

crate ของ​คอร์ส​อยู่​ที่ scripts/log-broker/ และ​ชื่อ binary คือ klog ทั้ง​คอร์ส​เขียน​ด้วย Rust standard library ล้วน

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

ผล​รัน​ทุก​บรรทัด​ใน​บท​นี้​มา​จาก​คำ​สั่ง​เดียว คือ klog produce-demo --dir data/orders-0 --acks 1 --batches 3 --now-ms 1700000000000 --truncate-tail ซึ่ง​เทสต์​รัน​ซ้ำ​แล้ว​เทียบ​ผล​ที​ละ byte ทุก​ครั้ง

หัวข้อ​นี้​กาง​ส่วน​หัว​คงที่​ของ​คำขอ แล้ว​บอก​ว่า​ทำไม broker ตัดสิน​ใจ​ได้​ก่อน​อ่าน​เนื้อ

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

request headerrequest headerส่วน​หัว​คงที่​ของ​ทุก​คำขอ​บน​สาย บอก​ชนิด​คำขอ รุ่น​ของ protocol และ​เลข​กำกับ​ที่​ใช้​จับ​คู่​คำ​ตอบ​กลับ ของ Kafka มี​สี่​ช่อง และ​สาม​ช่อง​แรก​กว้าง​คงที่​ทั้งหมด ความ​กว้าง​คงที่​ตรง​นี้​ไม่ใช่​รสนิยม มัน​คือ​สิ่ง​ที่​ทำให้​อ่าน​แค่​ต้น frame แล้ว​ส่ง​งาน​ต่อ​ได้​เลย

เอกสาร protocol ของ Kafka เขียน​กติกา​ของ​ขอบ​ไว้​ตรง​ตัว​ว่า “The client can read requests by first reading this 4 byte size as an integer N, and then reading and parsing the subsequent N bytes of the request”

และ​เขียน​เรื่อง​สอง​ช่อง​ถัด​มา​ไว้​ว่า “These two 16-bit numbers, when taken together, uniquely identify the schema of the message to follow”

src/produce.rs · ส่วน​หัว​คงที่ กับ​ความ​ยาว​ของ​ส่วน​ที่​ใช้​ส่ง​งาน​ต่อ
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RequestHeader {
pub api_key: i16,
pub api_version: i16,
pub correlation_id: i32,
pub client_id: Option<String>,
}
/// `size i32` + `api_key i16` + `api_version i16` + `correlation_id i32` = 12
/// อ่านแค่นี้ก็ส่งงานต่อได้ โดยยังไม่ต้องแตะ body สักไบต์
pub const ROUTING_PREFIX_LEN: usize = 12;

ช่อง​ที่​สี่​คือ client_id ซึ่ง​เป็น NULLABLE_STRING ความ​ยาว​ไม่​คงที่ มัน​จึง​อยู่​ท้าย​สุด และ​อยู่​นอก​ช่วง​ที่ broker ต้อง​อ่าน​เพื่อ​จะ​รู้​ว่า​ใคร​ควร​รับ​งาน​นี้​ไป

klog พิมพ์ frame จริง​ออก​มา​ให้​ดู​ทั้ง​หัว

expected/03-produce.txt (ตัด​มา​บาง​ส่วน)
== frame หนึ่งอัน · 12 byte แรกส่งงานต่อได้โดยไม่ต้องแตะ body ==
frame ทั้งอันยาว 190 byte (4 byte ของความยาว + 186 byte ของ payload)
00000000 00 00 00 ba 00 00 00 0d 00 00 00 07 00 0d 6b 61 |..............ka|
00000010 65 6e 2d 70 72 6f 64 75 |en-produ|
00..04 size 186
04..06 api_key 0 (0 = produce)
06..08 api_version 13
08..12 correlation_id 7
12.. client_id NULLABLE_STRING · ยาว i16 แล้วตามด้วย byte

00 00 00 ba คือ​ความ​ยาว​ของ payload ส่วน 00 00 ถัด​มา​คือ api key ของ produce ซึ่ง​เป็น​ศูนย์ แล้ว 00 0d คือ api version และ 00 00 00 07 คือ​เลข​กำกับ​ที่​ใช้​จับ​คู่​คำ​ตอบ

เลข​กำกับ​ตัว​นั้น​มีชื่อ​ว่า correlation_id และ​มัน​มี​อยู่​เพราะ​คำ​ตอบ​วิ่ง​กลับ​มา​บน​สาย​เดียวกัน client จึง​ต้อง​รู้​ว่า​คำ​ตอบ​ที่​ได้​เป็น​ของ​คำขอ​อัน​ไหน

เอกสาร​หน้า​เดียวกัน​รับ​ประกัน​อีก​ข้อ คือ​บน​สาย TCP เส้น​เดียว คำขอ​ถูก​ประมวล​ผล​ตาม​ลำดับ​ที่​ส่ง​เข้า​มา และ​คำ​ตอบ​วิ่ง​กลับ​ตาม​ลำดับ​นั้น​ด้วย ลำดับ​ที่​ว่า​นี้​เอง​คือ​ของ​ที่​ประโยค​ถัด​มา​รักษา​ไว้ “The broker’s request processing allows only a single in-flight request per connection in order to guarantee this ordering”

หัวข้อ​นี้​ชี้​ที่​ผิด​หนึ่ง​จุด​ใน​เอกสาร​ของ Kafka แล้ว​บอก​ว่า​เชื่อ file ไหน​แทน

หน้า protocol ที่ render แล้ว​ของ Kafka วาด request header v2 ไว้​สี่​ช่อง​เท่ากับ v1 พอดี ถ้า​เขียน​ตาม​หน้า​นั้น จะ​ได้ header ที่​สั้น​กว่า​ที่ schema ของ​โครงการ​ประกาศ​ไว้​หนึ่ง byte

ตัว​ที่​ถูก​อยู่​ที่ RequestHeader.json ใน source tree ของ​โครงการ​เอง มัน​ประกาศ "validVersions": "1-2" และ "flexibleVersions": "2+"

flexibleVersions ที่​ครอบ v2 แปล​ว่า v2 มี tagged field ต่อ​ท้าย แม้​จะ​ยัง​ไม่มี tag สัก​ตัว​ก็​ต้อง​เขียน​ตัว​นับ​ที่​เป็น​ศูนย์​ลง​ไป และ​ตัว​นับ​นั้น​กิน​ที่​หนึ่ง byte

src/produce.rs · ช่อง​ที่ v2 มี​แล้ว v1 ไม่มี
pub fn encode(&self, header_version: i16) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(&self.api_key.to_be_bytes());
out.extend_from_slice(&self.api_version.to_be_bytes());
out.extend_from_slice(&self.correlation_id.to_be_bytes());
put_nullable_string(&mut out, self.client_id.as_deref());
if header_version >= HEADER_V2 {
// ⚠ หน้าเอกสาร protocol ที่ render แล้วของ Kafka **แสดง header v2 ผิด**
// มันวาดสี่ field เท่ากับ v1 และไม่มี TAG_BUFFER · แต่ RequestHeader.json
// ประกาศ "flexibleVersions": "2+" ซึ่งแปลว่า v2 มี tagged field ต่อท้าย
// · เขียนตามหน้า HTML แล้ว broker จริงจะปฏิเสธ frame นั้น · เชื่อ .json
put_unsigned_varint(&mut out, 0);
}
out
}
expected/03-produce.txt (ตัด​มา​บาง​ส่วน)
== กับดักของ header v2 ==
header v1 ยาว 23 byte · header v2 ยาว 24 byte
ต่างกัน 1 byte คือ TAG_BUFFER ที่ว่างเปล่า (unsigned varint ค่า 0)
payload ทั้งอันจึงยาว 186 กับ 187 byte ตามลำดับ
หน้า protocol ที่ render แล้วของ Kafka วาด v2 ไว้เท่ากับ v1 และไม่มี TAG_BUFFER
แต่ RequestHeader.json ประกาศ flexibleVersions 2+ · เชื่อ .json ไม่ใช่หน้า HTML
และ varint ของ protocol เป็นแบบ unsigned ส่วนของ record เป็น zigzag · คนละตัว
decode payload v2 กลับได้ · correlation_id 7 · acks 1

ความ​ต่าง​ทั้งหมด​คือ​หนึ่ง byte และ​เป็น byte ที่​มอง​ไม่​เห็น​ใน​หน้า​เอกสาร

ตัว​นับ tag ตัว​นั้น​เป็น unsigned varint ซึ่ง​เป็น​คนละ​ตัว​กับ zigzag varint ที่ บท​ที่ 1 กาง​ไว้​สำหรับ​ช่อง​ข้าง​ใน record

เอา​สอง​อย่าง​นี้​ไป​ใช้ routine เดียวกัน​เมื่อ​ไหร่ พัง​ทั้ง​สอง​ฝั่ง เพราะ zigzag เขียน​หนึ่ง​เป็น 0x02 ส่วน unsigned varint เขียน​หนึ่ง​เป็น 0x01

⚠ เอกสาร​กับ schema ไม่​ตรง​กัน ให้​เชื่อ​ตัว​ที่​อยู่​ใน source tree

ของ​ที่​ตรวจ​ได้​มี​สอง​ชิ้น หน้า protocol ของ​รุ่น 4.3 วาด request header v2 ไว้​สี่​ช่อง​เท่ากับ v1 และ RequestHeader.json ที่ tag 4.1.0 ประกาศ "flexibleVersions": "2+" ไว้​ตรงๆ

สอง​ชิ้น​นี้​ไม่ใช่​รุ่น​เดียวกัน และ​คอร์ส​นี้​ไม่​ได้​เปิด broker จริง​มา​ตัดสิน สิ่ง​ที่​พูด​ได้​จึง​เป็น​แค่​ว่า​เอกสาร​ที่​เผย​แพร่​กับ schema ที่​อยู่​ใน source tree บอก​ไม่​ตรง​กัน

กฎ​ที่​ใช้ได้​ต่อ​คือ​เลือก​ชิ้น​ที่​อยู่​ใกล้ code ที่​รัน​จริง​ที่สุด .json ตัว​นั้น​ถูก ship ไป​กับ broker ส่วน​หน้า HTML เป็น​เอกสาร​ของ​รุ่น​ที่​เผย​แพร่​แล้ว

และ​เมื่อ​เลือก​ผิด สิ่ง​ที่​ได้​ไม่ใช่​คำ​เตือน แต่​เป็น frame ที่​ผิด​รูป​ตั้งแต่ byte สุดท้าย​ของ header ซึ่ง​ไม่มี​ตัว​ไหน​บอก​ตอน​คอม​ไพล์

ความ​ยาว​ที่​อีก​ฝั่ง​บอก​มา ต้อง​ตรวจ​ก่อน​จอง memory

หัวข้อ​ที่​มีชื่อ​ว่า “ความ​ยาว​ที่​อีก​ฝั่ง​บอก​มา ต้อง​ตรวจ​ก่อน​จอง memory”

หัวข้อ​นี้​ผูก​กฎ​สอง​ข้อ​ของ std::io เข้า​กับ​การ​อ่าน frame หนึ่ง​อัน

ความ​ยาว​ที่​นำ​หน้า frame เป็น​เลข​ที่​อีก​ฝั่ง​เป็น​คน​บอก ไม่ใช่​เลข​ที่​เรา​นับ​เอง

buf.resize(n, 0) ที่​รับ n มา​ตรงๆ จึง​เป็นการ​ให้​ใคร​ก็ได้​บน​เครือ​ข่าย​สั่ง​จอง​หน่วย​ความ​จำ​เท่าไร​ก็ได้ กติกา​คือ​ตรวจ​ให้​จบ​ก่อน แล้ว​ค่อย​จอง

src/produce.rs · อ่าน​หนึ่ง frame โดย​ไม่​เชื่อ​ความ​ยาว​ที่​รับ​มา
/// อ่านหนึ่ง frame · **ตรวจความยาวก่อนจอง memory** เพราะเลขนั้นมาจากอีกฝั่ง
///
/// `read_exact` ไม่ใช่ `read` · `read` คืน byte น้อยกว่าที่ขอได้ตามกฎหมาย
/// แม้จะยังไม่จบ stream · การอ่าน frame ด้วย `read` คือ bug ที่ผ่านเทสต์บนเครื่องเรา
pub fn read_frame<R: Read>(r: &mut R, buf: &mut Vec<u8>) -> io::Result<()> {
let mut len_bytes = [0u8; 4];
r.read_exact(&mut len_bytes)?;
let n = i32::from_be_bytes(len_bytes);
let n = usize::try_from(n).map_err(|_| bad(format!("ความยาว frame ติดลบ: {n}")))?;
if n > MAX_FRAME {
return Err(bad(format!("frame ยาว {n} byte เกินเพดาน {MAX_FRAME}")));
}
buf.clear();
buf.resize(n, 0);
r.read_exact(buf)
}

เพดาน MAX_FRAME ของ kaen-log สะท้อน socket.request.max.bytes ของ Kafka ซึ่ง​เอกสาร broker config บอก​ว่า​ตั้ง​ต้น​ที่ 104857600 byte และ​อธิบาย​ตัว​เอง​ไว้​แค่​ว่า เป็น​จำนวน byte มาก​ที่สุด​ใน​หนึ่ง​คำขอ

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

read_exact ใน​บรรทัด​ถัด​มา​ก็​ไม่ใช่​รสนิยม​เหมือน​กัน เอกสาร​ของ Read::read เขียน​ไว้​ว่า “It is not an error if the returned value n is smaller than the buffer size, even when the reader is not at the end of the stream yet”

แปล​ว่า read คืน byte ไม่​ครบ​ได้​ทั้ง​ที่ stream ยัง​ไม่​จบ การ​อ่าน frame ด้วย read จึง​เป็น bug ที่​ผ่าน​ทุก​เทสต์​บน​เครื่อง​เรา แล้วไป​พัง​บน​เครือ​ข่าย​จริง

expected/03-produce.txt (ตัด​มา​บาง​ส่วน)
== ตรวจความยาวก่อนจอง memory ==
frame ที่อ้างว่ายาว 104857601 byte ถูกปฏิเสธ · InvalidData
frame ที่บอกว่ายาว 8 byte แต่มีจริง 3 · UnexpectedEof
ความยาวเป็นเลขที่อีกฝั่งเป็นคนบอก · ตรวจก่อน resize ไม่ใช่หลัง

สอง​บรรทัด​นั้น​เป็น​ความ​ผิด​พลาด​คนละ​แบบ และ ErrorKind ที่​ได้​ก็​คนละ​ตัว

  • frame ที่​อ้าง​ความ​ยาว​เกิน​เพดาน ถูก​ปฏิเสธ​ด้วย InvalidData ตั้งแต่​ก่อน​จอง memory
  • frame ที่​บอก​ว่า​ยาว 8 byte แต่​มี​จริง 3 ถูก​ปฏิเสธ​ด้วย UnexpectedEof ตอน​อ่าน​เนื้อ

UnexpectedEof ที่​เกิด​กลาง frame เป็น​เรื่อง​จบ​สำหรับ​สาย​เส้น​นั้น เพราะ​เอกสาร​ของ read_exact เขียน​ไว้​ว่า “If this function returns an error, it is unspecified how many bytes it has read”

เมื่อ​ไม่รู้​ว่า​อ่าน​ไป​กี่ byte ก็​ไม่มี​ทาง​รู้​ว่า​ขอบ​ของ frame ถัด​ไป​อยู่​ตรง​ไหน ทาง​เดียว​ที่​เหลือ​คือ​ทิ้ง​สาย​นั้น ไม่ใช่​ลอง​อ่าน​ต่อ

batching เป็นการ​ตัดสิน​ใจ​เรื่อง​รูป​แบบ ไม่ใช่​การ​ปรับ​จูน

หัวข้อ​ที่​มีชื่อ​ว่า “batching เป็นการ​ตัดสิน​ใจ​เรื่อง​รูป​แบบ ไม่ใช่​การ​ปรับ​จูน”

หัวข้อ​นี้​บอก​ว่า​ทำไม​การ​รวม record เป็น​ชุด​ถึง​อยู่​ใน​รูป byte ไม่​ได้​อยู่​ที่​ค่า config

batchingbatchingการ​รวม​หลาย record เป็น batch เดียว​ก่อน​ส่ง · ซื้อ throughput มา​โดย​จ่าย​เป็น latency ของ record ตัว​แรก​ใน​ชุด มัก​ถูก​เล่า​เป็นการ​ปรับ​จูน คือ​ถ้า​อยาก​ได้ throughput ก็​เพิ่ม​ขนาด​ชุด แต่​ใน​รูป byte ของ Kafka มัน​ไม่ใช่​ตัว​เลือก มัน​คือ​หน่วย​ที่​ทั้ง​รูป​แบบ​ถูก​ออกแบบ​มาร​อบ​มัน

หนึ่ง batch มี​หัว​หนึ่ง​หัว​และ checksum หนึ่ง​ตัว ส่วน record ข้าง​ใน​เก็บ​แต่​ส่วน​ต่าง​จาก​หัว ทั้ง offset และ timestamp

ผล​คือ record ตัว​ที่​สอง​ใน​ชุด​ไม่​ได้​เก็บ offset ของ​ตัว​เอง มัน​เก็บ​เลข​หนึ่ง และ​เลข​หนึ่ง​ใน​รูป zigzag varint กิน​ที่​หนึ่ง byte

record ที่​อยู่​ตัว​เดียว​ใน​ชุด​จึง​แพง​กว่า record ที่​อยู่​รวม​กับ​เพื่อน ไม่ใช่​เพราะ syscall แต่​เพราะ​มัน​ต้อง​แบก​หัว​ทั้ง​หัว​คน​เดียว

ฝั่ง producer ของ Kafka มี​สอง​ค่าที่​แข่ง​กัน​เป็น​ตัว​ปิด​ชุด และ​เอกสาร producer config เขียน​ความ​สัมพันธ์​ไว้​ตรง​ตัว

  • batch.size ตั้ง​ต้น​ที่ 16384 และ​เป็น​เพดาน​ของ​ชุด
  • linger.ms ตั้ง​ต้น​ที่ 5 เป็น​เวลา​รอ​ที่​ยอม​จ่าย​เพื่อ​ให้​ชุด​โต​ขึ้น และ​เอกสาร​บอก​ว่า​ค่า​นี้​เปลี่ยน​จาก​ศูนย์​มา​เป็น​ห้า​ใน Kafka 4.0
  • ชุด​ที่​เต็ม​ก่อน​ชนะ​เวลา​รอ ประโยค​นี้​อยู่​ใน​คำ​อธิบาย​ของ linger.ms เอง คำ​ว่า this setting ใน​นั้น​จึง​หมาย​ถึง​เวลา​รอ ไม่ใช่​ขนาด​ชุด “once we get batch.size worth of records for a partition it will be sent immediately regardless of this setting”
  • batch.size ที่​เป็น​ศูนย์ ปิด batching ทิ้ง​ทั้งหมด ตาม​ที่​เอกสาร​เขียน​ว่า “a batch size of zero will disable batching entirely”

ราคา​ของ batching อยู่​ที่ record ตัว​แรก​ของ​ชุด​เสมอ มัน​ถูก​กัก​ไว้​รอ​เพื่อน​ที่​ยัง​มา​ไม่​ถึง สิ่ง​ที่​ซื้อ​มา​ได้​คือ throughputthroughputจำนวน operation ต่อ​วินาที​ที่​ระบบ​รับ​หรือ​จ่าย​ได้ · ใน broker มัน​คือ​ของ​ที่ batching ซื้อ​มา และ​เป็น​อีก​ฝั่ง​ของ latency ของ​ทั้ง​กลุ่ม สิ่ง​ที่​จ่าย​ไป​คือ latency ของ​ตัว​แรก

เอกสาร​ของ linger.ms เทียบ​ตัว​เอง​ไว้​ว่า “This can be thought of as analogous to Nagle’s algorithm in TCP” ซึ่ง​เป็นการ​เตือน​ที่​มี​ประโยชน์ เพราะ Nagle ยัง​ทำงาน​อยู่​ข้าง​ล่าง​อีก​ชั้น

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

หัวข้อ​นี้​แสดง​ว่าการ​แจก​ลำดับ​เกิด​ขึ้น​ที่ไหน และ​หนึ่ง​คำ​ตอบ​ครอบคลุม​กี่ record

producer ประกอบ batch ขึ้น​มา​โดยที่​ยัง​ไม่รู้​ว่า record ของ​มัน​จะ​ไป​นั่ง​ลำดับ​ไหน บท​ที่ 1 กาง​ไว้​แล้ว​ว่าการ​ประทับ​ลำดับ​เป็นการ​เขียน​ทับ 8 byte ที่​หัว batch โดย​ไม่​ต้อง​คิด checksum ใหม่

บท​นี้​เอาการ​ประทับ​นั้น​มา​วาง​ไว้​บน​ทาง​เดิน​จริง คือ append ตัว​เดียว​ของ​ทั้ง crate

src/produce.rs · การ append ตัว​เดียว​ของ crate
/// ประทับ base offset ลงบน **สำเนา** ของ `batch.raw` แล้วเขียนสำเนานั้น
/// คืน base offset ตัวแรกที่แจกไปในรอบนี้ · **broker เป็นคนแจก offset ไม่ใช่ producer**
///
/// `now_ms` คือนาฬิกาของ broker ตอน append และใช้กับเงื่อนไข roll ตามเวลา **เท่านั้น**
/// · timestamp ของ record เป็น CreateTime มาจาก producer จึงไม่ถูกแตะที่นี่
pub fn assign_and_append(&mut self, batches: &[Batch], now_ms: i64) -> io::Result<u64> {
// จำนวน batch เป็นเลขที่อีกฝั่งเป็นคนบอก · `count = 0` ผ่าน `decode` มาได้
// ถ้าปล่อยผ่าน ทางนี้จะคืน "สำเร็จ" โดยไม่ได้แจก offset ให้อะไรเลย
// แล้ว `acks=-1` ที่คิด `log_end - 1` ต่อจากนั้นจะลบ u64 ทะลุศูนย์
if batches.is_empty() {
return Err(bad("คำขอที่ไม่มี batch เลยสักก้อน"));
}
let assigned_base = self.next_offset;
for batch in batches {
let count =
u64::try_from(batch.header.records_count).map_err(|_| bad("records_count ติดลบ"))?;
if count == 0 {
return Err(bad("batch ที่ไม่มี record เลย"));
}
let last = self.next_offset + count - 1;
// Kafka เขียนทับ 8 byte ใน buffer ที่รับมา แล้วไม่คิด CRC ใหม่
// Rust ให้ slice มาแบบอ่านอย่างเดียว จึงสำเนาก่อนแล้วประทับบนสำเนา
let mut bytes = batch.raw.clone();
BatchHeader::stamp_last_offset(&mut bytes, last, batch.header.last_offset_delta)?;
BatchHeader::stamp_leader_epoch(&mut bytes, self.leader_epoch);
self.roll_if_needed(len_to_u64(bytes.len()), last, now_ms)?;
// ตรวจ **ก่อน** บวกตัวนับ · เหมือน LogSegment.append ทุกตัวอักษร
let add_index_entry =
self.bytes_since_last_index_entry > self.config.index_interval_bytes;
let seg = self.segments.last_mut().expect("log มีอย่างน้อยหนึ่งท่อนเสมอ");
seg.append(&bytes, last, batch.header.max_timestamp, add_index_entry)?;
if add_index_entry {
self.bytes_since_last_index_entry = 0;
}
self.bytes_since_last_index_entry += len_to_u64(bytes.len());
self.next_offset = last + 1;
// มี replica ตัวเดียว ISR จึงมีแต่ leader เอง และเส้น committed
// เดินตาม log end ทุกก้าว · บทที่ 8 ปิดข้อนี้เมื่อมี replica ตัวที่สอง
if self.hw_follows_log_end {
self.high_watermark = self.next_offset;
}
}
Ok(assigned_base)
}

มี​ข้อ​หนึ่ง​ที่ Rust บังคับ​ให้​ต่าง​จาก Kafka · ฝั่ง Kafka เขียน​ทับ 8 byte ลงบน buffer ที่​รับ​มา​ได้​เลย ส่วน batch.raw ที่​นี่​ยืม​มา​แบบ​อ่าน​อย่าง​เดียว จึง​ต้อง​สำเนา​ก่อน​แล้ว​ประทับ​บน​สำเนา

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

now_ms ที่​รับ​เข้า​มา​เป็น​นาฬิกา​ของ broker และ​มัน​มี​ผล​กับ​เงื่อนไข roll ตาม​เวลา​เท่านั้น timestamp ของ record เป็น CreateTime ที่มา​จาก producer จึง​ไม่​ถูก​แตะ​ที่​นี่​เลย

demo ยิง​สาม batch เข้าไป ชุด​ละ​ไม่​เท่า​กัน แล้ว​พิมพ์​สิ่ง​ที่ broker ตอบ​กลับ​มา

expected/03-produce.txt (ตัด​มา​บาง​ส่วน)
== broker เป็นคนแจก offset · หนึ่งคำตอบต่อหนึ่ง batch ==
acks ที่ใช้ในหัวข้อนี้ = 1
batch record base_offset ที่ได้ log end หลังเขียน error
0 2 0 2 0
1 3 2 5 0
2 4 5 9 0
log end offset สุดท้าย 9
base_offset เป็นของ batch ไม่ใช่ของ record · record ที่เหลือหาเอาจาก offset_delta

ชุด​แรก​มี​สอง record ได้ base_offset 0 แล้ว log end offset ขยับ​ไป​ที่ 2 ชุด​ที่​สอง​มี​สาม record ได้ base_offset 2 และ log end offset ไป​ที่ 5

ตัวเลข​ใน​ตาราง​นี้​เป็น​เลข​ของ​ชุด ไม่ใช่​เลข​ของ record คำ​ตอบ​หนึ่ง​อัน​บอก base_offset มา​ตัว​เดียว ส่วน record ที่​เหลือ​ใน​ชุด​เดียวกัน client หา​เอา​เอง​จาก offset_delta ที่​อยู่​ข้าง​ใน

เอกสาร protocol ของ Kafka วาง base_offset ไว้​เป็น​ช่อง​ระดับ partition ใน​คำ​ตอบ​เหมือน​กัน ไม่​ได้​มี​ช่อง​ต่อ record ให้​เลย​สัก​ช่อง

acks เปลี่ยน​เวลา​ที่​ตอบ ไม่​เคย​เปลี่ยน​สิ่ง​ที่​เขียน

หัวข้อ​ที่​มีชื่อ​ว่า “acks เปลี่ยน​เวลา​ที่​ตอบ ไม่​เคย​เปลี่ยน​สิ่ง​ที่​เขียน”

หัวข้อ​นี้​ไล่​ค่า​ของ acks ที​ละ​ค่า แล้ว​ชี้​ว่า​ลำดับ​การ​ทำงาน​ไม่​ขยับ​ตาม​มัน​เลย

acksacksค่าที่ producer สั่ง​ว่า broker ต้อง​ตอบ​กลับ​เมื่อ​ไหร่ · มัน​เปลี่ยน​แค่​จังหวะ​การ​ตอบ ไม่​ได้​เปลี่ยน​วิธี​เขียน​ลง log รับ​ได้​สาม​ค่า​เท่านั้น​บน wire และ​เลข​สาม​ตัว​นั้น​ไม่​ได้​แปล​ว่า “เอา​กี่​สำเนา”

src/produce.rs · สาม​ค่า​ของ acks และ​ตาราง error ของ kaen-log เอง
/// สาม value เดียวที่ `acks` รับได้บน wire · ไม่มี `acks=2`
/// "จะเอากี่ replica" เป็นหน้าที่ของ `min.insync.replicas` ไม่ใช่ของ `acks`
pub const ACKS_NONE: i16 = 0;
pub const ACKS_LEADER: i16 = 1;
pub const ACKS_ALL: i16 = -1;
/// **ตาราง error ของ kaen-log เป็นของตัวเอง** รอบวิจัยของคอร์สนี้ไม่ได้ยืนยันเลขจริง
/// ในตารางของ Kafka จึงไม่มีบทไหนอ้างว่าเลขพวกนี้เป็นเลขบน wire ของ Kafka
pub const ERR_NONE: i16 = 0;
pub const ERR_INVALID_REQUIRED_ACKS: i16 = 1;
pub const ERR_NOT_ENOUGH_REPLICAS: i16 = 2;
⚠ เลข error ใน​บล็อก​นี้​เป็น​ของ kaen-log ไม่ใช่​ของ Kafka

ERR_NONE ERR_INVALID_REQUIRED_ACKS และ ERR_NOT_ENOUGH_REPLICAS เป็น​เลข​ที่ crate นี้ ตั้ง​ขึ้น​เอง เพื่อ​ให้ demo พิมพ์​อะไร​ออก​มา​ได้

รอบ​วิจัย​ของ​คอร์ส​นี้​ไม่​ได้​ตาม​หา​ตาราง error code จริง​ของ Kafka มา​เทียบ จึง​ไม่มี​บท​ไหน​อ้าง​ว่า​เลข​พวก​นี้​เป็น​เลข​บน wire ของ Kafka ถ้า​ต้อง​ใช้​เลข​จริง ให้​ไป​เปิด​ของ​จริง ไม่ใช่​เดา​จาก​ที่​นี่

ฝั่ง Kafka ตรวจ​สาม​ค่า​นี้​ด้วย function บรรทัด​เดียว​ใน ReplicaManager.scala คือ requiredAcks == -1 || requiredAcks == 1 || requiredAcks == 0 และ​คอม​เมน​ต์​ใน function ที่​ตอบ error ตัว​นั้น​เขียน​ไว้​ว่า​ถ้า​ค่า​หลุด​ออก​นอก​ช่วง​นี้ แปล​ว่า client มี​อะไร​ผิด ให้​ตอบ error แล้ว​ไม่​ต้อง​รับคำ​ขอ​นั้น​เลย

ไม่มี acks=2 คำถาม​ว่า “ต้อง​มี​กี่​สำเนา​ถึง​จะ​นับ​ว่า commit” ไม่​ได้​ถาม​ที่ acks มัน​ถูก​ถาม​ที่ min.insync.replicas ซึ่ง​เป็น​ค่า​ฝั่ง broker และ​เป็น​เรื่อง​ของ​บท​ที่ 8

src/produce.rs · ทาง​เดิน​ของ​คำขอ produce ทั้ง​เส้น
/// `Ok(None)` คือ `acks=0` · **ไม่ส่ง frame ตอบกลับเลยสักไบต์**
/// `io::Result` เป็นชั้นนอก เพราะการเขียนลง disk ล้มเหลวได้ก่อนจะถึงเรื่องว่าจะตอบอะไร
pub fn handle_produce(
p: &Partition,
gate: &dyn ReplicaGate,
req: &ProduceRequest,
now_ms: i64,
) -> io::Result<Option<ProduceResponse>> {
if !matches!(req.acks, ACKS_NONE | ACKS_LEADER | ACKS_ALL) {
let response =
ProduceResponse::failed(req.header.correlation_id, ERR_INVALID_REQUIRED_ACKS);
return Ok(Some(response));
}
// เขียนใต้ lock · **fsync อยู่นอก lock** เพราะ fsync กินเวลาระดับมิลลิวินาที
// และการถือ lock คร่อมมันจะกั้น producer ทุกรายกับ fetch ที่จอดรออยู่ทุกตัว
let (base, log_end) = {
let mut log = p.log.lock().expect("log ถูก poison");
let base = log.assign_and_append(&req.batches, now_ms)?;
(base, log.log_end_offset())
};
p.sync_all()?;
p.publish_log_end(log_end);
let response = ProduceResponse {
correlation_id: req.header.correlation_id,
error_code: ERR_NONE,
base_offset: crate::record::wire_i64(base),
log_append_time_ms: -1,
log_start_offset: 0,
throttle_time_ms: 0,
};
match req.acks {
// ไม่มีคำตอบ · Kafka บอกเองว่าเมื่อเกิด error มันจะ **ปิด socket** แทนที่จะตอบ
// เพราะ producer ไม่ได้รออะไรอยู่ · offset ที่ client เห็นจะเป็น -1 เสมอ
ACKS_NONE => Ok(None),
ACKS_LEADER => Ok(Some(response)),
ACKS_ALL => Ok(Some(await_commit(gate, log_end - 1, req, now_ms).0)),
other => Err(bad(format!("acks {other} ผ่านการตรวจมาได้อย่างไร"))),
}
}

อ่าน​ลำดับ​ใน​นั้น​แล้ว​จะ​เห็น​ว่า​ทั้ง​การ​เขียน​และ sync_all จบ​ไป​ก่อน​จะ​ถึง match req.acks ตัว match ไม่​ได้​เลือก​ว่า​จะ​เขียน​อะไร มัน​เลือก​ว่า​จะ​พูด​อะไร​กลับ​ไป​หลัง​เขียน​เสร็จ​แล้ว

ค่าที่​ไม่​ถูก​ต้อง​ถูก​คัด​ออก​ก่อน​หน้า​นั้น​อีก​ชั้น​หนึ่ง คำขอ​ที่​ไป​ถึง match ได้​จึง​เขียน​ลง log ไป​แล้ว​ทุก​อัน ไม่มี​กิ่ง​ไหน​ของ match ที่​ย้อน​กลับ​ไป​เขียน​หรือ​ไม่​เขียน

sequenceDiagram
    autonumber
    participant P as producer
    participant B as broker
    participant D as disk
    P->>B: Produce · acks เป็น 0 หรือ 1 หรือ -1
    B->>B: แจกลำดับแล้วประทับลงหัว batch
    B->>D: write_all · ไปถึงแค่ page cache
    B->>D: sync_all · ทำนอก lock ของ log
    alt acks เป็น 0
        Note over B,P: ไม่ส่ง frame ตอบกลับเลยสักไบต์ · ถ้าพลาด Kafka ปิดสายทิ้ง
    else acks เป็น 1
        B->>P: ตอบทันทีพร้อม base_offset ของชุด
    else acks เป็น -1
        B->>B: ถาม ReplicaGate ว่าถึงเส้นที่ต้องการหรือยัง
        B->>P: ยังไม่พร้อม จนกว่าบทที่ 8 จะเอา replica ตัวจริงมาใส่
    end

คำ​บรรยาย​ภาพ: ภาพ​นี้​เป็น​ทาง​เดิน​ของ​คำขอ​หนึ่ง​อัน ไม่ใช่​ผล​รัน · สาม​กิ่ง​ล่าง​ต่าง​กัน​แค่​สิ่ง​ที่ broker พูด​กลับ​ไป ส่วน​สอง​ขั้น​บน​คือ​การ​แจก​ลำดับ​กับ​การ​เขียน เกิด​ขึ้น​เหมือน​กัน​หมด​ทุก​กิ่ง

demo ยิง​คำขอ​สี่​อัน​ที่​ต่าง​กัน​แค่​ค่า acks แล้ว​วัด log end ก่อน​กับ​หลัง​ทุก​ครั้ง

expected/03-produce.txt (ตัด​มา​บาง​ส่วน)
== acks เปลี่ยนเวลาที่ตอบ ไม่เคยเปลี่ยนสิ่งที่เขียน ==
acks คำตอบ log end ก่อน → หลัง
0 ไม่ส่ง frame ตอบกลับเลย 9 → 10
1 ok · base_offset 10 10 → 11
-1 not_enough_replicas 11 → 12
2 invalid_required_acks 12 → 12
acks=0 เขียนลง disk เหมือนกันทุกอย่าง แค่ไม่มีใครได้ยินคำตอบ
acks=2 ไม่ใช่ค่าที่รับได้ · log end ไม่ขยับ เพราะคำขอถูกปฏิเสธก่อนถึง disk
acks=-1 ยังตอบ not_enough_replicas เพราะ gate ของบทที่ 1 ถึง 7 ตอบ
"ยังไม่พร้อม" เสมอ · บทที่ 8 เป็นคนเอา replica จริงมาใส่

สาม​แถว​แรก​ขยับ log end ไป​ข้าง​หน้า​หนึ่ง​ขั้น​เท่า​กัน​หมด ทั้ง​ที่​คำ​ตอบ​ต่าง​กัน​คนละ​อย่าง

  • acks=0 ไม่มี frame ตอบ​กลับ​เลย แต่ record ลง​ไป​นั่ง​ใน log เรียบร้อย
  • acks=1 ตอบ​พร้อม base_offset ของ​ชุด​ที่​เพิ่ง​เขียน
  • acks=-1 ตอบ​ว่า not_enough_replicas เพราะ​ยัง​ไม่มี replica ตัว​ที่​สอง​ให้​รอ

แถว​สุดท้าย​คือ acks=2 ซึ่ง​ไม่ใช่​ค่าที่​รับ​ได้ และ​เป็น​แถว​เดียว​ที่ log end ไม่​ขยับ เพราะ​คำขอ​ถูก​ปฏิเสธ​ตั้งแต่​ก่อน​ถึง disk

acks=0 เป็น​แถว​ที่​อ่าน​ผิด​ง่าย​ที่สุด มัน​ไม่​ได้​แปล​ว่า​ไม่​เขียน มัน​แปล​ว่า​ไม่มี​ใคร​ได้ยิน​คำ​ตอบ เอกสาร producer ของ Kafka บอก​ด้วย​ว่า offset ที่ client เห็น​ใน​กรณี​นี้​จะ​เป็น​ลบ​หนึ่ง​เสมอ

src/produce.rs · รอย​ต่อ​ของ acks=-1 ที่​บท​ที่ 8 จะ​มา​ปิด
/// **ไม่มี function ชื่อ `park_until_hw` ใน crate นี้** · `acks=-1` เรียกผ่าน trait นี้
/// และบทที่ 1 ถึง 7 ให้ตัว stub ที่ตอบ "ยังไม่พร้อม" เสมอ
pub trait ReplicaGate: Send + Sync {
fn check_enough_replicas_reached(&self, required_offset: u64) -> bool;
}
/// stub ของบทที่ 1 ถึง 7 · ยังไม่มี replica สักตัว จึงยังไม่มีอะไรถึง required_offset ได้
pub struct NoReplicas;
impl ReplicaGate for NoReplicas {
fn check_enough_replicas_reached(&self, _required_offset: u64) -> bool {
false
}
}
⚠ `acks=-1` ของ​บท​นี้​ยัง​ไม่​จริง และ​มัน​ต้อง​ยัง​ไม่​จริง

acks=all ถูก​นิยาม​ไว้​กับ​เซต​ของ replica ที่​ตาม​ทัน ไม่ใช่​กับ disk ของ​เครื่อง​เดียว kaen-log ใน​บท​นี้​มี replica อยู่​ตัว​เดียว​คือ​ตัว​มัน​เอง จึง​ไม่มี​อะไร​ที่ acks=-1 จะ​รอ​ได้​อย่าง​ซื่อสัตย์

ทาง​ที่​ง่าย​กว่า​คือ​ให้ acks=-1 ตอบ ok เหมือน acks=1 แล้ว​เดิน​หน้า​ต่อ ซึ่ง​จะ​เป็น​คำ​โกหก​ที่​ผู้​อ่าน​ตรวจ​ไม่​ได้ เพราะ​ผล​รัน​จะ​สวย​เท่า​กัน​ทั้ง​ที่​ไม่มี​สำเนา​อยู่​จริง

crate จึง​เลือก​ให้​มัน​ตอบ not_enough_replicas ทุก​ครั้ง จนกว่า​บท​ที่ 8 จะ​เอา follower ตัว​จริง มา​เสียบ​ใน​ช่อง​เดียวกัน​นี้ ช่อง​ว่าง​ที่​ประกาศ​ตัว​เอง​ว่า​ว่าง ปลอดภัย​กว่า​ช่อง​ที่​แกล้ง​เต็ม

หัวข้อ​นี้​แยก​สิ่ง​ที่​ต้อง​ทำ​ใต้ lock ออก​จาก​สิ่ง​ที่​ต้อง​ทำ​นอก lock

fsyncfsyncบังคับ byte จาก page cache ลง storage จริง (`File::sync_all`/`sync_data`) · kaen-log เรียก​มัน​เพราะ​ยัง​ไม่มี replica ส่วน Kafka จริง​ไม่​เรียก​บน​ทาง write คือ​การ​บังคับ​ให้ byte ที่​อยู่​ใน page cache ลง storage จริง คอร์ส kaen-kvstore สอน​ไว้​แล้ว​ว่า write_all ไป​ถึง​แค่ page cache และ​คอร์ส​นี้​ถือว่า​รู้

สิ่ง​ที่​คอร์ส​นี้​เพิ่ม​คือ​คำถาม​ว่า​ใคร​ถือ lock อยู่​ตอน​ที่ fsync ทำงาน

ถ้า​เขียน​ตาม​สัญชาตญาณ​จะ​ได้ p.log.lock().unwrap().sync_all() ซึ่ง​อ่าน​แล้ว​ดู​สะอาด แต่​บรรทัด​นั้น​ถือ lock ของ log ไว้​ตลอด​การ​รอ storage

ผล​คือ producer ราย​อื่น​ทุก​ราย​และ​คำขอ fetch ที่​จอด​รอ​อยู่​ทุก​อัน ต้อง​รอ fsync ของ​คน​อื่น​ไป​ด้วย

src/partition.rs · ถือ lock แค่​ตอน​สำเนา descriptor
/// ถือ lock ของ log แค่ตอน `dup` descriptor แล้ว fsync **นอก lock ทุกตัว**
pub fn sync_all(&self) -> io::Result<()> {
let file = {
let log = self.log.lock().expect("log ถูก poison");
log.active_file()?
};
file.sync_all()
}

active_file() ใน​บรรทัด​กลาง​คือ​การ​เรียก File::try_clone หนึ่ง​ครั้ง และ try_clone ไม่​ได้​สำเนา​เนื้อ file เอกสาร​ของ std เขียน​ไว้​ว่า​มัน​คืน File ตัว​ใหม่​ที่ “shares the same underlying file handle as the existing File instance”

การ​เรียก sync_all() บน​ตัว​สำเนา​จึง​บังคับ byte ก้อน​เดียวกัน​ลง storage ไม่ใช่​ของ​คนละ​ก้อน

lock ถูก​ถือ​ไว้​แค่​ช่วง​ที่​สร้าง​สำเนา​นั้น​ขึ้น​มา ส่วน​การ​รอ storage เกิด​ขึ้น​ตอน​ที่​ไม่มี​ใคร​ถูก​กั้น

เอกสาร​ของ File::sync_all ยัง​เตือน​อีก​ข้อ​ที่​คุ้ม​ค่า​จะ​จำ ว่าการ​ปล่อย File ทิ้ง​เฉยๆ “will ignore all errors” การ​ไม่​เรียก sync_all จึง​ไม่ใช่​แค่​ไม่​รอ แต่​เป็นการ​ไม่รู้​ด้วย​ว่า​พลาด​หรือ​เปล่า

durabilitydurabilityการ​รับ​ประกัน​ว่า​ข้อมูล​ที่ ack แล้ว​รอด crash (ต้อง​ผ่าน fsync ก่อน) · ใน broker ข้อ​รับ​ประกัน​นี้​ย้าย​จาก disk ไป​อยู่​กับ​จำนวน​สำเนา​ได้ ของ kaen-log จึง​มี​ที่​อยู่​ที่​เดียว คือ disk ของ​เครื่อง​นี้ และ​มัน​ต้อง​ยืนยัน​ได้​ตอน​ที่​คำ​ตอบ​ถูก​ส่ง​ออก​ไป ไม่ใช่​ตอน​ที่ process ตาย​ไป​แล้ว

🔁 คอม​เมน​ต์​ใน​บล็อก​นั้น​อ้าง​เรื่อง​เวลา ส่วน​คอร์ส​นี้​ไม่​ได้​จับ​เวลา

คอม​เมน​ต์​ใน handle_produce เขียน​ว่า fsync กิน​เวลา​ระดับ​มิลลิ​วินาที ซึ่ง​เป็น​เหตุผล​ของ​การ​ออกแบบ ที่​ผู้​เขียน crate ใส่​ไว้ ไม่ใช่​ผล​วัด​ของ​คอร์ส​นี้

expected/03-produce.txt ไม่มี​ตัวเลข​เวลา​สัก​ตัว เพราะ demo ทุก​ตัว​ของ​คอร์ส​นี้​ต้อง​ให้​ผล​เหมือน​เดิม ทุกร​อบ และ​เวลา​ที่​วัด​จาก​เครื่อง​จริง​ไม่มี​ทาง​เหมือน​เดิม

ถ้า​อยาก​ได้​ตัวเลข​ของ​เครื่อง​ตัว​เอง ให้​เขียน probe ที่​วัด​แล้ว​บันทึก​ผล​ไว้ คอร์ส kaen-kvstore บท​ที่ 7 ทำ​แบบ​นั้น​ไว้​ทั้ง​บท

หัวข้อ​นี้​กลับ​ข้าง​สิ่ง​ที่​เพิ่ง​สร้าง แล้ว​บอก​ว่า​ทำไม​ของ​จริง​ถึง​เลือก​ทาง​ตรง​ข้าม

kaen-log fsync ก่อน​ตอบ ส่วน Kafka ไม่​ทำ​อย่าง​นั้น และ​ไม่ใช่​เพราะ​ลืม

ค่า​ตั้ง​ต้น​ของ log.flush.interval.messages คือ 9223372036854775807 ซึ่ง​เท่ากับ​ค่า​มาก​ที่สุด​ของ i64 ส่วน log.flush.interval.ms ไม่​ได้​ตั้ง​ค่า​ไว้​เลย

ทาง​เดิน append ของ Kafka มี​บรรทัด​ที่​เรียก flush อยู่​จริง คือ if (localLog.unflushedMessages() >= config().flushInterval) flush(false); แต่​ที่​ค่า​ตั้ง​ต้น เงื่อนไข​นั้น​เป็น​จริง​ไม่​ได้

นี่​คือ​กิ่ง​ที่​มี​อยู่​ใน​โปรแกรม แต่​เดิน​ไป​ถึง​ไม่​ได้​ด้วย​ค่า​ตั้ง​ต้น​ที่​เอกสาร​เอง​แนะนำ​ให้​ใช้

เอกสาร​ฝั่ง​ปฏิบัติ​การ​ของ​โครงการ​พูด​ตรง​กว่า​นั้น​อีก ว่า “We recommend using the default flush settings which disable application fsync entirely” และ “We generally feel that the guarantees provided by replication are stronger than sync to local disk”

เหตุผล​ที่​โครงการ​ให้​ไว้​ใน​เอกสาร​ออกแบบ​คือ “we do not want to require the use of fsync on every write for our consistency guarantees as this can reduce performance by two to three orders of magnitude”

⚠ ประโยค​นั้น​เป็น​เหตุผล​ของ​การ​ออกแบบ ไม่ใช่​ผล​วัด

“two to three orders of magnitude” อ่าน​แล้ว​เหมือน​ตัวเลข แต่​หน้า​นั้น​ไม่​ได้​แนบ benchmark ไม่​ได้​บอก​เครื่อง ไม่​ได้​บอก filesystem และ​ไม่​ได้​ลง​วัน​ที่​ไว้​เลย

มัน​จึง​เป็น​ข้อ​อ้าง​ที่​โครงการ​ใช้​อธิบาย​ทาง​เลือก​ของ​ตัว​เอง ไม่ใช่​ผล​การ​วัด​ที่​ใคร​ทำ​ซ้ำ​ได้ อ้าง​มัน​ได้​ใน​ฐานะ​เหตุผล​ที่ Kafka ให้​ไว้ ห้าม​อ้าง​ใน​ฐานะ​ตัวเลข

คอร์ส​นี้​ไม่​ได้​จับ​เวลา fsync สัก​ครั้ง จึง​ไม่มี​บท​ไหน​พูด​ว่า​เร็ว​ขึ้น​หรือ​ช้า​ลง​เท่าไร

Kafka ย้าย fsync ออก​จาก​ทาง​ที่ producer รอ​อยู่​จริงๆ ตอน roll segment คอม​เมน​ต์​ใน UnifiedLog.java เขียน​ไว้​ว่า “We avoid potentially-costly fsync call, since we acquire UnifiedLog#lock here which could block subsequent produces in the meantime”

กฎ​ที่​อยู่​ใต้​คอม​เมน​ต์​นั้น​เป็น​กฎ​เดียว​กับ​ที่ Partition::sync_all ของ kaen-log ใช้ คือ​อย่า​ถือ lock คร่อม​การ​รอ storage

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

และ Kafka ไม่ fsync เพราะ​มัน​มี​สำเนา​อยู่​หลาย​เครื่อง ความ​ทนทาน​ของ​มัน​จึง​ย้าย​จาก disk ไป​อยู่​กับ​จำนวน​สำเนา​แทน ตัว​เลือก​ที่​ถูก​ของ​ของ​เล่น เป็น​คำ​อธิบาย​ที่​ผิด​ของ Kafka

🔁 log ของ broker ไม่ใช่ WAL

WALWALwrite-ahead log: บันทึก​ลง log ให้ durable ก่อน​ถือว่า commit · log ของ broker ไม่ใช่ WAL เพราะ​มัน​ไม่​ได้​วาง​อยู่​หน้า​โครงสร้าง​ข้อมูล​อื่น มัน​คือ​โครงสร้าง​นั้น​เอง คือ​การ​เขียน​ลง log ให้ durable ก่อน แล้ว​ค่อย​ไป​แก้​โครงสร้าง​ข้อมูล​จริง​ตาม​หลัง ฐาน​ข้อมูล​ใช้​รูป​นี้​เพราะ log ของ​มัน​เป็น​ทาง​ผ่าน ปลาย​ทาง​คือ B-tree หรือ​ตาราง​ที่​อยู่​อีก​ที่​หนึ่ง

log ของ broker ไม่มี​ปลาย​ทาง​แบบ​นั้น มัน​ไม่​ได้​เขียน​ล่วง​หน้า​ก่อน​อะไร เพราะ​มัน​คือ​ตัว​ข้อมูล​เอง คำ​ว่า “ahead” ใน​ชื่อ​จึง​ไม่มี​ของ​ให้​ไป​อยู่​ข้าง​หน้า

ความ​ต่าง​นี้​มี​ผล​จริง​ใน​บท​ถัดๆ ไป log ของ​ฐาน​ข้อมูล​ตัด​ทิ้ง​ได้​เมื่อ checkpoint ผ่าน​แล้ว ส่วน log ของ broker ตัด​ทิ้ง​ได้​ก็​ต่อ​เมื่อ retention บอก​ให้​ตัด ซึ่ง​เป็น​เรื่อง​ของ​บท​ที่ 6

ท้าย file ที่​ขาด ต้อง​ตัด​ทิ้ง​ถึง batch ที่​ครบ​ตัว​สุดท้าย

หัวข้อ​ที่​มีชื่อ​ว่า “ท้าย file ที่​ขาด ต้อง​ตัด​ทิ้ง​ถึง batch ที่​ครบ​ตัว​สุดท้าย”

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

เครื่อง​ดับ​กลาง​การ​เขียน​ไม่​ได้​ทิ้ง log ที่​เสีย​หาย​ทั้ง​ก้อน​ไว้ มัน​ทิ้ง log ที่​ดี​ทั้ง​ก้อน บวก​เศษ​ของ batch สุดท้าย​ที่​เขียน​ไม่​จบ

truncate-tail recoverytruncate-tail recoveryการ​กู้​คืน​หลัง crash ด้วย​การ​ตัด​หาง​ที่​เขียน​ไม่​ครบ​ทิ้ง แล้ว​เขียน​ต่อ​จาก​ขอบ​ของ record สมบูรณ์​ตัว​สุดท้าย คือ​การ​ตัด​เศษ​นั้น​ทิ้ง แล้ว​เขียน​ต่อ​จาก​ขอบ​ของ batch ที่​ครบ​ตัว​สุดท้าย

Kafka เขียน​กฎ​การ​ตรวจ​ไว้​ใน​เอกสาร​ว่า “A message entry is valid if the sum of its size and offset are less than the length of the file AND the CRC32 of the message payload matches the CRC stored with the message. In the event corruption is detected the log is truncated to the last valid offset”

สอง​เงื่อนไข​นั้น​จับ​ความ​เสีย​หาย​คนละ​แบบ ความ​ยาว​จับ block ที่​หาย​ไป​ตอน​เครื่อง​ดับ ส่วน CRC จับ block มั่ว​ที่​ถูก​เพิ่ม​เข้า​มา ซึ่ง​เกิด​ได้​เมื่อ inode ถูก​อัปเดต​ก่อน​เนื้อ​จะ​ลง​ถึง

src/produce.rs · เดิน​ที​ละ batch แล้ว​หยุด​ที่​ตัว​แรก​ที่​ไม่​ผ่าน
/// กฎของ Kafka: batch ใช้ได้ก็ต่อเมื่อ **ขนาดบวกตำแหน่งไม่เกินความยาวของ file**
/// และ **CRC ตรง** · เจอตัวแรกที่ไม่ผ่านเมื่อไหร่ ให้ตัดทิ้งตั้งแต่ตรงนั้น
///
/// ความเสียหายมีสองแบบ: ท้าย file ขาดเพราะ block ที่ยังไม่ถูกเขียนหายไปตอนเครื่องดับ
/// และ block มั่วที่ถูก *เพิ่ม* เข้ามา · การตรวจ CRC จับแบบที่สอง การตรวจความยาวจับแบบแรก
pub fn recover_truncate_tail(log: &mut Log) -> io::Result<Recovery> {
let seg = log.segments.last_mut().expect("log มีอย่างน้อยหนึ่งท่อนเสมอ");
let file_len = seg.file_len()?;
let mut pos = 0u64;
let mut scanned = 0usize;
let mut last_valid_offset = None;
while pos < file_len {
let Some(header) = seg.read_batch_header_at(pos)? else { break };
let total = len_to_u64(header.total_size());
if pos + total > file_len {
break;
}
let len = u32::try_from(header.total_size()).map_err(|_| bad("batch ยาวเกิน u32"))?;
// `read_batch_at` ตรวจ CRC ให้ในตัว · batch ที่ CRC ไม่ตรงคือ block มั่ว
if seg.read_batch_at(pos, len).is_err() {
break;
}
scanned += 1;
last_valid_offset = Some(header.last_offset()?);
pos += total;
}
let dropped = file_len - pos;
if dropped > 0 {
seg.truncate_to(pos)?;
}
if let Some(last) = last_valid_offset {
log.next_offset = last + 1;
log.high_watermark = log.next_offset;
}
Ok(Recovery {
scanned_batches: scanned,
last_valid_offset,
valid_bytes: pos,
dropped_bytes: dropped,
})
}

demo ต่อ​ครึ่ง batch ไว้​ท้าย file แล้ว​เปิด log ขึ้น​มา​ใหม่

expected/03-produce.txt (ตัด​มา​บาง​ส่วน)
== ท้าย file ที่ขาด · ตัดทิ้งถึง batch ที่ครบตัวสุดท้าย ==
ก่อนทำให้เสีย 1278 byte
ต่อท้ายครึ่ง batch 147 byte → 1425 byte
เปิดใหม่ · log end offset ที่อ่านได้ 12
แต่ file ยังยาว 1425 byte · O_APPEND เขียนที่ท้าย file จริง
ไม่ใช่ที่ท้ายส่วนที่ครบ · ถ้าไม่ตัดทิ้ง batch ถัดไปจะไปนั่งหลังขยะ
scan ไป 6 batch
offset ที่ครบตัวสุดท้าย Some(11)
เหลือไว้ 1278 byte · ตัดทิ้ง 147 byte
ความยาว file หลังตัด 1278
เขียนต่อได้ที่ offset 12 · ไม่มีรูโหว่คั่นกลาง
Kafka ใช้กฎเดียวกัน: ขนาดบวกตำแหน่งต้องไม่เกินความยาว file และ CRC ต้องตรง

สอง​บรรทัด​ที่​ต้อง​อ่าน​คู่​กัน​คือ​บรรทัด​ที่​บอก offset กับ​บรรทัด​ที่​บอก​ความ​ยาว การ​เปิด log ขึ้น​มา​ใหม่​คือ​การ replayreplayการ​อ่าน log ตั้งแต่​ต้น​จน​จบ​เพื่อ​สร้าง​สถานะ​ขึ้น​ใหม่ · broker ทำ​ตอน​เปิด เพื่อ​หา​ขอบ​ของ segment และ offset ตัว​ถัด​ไป​ที่​จะ​แจก คือ​เดิน log ตั้งแต่​ต้น​เพื่อ​สร้าง​สถานะ​ขึ้น​ใหม่ และ​มัน​ให้ offset ตัว​ถัด​ไป​ที่​ถูก​ต้อง​แล้ว

แต่​ความ​ยาว​ของ file ยัง​เป็น 1425 byte เท่า​เดิม เพราะ​การ replay อ่าน​อย่าง​เดียว ไม่​ได้​แก้​อะไร

O_APPEND เขียน​ที่​ท้าย file จริง ไม่ใช่​ที่​ท้าย​ส่วน​ที่​ครบ ถ้า​ไม่​ตัด​ขยะ​ทิ้ง​ก่อน batch ถัด​ไป​จะ​ไป​นั่ง​ต่อ​จาก​ขยะ และ log ก้อน​นั้น​ก็​จะ​อ่าน​ไม่​ได้​ตั้งแต่​จุด​นั้น​ไป

การ​ตัด​หาง​เอา​ความ​ยาว​กลับ​มา​ที่ 1278 byte ซึ่ง​เป็น​ขอบ​ของ batch ที่​ครบ​ตัว​สุดท้าย แล้ว​การ​เขียน​ครั้ง​ถัด​ไป​ได้ offset 12 ต่อ​จาก offset ที่​ครบ​ตัว​สุดท้าย​พอดี ไม่มี​รู​โหว่​คั่น​กลาง

🔁 การ​ฆ่า process จริง​เป็น​ของ​คอร์ส​ที่ 22 และ​บท​นี้​ไม่​ได้​ทำ

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

บท​นี้​ไม่​ได้​ทำ​อย่าง​นั้น และ​เหตุผล​ไม่ใช่​ความ​ขี้​เกียจ SIGKILL ทิ้ง​จำนวน byte ที่​เขียน​ไป​แล้ว ไว้​ไม่​เท่า​กัน​ทุกร​อบ ผล​รัน​จึง​เปลี่ยน​ทุก​ครั้ง​ที่​รัน

เทสต์​ของ​คอร์ส​นี้​รัน demo ห้า​รอบ​แล้ว​บังคับ​ให้​ทั้ง​ห้า​รอบ​ได้​ผล​เหมือน​กัน​ทุก byte demo จึง​ต่อ​ครึ่ง batch เข้าไป​เอง ซึ่ง​ได้​ท้าย file ที่​ขาด​แบบ​เดียวกัน แต่​ขาด​เท่า​เดิม​ทุกร​อบ

ของ​สอง​อย่าง​นี้​พิสูจน์​คนละ​ข้อ SIGKILL พิสูจน์​ว่า​เครื่อง​ดับ​จริง​แล้ว​ยัง​กู้​ได้ ส่วน​การ​ต่อ​ครึ่ง batch พิสูจน์​ว่า​ตัว​กู้​คืน​เจอ​ขอบ​ที่​ถูก​ต้อง เครดิต​ของ​วิธี​แรก​เป็น​ของ​คอร์ส​ที่ 22

หัวข้อ​นี้​ประกาศ​เส้น​ขอบเขต​ของ​บท รวม​ถึง​ข้อ​ที่​ถอย​หลัง​จาก​คอร์ส​ก่อน​โดย​ตั้งใจ

คอร์ส Async กับ Tokio จบ​ด้วย​การ​ยก​ของ​ขึ้น Tokio แล้ว​เถียง​ว่า task ต่อ​หนึ่ง​สาย​ชนะ thread ต่อ​หนึ่ง​สาย ซึ่ง​เป็น​ข้อ​สรุป​ที่​ยัง​ถูก​อยู่

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

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

ของ​ที่​ยัง​ไม่มี​ใน​บท​นี้ และ​ไม่มี​บท​ไหน​แอบ​อ้าง​ว่า​มี

  • ไม่มี​การ​นับ record ซ้ำ ส่ง​คำขอ​เดิม​สอง​ครั้ง​ได้ record สอง​ชุด ซึ่ง​เป็น​ของ​บท​ที่ 7
  • ไม่มี compression standard library ไม่มี codec ให้ และ Kafka บีบ​ทั้ง batch ไม่ใช่​ที​ละ record
  • ไม่มี quota และ​ไม่มี​การ​ยืนยัน​ตัว​ตน client ราย​เดียว​กิน broker ทั้ง​ตัว​ได้
  • ไม่มี replica ตัว​ที่​สอง acks=-1 จึง​ยัง​ตอบ​ว่า​ไม่​พร้อม​เสมอ จนกว่า​จะ​ถึง​บท​ที่ 8

บท​ที่ 4 จะ​กลับ​ด้าน​ของ​ทาง​เดิน​นี้ คือ​คำขอ​ที่​ขอ record ที่​ยัง​ไม่มี​ใคร​เขียน​มา และ​คำถาม​ว่า broker ควร​ตอบ​ว่าง หรือ​ควร​จอด​คำขอ​นั้น​ไว้​ก่อน


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • Apache Kafka 4.3 — Protocol Guide (ตรวจ​แล้ว 2026-09-03) — ประโยค​เรื่อง​การ​อ่าน​ความ​ยาว​สี่ byte แล้ว​อ่าน​ต่อ​อีก N byte ประโยค​เรื่อง​เลข 16 bit สอง​ตัว​ที่​ระบุ schema ช่อง​ของ request header v1 ประโยค​เรื่อง​คำขอ​ที่​ค้าง​อยู่​ได้​ครั้ง​ละ​หนึ่ง​อัน​ต่อ​หนึ่ง​สาย และ​ช่อง base_offset ที่​อยู่​ระดับ partition ใน​คำ​ตอบ​ของ produce
  • RequestHeader.json tag 4.1.0 (ตรวจ​แล้ว 2026-09-03) — "validVersions": "1-2" กับ "flexibleVersions": "2+" ซึ่ง​เป็น​หลัก​ฐาน​ว่า header v2 มี tagged field ทั้ง​ที่​หน้า HTML ไม่​ได้​วาด​ไว้
  • Apache Kafka 4.3 — Broker Configs (ตรวจ​แล้ว 2026-09-03) — ค่า​ตั้ง​ต้น​ของ socket.request.max.bytes และ​ค่า​ตั้ง​ต้น​ของ log.flush.interval.messages กับ log.flush.interval.ms
  • Apache Kafka 4.3 — Producer Configs (ตรวจ​แล้ว 2026-09-03) — ค่า​ตั้ง​ต้น​ของ batch.size กับ linger.ms ประโยค​ที่​ว่า​ชุด​เต็ม​แล้ว​ส่ง​ทันที​โดย​ไม่​สน​ค่า​หน่วง ซึ่ง​อยู่​ใน​คำ​อธิบาย​ของ linger.ms ประโยค​ที่​ว่า​ขนาด​ชุด​เป็น​ศูนย์​คือ​ปิด batching ประโยค​ที่​เทียบ​ตัว​เอง​กับ Nagle’s algorithm และ​ประโยค​ใต้ acks=0 ที่​ว่า offset ซึ่ง​คืนให้​ทุก record จะ​เป็น​ลบ​หนึ่ง​เสมอ
  • ReplicaManager.scala tag 4.1.0 (ตรวจ​แล้ว 2026-09-03) — isValidRequiredAcks ที่​รับ​แค่​ลบ​หนึ่ง หนึ่ง และ​ศูนย์ กับ​คอม​เมน​ต์​ที่​บอก​ว่า​ค่าที่​หลุด​ช่วง​แปล​ว่า client ผิด
  • KafkaApis.scala tag 4.1.0 (ตรวจ​แล้ว 2026-09-03) — คอม​เมน​ต์​ที่​บรรทัด 501 ซึ่ง​บอก​ว่า​เมื่อ producer ไม่​ได้​รอ​คำ​ตอบ​อยู่ แล้ว​เกิด error ขึ้น server จะ​ปิด socket แทน​การ​ตอบ กับ requestChannel.closeConnection ที่​บรรทัด 512
  • UnifiedLog.java tag 4.1.0 (ตรวจ​แล้ว 2026-09-03) — บรรทัด if (localLog.unflushedMessages() >= config().flushInterval) flush(false); ที่​บรรทัด 1248 และ​คอม​เมน​ต์​เรื่อง​การ​เลี่ยง fsync ตอน​ถือ UnifiedLog#lock ที่​บรรทัด 2109
  • Apache Kafka 4.3 — Design (ตรวจ​แล้ว 2026-09-03) — ประโยค “two to three orders of magnitude” ที่​ไม่มี benchmark ไม่มี​เครื่อง และ​ไม่มี​วัน​ที่​แนบ​มา​ด้วย และ​ประโยค​ที่​ว่า​ข้อมูล​ลง pagecache ของ kernel ก่อน
  • Apache Kafka 4.3 — Hardware and OS (ตรวจ​แล้ว 2026-09-03) — ประโยค​ที่​แนะนำ​ให้​ใช้​ค่า​ตั้ง​ต้น​ซึ่ง​ปิด fsync ฝั่ง application ทิ้ง และ​ประโยค​ที่​ว่าการ​รับ​ประกัน​จาก​การ​ทำ​สำเนา​แข็ง​แรง​กว่า​การ sync ลง disk ของ​เครื่อง​ตัว​เอง
  • Apache Kafka 4.0 — Implementation › Log (ตรวจ​แล้ว 2026-09-03) — กฎ​ว่า entry ใช้ได้​เมื่อ​ขนาด​บวก​ตำแหน่ง​ไม่​เกิน​ความ​ยาว file และ CRC ตรง กับ​ความ​เสีย​หาย​สอง​แบบ​ที่​ต้อง​รับมือ
  • Rust std — trait Read (ตรวจ​แล้ว 2026-09-03) — ประโยค​ที่​ว่า read คืน byte ไม่​ครบ​ได้​ทั้ง​ที่​ยัง​ไม่​จบ stream และ​ประโยค​ของ read_exact ที่​ว่า​เมื่อ​คืน error แล้ว​จะ​ไม่​ระบุ​ว่า​อ่าน​ไป​กี่ byte
  • Rust std — struct File (ตรวจ​แล้ว 2026-09-03) — คำ​อธิบาย​ของ sync_all รวม​ถึง​ประโยค​ที่​ว่าการ​ปล่อย File ทิ้ง จะ​กลืน error ทุก​ตัว

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

ข้อ 1 / 3

kaen-log เรียก `sync_all` ก่อนตอบทุกครั้ง ส่วน Kafka ไม่ fsync บนทาง write เลย ข้อไหนอธิบายความต่างนี้ได้ถูกต้อง