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

ผู้​ตาม fetch เหมือน​ผู้​บริโภค · high watermark ตาม​มา​ช้า​หนึ่ง​รอบ

เจ็ด​บท​ที่​ผ่าน​มา kaen-log มี broker ตัว​เดียว บท​นี้​ต่อ​ตัว​ที่​สอง​เข้าไป

สิ่ง​ที่​เพิ่ม​ขึ้น​ไม่ใช่ protocol ใหม่ replicationreplicationการ​ที่ follower ดึง log ของ leader มา​ต่อ​ท้าย​ของ​ตัว​เอง ด้วย protocol เดียว​กับ​ที่​ผู้​อ่าน​ทั่วไป​ใช้ ของ Kafka คือ​การ​ที่ broker ตัว​ที่​สอง ส่ง​คำขอ fetch อัน​เดียว​กับ​ที่​ผู้​บริโภค​ส่ง แล้ว​เขียน​สิ่ง​ที่​ได้​ลง log ของ​ตัว​เอง

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

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

crate ของ​คอร์ส​อยู่​ที่ scripts/log-broker/ และ​ชื่อ binary คือ klog บท​นี้​เป็น​เจ้าของ scripts/log-broker/src/replica.rs ทั้ง file

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

ผล​รัน​ทุก​บรรทัด​ใน​บท​นี้​มา​จาก​คำ​สั่ง​เดียว คือ klog replica-demo --rounds 4 --min-isr 2 --now-ms 1700000000000 ซึ่ง​เทสต์​รัน​แล้ว​เทียบ​กับ expected/ ทีละ byte และ​รัน​ซ้ำ​ห้า​รอบ​เพื่อ​พิสูจน์​ว่า​ได้​ผล​เดิม

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

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

หัวข้อ​นี้​อธิบาย​ว่า​ทำไม broker ที่​มี replica เดียว​ถึง​ไม่​เคย​ต้อง​คิด​เรื่อง high watermark เลย

ตั้งแต่​บท​ที่ 3 log ของ kaen-log เลื่อน​เส้น committed ตาม log end ของ​ตัว​เอง​ทันที​ที่ append เสร็จ ผู้​อ่าน​จึง​เห็น​ทุก record ที่​เขียน​ลง​ไป​แล้ว โดย​ไม่​ต้อง​รอ​ใคร

พฤติกรรม​นั้น​ไม่ใช่​ทาง​ลัด มัน​คือ​พฤติกรรม​จริง​ของ Kafka ที่ replication factor เท่ากับ​หนึ่ง เพราะ​เมื่อ​มี​สำเนา​เดียว เซต​ของ​สำเนา​ที่​ตาม​ทัน​ก็​มี​สมาชิก​ตัว​เดียว ค่าที่​น้อย​ที่สุด​ใน​เซต​นั้น​จึง​เป็น​ของ leader เอง

พอ broker ตัว​ที่​สอง​เข้า​มา ข้อ​นั้น​เลิก​จริง​ทันที และ Cluster::open ต้อง​ปิด​โหมด​เดิม​ด้วย​มือ

src/replica.rs · บรรทัด​ที่​ปิด​โหมด replica เดียว
pub fn open(root: &Path, min_isr: usize, now_ms: i64) -> io::Result<Cluster> {
let mut leader = Replica::open(1, Role::Leader, &root.join("broker-1/orders-0"), now_ms)?;
let follower = Replica::open(2, Role::Follower, &root.join("broker-2/orders-0"), now_ms)?;
leader.become_leader(0);
// **บรรทัดที่บทนี้มีไว้เพื่อสอน** · ตราบใดที่มี replica ตัวเดียว high watermark
// เดินตาม log end · พอมีตัวที่สอง มันต้องมาจากค่าน้อยที่สุดในหมู่ ISR แทน
leader.partition.log_guard().stop_eager_high_watermark();
follower.partition.log_guard().stop_eager_high_watermark();
let isr = IsrState::new(leader.id, &[follower.id], min_isr, now_ms);
Ok(Cluster { leader, follower, isr, link: Link::default(), correlation: 0 })
}

สอง​บรรทัด​ที่​เรียก stop_eager_high_watermark() คือ​ความ​ต่าง​ทั้งหมด​ระหว่าง​หนึ่ง replica กับ​สอง ถ้า​ลบ​มัน​ทิ้ง ทุกร​อบ​จะ​พิมพ์ HW เท่ากับ LEO และ​ข้อ​อ้าง​หลัก​ของ​บท​จะ​มอง​ไม่​เห็น

⚠ demo ที่​นิ่ง​ทุกร​อบ ไม่​ได้​แปล​ว่า​มัน​แสดง​สิ่ง​ที่​มัน​อ้าง

ระหว่าง​สร้าง crate ตัว​นี้ replica-demo เคย​พิมพ์ HW เท่ากับ LEO ทุกร​อบ ด้วย hash ที่​นิ่ง​สนิท เกต​ความ deterministic จึง​เขียว ทั้ง​ที่​บท​นี้​ไม่มี​อะไร​ให้​ดู​เลย

demo ของ​บท​นี้​จึง​จบ​ด้วย​การ​ตรวจ​ตัว​เอง​ว่า​เงื่อนไข​ที่​มัน​อ้าง​เกิด​ขึ้น​จริง​หรือ​ไม่ ถ้า​ไม่​เกิด มัน​คืน error และ expected/ ก็​สร้าง​ใหม่​ไม่​ได้ · ด่าน​ที่​พิสูจน์​ว่า “เหมือน​เดิม​ทุก​ครั้ง” ไม่​ได้​พิสูจน์​ว่า “อัน​นี้​แสดง​สิ่ง​ที่​มัน​อ้าง”

หัวข้อ​นี้​ชี้​ว่า​ความ​ต่าง​ระหว่าง​ผู้​ตาม​กับ​ผู้​บริโภค​อยู่​ที่ field เดียว​ใน​คำขอ และ​มัน​เปลี่ยน​แค่​เพดาน​การ​อ่าน

FetchRequest.json ของ Kafka 4.0.0 ประกาศ field ที่​แยก​สอง​ฝ่าย​นี้​ออก​จาก​กัน​ไว้​ตรง​ตัว คือ {"name":"ReplicaId","type":"int32","versions":"0-14","default":"-1","entityType":"brokerId", "about":"The broker ID of the follower, of -1 if this request is from a consumer."}

ค่า​ปริยาย​เป็น​ลบ​หนึ่ง แปล​ว่า​คำขอ​มา​จาก​ผู้​บริโภค ส่วน​ค่าที่​ไม่​ติดลบ​คือ​เลข​ประจำ broker ของ​ผู้​ตาม ไม่มี api key ของ​ตัว​เอง ไม่มี path ของ​ตัว​เอง

เอกสาร​ออกแบบ​ของ Kafka พูด​ข้อ​เดียวกัน​นี้​ด้วย​คำ​ของ​โครงการ​เอง​ว่า “Followers consume messages from the leader just as a normal Kafka consumer would and apply them to their own log”

ฝั่ง​รับ Kafka แปลง field นั้น​เป็น​เพดาน​การ​อ่าน​ด้วย​การ​แยก​สาม​ทาง ใน FetchIsolation.of คือ if (!FetchRequest.isConsumer(replicaId)) { return LOG_END; } แล้ว​ค่อย​แยก​ผู้​บริโภค​ที่​ขอ READ_COMMITTED ออก​จาก​ผู้​บริโภค​ทั่วไป​ที่​ได้ HIGH_WATERMARK

kaen-log ทำ​สอง​ทาง​จาก​สาม​ทาง​นั้น เพราะ​คอร์ส​นี้​ไม่มี transaction และ​บท​ที่ 4 เขียน fetch isolation ไว้​แล้ว​ใน​หัวข้อ​ของ​มัน​เอง

src/replica.rs · เพดาน​การ​อ่าน​คือ​ความ​ต่าง​ทั้งหมด
/// ผู้ตามคือผู้บริโภคที่มี replica id · เพดานการอ่านต่างกันแค่นั้น
#[test]
fn a_follower_reads_to_the_log_end_and_a_consumer_stops_at_the_high_watermark() {
assert_eq!(isolation_of(2), crate::fetch::FetchIsolation::LogEnd);
assert_eq!(isolation_of(-1), crate::fetch::FetchIsolation::HighWatermark);
}

ผู้​ตาม​อ่าน​ได้​ถึง​ปลาย log ของ leader ส่วน​ผู้​บริโภค​หยุด​ที่ high watermark ที่​เหลือ​ทั้งหมด ตั้งแต่​การ encode คำขอ การ​หุ้ม frame ไป​จนถึง​การ​อ่าน range ออก​จาก segment เป็น​ทาง​เดียวกัน

หัวข้อ​นี้​แยก​เลข​สอง​ตัว​ที่​ผู้​อ่าน​มัก​เข้าใจ​ว่า​เป็น​ตัว​เดียวกัน แล้ว​บอก​ว่า​ใคร​เป็น​คน​คำนวณ​ตัว​ไหน

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

high watermarkhigh watermarkค่า​ต่ำ​สุด​ของ​ปลาย log ใน​บรรดา replica ที่​อยู่​ใน ISR · เส้น​นี้​คือ​ขอบ​ของ​สิ่ง​ที่​ผู้​อ่าน​มอง​เห็น​ได้ คือ​ค่า LEO ที่​น้อย​ที่สุด​ใน​หมู่​สมาชิก​ของ​เซต​สำเนา​ที่​ตาม​ทัน และ​มัน​เป็น​เพดาน​แบบ ไม่​รวม​ตัว​มัน​เอง record ที่ offset น้อย​กว่า​ค่า​นี้​คือ​ของ​ที่ committed แล้ว

ความ​ต่าง​ข้อ “ไม่​รวม​ตัว​มัน​เอง” ไม่ใช่​ราย​ละเอียด​ของ​การ​นับ log ที่​มี HW เป็น​ศูนย์​คือ log ที่​ยัง​ไม่มี​อะไร committed เลย ต่อ​ให้​มัน​มี record อยู่​บน disk แล้ว​ก็ตาม

advance_hw เป็น​ตัว​ที่​คำนวณ​ค่า​นั้น และ​นาฬิกา​ไม่ใช่ parameter ของ​มัน ส่วน​ที่​ทำให้​มัน​อ่าน​นาฬิกา​ไม่​ได้​จริง​คือ​กฎ​ของ​ทั้ง crate ที่​ห้าม SystemTime::now() และ​มี​ด่าน​กวาด​ไว้

src/replica.rs · ค่า​น้อย​ที่สุด​ใน​หมู่​สมาชิก ISR
/// **ไม่มีนาฬิกา** · แยกออกจาก `shrink_isr` อย่างจงใจ เพื่อให้ครึ่งที่บริสุทธิ์
/// พิสูจน์ได้ว่าบริสุทธิ์: นาฬิกาไม่ใช่ parameter มันจึงอ่านนาฬิกาไม่ได้
///
/// เริ่มที่ LEO ของ leader เอง แล้วเอา **ค่าน้อยที่สุด** ในหมู่สมาชิก ISR
/// · `None` แปลว่าอยู่ต่ำกว่า `min.insync.replicas` จึงห้ามเลื่อนเลย
///
/// การไม่ถอยหลังเป็นหน้าที่ของผู้เรียก — ค่าที่คืนคือ *ค่าที่เสนอ* ไม่ใช่ค่าที่ตั้งให้แล้ว
pub fn advance_hw(
leader_leo: u64,
follower_leos: &[(i32, u64)],
maximal_isr: &[i32],
min_isr: usize,
) -> Option<u64> {
if maximal_isr.len() < min_isr {
return None;
}
let mut candidate = leader_leo;
for (id, leo) in follower_leos {
if maximal_isr.contains(id) && *leo < candidate {
candidate = *leo;
}
}
Some(candidate)
}

ให้​อ่าน​ค่าที่ function นี้​คืน​ว่า​เป็น ค่าที่​เสนอ ไม่ใช่​ค่าที่​ตั้ง​ให้​แล้ว signature ของ​มัน​ไม่มี​ช่อง​รับ​ค่า high watermark ปัจจุบัน มัน​จึง​เทียบ​ไม่​ได้​ว่า​ค่า​ใหม่​ถอย​หลัง​หรือ​ไม่

การ​ห้าม​ถอย​หลัง​เป็น​หน้าที่​ของ​ผู้​เรียก และ​ผู้​เรียก​ใน​ที่​นี้​คือ recompute_hw

src/replica.rs · ผู้​เรียก​เป็น​คน​บังคับ​ว่า​ห้าม​ถอย​หลัง
/// เลื่อน high watermark ของ leader · ไม่เคยถอยหลัง
pub fn recompute_hw(&mut self) {
let leader_leo = self.leader.leo();
let candidate = advance_hw(
leader_leo,
&self.isr.follower_leos,
&self.isr.maximal_isr,
self.isr.min_isr,
);
let mut log = self.leader.partition.log_guard();
if candidate.is_some_and(|new_hw| new_hw > log.high_watermark) {
log.high_watermark = candidate.expect("เพิ่งตรวจว่าเป็น Some");
}
}

เงื่อนไข new_hw > log.high_watermark คือ​กฎ​ห้าม​ถอย​หลัง​ทั้ง​ข้อ และ​มัน​อยู่​ที่​ผู้​เรียก อย่า​อ่าน advance_hw ว่า​เป็น function ที่​รักษา​ความ monotonic ไว้​เอง เพราะ​มัน​ทำ​ไม่​ได้

Kafka วาง​กฎ​เดียวกัน​ไว้​ใน maybeIncrementLeaderHW โดย​เริ่ม​ที่ var newHighWatermark = leaderLogEndOffset แล้ว​ไล่​ลด​ลง​เป็น​ค่า​ของ replica ที่​ตาม​หลัง และ doc comment ของ​มัน​บอก​ว่า function นี้​ถูก​ปลุก​เมื่อ ISR เปลี่ยน หรือ​เมื่อ LEO ของ replica ตัว​ไหน​ก็ตาม​เปลี่ยน

ข้อ​ที่​ควร​สังเกต​คือ Kafka เทียบ​กับ maximal ISR ไม่ใช่ ISR ที่ committed แล้ว สำเนา​ที่​ตาม​ทัน​แต่ controller ยัง​ไม่​รับ​เข้า​เซต ก็​ถ่วง high watermark ไว้​เหมือน​กัน

ความ​ช้า​หนึ่ง​รอบ · เพราะ HW เดิน​ทาง​กลับ​มา​ใน​คำ​ตอบ

หัวข้อ​ที่​มีชื่อ​ว่า “ความ​ช้า​หนึ่ง​รอบ · เพราะ HW เดิน​ทาง​กลับ​มา​ใน​คำ​ตอบ”

หัวข้อ​นี้​กาง​หนึ่ง​รอบ​ของ harness ออก​ให้​เห็น​ว่า leader รู้​อะไร​ตอน​ไหน และ​ผู้​ตาม​รู้ที​หลัง​เพราะ​อะไร

ข้อมูล​สอง​อย่าง​เดิน​สวน​ทาง​กัน​ใน​หนึ่ง​รอบ ตัว record เดิน​จาก leader ไป​หา​ผู้​ตาม ส่วน​เลข LEO ของ​ผู้​ตาม​เดิน​จาก​ผู้​ตาม​ไป​หา leader และ​มัน​ติด​ไป​กับ คำขอ ไม่ใช่​กับ​คำ​ตอบ

src/replica.rs · leader รู้ LEO ของ​ผู้​ตาม​จาก​คำขอ (ตัด​มา​บาง​ส่วน)
// ── ฝั่ง leader ────────────────────────────────────────────────────
let mut payload = Vec::new();
read_frame(&mut self.link.to_leader, &mut payload)?;
let decoded = FetchRequestFrame::decode(&payload)?;
// leader รู้ LEO ของผู้ตามจาก **คำขอ** ครั้งนี้ · ความช้าหนึ่งรอบเกิดตรงนี้เอง
self.isr.observe(decoded.replica_id, decoded.fetch_offset, now_ms);
self.recompute_hw();

fetch_offset ใน​คำขอ คือ LEO ของ​ผู้​ตาม​พอดี เพราะ​ผู้​ตาม​ขอ record ตัว​ถัด​ไป​จาก​ที่​ตัว​เอง​มี​เสมอ leader จึง​เลื่อน high watermark ได้​ตั้งแต่​ก่อน​อ่าน record ออก​จาก log

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

src/replica.rs · ฝั่ง​ผู้​ตาม · HW มา​ถึง​ใน​คำ​ตอบ (ตัด​มา​บาง​ส่วน)
// ── ฝั่งผู้ตาม ─────────────────────────────────────────────────────
let mut back = Vec::new();
read_frame(&mut self.link.to_follower, &mut back)?;
let response = FetchResponseFrame::decode(&back)?;
let shipped = decode_batches(&response.records)?.iter().map(|b| b.records.len()).sum();
{
let mut log = self.follower.partition.log_guard();
append_as_follower(&mut log, &response.records, now_ms)?;
}
let follower_leo = self.follower.leo();
// high watermark มาถึงใน **คำตอบ** · และไม่มีทางเกิน LEO ของตัวเอง
self.follower.hw =
self.follower.hw.max(from_wire_i64(response.high_watermark)?.min(follower_leo));

FetchResponse.json ของ Kafka มี field นี้​อยู่​จริง​ต่อ​หนึ่ง partition คือ {"name":"HighWatermark","type":"int64","versions":"0+","about":"The current high water mark."} และ​ฝั่ง​ผู้​ตาม​ของ Kafka ก็​เรียก log.maybeUpdateHighWatermark(partitionData.highWatermark) ด้วย​ค่าที่​ได้​จาก​คำ​ตอบ​นั้น​ตรงๆ

การ min กับ LEO ของ​ตัว​เอง​ใน​บรรทัด​สุดท้าย​ไม่ใช่​การ​กัน​พลาด มัน​คือ​กฎ ผู้​ตาม​ไม่มี​ทาง​มี high watermark สูง​กว่า​จำนวน record ที่​มัน​ถือ​อยู่​จริง

sequenceDiagram
    participant P as producer
    participant L as leader id 1
    participant F as follower id 2
    P->>L: produce record ตัวที่หนึ่ง
    Note over L: LEO 1 · HW 0
    F->>L: fetch · replica_id 2 · fetch_offset 0
    Note over L: leader เพิ่งรู้ว่า LEO ของผู้ตาม คือ 0
    L-->>F: records หนึ่งตัว · high_watermark 0
    Note over F: LEO 1 · HW 0 · ถือของไว้แล้วแต่ยังไม่รู้ว่า committed
    P->>L: produce record ตัวที่สอง
    F->>L: fetch · replica_id 2 · fetch_offset 1
    Note over L: LEO 2 · HW 1
    L-->>F: records หนึ่งตัว · high_watermark 1
    Note over F: LEO 2 · HW 1 · เพิ่งรู้เรื่องตัวแรกตอนได้ตัวที่สอง

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

demo ขับ​สี่​รอบ​ด้วย​รูป​เดียวกัน​นี้ หนึ่ง​รอบ​คือ produce หนึ่ง​ครั้ง​แล้ว fetch_once หนึ่ง​ครั้ง

expected/08-replication.txt · สี่​รอบ​ที่​ขับ​ด้วย​มือ (ตัด​มา​บาง​ส่วน)
== 4 รอบ · หนึ่งรอบคือ produce หนึ่งครั้ง แล้ว fetch_once หนึ่งครั้ง ==
รอบ บทบาท id LEO HW record ที่ส่ง HW ในคำตอบ
1 leader 1 1 0 - -
follower 2 1 0 1 0
2 leader 1 2 1 - -
follower 2 2 1 1 1
3 leader 1 3 2 - -
follower 2 3 2 1 2
4 leader 1 4 3 - -
follower 2 4 3 1 3
ทุกรอบ ผู้ตามมี LEO สูงกว่า HW ของตัวเอง · มันถือ record ไว้แล้ว
แต่ยังไม่รู้ว่า record นั้น committed · ต้องรอคำตอบของรอบถัดไปถึงจะรู้
high watermark เป็นเพดานแบบ **ไม่รวมตัวมันเอง** · record ที่ offset < HW คือของที่ committed แล้ว

ช่อง​ขวา​สุด​คือ​ค่า high watermark ที่ leader ใส่​กลับ​มา​ใน​คำ​ตอบ​ของ​รอบ​นั้น มัน​ตาม​หลัง​ช่อง LEO ของ​ผู้​ตาม​อยู่​หนึ่ง​เสมอ ทั้ง​สี่​รอบ

เทสต์​ที่​ตรึง​ข้อ​อ้าง​นี้​ไว้​ไม่​ได้​อ่าน expected/ มัน​ยืนยัน​ด้วย​จำนวน​เต็ม​ตรงๆ

src/replica.rs · ด่าน​ที่​ตรึง​ความ​ช้า​หนึ่ง​รอบ
/// **ด่านที่หนึ่งจากสอง** · ไม่พิมพ์อะไรออก stdout และไม่ป้อน `expected/` เลย
#[test]
fn hw_lags_one_round_trip() {
let dir = temp("hw-lag");
let now = 1_700_000_000_000;
let mut c = Cluster::open(&dir, 2, now).unwrap();
c.produce(0, ACKS_LEADER, now).unwrap();
assert_eq!((c.leader.leo(), c.leader.partition.high_watermark()), (1, 0));
// รอบที่หนึ่ง · ผู้ตาม **มี** record แล้ว แต่ยังไม่รู้ว่ามัน committed
let first = c.fetch_once(now).unwrap();
assert_eq!((first.follower_leo, first.follower_hw), (1, 0));
assert_eq!(first.records_shipped, 1);
assert_eq!(c.leader.partition.high_watermark(), 0);
// รอบที่สอง · คำขอพก LEO=1 มาด้วย leader จึงเลื่อน HW แล้วส่งค่ากลับ
let second = c.fetch_once(now).unwrap();
assert_eq!(c.leader.partition.high_watermark(), 1);
assert_eq!(second.follower_hw, 1);
assert_eq!(second.records_shipped, 0);
let _ = std::fs::remove_dir_all(&dir);
}

รอบ​แรก​ผู้​ตาม​ได้ record ไป​หนึ่ง​ตัว​แล้ว แต่ HW ของ​มัน​ยัง​เป็น​ศูนย์ รอบ​ที่​สอง​ไม่มี record ใหม่​เลย มี​แต่​เลข HW ที่​ตาม​มา

🔁 เทคนิค​การ​ขับ​ที​ละ​รอบ​เป็น​ของ​คอร์ส​ที่ 23 ไม่ใช่​ของ​บท​นี้

บท​ที่ 7 ของ​คอร์ส Distributed Systems สร้าง harness ที่​มี scheduler ตัว​เดียว ขับ​เป็น​รอบ​ที่​สั่ง​เอง และ​มี​งบ​จำกัด​ตายตัว เพื่อ​ให้​สถานการณ์​ที่​เกี่ยว​กับ​เวลา​ให้​ผล​เดิม​ทุก​ครั้ง

fetch_once() ของ​บท​นี้​คือ​หนึ่ง​รอบ​ของ harness นั้น ยืม​มา​ใช้ ไม่​ได้​คิด​ใหม่ สิ่ง​ที่​บท​นี้​เพิ่ม​เข้าไป​คือ สาย​จริง ระหว่าง​สอง broker ที่​เป็น​คู่ buffer ใน​หน่วย​ความ​จำ ทั้ง​สอง​ฝั่ง​เขียน​และ​อ่าน​ด้วย write_frame กับ read_frame ตัว​เดียว​กับ​ที่ producer ของ​บท​ที่ 3 ใช้

หัวข้อ​นี้​ลาก​เส้น​ระหว่าง​สิ่ง​ที่​คอร์ส​ที่ 23 พิสูจน์​ไว้​แล้ว กับ​สิ่ง​ที่ Kafka ทำ​คนละ​อย่าง

ISRISRin-sync replicas: เซต​ของ replica ที่​ตาม​ทัน leader พอ​จะ​นับ​เป็น​เจ้าของ​สำเนา​ที่ commit แล้ว · เป็น​สมาชิก​ของ​เซต ไม่ใช่​เลขคณิต​แบบ quorum คือ​เซต​ของ​สำเนา​ที่​ตาม​ทัน leader พอที่​จะ​นับ​ได้​ว่า​มัน​ถือ​ของ​ที่ commit แล้ว​อยู่​จริง เอกสาร​ออกแบบ​ของ Kafka เปิด​หัวข้อ​นี้​ด้วย​การ​ปฏิเสธ​ทาง​เลือก​อีก​ทาง​ตรงๆ ว่า “Instead of majority vote, Kafka dynamically maintains a set of in-sync replicas (ISR) that are caught-up to the leader”

กติกา​การ commit จึง​เป็น​คนละ​กติกา​กับ​การ​นับ​คะแนน หน้า​เดียวกัน​เขียน​ว่า “A write to a Kafka partition is not considered committed until all in-sync replicas have received the write”

ผล​ที่​ตาม​มา​คือ​เลข​ที่​ต่าง​กัน หน้า​นั้น​สรุป​ว่า “With this ISR model and f+1 replicas, a Kafka topic can tolerate f failures without losing committed messages” ขณะ​ที่​การ​นับ​คะแนน​ข้าง​มาก​ต้อง​ใช้ 2f+1

🔁 คอร์ส​ที่ 23 พิสูจน์ quorum ไป​แล้ว บท​นี้​ห้าม​พิสูจน์​ซ้ำ

คอร์ส Distributed Systems ไล่​กรณี​ของ R + W > N จน​ครบ​ไป​แล้ว บท​นี้​ไม่​แตะ​ข้อ​นั้น​เลย และ​ผู้​อ่าน​ก็​ไม่​ต้อง​เอา​ข้อ​นั้น​มา​ใช้​ที่​นี่

สิ่ง​ที่​ต่าง​กัน​มี​สาม​ข้อ และ​มัน​ต่าง​ตั้งแต่​ราก

  • ผู้​เขียน — cluster แบบ Dynamo ไม่มี leader ทุก node รับ write ได้ ส่วน partition ของ Kafka มี​ผู้​เขียน​ราย​เดียว
  • ลำดับ — ของ​แบบ​แรก​ไม่มี​ลำดับ​รวม ต้อง​คลี่ conflict ด้วย version vector ตอน​อ่าน ส่วน log ของ broker มี​ลำดับ​สมบูรณ์​อยู่​แล้ว​ตั้งแต่​ตอน​เขียน
  • เส้น​แบ่ง committed — ของ​แบบ​แรก​ไม่มี​เส้น​นั้น มี​แต่​การ​บรรจบ​กัน​ใน​ที่สุด ส่วน​ของ​บท​นี้​มี และ​มัน​เป็น ตำแหน่ง ไม่ใช่​ผล​ของ​การ​นับ

ความ​ต่าง​ข้อ​สุดท้าย​กิน​ไป​ถึง​กลไก​ที่​หน้าตา​คล้าย​กัน​ที่สุด​ของ​สอง​คอร์ส คือ anti-entropy กับ follower fetch

anti-entropy ด้วย gossip เอา peer สอง​ตัว​ที่​ต่าง​ก็​รับ write ได้ มา​เทียบ​กัน​แล้ว​รวม​สิ่ง​ที่​ขาด​ของ​แต่ละ​ฝั่ง ทิศทาง​ของ​ข้อมูล​จึง​เป็น​สอง​ทาง และ​ต้อง​มี​กติกา​คลี่ conflict เสมอ

follower fetch คัด log ที่​เรียง​เสร็จ​แล้ว​จาก​ผู้​เขียน​ราย​เดียว ตาม​ลำดับ ไม่มี​การ​รวม ไม่มี​การ​คลี่ conflict ผู้​ตาม​เขียน byte ของ leader ลง​ไป​โดย​ไม่​แจก offset ใหม่​ให้​สัก​ตัว

และ gossip ไม่มี​อะไร​เทียบ​เท่า high watermark เลย มัน​บรรจบ​กัน​ใน​ที่สุด​โดย​ไม่มี​เส้น​บอก​ว่า​ตรง​ไหน​คือ​ของ​ที่​แน่​แล้ว

ISR หด​ด้วย​นาฬิกา​ที่​ป้อน​เข้า​มา ไม่ใช่​ด้วย​ตัว​จับ​เวลา

หัวข้อ​ที่​มีชื่อ​ว่า “ISR หด​ด้วย​นาฬิกา​ที่​ป้อน​เข้า​มา ไม่ใช่​ด้วย​ตัว​จับ​เวลา”

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

เอกสาร​ของ Kafka บอก​เงื่อนไข​ของ​คำ​ว่า​ตาม​ทัน​ไว้​สอง​ข้อ ข้อ​แรก​คือ “Brokers must maintain an active session with the controller in order to receive regular metadata updates” ข้อ​ที่​สอง​คือ “Brokers acting as followers must replicate the writes from the leader and not fall ‘too far’ behind”

ค่าที่​นิยาม​คำ​ว่า​ไกล​เกิน​ไป​คือ replica.lag.time.max.ms ซึ่ง ReplicationConfigs.java ของ 4.0.0 ตั้ง​ค่า​ปริยาย​ไว้​ที่ 30000 พร้อม​คำ​อธิบาย​ว่า ถ้า​ผู้​ตาม​ไม่​ได้​ส่ง​คำขอ fetch หรือ​ยัง​ตาม​ไม่​ถึง​ปลาย log ของ leader นาน​เกิน​ค่า​นี้ leader จะ​ถอด​มัน​ออก​จาก ISR

shrink_isr เป็น​ตัว​เดียว​ใน replica.rs ที่​เอา​ค่า​นาฬิกา​มา​เทียบ​กับ​เส้น cutoff ส่วน function อื่น​ใน file เดียวกัน​รับ now_ms ไป​บันทึก​หรือ​ส่ง​ต่อ​เท่านั้น

src/replica.rs · ตัว​เดียว​ที่​เอา​นาฬิกา​มา​เทียบ​กับ​เส้น cutoff
/// **function เดียวใน file นี้ที่รับนาฬิกา**
///
/// leader เป็นคนตัดสินข้อนี้ · ผู้ตามที่ไม่ได้ส่งคำขอ fetch มานานเกิน
/// `replica.lag.time.max.ms` ถูกถอดออกจาก ISR · leader ไม่เคยถอดตัวเอง
///
/// `replica-demo` **เรียก** function นี้ โดยป้อน `now_ms` ให้เอง ซึ่งเป็นขั้นที่
/// สั่งเองพอๆ กับการถอดผู้ตามด้วยมือ · สิ่งที่ demo ไม่มีคือ *ตัวจับเวลาจริง*
/// ไม่ใช่การหด ISR ตามเวลา · กฎที่สอนแล้วไม่เคยแสดงให้ดู คือกฎที่อ้างเกินสิ่งที่วัด
pub fn shrink_isr(&mut self, now_ms: i64) -> bool {
let cutoff = now_ms - self.lag_max_ms;
let before = self.maximal_isr.len();
let leader = self.leader_id;
let caught_up = self.last_caught_up_ms.clone();
self.maximal_isr.retain(|id| {
if *id == leader {
return true;
}
caught_up.iter().find(|(f, _)| f == id).is_some_and(|(_, at)| *at >= cutoff)
});
before != self.maximal_isr.len()
}

retain ข้าม​เงื่อนไข​ให้ leader เสมอ เพราะ leader ไม่​เคย​ถอด​ตัว​เอง​ออก​จาก ISR ของ​ตัว​เอง ส่วน​ผู้​ตาม​ต้อง​มี​เวลา fetch ครั้ง​สุดท้าย​ที่​ไม่​เก่า​กว่า​เส้น cutoff

demo เรียก function นี้​จริง โดย​ป้อน​ค่า​นาฬิกา​ที่​ข้าม​เกณฑ์​ไป​หนึ่ง​มิลลิ​วินาที

expected/08-replication.txt · ISR หด​จริง (ตัด​มา​บาง​ส่วน)
== ISR หดตามเวลา · shrink_isr(now_ms) ==
ISR ก่อนหน้า [1, 2]
replica fetch ครั้งสุดท้ายเมื่อ
2 1700000000000
ป้อน now_ms = 1700000030001 ซึ่งเลยเกณฑ์ 30000 ms ไปหนึ่งมิลลิวินาที
shrink_isr คืน true · ISR ตอนนี้ [1]
ต่ำกว่า min.insync.replicas แล้ว: ใช่
leader ไม่เคยถอดตัวเองออกจาก ISR ของตัวเอง · ที่เหลืออยู่คือ id 1

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

ส่วน​การ​รับ​กลับ​เข้า ISR ไม่ใช่​หน้าที่​ของ​นาฬิกา​เลย เงื่อนไข​สอง​ข้อ​ข้าง​บน​มี​ผู้​ตัดสิน​คนละ​คน

expected/08-replication.txt · สอง​ผู้​ตัดสิน (ตัด​มา​บาง​ส่วน)
== สองเงื่อนไขที่ถอด replica ออกจาก ISR ได้ · คนละผู้ตัดสิน ==
ISR ตั้งต้น [1, 2, 3]
leader ถอดตัวที่ตามไม่ทัน (เกณฑ์เวลา) [1, 2]
controller ถอดตัวที่ขาด session กับ controller [1]
เงื่อนไขข้อแรก leader เป็นคนวัด · ข้อที่สอง controller เป็นคนวัด
replica ตัวเดียวจึงหลุด ISR ได้จากสองทางที่ไม่เกี่ยวกันเลย

leader วัด​ข้อ​เวลา เพราะ​มัน​เป็น​ฝ่าย​ที่​เห็น​คำขอ fetch ทุก​ใบ ส่วน controller วัด​ข้อ session เพราะ​มัน​เป็น​ฝ่าย​ที่​ถือ session นั้น​อยู่

replica ตัว​เดียว​จึง​หลุด​ออก​จาก ISR ได้​จาก​สอง​ทาง​ที่​ไม่​เกี่ยว​กัน​เลย และ​กลับ​เข้า​มา​ได้​ทาง​เดียว คือ​เมื่อ controller รับ​มัน​กลับ

หัวข้อ​นี้​ต่อ​ค่า min.insync.replicas เข้า​กับ acks=all แล้ว​แยก​ข้อ​ผิด​พลาด​สอง​ข้อ​ที่​เกิด​คนละ​จังหวะ

min.insync.replicasmin.insync.replicasจำนวน​สมาชิก ISR ขั้น​ต่ำ​ที่​ต้อง​มี ไม่​งั้น broker ปฏิเสธ​การ​เขียน​ที่​ขอ `acks=all` ตั้งแต่​ต้น คือ​จำนวน​สมาชิก ISR ขั้น​ต่ำ​ที่​ต้อง​มี ไม่​งั้น broker ปฏิเสธ​การ​เขียน​ที่​ขอ acks=all ตั้งแต่​ก่อน​แตะ log

ServerLogConfigs.java ของ 4.0.0 ตั้ง MIN_IN_SYNC_REPLICAS_DEFAULT ไว้​ที่​หนึ่ง ค่า​ปริยาย​จึง​ยอม​ให้ ISR ที่​มี leader ตัว​เดียว​ตอบ acks=all ได้ ซึ่ง​แปล​ว่า​ความ​ทนทาน​ที่​ผู้​ใช้​คิด​ว่า​ซื้อ​มา ไม่​ได้​อยู่​ที่​ธง acks แต่​อยู่​ที่​ค่า​นี้

kaen-log ตรวจ​ข้อ​นี้​ก่อน append เหมือน Kafka

src/replica.rs · ตรวจ​ก่อน append (ตัด​มา​บาง​ส่วน)
/// producer เขียนหนึ่ง record ลง leader
pub fn produce(&mut self, n: u64, acks: i16, now_ms: i64) -> io::Result<(u64, i16)> {
// error 19 · ตรวจ **ก่อน** append · การตรวจนี้ไม่ได้เกิดพร้อมกับการเขียน
if acks == ACKS_ALL && self.isr.under_min_isr() {
return Ok((0, ERR_NOT_ENOUGH_REPLICAS));
}

ผล​ที่​ตาม​มา​เห็น​ได้​ทันที​ใน​ผล​รัน หลัง​จาก ISR หด​ลง​เหลือ leader ตัว​เดียว

expected/08-replication.txt · ผล​ที่​ตาม​มา​ทันที (ตัด​มา​บาง​ส่วน)
== ผลที่ตามมาทันที ==
produce แบบ acks=all → error 2 (NOT_ENOUGH_REPLICAS · ตรวจก่อน append)
produce แบบ acks=1 → base_offset 4 · error 0 (เขียนได้ตามปกติ)
high watermark 3 → 3 (ไม่เลื่อน ขณะอยู่ต่ำกว่าเกณฑ์)
acks เปลี่ยนเวลาที่ตอบ ไม่เคยเปลี่ยนสิ่งที่เขียน · ข้อนี้ยังจริงอยู่ที่นี่
record ที่ offset 4 อยู่บน disk แล้ว แต่ยังไม่ committed

acks=1 ยัง​เขียน​ได้​ตาม​ปกติ และ​ได้ base_offset ของ​ตัว​เอง​กลับ​มา ข้อ​ที่​บท​ที่ 3 วาง​ไว้​จึง​ยัง​จริง​อยู่​ที่​นี่ คือ acks เปลี่ยน​จังหวะ​การ​ตอบ ไม่​เคย​เปลี่ยน​สิ่ง​ที่​เขียน​ลง log

บรรทัด​ที่​สำคัญ​ที่สุด​ใน​บล็อก​นั้น​คือ​บรรทัด​สุดท้าย record ตัว​นั้น​อยู่​บน disk แล้ว​แต่​ยัง​ไม่ committed เพราะ high watermark ไม่​เลื่อน​ขณะ​ที่ ISR ต่ำ​กว่า​เกณฑ์

และ​เพราะ​การ​ตรวจ ISR ไม่​ได้​เกิด​พร้อม​กับ​การ append ข้อ​ผิด​พลาด​จึง​มี​สอง​ข้อ ไม่ใช่​ข้อ​เดียว

expected/08-replication.txt · สอง​ข้อ​ผิด​พลาด (ตัด​มา​บาง​ส่วน)
== สองข้อผิดพลาด ไม่ใช่ข้อเดียว ==
สถานการณ์ คำตัดสิน
HW ยังไม่ถึง offset ที่ต้องการ NotYet
HW ถึงแล้ว และ ISR ยังครบ Committed
HW ถึงแล้ว แต่ ISR หดหลัง append ไปแล้ว NotEnoughReplicasAfterAppend
ข้อแรกคือ error 19 ตรวจ **ก่อน** append · ข้อหลังคือ error 20 ตรวจ **หลัง** append
ทั้งคู่ retry ได้ · ซึ่งเป็นเหตุผลว่าทำไม acks=all ถึงผลิตของซ้ำได้
ถ้าไม่มี producer แบบกันซ้ำของบทที่ 7 มาช่วย

Errors.java ของ Kafka 4.0.0 ให้​เลข​กับ​สอง​ข้อ​นี้​แยก​กัน คือ NOT_ENOUGH_REPLICAS(19, "Messages are rejected since there are fewer in-sync replicas than required.") กับ NOT_ENOUGH_REPLICAS_AFTER_APPEND(20, "Messages are written to the log, but to fewer in-sync replicas than required.")

⚠ เลข​ข้อ​ผิด​พลาด​ใน kaen-log เป็น​ของ kaen-log เอง

ผล​รัน​ข้าง​บน​พิมพ์ error 2 ซึ่ง​เป็น​เลข​ใน​ตาราง​ของ kaen-log ไม่ใช่​เลข​บน wire ของ Kafka สอง​เลข​นี้​ไม่มี​ความ​เกี่ยวข้อง​กัน​เลย และ​คอร์ส​นี้​ไม่​เคย​ดึง​ตาราง​เลข​จริง​ของ Kafka มา​ลอก

เลข 19 กับ 20 ใน​ย่อหน้า​ข้าง​บน​มา​จาก Errors.java ของ 4.0.0 โดยตรง ส่วน​คำ​ว่า ก่อน กับ หลัง append คือ​สิ่ง​ที่​บท​นี้​สอน ไม่ใช่​ตัวเลข

การ​ตัดสิน​ฝั่ง kaen-log อยู่​ใน IsrGate ซึ่ง​บท​ที่ 3 เรียก​ผ่าน trait ตัว​เดียวกัน

src/replica.rs · คำ​ตัดสิน​สาม​ทาง
impl IsrGate {
pub fn verdict(&self, required_offset: u64) -> AckVerdict {
if self.high_watermark < required_offset {
return AckVerdict::NotYet;
}
if self.min_isr <= self.isr_len {
AckVerdict::Committed
} else {
AckVerdict::NotEnoughReplicasAfterAppend
}
}
}
impl ReplicaGate for IsrGate {
fn check_enough_replicas_reached(&self, required_offset: u64) -> bool {
self.verdict(required_offset) == AckVerdict::Committed
}
}

ข้อ​ผิด​พลาด​ทั้ง​สอง​ข้อ retry ได้ และ​นั่น​คือ​รอย​ต่อ​ที่​บท​ที่ 7 มา​ยืน​พอดี producer ที่ retry คำขอ​ที่ append ไป​แล้ว จะ​ผลิต​ของ​ซ้ำ ถ้า​ไม่มี หน้าต่าง​กัน​ซ้ำ​ฝั่ง broker มา​รับ​ไว้

หัวข้อ​นี้​แสดง​ว่าการ​ที่​ผู้​ตาม​ตัด log ถึง HW ของ​ตัว​เอง​ทำ​ข้อมูล​หาย และ​เวก​เตอร์ epoch แก้​ตรง​ไหน

leader epochleader epochเลข​รุ่น​ของ​ความ​เป็น leader เพิ่ม​ขึ้น​ทุก​ครั้ง​ที่​เปลี่ยน​ตัว · ใช้​ตัด​หาง log ที่​ตกค้าง​จาก​รุ่น​ก่อน​ทิ้ง คือ​เลข​รุ่น​ของ​ความ​เป็น leader ที่​เพิ่ม​ขึ้น​ทุก​ครั้ง​ที่​เปลี่ยน​ตัว KIP-101 นิยาม​ไว้​ว่า​เป็น “A 32 bit, monotonically increasing number representing a continuous period of leadership for a single partition”

หัวข้อ​สถานการณ์​แรก​ของ KIP นั้น​ชื่อ “Scenario 1: High Watermark Truncation followed by Immediate Leader Election” และ​มัน​เล่า​เรื่อง​ที่​ต่อย​อด​จาก​ความ​ช้า​หนึ่ง​รอบ​ข้าง​บน​ตรงๆ

demo ขับ​สถานการณ์​นั้น​ด้วย​สาม​รอบ fetch คือ produce m1 แล้ว fetch สอง​ครั้ง​ให้​ผู้​ตาม​รู้​ว่า m1 committed จาก​นั้น produce m2 แล้ว fetch อีก​ครั้ง​เดียว

expected/08-replication.txt · ตัด​ถึง high watermark แล้ว​ข้อมูล​หาย (ตัด​มา​บาง​ส่วน)
== KIP-101 สถานการณ์ที่ 1 · ตัดถึง high watermark แล้วข้อมูลหาย ==
leader มี LEO 2 · HW 1
ผู้ตามมี LEO 2 · HW ของตัวเอง 1
ผู้ตามถือ m2 ไว้แล้ว แต่ยังไม่รู้ว่ามัน committed หรือยัง
ผู้ตาม restart แล้วตัด log ถึง HW ของตัวเอง → LEO เหลือ 1
จากนั้น leader ล้ม · ผู้ตามขึ้นเป็นผู้นำด้วย epoch 1 และของเก่ากลายเป็นผู้ตาม
record ที่หายถาวร 1 ตัว

จำนวน​รอบ​เป็น​เนื้อ​ของ​สถานการณ์ ไม่ใช่​ราย​ละเอียด สอง​รอบ​แรก​มี​ไว้​ให้​ผู้​ตาม​รู้​ก่อน​ว่า m1 committed แล้ว m2 ถึง​จะ​เข้า​มา​เป็น record ตัว​เดียว​ที่​ผู้​ตาม​ถือ​ไว้​โดย​ยัง​ไม่รู้​สถานะ ซึ่ง​คือ​สถานะ​ตั้ง​ต้น​ที่ KIP บรรยาย

จาก​ตรง​นั้น ผู้​ตาม restart แล้ว​ตัด log ถึง HW ของ​ตัว​เอง record จึง​หาย​ไป​หนึ่ง​ตัวอย่าง​ถาวร โดยที่​ทุก​ฝ่าย​ทำ​ถูกตา​มก​ติ​กา​ของ​ตัว​เอง​ทุก​ก้าว

ทาง​ที่​ถูก​คือ​ผู้​ตาม​ไม่​ตัด​ถึง high watermark ของ​ตัว​เอง แต่​ถาม leader ว่า epoch ที่​ตัว​เอง​ถือ​อยู่​จบ​ที่ offset ไหน

expected/08-replication.txt · ถาม end offset ของ epoch (ตัด​มา​บาง​ส่วน)
== ทางที่ถูก · ถามหา end offset ของ epoch ที่ตัวเองมี ==
ผู้ตามอยู่ในสถานะเดียวกันเป๊ะ · LEO 2 · HW ของตัวเอง 1
ผู้ตามถาม end offset ของ epoch 0 → leader ตอบ Some((0, 2))
ผู้ตามตัดถึง min(LEO ของตัวเอง, end offset ที่ได้) = 2 → LEO เหลือ 2
record ที่หาย 0 ตัว
**ผู้ตามไม่เคยตัดถึง high watermark ของตัวเอง** มันตัดถึงคำตอบของ leader
สถานการณ์ที่ 1 คือข้อมูล **หาย** · สถานการณ์ที่ 2 ของ KIP เดียวกันคือ log **แตกสาย**
ซึ่ง KIP-279 เป็นคนปิดรูที่เหลือ · ทั้งสองอย่างวาดไว้ ไม่ได้ขับ

การ​ค้น​ใน​เวก​เตอร์ epoch ใช้​การ​เทียบ​แบบ​ไม่​เกิน ไม่ใช่​แบบ​เท่า​กัน​พอดี และ​นั่น​คือ​ส่วน​ที่ KIP-279 แก้

src/replica.rs · เทียบ​แบบ​ไม่​เกิน ไม่ใช่​แบบ​เท่า​กัน​พอดี
/// **`<=` ไม่ใช่ `==`** — นี่คือส่วนที่ KIP-279 แก้
/// คืน epoch ที่ใหญ่ที่สุดซึ่งไม่เกิน `e` พร้อม offset ที่ epoch นั้นจบลง
///
/// `None` แปลว่าไม่พบ · **ไม่ใช่ค่าพิเศษที่ยัดลงชนิด unsigned**
/// เพราะ `UNDEFINED_EPOCH` กับ `UNDEFINED_OFFSET` เป็น `-1` ทั้งคู่
/// ซึ่งใส่ `u32` หรือ `u64` ไม่ได้เลย
pub fn end_offset_for_epoch(&self, e: i32, leo: u64) -> Option<(i32, u64)> {
let i = self.entries.iter().rposition(|x| x.epoch <= e)?;
let found = self.entries[i];
let end = self.entries.get(i + 1).map_or(leo, |n| n.start_offset);
Some((found.epoch, end))
}

KIP-279 เขียน​กติกา​การ​ตอบ​ไว้​ว่า “the leader responds with a pair (largest epoch less than or equal to the requested epoch, the end offset of this epoch)” และ​อธิบาย​เป้า​หมาย​ว่า “the idea is to converge to the largest common epoch, and then to the largest common offset in that epoch”

ผล​รัน​ของ demo ถาม​หลาย​ค่า รวม​ทั้ง epoch ที่​ไม่​เคย​มี​อยู่​จริง และ​ค่าที่​แปล​ว่า​ไม่มี

expected/08-replication.txt · เวก​เตอร์ epoch (ตัด​มา​บาง​ส่วน)
== leader epoch · เวกเตอร์ที่ leader เติมตอน become_leader ==
epoch start_offset
0 0
1 5
3 6
ขอ epoch ได้ (epoch, end_offset)
0 Some((0, 5))
1 Some((1, 6))
2 Some((1, 6))
3 Some((3, 6))
4 Some((3, 6))
-1 None
ขอ epoch 2 ซึ่งไม่เคยมีอยู่ แล้วได้ epoch 1 กลับมา · นี่คือส่วนที่ KIP-279 แก้
การเทียบเป็น `<=` ไม่ใช่ `==` · ผู้ตามจึงบรรจบที่ epoch ร่วมที่ใหญ่ที่สุดได้
`None` แปลว่าไม่พบ · ไม่ใช่ค่าพิเศษที่ยัดลง u64 เพราะ -1 ใส่ u64 ไม่ได้
ที่ขอบของ wire เท่านั้นที่ `None` กลายเป็น (-1, -1)
ในหน่วยความจำมันเป็น `Option<(i32, u64)>` ตลอดทาง

None ที่​แถว​สุดท้าย​ไม่ใช่​การ​เลี่ยง​ปัญหา มัน​จำเป็น เพราะ​ค่าที่ Kafka ใช้​แทน​คำ​ว่า​ไม่มี​คือ​ลบ​หนึ่ง ทั้ง​ของ epoch และ​ของ offset ซึ่ง​ใส่​ชนิด unsigned ไม่​ได้​เลย

Option จึง​อยู่​ตลอด​ทางใน​หน่วย​ความ​จำ แล้วก​ลาย​เป็น​คู่​ลบ​หนึ่ง​ที่​ขอบ​ของ wire เท่านั้น

หัวข้อ​นี้​บอก​ว่า consensus อยู่​ตรง​ไหน​ของ Kafka จริง และ​ทำไม​มัน​ไม่​ได้​อยู่​ใน​ทาง replication ของ partition

ISR ของ kaen-log เป็น Vec ตัว​หนึ่ง​ที่​นั่ง​อยู่​ใน​หน่วย​ความ​จำ​ของ process เดียว ไม่มี​สำเนา​ที่ไหน​อีก ส่วน ISR ของ Kafka เป็น field ชื่อ Isr ของ PartitionRecord ใน metadata log ซึ่ง controller เป็น​คน​เขียน และ KRaft ทำ​สำเนา record นั้น​ด้วย quorum แบบ Raft

flowchart TB
    subgraph M["metadata · ใช้ consensus แบบ Raft"]
        C1["controller 1"] <--> C2["controller 2"]
        C2 <--> C3["controller 3"]
        C1 <--> C3
    end
    subgraph D["ข้อมูลใน partition · ใช้ ISR ไม่ใช่การนับคะแนน"]
        LD["leader ของ orders-0"] --> FA["follower A"]
        LD --> FB["follower B"]
    end
    M -. "แจกว่าใครเป็น leader และ ISR มีใครบ้าง" .-> D

คำ​บรรยาย​ภาพ: ภาพ​นี้​เป็น​รูป​วาด ไม่ใช่​ผล​รัน · KRaft คือ Raft สำหรับ metadata เท่านั้น ส่วน​ข้อมูล​ใน partition ยัง​เดิน​ด้วย​กติกา ISR ที่​บท​นี้​สร้าง​ขึ้น​มา​ทั้ง​บท คอร์ส​นี้​ไม่​ได้​ลงมือ​สร้าง KRaft และ​คอร์ส​ที่ 23 ก็​ประกาศ​ไว้​แต่​แรก​ว่า consensus เป็น​รูป​วาด

การ​พูด​ว่า Kafka หนี​จาก consensus ได้​จึง​ไม่​จริง มัน​ย้าย​ที่​ให้ consensus ต่าง​หาก เอกสาร​ของ​โครงการ​ยัง​ระบุ​ด้วย​ว่า​งาน​วิชาการ​ที่​ใกล้​กับ​ของ​จริง​ที่สุด​คือ PacificA ของ Microsoft ส่วน Raft กับ Zab ถูก​จัด​ไว้​ใน​ตระกูล​การ​นับ​คะแนน​ข้าง​มาก

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

หัวข้อ​นี้​คือ​ย่อหน้า​ที่​สำคัญ​ที่สุด​ของ​ทั้ง​คอร์ส มัน​บอก​ว่า​ของ​เล่น​นี้​แสดง​อะไร​ได้ และ​แสดง​อะไร​ไม่​ได้

expected/08-replication.txt · คำ​ปฏิเสธ​ที่ demo พิมพ์​ออก​มา​เอง (ตัด​มา​บาง​ส่วน)
== สิ่งที่ capstone นี้ปฏิเสธที่จะอ้าง ==
สอง broker ในหนึ่ง process ใช้ kernel เดียว page cache เดียว และไฟฟ้าเส้นเดียวกัน
kaen-log แสดง **protocol** ของการทำสำเนาได้ครบ แต่แสดงความทนทานต่อความล้มเหลว
ไม่ได้เลยแม้แต่นิดเดียว · `acks=all` ที่ทน f ตัวตายคือข้ออ้างที่บทนี้ไม่ยอมพูด
และ ISR ของ kaen-log เป็น Vec<i32> ใน struct ที่ demo ตัวนี้ถือไว้คนเดียว
ของ Kafka เป็น field ชื่อ Isr ของ PartitionRecord ใน metadata log · controller
เป็นคนเขียน และ KRaft ทำสำเนา record นั้นด้วย quorum แบบ Raft

สอง broker ใน​หนึ่ง process ใช้ kernel เดียว page cache เดียว และ​ปลั๊ก​ไฟ​เส้น​เดียวกัน ถ้า​เครื่อง​ดับ ทั้ง​สอง​สำเนา​ดับ​พร้อม​กัน

สิ่ง​ที่​บท​นี้​แสดง​ได้​ครบ​คือ กลไก ของ​การ​ทำ​สำเนา ตั้งแต่​คำขอ fetch ของ​ผู้​ตาม การ​เลื่อน high watermark ด้วย​ค่า​น้อย​ที่สุด​ใน​หมู่ ISR ความ​ช้า​หนึ่ง​รอบ การ​ปฏิเสธ​ของ acks=all และ​การ​ตัด​หาง​ด้วย epoch

สิ่ง​ที่​มัน​ไม่​ได้​แสดง​เลย​แม้แต่​นิด​เดียว​คือ​ความ​ทนทาน​ต่อ​ความ​ล้ม​เหลว ข้อ​อ้าง​ว่า acks=all ทน f ตัว​ตาย​ได้ จึง​เป็น​ข้อ​อ้าง​ที่​บท​นี้​ไม่​ยอม​พูด

และ​มี​สาม​อย่าง​ใน​บท​นี้​ที่​วาด​ไว้ ไม่​ได้​ขับ ผล​รัน​ของ demo พิมพ์​รายการ​นั้น​ออก​มา​เอง

expected/08-replication.txt · รายการ​ของ​ที่​วาด​ไว้ (ตัด​มา​บาง​ส่วน)
== สามอย่างที่วาดไว้ ไม่ได้สร้าง ==
1. ตัวจับเวลาจริง · `shrink_isr(now_ms)` ถูกขับจริงข้างบนด้วยนาฬิกาที่ป้อนเข้ามา
สิ่งที่ไม่มีคือ thread ที่ตื่นมาเรียกมันเอง ซึ่งจะทำให้ผลรันไม่นิ่ง
2. สถานการณ์ leader ล้มเร็วของ KIP-279 · การค้นแบบ epoch ที่ใหญ่ที่สุดแต่ไม่เกิน
ถูกสร้างและมีเทสต์แล้ว · การสลับผู้นำ A→B→A เป็นรูปวาด
3. KRaft กับ ELR · วาดไว้ตามสัญญาเดิมของคอร์สที่ 23 · KRaft คือ Raft
สำหรับ metadata เท่านั้น ข้อมูลใน partition ยังใช้ ISR อยู่

ข้อ​แรก​แยก​ให้​ชัด​ว่า​อะไร​ถูก​ขับ​จริง shrink_isr ถูก​เรียก​จริง​ด้วย​นาฬิกา​ที่​ป้อน​เข้า​มา สิ่ง​ที่​ไม่มี​คือ thread ที่​ตื่น​มา​เรียก​มัน​เอง

ข้อ​ที่​สอง​แยก​เหมือน​กัน การ​ค้น​แบบ epoch ที่​ใหญ่​ที่สุด​แต่​ไม่​เกิน ถูก​สร้าง​และ​มี​เทสต์​แล้ว ส่วน​การ​สลับ​ผู้นำ​สาม​จังหวะ​ของ KIP-279 เป็น​รูป​วาด

capstone ที่​ค่อยๆ ลด​ขอบเขต​ของ​ตัว​เอง​เงียบๆ แย่​กว่า capstone ที่​บอก​ว่า​ส่วน​ไหน​เป็น​รูป​วาด

หัวข้อ​นี้​สรุป​ว่า​แปดบท​ประกอบ​กัน​เป็น​อะไร และ​ชี้​ทาง​ต่อ​ไป​ใน​เสา Rust

kaen-log ที่​คุณ​เพิ่ง​อ่าน​จบ​เป็น broker ทรง Kafka ที่​เขียน​ด้วย Rust standard library ล้วน ไม่มี crate ภายนอก​สัก​ตัว และ​ไม่มี async runtime

ของ​ที่​คุณ​อ่าน code ของ​มัน​มา​แล้ว​ทั้ง​แปดบท มี​ดังนี้

  • รูป​แบบ​บน disk — batch ที่​มี CRC-32C กับ varint และ​การ​แจก offset ที่​ไม่​ต้อง​คิด checksum ใหม่
  • การ​เก็บ — log ที่​ตัด​เป็น​ท่อน พร้อม​สารบัญ​ที่​จงใจ​ไม่​เก็บ​ทุก record
  • ทาง produce กับ​ทาง fetch — frame ที่​หุ้ม​ด้วย​ความ​ยาว​นำ​หน้า การ​จอด​คำขอ​ที่​ยัง​ไม่มี​ข้อมูล และ​การ​กู้​คืน​ด้วย​การ​ตัด​หาง
  • สถานะ​ของ​ผู้​อ่าน — group ที่​บันทึก offset ของ​ตัว​เอง​เป็น record ใน log ของ​ตัว​เอง
  • การ​ลืม — retention ที่​ลืม​เป็น​ท่อน และ compaction ที่​ห้าม​ขยับ offset
  • กติกา​การ​ส่ง — หน้าต่าง​กัน​ซ้ำ​ฝั่ง broker และ​เส้น​ขอบ​ของ​คำ​ว่า exactly-once
  • การ​ทำ​สำเนา — ทั้ง​บท​นี้

สิ่ง​ที่​คอร์ส​นี้​ไม่​ได้​ให้​คือ cluster จริง ถ้า​อยาก​ได้​ของ​นั้น ทาง​ต่อ​ไป​อยู่​ที่​คอร์ส​อื่น​ใน​เสา​เดียวกัน


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • Apache Kafka 4.0 — Design (ตรวจ​แล้ว 2026-09-03) — ประโยค​ที่​ปฏิเสธ​การ​นับ​คะแนน​ข้าง​มาก ประโยค​ที่​ระบุ​ว่า write ยัง​ไม่ commit จนกว่า in-sync replica ทุก​ตัว​จะ​ได้​รับ ประโยค f+1 ทน f ตัว​ตาย เงื่อนไข​สอง​ข้อ​ของ​คำ​ว่า​ตาม​ทัน ประโยค​ที่​ระบุ​ว่า​ผู้​ตาม​บริโภค​เหมือน​ผู้​บริโภค​ทั่วไป และ​ชื่อ PacificA
  • FetchRequest.json tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — นิยาม​ของ field ReplicaId ทั้ง​บรรทัด รวม​ค่า​ปริยาย​ลบ​หนึ่ง · file รุ่น​นี้​ประกาศ validVersions ไว้​ที่ 4-17 และ​ตั้งแต่ v15 field นี้​ย้าย​เข้าไป​อยู่​ใน ReplicaState
  • FetchResponse.json tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — field HighWatermark ที่​เดิน​ทาง​กลับ​มา​ใน​คำ​ตอบ และ field DivergingEpoch ที่​ทำให้​การ​ตรวจ​การ​แตก​สาย​ติด​ไป​กับ fetch ได้​ตั้งแต่ 12+
  • FetchIsolation.java tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — การ​แยก​สาม​ทาง​ที่​แปลง replicaId เป็น​เพดาน​การ​อ่าน
  • Partition.scala tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — maybeIncrementLeaderHW ที่​เริ่ม​จาก LEO ของ leader แล้ว​ไล่​ลด​ลง ตาม maximal ISR และ doc comment ที่​ระบุ​สอง​เหตุการณ์​ที่​ปลุก​มัน
  • ReplicaFetcherThread.scala tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — บรรทัด​ที่​ผู้​ตาม​ฝั่ง Kafka รับ​ค่า high watermark จาก​คำ​ตอบ​ของ fetch
  • ReplicationConfigs.java tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — ค่า​ปริยาย​ของ replica.lag.time.max.ms และ​คำ​อธิบาย​เงื่อนไข​ที่ leader ใช้​ถอด​ผู้​ตาม​ออก​จาก ISR
  • ServerLogConfigs.java tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — MIN_IN_SYNC_REPLICAS_DEFAULT ที่​เป็น​หนึ่ง
  • Errors.java tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — เลข 19 กับ 20 และ​ข้อความ​ของ​ทั้ง​สอง​ข้อ
  • KIP-101 — Alter Replication Protocol to use Leader Epoch rather than High Watermark for Truncation (ตรวจ​แล้ว 2026-09-03 · สถานะ Accepted) — นิยาม​ของ Leader Epoch ที่​ยก​มา​ทั้ง​ประโยค และ​หัวข้อ Scenario 1 ที่​บท​นี้​ขับ​ตาม
  • KIP-279 — Fix log divergence between leader and follower after fast leader fail over (ตรวจ​แล้ว 2026-09-03 · สถานะ Accepted) — กติกา​การ​ตอบ​แบบ epoch ที่​ใหญ่​ที่สุด​แต่​ไม่​เกิน และ​ประโยค​ที่​อธิบาย​การ​บรรจบ​ที่ epoch ร่วม

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

ข้อ 1 / 3

ผู้ตามเพิ่งรับ record ตัวหนึ่งมาจาก leader ในรอบ fetch นี้ แล้วเขียนลง log ของตัวเองเรียบร้อย ทำไมมันยังไม่รู้ว่า record ตัวนั้น committed แล้ว