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

ส่ง​ซ้ำ​ได้ ไม่ใช่​ส่ง​ครั้ง​เดียว

producer ส่ง batch ออก​ไป แล้ว ack ไม่​กลับ​มา สาย​อาจ​ขาดตอน​คำขอ​กำลัง​ไป หรือ​ขาดตอน ack กำลัง​กลับ ทั้ง​สอง​กรณี​หน้าตา​เห​มือ​นกันเป๊ะ​จาก​ฝั่ง producer

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

บท​นี้​เปิด​ทาง​ที่​สาม ชื่อ​ของ​มัน​คือ idempotent produceridempotent producerproducer ที่​ส่ง​ซ้ำ​ได้​โดย​ไม่​เกิด record ซ้ำ​ใน log เพราะ broker จำ id กับ​ลำดับ​ล่าสุด​ของ​มัน​ไว้ ซึ่ง​เป็น producer ที่​ส่ง​ซ้ำ​ได้​เท่า​ที่​อยาก​ส่ง โดย​มี broker เป็น​คน​ดู​ออก​เอง​ว่า batch ก้อน​นี้​มัน​เคย​เขียน​ไป​แล้ว

ราคา​ที่​จ่าย​คือ struct เล็กๆ ห้า​ตัว​ต่อ​หนึ่ง producer ต่อ​หนึ่ง partition และ​มัน​กัน​ซ้ำ​ได้​เฉพาะ​การ​ส่ง​ใหม่​ที่​เกิด ภายใน session เดียวกัน เท่านั้น

และ​ของ​ที่​ได้​มา ไม่ใช่ exactly-once ทั้ง​บท​นี้​ครึ่ง​หนึ่ง​พูด​เรื่อง​เส้น​ที่​มัน​ไป​ไม่​ถึง

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

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

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

ผล​รัน​ทุก​บรรทัด​ใน​บท​นี้​มา​จาก​คำ​สั่ง​เดียว คือ klog idem-demo --pid 1000 --seq 7..9 --resend --now-ms 1700000000000 ซึ่ง​เทสต์​รัน​ซ้ำ​แล้ว​เทียบ​ผล​ที​ละ byte ทุก​ครั้ง

สาม​ความ​หมาย​ของ​การ​ส่ง และ​คำ​ที่​ข้อ​ที่​สาม​ใช้​ไม่​เหมือน​กัน

หัวข้อ​ที่​มีชื่อ​ว่า “สาม​ความ​หมาย​ของ​การ​ส่ง และ​คำ​ที่​ข้อ​ที่​สาม​ใช้​ไม่​เหมือน​กัน”

หัวข้อ​นี้​ยก​นิยาม​สาม​ข้อ​ของ Kafka มา​วาง​เรียง​กัน แล้ว​ชี้​คำ​ที่​ข้อ​ที่​สาม​ใช้​ต่าง​จาก​สอง​ข้อ​แรก

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

expected/07-idem.txt (ตัด​มา​บาง​ส่วน)
== สามความหมายของการส่ง ==
at most once ข้อความหายได้ แต่ไม่เคยถูกส่งซ้ำ
at least once ข้อความไม่เคยหาย แต่ถูกส่งซ้ำได้
exactly once ข้อความถูก **ประมวลผล** ครั้งเดียวเท่านั้น
สองข้อแรกพูดถึงการ *ส่ง* ข้อที่สามพูดถึงการ *ประมวลผล* · ความต่างนี้ไม่ใช่เรื่องถ้อยคำ

คำ​ใน​เอกสาร​ต้นทาง​เป็น​อย่าง​นี้ At most once คือ “Messages may be lost but are never redelivered” At least once คือ “Messages are never lost but may be redelivered” ส่วน Exactly once คือ “Each message is processed once and only once”

สอง​ข้อ​แรก​ใช้​คำ​ว่า lost กับ redelivered ซึ่ง​เป็น​คำ​ของ​การ ส่ง ข้อ​ที่​สาม​เปลี่ยน​ไป​ใช้​คำ​ว่า processed ซึ่ง​เป็น​คำ​ของ​การ ประมวล​ผล

ความ​ไม่​สมมาตร​ตรง​นี้​ไม่ใช่​ความ​สะเพร่า​ของ​คน​เขียน​เอกสาร มัน​คือ​คำ​สารภาพ ไม่มี​ใคร​ส่ง​ข้อความ​บน​สาย​ให้​ถึง​ครั้ง​เดียว​จริงๆ ได้ สิ่ง​ที่​ทำได้​คือ​ทำให้​ผล​ของ​การ​รับ​ซ้ำ​เท่ากับ​รับ​ครั้ง​เดียว

เจ้าของ​แต่ละ​ข้อ​จึง​เป็น​คนละ​องค์​ประกอบ​กัน

  • at most once เป็น​ของ producer producer ที่​ไม่​ลอง​ส่ง​ใหม่​เลย​ได้​ข้อ​นี้​ทันที และ​เสีย​ข้อความ​ไป​เงียบๆ
  • at least once เป็น​ของ producer บวก broker producer ลอง​ส่ง​ใหม่ ส่วน broker รับ​ประกัน​ว่า​สิ่ง​ที่ ack ไป​แล้ว​อยู่​บน disk
  • exactly once ไม่​ได้​เป็น​ของ​ใคร​คน​เดียว มัน​ต้อง​ให้​ผู้​อ่าน​กับ​ผู้​เขียน​ตกลง​กัน​ข้าม​ระบบ ซึ่ง​บท​นี้​ไม่​ได้​สร้าง

เอกสาร​หน้า​เดียวกัน​เตือน​เรื่อง​นี้​เอง ว่า​หลาย​ระบบ​อ้าง​ว่า​ให้ exactly-once แต่ “it is important to read the fine print, because sometimes these claims are misleading” แล้ว​ไล่​กรณี​ที่​ข้อ​อ้าง​นั้น​พัง คือ​ตอน​ผู้​อ่าน​หรือ producer ล้ม ตอน​มี​ผู้​อ่าน​หลาย​ราย และ​ตอน​ของ​บน disk หาย

ประโยค​เดียว​ที่​แยก​บท​นี้​ออก​จาก​คอร์ส​ที่ 23

คอร์ส Distributed Systems สอน​การ​ตัดสิน​ด้วย​เสียง​ข้าง​มาก ทั้ง quorum แบบ R บวก W มากกว่า N และ​การ​ซ่อม​ตอน​อ่าน กลไก​พวก​นั้น​ตัดสิน​โดย​การ นับ สำเนา​หลาย​ชุด

การ​กัน​ซ้ำ​ใน​บท​นี้​ไม่​ได้​นับ​อะไร​เลย มัน​คือ​ตัว​นับ ต่อ​ผู้​เขียน​หนึ่ง​ราย ที่ broker เจ้าของ partition ถือ​ไว้​ฝ่าย​เดียว ไม่มี​การ​โหวต และ​ไม่มี​สำเนา​ที่​สอง​เข้า​มา​เกี่ยว

field สาม​ช่อง​ที่​รอ​อยู่​ใน​หัว batch มา​ตั้งแต่​บท​แรก

หัวข้อ​ที่​มีชื่อ​ว่า “field สาม​ช่อง​ที่​รอ​อยู่​ใน​หัว batch มา​ตั้งแต่​บท​แรก”

หัวข้อ​นี้​บอก​ว่าการ​กัน​ซ้ำ​ใช้​ช่อง​ไหน​ใน​หัว batch และ​ทำไม​ช่อง​พวก​นั้น​ถึง​มี​มา​ก่อน​แล้ว

บท​ที่ 1 กาง​หัว batch ออก​มา​ที​ละ​ช่อง และ​ใน​ตาราง​นั้น​มี​สาม​ช่อง​ที่​มี​ค่า​เป็น​ลบ​หนึ่ง​ทั้งหมด คือ producer_id producer_epoch กับ base_sequence

ลบ​หนึ่ง​ใน​สาม​ช่อง​นั้น​แปล​ว่า​ยัง​ไม่มี​ใคร​ใช้ บท​นี้​คือ​บท​ที่​เริ่ม​ใช้​มัน

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

producer idproducer idเลข​ประจำ​ตัว​ที่ broker แจก​ให้ producer · เป็น​กุญแจ​ของ​หน้าต่าง​กัน​ซ้ำ​ฝั่ง broker คือ​เลข​ประจำ​ตัว​ที่ broker แจก​ให้ producer ตอน​เริ่ม session KIP-98 เขียน​ไว้​ว่า “Every new producer will be assigned a unique PID during initialization” และ​ย้ำ​ว่าการ​แจก​นี้ “is completely transparent to users and is never exposed by clients”

sequence numbersequence numberเลขลำดับ​ต่อ producer ต่อ partition ที่​เพิ่ม​ที​ละ 1 · broker ใช้​ตัดสิน​ว่า batch นี้​ซ้ำ ขาด​ช่วง หรือ​รับ​ได้ คือ​เลขลำดับ​ต่อ​หนึ่ง producer ต่อ​หนึ่ง partition KIP เดียวกัน​เขียน​ว่า “For a given PID, sequence numbers will start from zero and be monotonically increasing, with one sequence number per topic partition produced to”

ประโยค​หลัง​มี​คำ​สำคัญ​อยู่​ที่ per topic partition ตัว​นับ​ไม่​ได้​มี​ตัว​เดียว​ต่อ producer producer หนึ่ง​ราย​ที่​เขียน​ลง​สาม partition ถือตัว​นับ​สาม​ตัว​แยก​กัน

ส่วน producer_epoch เป็น​เลข​รุ่น​ของ session ซึ่ง​บท​นี้​ใช้​เป็น​ตัวประกอบ​ของ​การ​เทียบ​เท่านั้น kaen-log ไม่มี​กลไก​กันตัว​เก่า​ออก​ด้วย epoch เหมือน Kafka และ​ไม่​ได้​อ้าง​ว่า​มี

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

สิ่ง​ที่ broker จำ​ไว้​ต่อ​หนึ่ง batch ไม่ใช่​ตัว batch มัน​คือ​ตัวเลข​สี่​ตัว

src/idem.rs · สี่​ตัวเลข​ต่อ​หนึ่ง batch ที่​เก็บ​ไว้
/// สี่ตัวเลขต่อหนึ่ง batch ที่เก็บไว้ · ตรงกับ `BatchMetadata` ของ Kafka
/// ซึ่งที่ tag 4.1.0 ประกาศไว้เป็น `public class` ที่มี `public final` สี่ช่องนี้พอดี
/// (บน trunk มันถูกเขียนใหม่เป็น `record` แล้ว แต่บทนี้อ้างรุ่น 4.1.0 ทุกที่)
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BatchMetadata {
pub last_seq: i32,
pub last_offset: i64,
pub offset_delta: i32,
pub timestamp: i64,
}
impl BatchMetadata {
pub fn first_seq(&self) -> i32 {
self.last_seq.wrapping_sub(self.offset_delta)
}
pub fn first_offset(&self) -> i64 {
self.last_offset - i64::from(self.offset_delta)
}
}

first_seq กับ first_offset ไม่​ได้​ถูก​เก็บ ทั้ง​คู่​คำนวณ​กลับ​จาก offset_delta ซึ่ง​เป็น​ค่า​เดียว​กับ​ที่​บท​ที่ 1 บอก​ว่า​เท่ากับ​จำนวน record ใน​หนึ่ง batch ลบ​หนึ่ง

Kafka ทำ​เหมือน​กัน​ที​ละ​บรรทัด BatchMetadata.java ของ tag 4.1.0 ประกาศ​ตัว​เอง​เป็น public class ที่​มี public final สี่​ช่อง​นี้​พอดี แล้ว​มี firstSeq() กับ firstOffset() ที่​คำนวณ​กลับ​จาก offsetDelta เหมือน​กัน

รูป​ของ​มัน​เปลี่ยน​ไป​แล้ว​บน trunk ที่​นั่น​มัน​ถูก​เขียน​ใหม่​เป็น record ซึ่ง​ได้​ตัว​อ่าน​ทั้ง​สี่​ช่อง​มา​ฟรี บท​นี้​อ้าง​รุ่น 4.1.0 ทุก​ที่ จึง​เรียก​มัน​ตาม​รูป​ที่ tag นั้น​มี​จริง ไม่ใช่​ตาม​รูป​บน trunk

จำนวน​ของ​ที่​เก็บ​จึง​เป็น​ค่า​คงที่ ไม่​ผูก​กับ​ขนาด​ของ batch ที่ producer ส่ง​มา batch ที่​มี record หนึ่ง​ตัว​กับ batch ที่​มี​พัน​ตัว กิน​ที่​ใน​สถานะ​ของ broker เท่า​กัน​เป๊ะ

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

broker ตัดสิน batch ที่​เข้า​มา​ได้​สาม​แบบ และ​มัน​ตัดสิน ก่อน ที่​จะ​เขียน​อะไร​ลง disk

src/idem.rs · สาม​คำ​ตัดสิน​ของ broker
/// สามคำตัดสิน · ตัวที่คนคาดไม่ถึงคือตัวกลาง
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Verdict {
/// `first_seq` เท่ากับตัวที่คาดหวังพอดี
Append,
/// **ตอบว่าสำเร็จ พร้อม offset ชุดเดิม** ไม่ใช่ตอบว่าผิดพลาด
Duplicate { first_offset: i64, last_offset: i64, timestamp: i64 },
/// มี **ช่องโหว่** · การเขียนหายไปหนึ่งครั้ง ไม่ใช่ซ้ำ
/// การกันซ้ำจึงเปลี่ยนการสูญหายแบบเงียบๆ ให้เป็นข้อผิดพลาดที่ดังออกมา
OutOfOrderSequenceNumber { expected: i32, got: i32 },
}

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

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

UnifiedLog.java ของ Kafka รุ่น 4.1.0 เขียน​สาขา​นี้​ไว้​ตรง​ตัว เมื่อ result.maybeDuplicate มี​ค่า มัน​เซ็ต setFirstOffset setLastOffset และ setLogAppendTime จาก​ของ​เก่า แล้ว​สาขา else ที่​เรียก localLog.append(...) ถูก​ข้าม​ไป​ทั้ง​ก้อน

ตัว​เทียบ​เอง​ก็​เป็น​แบบ​ช่วง​ตรง​เป๊ะ ไม่ใช่​แบบ​คลุมเครือ

src/idem.rs · การ​เทียบ​เป็น​แบบ​ช่วง​ตรง​เป๊ะ
/// การเทียบเป็นแบบ **ช่วงตรงเป๊ะ** · ทั้ง `first_seq` และ `last_seq` ต้องตรงกับ
/// batch ที่เก็บไว้ และ epoch ต้องตรงด้วย
pub fn judge(entry: &ProducerStateEntry, epoch: i16, first: i32, last: i32) -> Verdict {
let exact_match = entry
.batches
.iter()
.find(|b| b.first_seq() == first && b.last_seq == last)
.filter(|_| epoch == entry.producer_epoch);
if let Some(b) = exact_match {
return Verdict::Duplicate {
first_offset: b.first_offset(),
last_offset: b.last_offset,
timestamp: b.timestamp,
};
}
let expected = entry.expected_first_seq();
if first == expected {
Verdict::Append
} else {
Verdict::OutOfOrderSequenceNumber { expected, got: first }
}
}

ทั้ง​ปลาย​ซ้าย​และ​ปลายข​วา​ของ​ช่วง​ต้อง​ตรง และ epoch ต้อง​ตรง​ด้วย findDuplicateBatch ของ Kafka ตัด​ที่ epoch ก่อน แล้ว​ค่อย​หา batch ที่​มี​ช่วง sequence ตรง​เป๊ะ

ผล​ที่​ตาม​มา​คือ batch ที่​ซ้อน​ทับ​กัน​ครึ่ง​เดียว​ไม่ใช่​ของ​ซ้ำ producer ที่​รวม​สอง batch เป็น​ก้อน​เดียว​แล้ว​ส่ง​ใหม่ จะ​ไม่​ได้​คำ​ตัดสิน Duplicate

ทาง​เดิน​ของ​การ​เขียน​จึง​ตัดสิน​ก่อน แล้ว​ค่อย​เขียน

src/idem.rs · ตัดสิน​ก่อน แล้ว​ค่อย​เขียน (ตัด​มา​บาง​ส่วน)
/// ตัดสินก่อน แล้วค่อยเขียน · batch ที่ซ้ำ **ไม่ถูกเขียนลง disk เลย**
pub fn append_idempotent(
partition: &Partition,
state: &mut ProducerState,
producer_id: i64,
producer_epoch: i16,
first_seq: i32,
records: Vec<Record>,
now_ms: i64,
) -> io::Result<IdempotentAppend> {
let count = i32::try_from(records.len()).map_err(|_| bad("batch เดียวเกิน i32 record"))?;
let last_seq = first_seq + count - 1;
let entry = state.entry_mut(producer_id, producer_epoch);
let verdict = judge(entry, producer_epoch, first_seq, last_seq);
match verdict {
Verdict::Duplicate { first_offset, .. } => {
Ok(IdempotentAppend { verdict, base_offset: first_offset })
}
Verdict::OutOfOrderSequenceNumber { .. } => {
Ok(IdempotentAppend { verdict, base_offset: -1 })
}

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

sequenceDiagram
    autonumber
    participant P as producer · pid 1000
    participant B as broker · orders-0
    P->>B: ส่ง batch · seq 7..9
    B->>B: ตัดสิน APPEND แล้วต่อท้าย log
    B--xP: ack หายกลางทาง
    Note over P: ไม่รู้ว่าเขียนสำเร็จหรือไม่ · จึงส่งใหม่
    P->>B: ส่ง batch เดิมซ้ำ · seq 7..9
    B->>B: เจอช่วงตรงเป๊ะในหน้าต่างห้าก้อน
    B-->>P: สำเร็จ · offset ชุดเดิม
    Note over B: ไม่เขียนอะไรลง disk · ปลาย log ไม่ขยับ

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

ผล​รัน​ของ​จริง​พูด​ข้อ​เดียวกัน

expected/07-idem.txt (ตัด​มา​บาง​ส่วน)
== ack หาย · producer ส่ง seq 7..9 ซ้ำ ==
คำตัดสิน DUPLICATE · offset เดิม 7..9
base_offset 7
log end offset 10 (ไม่ขยับ)
**ของซ้ำไม่ใช่ข้อผิดพลาด** broker ตอบว่าสำเร็จ พร้อม offset ชุดเดิม
และไม่เขียนอะไรลง disk เลย · ทาง append ถูกข้ามทั้งเส้น

บรรทัด​ที่​พิสูจน์​ข้อ​อ้าง​คือ​บรรทัด log end offset ที่​ค้าง​อยู่​ที่​ค่า​เดิม ถ้า batch ที่​ซ้ำ​ถูก​เขียน​ลง​ไป​จริง ตัวเลข​นั้น​จะ​ขยับ และ demo จะ​จบ​ด้วย​ข้อ​ผิด​พลาด​ทันที

หน้าต่าง​ห้า batch และ​เลข 5 ที่​โผล่​สอง​ที่​แต่​เป็น​ตัว​เดียวกัน

หัวข้อ​ที่​มีชื่อ​ว่า “หน้าต่าง​ห้า batch และ​เลข 5 ที่​โผล่​สอง​ที่​แต่​เป็น​ตัว​เดียวกัน”

หัวข้อ​นี้​บอก​ว่า broker จำ batch ไว้​ได้​กี่​ก้อน อะไร​ถูก​ดัน​ออก และ​เลข​นั้น​ผูก​กับ config ตัว​ไหน

หน้าต่าง​กัน​ซ้ำ​มี​ขนาด​ตายตัว และ​เลข​ของ​มัน​มา​จาก Kafka ตรงๆ

src/idem.rs · ขนาด​ของ​หน้าต่าง
/// **ห้า batch ไม่ใช่ห้า record** · ตรงกับ `NUM_BATCHES_TO_RETAIN = 5` ของ Kafka
/// และเป็นเลข 5 ตัวเดียวกับ `max.in.flight.requests.per.connection` ที่ต้องไม่เกิน 5
pub const NUM_BATCHES_TO_RETAIN: usize = 5;

ProducerStateEntry.java ของ​รุ่น 4.1.0 ประกาศ public static final int NUM_BATCHES_TO_RETAIN = 5; และ javadoc ของ​มัน​บอก​วิธี​จัด​คิว​ไว้​ด้วย ว่า sequence ต่ำ​สุด​อยู่​หัว​คิว สูงสุด​อยู่​ท้าย​คิว แล้ว “When the queue is at capacity, we remove the first element to make space for the incoming batch”

ห้า​ก้อน​นั้น​คือ​ห้า batch ไม่ใช่​ห้า record batch หนึ่ง​ก้อน​อาจ​มี record กี่​ตัว​ก็ได้ คน​ที่​อ่าน​เป็น​ห้า record จะ​ประเมิน​ขอบ​ของ​การ​กัน​ซ้ำ​ต่ำ​กว่า​ความ​จริง​มาก

kaen-log ทำ​แบบ​เดียวกัน​ด้วย​คิว​สอง​ปลาย

src/idem.rs · คิว​เต็ม​แล้ว​ดัน​ตัว​หน้า​ออก
/// คิวเต็มแล้วดันตัวหน้าออก · batch ที่ถูกดันออกไม่ถูกกันซ้ำอีกต่อไป
pub fn record(&mut self, meta: BatchMetadata) {
if self.batches.len() == NUM_BATCHES_TO_RETAIN {
self.batches.pop_front();
}
self.last_timestamp = meta.timestamp;
self.batches.push_back(meta);
}
/// sequence ตัวถัดไปที่คาดหวัง · producer ที่ยังไม่เคยเขียนอะไรเลยเริ่มที่ 0
pub fn expected_first_seq(&self) -> i32 {
self.batches.back().map_or(0, |b| b.last_seq.wrapping_add(1))
}

demo เดิน​หน้าต่าง​ให้​ดู​ที​ละ batch จน​เต็ม​พอดี

expected/07-idem.txt (ตัด​มา​บาง​ส่วน)
== หน้าต่างกันซ้ำ · 5 batch ต่อหนึ่ง producer ต่อหนึ่ง partition ==
producer id 1000 · producer epoch 0
seq ที่ส่ง คำตัดสิน base_offset หน้าต่างหลังจากนั้น
0..1 APPEND 0 [0..1]
2..3 APPEND 2 [0..1 2..3]
4..5 APPEND 4 [0..1 2..3 4..5]
6..6 APPEND 6 [0..1 2..3 4..5 6..6]
7..9 APPEND 7 [0..1 2..3 4..5 6..6 7..9]
log end offset 10

แถว​สุดท้าย​มี batch อยู่​ห้า​ก้อน​พอดี ก้อน​ถัด​ไป​ที่​เข้า​มา​จะ​ดัน​ก้อน​หัว​คิว​ออก และ​หลัง​จาก​นั้น การ​ส่ง​ซ้ำ​ของ​ก้อน​ที่​ถูก​ดัน​ออก​จะ​ไม่​ได้​คำ​ตัดสิน Duplicate อีก

expected/07-idem.txt (ตัด​มา​บาง​ส่วน)
== หน้าต่างล้น · batch ที่ถูกดันออกไม่ถูกกันซ้ำอีก ==
หน้าต่างตอนนี้ [10..10 11..11 12..12 13..13 14..14]
ส่ง seq 7..9 ซ้ำอีกครั้ง → OUT_OF_ORDER_SEQUENCE_NUMBER · คาดว่า 15 ได้ 7
เลข 5 ตัวนี้เป็นตัวเดียวกับเพดานของ max.in.flight.requests.per.connection
ส่งค้างไว้เกินห้า แล้ว batch เก่าจะหลุดหน้าต่างก่อนที่ ack จะกลับมา

ช่วง 7..9 ที่​เมื่อ​กี้​ได้​คำ​ตัดสิน​ว่า​ซ้ำ ตอน​นี้​ได้​คำ​ตัดสิน​ว่า​ลำดับ​ผิด สิ่ง​ที่​เปลี่ยน​ไม่ใช่ batch มัน​คือ​สิ่ง​ที่ broker ยัง​จำ​ได้

เลข 5 ที่​โผล่​ใน​สอง​ที่​เป็น​ตัว​เดียวกัน เอกสาร​ของ ProducerConfig เขียน​ไว้​เอง​ว่า การ​เปิด idempotence บังคับ​ให้ max.in.flight.requests.per.connection ไม่​เกิน​ห้า “because broker only retains at most 5 batches for each producer”

⚠ ขนาด​ของ​หน้าต่าง​คือ​ขอบ​ของ​ข้อ​อ้าง ไม่ใช่​ราย​ละเอียด​การ​ทำงาน

ประโยค​ที่​ว่า producer แบบ idempotent ส่ง​ซ้ำ​ได้​โดย​ไม่​เกิด​ของ​ซ้ำ เป็น​จริง​ใน​ขอบ​ของ​หน้าต่าง​นี้​เท่านั้น

ถ้า​ปล่อย​ให้​มี​คำขอ​ค้าง​อยู่​บน​สาย​เกิน​ห้า​ก้อน แล้ว ack ของ​ก้อน​แรก​เพิ่ง​กลับ​มา​ช้า batch ก้อน​นั้น​อาจ​หลุด​หน้าต่าง​ไป​แล้ว​ตอน​ที่ producer ตัดสิน​ใจ​ส่ง​ซ้ำ

นั่น​คือ​เหตุผล​ที่ Kafka บังคับ​เพดาน​ไว้​ที่​ห้า ไม่ใช่​เพราะ​ห้า​เป็น​เลข​สวย และ​เป็น​ตัวอย่าง​ของ config ที่​ผูก​กับ​โครงสร้าง​ข้อมูล​ฝั่ง broker โดยตรง

ช่อง​โหว่​แปล​ว่าการ​เขียน​หาย​ไป ไม่ใช่​ของ​ซ้ำ

หัวข้อ​ที่​มีชื่อ​ว่า “ช่อง​โหว่​แปล​ว่าการ​เขียน​หาย​ไป ไม่ใช่​ของ​ซ้ำ”

หัวข้อ​นี้​อ่าน​คำ​ตัดสิน​ตัว​ที่​สาม แล้ว​เถียง​ว่า​มัน​อาจ​มี​ค่า​กว่า​การ​กัน​ซ้ำ​เอง

คำ​ตัดสิน​ตัว​ที่​สาม​เกิด​เมื่อ first_seq ที่​เข้า​มา​ไม่​ตรง​กับ​ตัว​ที่ broker คาด​ไว้ และ​ไม่​ตรง​กับ​ของ​เก่า​ด้วย

expected/07-idem.txt (ตัด​มา​บาง​ส่วน)
== ช่องโหว่ · ข้ามไปสามลำดับ ==
คำตัดสิน OUT_OF_ORDER_SEQUENCE_NUMBER · คาดว่า 10 ได้ 13
log end offset 10 (ไม่ขยับ)
ช่องโหว่แปลว่ามีการเขียนที่ **หายไป** ไม่ใช่ที่ซ้ำ
การกันซ้ำจึงเปลี่ยนการสูญหายแบบเงียบๆ ให้เป็นข้อผิดพลาดที่ดังออกมา
ซึ่งอาจเป็นของที่มีค่ากว่าการกันซ้ำเสียอีก

ความ​หมาย​ของ​มัน​คือ มี​การ​เขียน​หาย​ไป ไม่ใช่​มี​การ​เขียน​ซ้ำ sequence เป็น​เลข​ที่​เพิ่ม​ที​ละ​หนึ่ง ช่อง​ว่าง​ที่​เดิน​ล้ำ​ไป​ข้าง​หน้า​จึง​แปล​ว่า​ของ​ที่​ควร​มา​ก่อน​ยัง​ไม่​เคย​มา​ถึง

Errors.java ของ​รุ่น 4.1.0 มี​สอง​รหัส​ที่​เกี่ยว​กับ​เรื่อง​นี้​อยู่​ติด​กัน

  • OUT_OF_ORDER_SEQUENCE_NUMBER(45, "The broker received an out of order sequence number.")
  • DUPLICATE_SEQUENCE_NUMBER(46, "The broker received a duplicate sequence number.")

ตัว​ที่ 45 คือ​ตัว​ที่ broker โยน​จริง ProducerAppendInfo.java โยน OutOfOrderSequenceException เมื่อ inSequence(currentLastSeq, appendFirstSeq) เป็น​เท็จ และ inSequence เอง​ก็​สั้น​มาก มัน​แค่​ถาม​ว่า nextSeq == lastSeq + 1L หรือ​เป็น​กรณี​วน​กลับ​ที่​ปลายข​อง Integer.MAX_VALUE

ตัว​ที่ 46 เป็น​อีก​เรื่อง การ​ไล่​หา​ทาง​เดิน​ที่​โยน​มัน​ฝั่ง broker ไม่​พบ​เลย​ใน ProducerAppendInfo.java ProducerStateManager.java และ UnifiedLog.java ของ​รุ่น 4.1.0

ฝั่ง​ที่​จัดการ​มัน​จริง​คือ client ใน Sender.java ซึ่ง​ตอบ​ผู้​ใช้​ว่า​สำเร็จ แล้ว​เขียน​คอม​เมน​ต์​กำกับ​ไว้​เอง​ว่า “The only thing we can do is to return success to the user and not return a valid offset and timestamp”

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

การ​รีเซ็ต sequence เพื่อ​ให้ producer เดิน​ต่อ​จาก​ช่อง​โหว่​ได้ เท่ากับ​ยอมรับ​ว่า​ของ​ที่​หาย​ไป​หาย​จริง ข้อ​นี้​เป็นการ​อนุมาน​จาก​สอง​ที่​ที่​ยืนยัน​ได้ ไม่ใช่​ประโยค​ที่ Kafka เขียน​ไว้​เอง

สร้าง​สถานะ​ขึ้น​ใหม่​ตอน​เปิด และ​ขอบ​ที่​การก​วาด​ไป​ไม่​ถึง

หัวข้อ​ที่​มีชื่อ​ว่า “สร้าง​สถานะ​ขึ้น​ใหม่​ตอน​เปิด และ​ขอบ​ที่​การก​วาด​ไป​ไม่​ถึง”

หัวข้อ​นี้​บอก​ว่า​สถานะ​กัน​ซ้ำ​รอด restart ของ broker ได้​อย่างไร และ​มัน​เห็น​แค่​ไหน

สถานะ​ที่​บท​นี้​พูด​ถึง​ทั้งหมด​อยู่​ใน​หน่วย​ความ​จำ ไม่มี file แยก​ที่​เก็บ​มัน​ไว้ คำถาม​จึง​ตาม​มา​ทันที ว่า broker ที่​เพิ่ง​เปิด​ใหม่​รู้​ได้​อย่างไร​ว่า producer เดิน​มา​ถึง sequence ไหน​แล้ว

คำ​ตอบ​คือ​มัน​อ่าน​กลับ​จาก log ที่​มัน​เพิ่ง​เปิด

src/idem.rs · สร้าง​สถานะ​ขึ้น​ใหม่​จาก​การก​วาด​ท่อน​สุดท้าย
/// สร้างสถานะขึ้นใหม่จากการกวาด **ท่อนสุดท้าย** ตอนเปิด
///
/// kaen-log ไม่มี file `.snapshot` แบบ Kafka · การกวาดนี้คือเหตุผลเดียวที่ข้ออ้าง
/// หลักของบทนี้รอดข้ามการ restart ของ broker · และมันเห็นแค่สิ่งที่ท่อนสุดท้าย
/// ยังถืออยู่ producer ที่ batch ของมัน roll ตกไปท่อนเก่าแล้วจึงไม่ถูกกันซ้ำอีก
pub fn rebuild_producer_state(log: &Log) -> io::Result<ProducerState> {
let mut state = ProducerState::default();
let segment = log.active_segment();
for (_, batch) in segment.read_all_batches()? {
let header = batch.header;
if header.producer_id < 0 {
continue;
}
let entry = state.entry_mut(header.producer_id, header.producer_epoch);
entry.producer_epoch = header.producer_epoch;
entry.record(BatchMetadata {
last_seq: header.base_sequence.wrapping_add(header.last_offset_delta),
last_offset: wire_i64(header.last_offset()?),
offset_delta: header.last_offset_delta,
timestamp: header.max_timestamp,
});
}
Ok(state)
}

ทุก​ค่าที่​มัน​ต้อง​ใช้​อยู่​ใน​หัว batch อยู่​แล้ว การก​วาด​จึง​ไม่​ได้​ใช้​เนื้อ​ของ record เลย​สัก​ตัว last_seq ได้​จาก base_sequence บวก last_offset_delta ซึ่ง​เป็น​สอง​ช่อง​ที่​อยู่​ใน​หัว batch ทั้ง​คู่

🔁 นี่​คือ​รอย​ต่อ​ระหว่าง​บท​ที่ 6 กับ​บท​นี้

บท​ที่ 6 กวาด log ด้วย​การ​เขียน batch ขึ้น​ใหม่ ถ้า​การ​เขียน​ใหม่​นั้น​รีเซ็ต producer_id producer_epoch และ base_sequence กลับ​เป็น​ลบ​หนึ่ง การก​วาด​ข้าง​บน​จะ​ไม่​เจอ​อะไร​เลย

crate จึง​มี​ตัว​สร้าง batch สอง​ตัว​แยก​กัน และ​ตัว​ที่ compaction ใช้​เป็น​ตัว​ที่​รักษา​สาม​ช่อง​นั้น​ไว้ เอกสาร​ของ DefaultRecordBatch ฝั่ง Kafka ให้​เหตุผล​เดียวกัน ว่า​ต้อง​รักษา sequence ตัว​แรก​กับ​ตัว​สุดท้าย​ไว้ เพื่อ​ให้​สร้าง​สถานะ​ของ producer ขึ้น​ใหม่​ได้​ตอน​อ่าน log กลับ

ข้อ​นี้​พัง​ตอน​รัน ไม่ใช่​ตอน​อ่าน​บน​กระดาษ

ผล​รัน​ยืนยัน​ว่าการ​กวาด​ใช้ได้​จริง

expected/07-idem.txt (ตัด​มา​บาง​ส่วน)
== broker restart · สร้างสถานะขึ้นใหม่จากการกวาดท่อนสุดท้าย ==
producer id ที่กวาดเจอ [1000]
หน้าต่างที่กวาดได้ [10..10 11..11 12..12 13..13 14..14]
ส่ง seq 14..14 ซ้ำหลัง restart → DUPLICATE · offset เดิม 14..14

หน้าต่าง​ที่​กวาด​ได้​ตรง​กับ​หน้าต่าง​ก่อน restart ทุก​ช่อง และ​การ​ส่ง​ซ้ำ​หลัง restart ยัง​ถูกกัน

หัวข้อ​นี้​ไล่​ข้อ​จำกัด​สาม​ข้อ​ของ​สิ่ง​ที่​เพิ่ง​สร้าง​เสร็จ โดย​สอง​ข้อ​แรก​มี​ผล​รัน​กำกับ

ข้อ​จำกัด​สาม​ข้อ​ข้าง​ล่าง​ไม่ใช่​รายการ​สิ่ง​ที่​ยัง​ทำ​ไม่​เสร็จ ทั้ง​สาม​ข้อ​เป็น​ขอบ​ของ​กลไก​นี้​เอง

ข้อ​แรก process ที่​เริ่ม​ใหม่​ได้ producer id ตัว​ใหม่ เมื่อ id เปลี่ยน sequence ก็​เริ่ม​นับ​ใหม่​จาก​ศูนย์ batch ที่​มี payload เดิม​เป๊ะ​จึง​เป็น​ของ​ใหม่​ใน​สายตา​ของ broker

expected/07-idem.txt (ตัด​มา​บาง​ส่วน)
== สองขอบที่การกันซ้ำไปไม่ถึง ==
1. process ที่เริ่มใหม่ได้ producer id ตัวใหม่
ส่ง payload ชุดเดิมด้วย pid 1001 seq 0 → APPEND
base_offset 15 · record ชุดเดิมถูกเขียนลง log เป็นของใหม่ทั้งหมด
การกันซ้ำจึงครอบแค่ session เดียว ไม่ใช่ครอบ payload

การ​กัน​ซ้ำ​จึง​ครอบ session ไม่​ได้​ครอบ​เนื้อ​ของ​ข้อความ เอกสาร​ของ ProducerConfig พูด​ข้อ​นี้​กลับ​ด้าน ว่า​ถ้า​ไม่​ตั้ง transactional.id “then the producer is limited to idempotent delivery” ส่วน​สิ่ง​ที่​ข้าม session ได้​คือ transaction

ข้อ​ที่​สอง การก​วาด​ตอน​เปิด​เห็น​แค่​สิ่ง​ที่​ท่อน​สุดท้าย​ยัง​ถือ​อยู่ producer ที่ batch ของ​มัน​ถูก roll ตก​ไป​ท่อน​เก่า​แล้ว จะ​ไม่​ถูกกัน​ซ้ำ​อีก​หลัง restart

expected/07-idem.txt (ตัด​มา​บาง​ส่วน)
2. การกวาดตอนเปิดเห็นแค่สิ่งที่ **ท่อนสุดท้าย** ยังถืออยู่
เขียน 8 batch ลง partition ที่ segment.bytes = 256 · จำนวนท่อน 8
หน้าต่างก่อน restart [3..3 4..4 5..5 6..6 7..7]
หน้าต่างหลัง restart [7..7]
แคบลงจาก 5 เหลือ 1 · producer ที่ batch ของมัน roll
ตกไปท่อนเก่าแล้ว จึงไม่ถูกกันซ้ำอีกหลัง restart แม้ก่อนหน้านั้นจะถูกกัน

ข้อ​นี้​อ่าน​ง่าย​จาก​ตัวเลข​สอง​บรรทัด หน้าต่าง​ก่อน restart เต็ม​ห้า​ก้อน หลัง restart เหลือ​ก้อน​เดียว demo ตั้ง​ขนาด​ท่อน​ไว้​เล็ก​มาก​เพื่อ​บังคับ​ให้​เกิด และ​มัน​จบ​ด้วย​ข้อ​ผิด​พลาด​ถ้า​หน้าต่าง​ไม่​แคบ​ลง​จริง

Kafka ไม่มี​ปัญหา​ข้อ​นี้​ใน​รูป​เดียวกัน เพราะ​มัน snapshot สถานะ​ของ producer ลง disk แยก​ไว้ แต่​มัน​มี​ขอบ​ของ​ตัว​เอง​คนละ​แบบ คือ​สถานะ​ของ producer หมด​อายุ​ตาม​เวลา

ข้อ​ที่​สาม exactly-once ไม่​เคย​เป็น​สมบัติ​ของ​การ​ส่ง มัน​เป็น​สมบัติ​ของ ผลลัพธ์ ที่​ปลาย​ทาง และ​ปลาย​ทาง​นั้น​ต้อง​กัน​ซ้ำ​เป็น​ด้วย​ตัว​มัน​เอง

การ​ตัด​บัตร การ​ส่ง SMS และ​การ​ยิง​คำ​สั่ง​เข้า​ระบบ​ภายนอก ไม่มี​อะไร​ใน​บท​นี้​คุ้มครอง​มัน exactly-onceexactly-onceข้อ​อ้าง​ที่​เป็น​จริง​เฉพาะ​ภายใน​ขอบ​ของ​ระบบ​เดียว ประกอบ​ขึ้น​จาก​การ​กัน​ซ้ำ​กับ transaction ไม่ใช่​การ​ส่ง​ครั้ง​เดียว​จริงๆ บน​สาย จึง​เป็น​ข้อ​อ้าง​ที่​เป็น​จริง​ภายใน​ขอบ​ของ​ระบบ​เดียว​เท่านั้น

เอกสาร​ออกแบบ​ของ Kafka ไม่​ได้​เขียน​ประโยค​นี้​ด้วย​ถ้อยคำ​แบบ​นี้ ประโยค​ที่​ใกล้​ที่สุด​ที่​มัน​เขียน​คือ “Exactly-once delivery for other destination systems generally requires cooperation with such systems, but Kafka provides the primitives which makes implementing this feasible”

และ​มัน​เสนอ​ทางออก​ที่​ง่าย​กว่า​ไว้​ใน​หน้า​เดียวกัน ว่า​ให้​ผู้​อ่าน​เก็บ​ตำแหน่ง​ของ​ตัว​เอง​ไว้​ที่​เดียว​กับ​ผลลัพธ์ เพราะ “many of the output systems a consumer might want to write to will not support a two-phase commit”

หัวข้อ​นี้​อธิบาย​และ​วาด​สิ่ง​ที่ transaction ต้อง​มี แล้ว​ประกาศ​ว่า kaen-log ไม่​ได้​สร้าง​มัน​สัก​ชิ้น

ประโยค​ต่อ​ไป​นี้​ตรง​ตัว kaen-log ไม่มี transaction ทุก​อย่าง​ใน​หัวข้อ​นี้​ถูก​อธิบาย​และ​วาด​ไว้​เฉยๆ ไม่มี​บรรทัด​ไหน​ใน crate ที่​ทำ​มัน และ​ไม่มี​บท​ไหน​ต่อ​จาก​นี้​ที่​จะ​สร้าง

สิ่ง​ที่ transaction ต้อง​มี และ kaen-log ไม่มี​สัก​อย่าง มี​อยู่​สาม​ชิ้น

  • transaction coordinator ตัวกลาง​ที่​จำ​สถานะ​ของ transaction ที่​เปิด​ค้าง​อยู่
  • topic ภายใน​ชื่อ __transaction_state ที่ Topic.java ประกาศ​ไว้​คู่​กับ __consumer_offsets
  • control record ใน partition ของ​ผู้​ใช้ ที่​บอก​ว่า transaction นั้น​จบ​แบบ​ไหน

control record ไม่ใช่​ของ​ที่​เก็บ​แยก มัน​เป็น record จริง​ที่​นั่ง​อยู่​ใน partition เดียว​กับ​ข้อมูล ControlRecordType.java ของ​รุ่น 4.1.0 ประกาศ​ค่า​ของ​มัน​ไว้​ว่า ABORT((short) 0) และ COMMIT((short) 1)

ค่า​คู่​นี้​ต้อง​อ่าน​จาก code ไม่ใช่​จาก KIP-98 เพราะ​ร้อย​แก้ว​ของ KIP เขียน​คู่​นี้​สลับ​กัน ตัว​ที่​ต้อง compile คือ​ตัว​ที่​ถูก

ฝั่ง​ผู้​อ่าน​มี config หนึ่ง​ตัว​ที่​เปลี่ยน​ขอบ​ของ​การ​มอง​เห็น คือ isolation.level ค่า​ปริยาย​ของ​มัน​คือ read_uncommitted และ​เอกสาร​ของ ConsumerConfig เขียน​ว่า​ใน​โหมด read_committed consumer.poll() จะ​คืน​ข้อความ​ได้​แค่​ถึง last stable offset ซึ่ง “is the one less than the offset of the first open transaction”

flowchart LR
    subgraph L["log ของ orders-0 · เรียงตามลำดับ"]
        direction LR
        S1["record ที่ commit แล้ว"] --> S2["record ของ transaction ที่ยังเปิดค้าง"] --> S3["record ที่ยังไม่ถึงเส้น committed"]
    end
    LSO["LSO · read_committed อ่านได้ถึงตรงนี้"] -.-> S1
    HW["HW · read_uncommitted อ่านได้ถึงตรงนี้"] -.-> S2
    LEO["LEO · ตำแหน่งที่จะเขียนตัวถัดไป"] -.-> S3
    N["kaen-log สร้าง LEO กับ HW · เอ่ยชื่อ LSO เฉยๆ"]

คำ​บรรยาย​ภาพ: ภาพ​นี้​เป็น​ตัวอย่าง ไม่ใช่​ผล​รัน · เส้น​สาม​เส้น​เรียง​กัน​เสมอ​ใน​ทิศทาง​เดียว โดย LSO ไม่มี​วัน​เลย HW และ HW ไม่มี​วัน​เลย LEO · transaction ที่​เปิด​ค้าง​หนึ่ง​ตัว​จึง​ตรึง LSO ไว้​กับ​ที่ และ​ผู้​อ่าน​โหมด read_committed ทุก​ราย​บน partition นั้น​หยุด​ตาม​ไป​ด้วย

ผล​รัน​ของ demo ปิด​ท้าย​ด้วย​การ​เอ่ย​ชื่อ​ทั้ง​สาม​เส้น และ​ประกาศ​ว่า​อัน​ไหน​สร้าง​จริง

expected/07-idem.txt (ตัด​มา​บาง​ส่วน)
== transaction · วาดไว้ ไม่ได้สร้าง ==
การกันซ้ำไม่ใช่ exactly-once · transaction ต้องมี coordinator
มี topic ภายในชื่อ __transaction_state และมี control record ใน partition ของผู้ใช้
ทั้งหมดนั้นตั้งอยู่บนการทำ replication ของบทที่ 8 ขึ้นไปอีกชั้น
เส้นแบ่งสามเส้นเมื่อ transaction มีจริง: LEO >= HW >= LSO
LEO offset ตัวถัดไปที่จะเขียน
HW เส้น committed · read_uncommitted อ่านได้ถึงตรงนี้
LSO offset ของ transaction ที่เปิดค้างตัวแรก · read_committed อ่านได้ถึงตรงนี้
kaen-log สร้างสองเส้นแรก และเอ่ยชื่อเส้นที่สามเฉยๆ
และ exactly-once ไม่เคยเป็นสมบัติของการส่ง · มันเป็นสมบัติของ **ผลลัพธ์**
ที่ปลายทางซึ่งกันซ้ำเป็น · การตัดบัตรหรือส่ง SMS ไม่ได้อยู่ในความคุ้มครองนี้

กลไก​ที่ Kafka ใช้​ประกอบ exactly-once ขึ้น​มา​จริงๆ อธิบาย​ได้​ใน​ประโยค​เดียว คือ​มัน​เขียน​ตำแหน่ง​ของ​ผู้​อ่าน​ลง topic ภายใน แล้ว​มัด​การ​เขียน​นั้น​ไว้​ใน transaction เดียว​กับ​ผลลัพธ์

เอกสาร​ออกแบบ​เขียน​ไว้​ตรง​ตัว​ว่า “we can write the offset to Kafka in the same transaction as the output topics receiving the processed data” และ​ย้ำ​ว่า​ใน​ระบบ​นี้​ผู้​ที่​ทำ transaction ได้​คือ producer ไม่ใช่ consumer

นั่น​แปล​ว่า exactly-once ของ Kafka เป็น​เรื่อง​ของ​การ​อ่าน​แล้ว​เขียน​กลับ​เข้า Kafka มัน​ไม่​ได้​ครอบ​ผล​ที่​เกิด​กับ​ระบบ​ข้าง​นอก​เลย​แม้แต่​กรณี​เดียว

หัวข้อ​นี้​ประกาศ​เส้น​ขอบเขต​ของ src/idem.rs แล้ว​ส่ง​ต่อ​ไป​บท​สุดท้าย

  • ไม่มี transaction และ​ไม่มี coordinator ทั้งหมด​ถูก​วาด​ไว้​ใน​หัวข้อ​ก่อน​หน้า​เท่านั้น
  • ไม่มี​การ​กันตัว​เก่า​ออก​ด้วย epoch kaen-log ใช้ epoch เป็น​ตัวประกอบ​ของ​การ​เทียบ ไม่​ได้​ใช้​กัน​คน​เก่า
  • ไม่มี snapshot ของ​สถานะ producer ลง disk สถานะ​สร้าง​ขึ้น​ใหม่​จาก​การก​วาด​ท่อน​สุดท้าย​เท่านั้น
  • ไม่มี​การ​หมด​อายุ​ตาม​เวลา Kafka ล้าง​สถานะ​ของ producer ที่​เงียบ​ไป​นาน ส่วน kaen-log ไม่​ล้าง

ข้อ​แรก​เป็น​ข้อ​เดียว​ที่​ต้องการ​ชั้น​ที่​บท​นี้​ยัง​ไม่มี อีก​สาม​ข้อ​เป็นการ​ตัด​ขอบเขต​ของ​บท​นี้​เอง transaction ตั้ง​อยู่​บน​การ​ทำ​สำเนา​ซึ่ง​เป็น​เนื้อ​ของ​บท​ถัด​ไป

บท​ที่ 8 จะ​เอา log ก้อน​เดิม​นี้​ไป​ทำ​สำเนา​ไว้​อีก​ที่ แล้ว​นิยาม​เส้น committed ขึ้น​มา​จาก​สำเนา​ที่​ตาม​ทัน ตอน​นั้น​เส้น HW ใน​ภาพ​ข้าง​บน​จะ​เลิก​เดิน​ตาม​ปลาย log และ​กลาย​เป็น​ค่าที่​คำนวณ​จาก​สำเนา


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • Apache Kafka 4.1 — Design (ตรวจ​แล้ว 2026-09-03) — นิยาม​สาม​ข้อ​ของ​การ​ส่ง ประโยค​เตือน​เรื่อง fine print ประโยค​ที่​ระบุ​ว่า​ตั้งแต่ 0.11.0.0 broker แจก id ให้ producer แล้ว​กัน​ซ้ำ​ด้วย sequence ประโยค​เรื่อง​การ​เขียน​ตำแหน่ง​ผู้​อ่าน​ใน transaction เดียว​กับ​ผลลัพธ์ และ​ประโยค​ที่​จำกัด​ขอบ​ของ exactly-once ไว้​ที่​ระบบ​ปลาย​ทาง​ซึ่ง​ต้อง​ร่วม​มือ​ด้วย
  • KIP-98 — Exactly Once Delivery and Transactional Messaging (ตรวจ​แล้ว 2026-09-03) — ประโยค​ที่ producer ทุก​ตัว​ได้ PID ตอน initialize และ​ประโยค​ที่ sequence เริ่ม​จาก​ศูนย์ โดย​มี​หนึ่ง​ตัว​นับ​ต่อ​หนึ่ง topic partition
  • ProducerStateEntry.java tag 4.1.0 (ตรวจ​แล้ว 2026-09-03) — NUM_BATCHES_TO_RETAIN = 5 javadoc ที่​อธิบาย​การ​ดัน​ตัว​หน้า​ออก​เมื่อ​คิว​เต็ม และ findDuplicateBatch ที่​ตัด​ด้วย epoch ก่อน​แล้ว​หา​ช่วง sequence ที่​ตรง​เป๊ะ
  • BatchMetadata.java tag 4.1.0 (ตรวจ​แล้ว 2026-09-03) — field สี่​ตัว lastSeq lastOffset offsetDelta timestamp กับ firstSeq() และ firstOffset() ที่​คำนวณ​กลับ​จาก offsetDelta
  • UnifiedLog.java tag 4.1.0 (ตรวจ​แล้ว 2026-09-03) — สาขา result.maybeDuplicate ที่​ตอบ​ด้วย offset ชุด​เดิม และ​ข้าม​สาขา​ที่​เรียก localLog.append(...) ไป​ทั้ง​ก้อน
  • ProducerAppendInfo.java tag 4.1.0 (ตรวจ​แล้ว 2026-09-03) — จุด​ที่​โยน OutOfOrderSequenceException และ​ตัว inSequence ที่​นิยาม​ความ​ต่อ​เนื่อง​ว่า nextSeq == lastSeq + 1L
  • ProducerStateManager.java tag 4.1.0 (ตรวจ​แล้ว 2026-09-03) — file .snapshot ที่ Kafka มี​และ kaen-log ไม่มี กับ isProducerExpired ที่​ล้าง​สถานะ​ของ producer ที่​เงียบ​เกิน producerIdExpirationMs และ​เป็น​หนึ่ง​ใน​สาม file ฝั่ง broker ที่​ไม่มี​ทาง​เดิน​ไหน​โยน​รหัส 46 เลย
  • Errors.java tag 4.1.0 (ตรวจ​แล้ว 2026-09-03) — รหัส 45 OUT_OF_ORDER_SEQUENCE_NUMBER และ​รหัส 46 DUPLICATE_SEQUENCE_NUMBER พร้อม​ข้อความ​ของ​ทั้ง​คู่
  • Sender.java tag 4.1.0 (ตรวจ​แล้ว 2026-09-03) — ฝั่ง client ที่​จัดการ​รหัส 46 เอง โดย​ตอบ​ผู้​ใช้​ว่า​สำเร็จ แล้ว​ไม่​คืน offset กับ timestamp ที่​ถูก​ต้อง
  • ProducerConfig.java tag 4.1.0 (ตรวจ​แล้ว 2026-09-03) — เพดาน max.in.flight.requests.per.connection ที่​ผูก​กับ​จำนวน batch ที่ broker เก็บ​ไว้ และ​ประโยค​ที่​ว่า​ถ้า​ไม่มี transactional.id ก็ได้​แค่ idempotent delivery
  • ConsumerConfig.java tag 4.1.0 (ตรวจ​แล้ว 2026-09-03) — ค่า​ปริยาย read_uncommitted และ​นิยาม​ของ last stable offset ว่า​อยู่​ก่อน offset ของ transaction ที่​เปิด​ค้าง​ตัว​แรก
  • ControlRecordType.java tag 4.1.0 (ตรวจ​แล้ว 2026-09-03) — ABORT((short) 0) และ COMMIT((short) 1) ซึ่ง​เป็น​ค่าที่​ต้อง​เชื่อ แทน​ร้อย​แก้ว​ของ KIP-98 ที่​เขียน​คู่​นี้​สลับ​กัน
  • DefaultRecordBatch.java tag 4.1.0 (ตรวจ​แล้ว 2026-09-03) — javadoc หัวข้อ On Compaction ที่​ระบุ​ว่า​รูป​แบบ v2 รักษา offset กับ sequence ตัว​แรก​และ​ตัว​สุดท้าย​ไว้​ตอน​กวาด log เพื่อ​ให้​สร้าง​สถานะ​ของ producer ขึ้น​ใหม่​ได้​ตอน​อ่าน log กลับ
  • Topic.java tag 4.1.0 (ตรวจ​แล้ว 2026-09-03) — TRANSACTION_STATE_TOPIC_NAME ที่​ประกาศ​ไว้​คู่​กับ GROUP_METADATA_TOPIC_NAME ใน​ราย​ชื่อ topic ภายใน
  • Apache Kafka 0.11.0.0 บน​แหล่ง​เก็บ​ของ ASF (ตรวจ​แล้ว 2026-09-03) — วัน​ที่ 2017-06-28 ของ​ทุก​ชิ้น​ใน​รุ่น​ที่​การ​กัน​ซ้ำ​เข้า​มา​เป็น​ครั้ง​แรก

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

ข้อ 1 / 3

broker ได้ batch ที่มี producer id เดิม และช่วง sequence ตรงกับ batch ที่มันเก็บไว้ในหน้าต่างพอดี มันตอบอะไรกลับไป