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

เลข​สอง​ตัว​ของ​ผู้​บริโภค · ตำแหน่ง กับ offset ที่ commit แล้ว

บท​ที่ 4 วาง​ทาง​อ่าน​ไว้​ครบ​แล้ว ผู้​อ่าน​ขอ record จาก offset ที่​ต้องการ แล้ว​ได้​ของ​กลับ​ไป สิ่ง​ที่​ยัง​ไม่มี​ใคร​ตอบ​คือ ผู้​อ่าน​รู้​ได้​อย่างไร​ว่า​ครั้ง​หน้า​ต้อง​ขอ​จาก​ตรง​ไหน

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

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

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

crate ของ​คอร์ส​อยู่​ที่ scripts/log-broker/ และ​ชื่อ binary คือ klog เหมือน​ทุก​บท

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

ผล​รัน​ทุก​บรรทัด​ใน​บท​นี้​มา​จาก​คำ​สั่ง​เดียว คือ klog group-demo --dir data --group kitchen --crash-after 12 --now-ms 1700000000000 ซึ่ง​เทสต์​รัน​ซ้ำ​แล้ว​เทียบ​ผล​ที​ละ byte ทุก​ครั้ง

บท​นี้​เป็น​บท​แรก​ที่​สร้าง partition ใบ​ที่​สอง คือ data/__offsets-0/ demo ลบ folder ทั้ง​สอง​ใบ​ทิ้ง​ทั้ง​ก่อน​และ​หลัง​รัน

ผู้​อ่าน​หนึ่ง​ราย​ถือ​เลข​สอง​ตัว และ​มี​ตัว​เดียว​ที่​รอด​จาก​การ​ล้ม

หัวข้อ​ที่​มีชื่อ​ว่า “ผู้​อ่าน​หนึ่ง​ราย​ถือ​เลข​สอง​ตัว และ​มี​ตัว​เดียว​ที่​รอด​จาก​การ​ล้ม”

หัวข้อ​นี้​แยก​ตำแหน่ง​ใน​หน่วย​ความ​จำ ออก​จาก offset ที่ commit แล้ว​บน disk

ผู้​อ่าน​ของ kaen-log ไม่​ได้​ทำงาน​คน​เดียว มัน​สังกัด consumer groupconsumer groupกลุ่ม​ผู้​อ่าน​ที่​แบ่ง partition กัน​อ่าน · หนึ่ง partition มี​สมาชิก​อ่าน​ได้​ตัว​เดียว​ใน​หนึ่ง​ช่วง​เวลา หนึ่ง​กลุ่ม และ log ของ​คอร์ส​นี้​มี​สาม​กลุ่ม​ที่​อ่าน​อยู่​พร้อม​กัน คือ kitchen courier และ analytics

เลข​ตัว​แรก​ที่​สมาชิก​กลุ่ม​ถือ​คือ positionpositionoffset ตัว​ถัด​ไป​ที่​ผู้​อ่าน​จะ​ขอ · อยู่​ใน​หน่วย​ความ​จำ​ของ​ผู้​อ่าน​เอง และ​เดิน​นำ committed offset ได้​เสมอ มัน​คือ offset ตัว​ถัด​ไป​ที่​จะ​ขอ มัน​เดิน​หน้า​เอง​ทุก​ครั้ง​ที่ poll ได้​ของ​กลับ​มา และ​มัน​อยู่​ใน​หน่วย​ความ​จำ​ของ process นั้น​ล้วนๆ

เอกสาร​ของ KafkaConsumer เขียน​นิยาม​นี้​ไว้​เอง ว่า​ตำแหน่ง “gives the offset of the next record that will be given out. It will be one larger than the highest offset the consumer has seen in that partition. It automatically advances every time the consumer receives messages in a call to poll(Duration)”

เลข​ตัว​ที่​สอง​คือ committed offsetcommitted offsetoffset ที่ group บันทึก​ไว้​ว่า​อ่าน​ถึง​แล้ว · เก็บ​เป็น record ใน log ของ​ตัว​เอง ไม่ใช่​เก็บ​ใน​หน่วย​ความ​จำ เอกสาร​หน้า​เดียวกัน​เรียก​มัน​ว่า committed position และ​นิยาม​ว่า “is the last offset that has been stored securely. Should the process fail and restart, this is the offset that the consumer will recover to”

คำ​ที่​แยก​สอง​เลข​นี้​ออก​จาก​กัน​คือ securely ตำแหน่ง​ไม่​ได้​ถูก​เก็บ​ที่ไหน​เลย ส่วน offset ที่ commit แล้ว​ถูก​เขียน​ลง log ของ broker และ​รอด​จาก​การ​ที่ process ตาย

ประโยค​ปิด​ของ​ย่อหน้า​นั้น​คือ​เหตุผล​ที่​คอร์ส​นี้​ให้​ทั้ง​บท​กับ​เรื่อง​นี้ ว่า “This distinction gives the consumer control over when a record is considered consumed”

🔁 คอร์ส​ที่ 22 ไม่มี​เลข​ตัว​นี้​เลย​สัก​ตัว

คอร์ส kaen-kvstore เก็บ log ที่​ต่อ​ท้าย​อย่าง​เดียว​ไว้​แล้ว มี​สารบัญ​ของ​มัน​เอง มี compaction ของ​มัน​เอง และ​มี​การ​กู้​คืน​หลัง crash ของ​มัน​เอง

สิ่ง​ที่​มัน​ไม่มี​คือ​ความ​คิด​เรื่อง ตำแหน่ง​ของ​ผู้​อ่าน เพราะ​ผู้​อ่าน​ของ​มัน​คือ​ตัว​มัน​เอง get หนึ่ง​ครั้ง​เปิด​สารบัญ​แล้ว seek ไป​อ่าน ไม่มี​ใคร​เดิน​ไป​ข้าง​หน้า​และ​ไม่มี​ใคร​ต้อง​จำ​ว่า​อ่าน​ถึง​ไหน

ช่อง​ว่าง​นั้น​คือ​สิ่ง​ที่​บท​นี้​เติม และ​มัน​เป็น​ช่อง​ว่าง​ที่​โผล่​ขึ้น​มา​ตอน​ที่​ผู้​อ่าน​กลาย​เป็น​คน​แปลก​หน้า ที่​อยู่​คนละ process กับ​คน​เขียน

offset ที่ commit คือ record ตัว​ถัด​ไป ไม่ใช่​ตัว​สุดท้าย​ที่​ทำ​เสร็จ

หัวข้อ​ที่​มีชื่อ​ว่า “offset ที่ commit คือ record ตัว​ถัด​ไป ไม่ใช่​ตัว​สุดท้าย​ที่​ทำ​เสร็จ”

หัวข้อ​นี้​ตรึง​ความ​หมาย​ของ​ค่าที่​เก็บ ว่า​เป็น​เลข​ของ record ตัว​ถัด​ไป

ค่าที่ commit ลง​ไป​ไม่ใช่​หมายเลข​ของ record ที่​เพิ่ง​ประมวล​ผล​เสร็จ มัน​คือ​หมายเลข​ของ record ตัว​ถัด​ไป ประมวล​ผล record หมายเลข N เสร็จ​แล้ว​ต้อง​เก็บ N+1

src/group.rs เขียน​ข้อ​นี้​ไว้​ที่​หัว​ของ struct เอง เพราะ​มัน​คือ​ที่​เดียว​ที่​ผิด​แล้ว​จะ​ไม่มี​อะไร​เตือน

src/group.rs · ค่าที่ commit คือ record ตัว​ถัด​ไป
/// **`offset` คือ record ตัวถัดไปที่จะอ่าน ไม่ใช่ตัวสุดท้ายที่ประมวลผลไปแล้ว**
/// · commit หลังประมวลผล record หมายเลข N แปลว่าเก็บค่า N+1
///
/// บน wire ค่านี้เป็น `int64` · ในหน่วยความจำเป็น `u64` และ `from_wire_i64`
/// เป็นตัวเดียวที่แปลง โดยปฏิเสธค่าติดลบ
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OffsetValue {
pub offset: u64,
pub leader_epoch: i32,
pub metadata: String,
pub commit_timestamp_ms: i64,
}

เอกสาร​ของ KafkaConsumer เน้น​ข้อ​นี้​ด้วย​ตัว​หนา​ใน​หน้า​ของ​มัน​เอง ว่า “The committed offset should always be the offset of the next message that your application will read”

ประโยค​เดียวกัน​ใน​เอกสาร​ของ commitSync เติม​ทาง​ที่​ปลอดภัย​กว่า​เข้าไป​ด้วย ว่า​ให้​ใช้ nextRecordToBeProcessed.offset() หรือ ConsumerRecords#nextOffsets() แทน​การ​บวก​หนึ่ง​เอง

ข้อ​เท็จ​จริง​ที่​ทำให้​การ​บวก​หนึ่ง​เอง​น่า​กลัว​อยู่​ใน​เอกสาร​หน้า​เดียวกัน ว่า offset “are not guaranteed to be consecutive (such as compacted topic or when records have been produced using transactions)” เอกสาร​ไม่​ได้​ผูก​สอง​ข้อ​นี้​เข้า​ด้วย​กันเอง คอร์ส​นี้​เป็น​คน​ผูก

บท​ที่ 6 คือ​บท​ที่​สร้าง​ช่อง​ว่าง​แบบ​นั้น​ขึ้น​มา​จริงๆ compaction ลบ record ทิ้ง​ได้​โดย​ไม่​ขยับ offset ของ​ตัว​ที่​เหลือ ลำดับ​ที่​เคย​ติด​กัน​จึง​ขาด​เป็น​ช่วงๆ ได้

run_at_least_once ของ kaen-log ก็​บวก​หนึ่ง​เอง​เหมือน​กัน สิ่ง​ที่​ทำให้​มัน​รอด​ช่อง​ว่าง​ไม่ใช่​การ​บวก แต่​เป็น poll ที่​คัด​เฉพาะ record ที่ offset >= from ค่าที่​ตกลง​ไป​ใน​ช่อง​ว่าง​จึง​ได้​ตัว​ถัด​ไป ที่​ยัง​มี​อยู่​จริง เอกสาร​ของ Kafka เขียน​พฤติกรรม​เดียวกัน​ไว้ ว่า​ถ้า​ตำแหน่ง​เป็น 5 แต่​ไม่มี record ที่ offset 5 “the consumer will return the record with the next higher offset”

หน้าต่าง​ของ​การ​ส่ง​ซ้ำ คือ​ผล​ลบ​ของ​เลข​สอง​ตัว

หัวข้อ​ที่​มีชื่อ​ว่า “หน้าต่าง​ของ​การ​ส่ง​ซ้ำ คือ​ผล​ลบ​ของ​เลข​สอง​ตัว”

หัวข้อ​นี้​ล้ม consumer กลาง​คัน แล้ว​วัด​ว่า​มี order กี่​ตัว​ที่​ถูก​ทำ​ซ้ำ

ลำดับ​ของ at-least-once คือ​อ่าน แล้ว​ประมวล​ผล แล้ว​ค่อย​เก็บ​ตำแหน่ง เอกสาร​ออกแบบ​ของ Kafka เขียน​ลำดับ​นี้​ไว้​ตรง​ตัว ว่า “It can read the messages, process the messages, and finally save its position”

src/group.rs · อ่าน แล้ว​ประมวล​ผล แล้ว​ค่อย commit
/// อ่าน → ประมวลผล → แล้วค่อยเก็บตำแหน่ง · **at-least-once**
pub fn run_at_least_once(
orders: &Partition,
offsets: &Partition,
group: &str,
start: u64,
crash_after: u64,
poll_size: usize,
now_ms: i64,
) -> io::Result<RunOutcome> {
let mut position = start;
let mut committed = start;
let mut processed = Vec::new();
while position < orders.high_watermark() {
let records = poll(orders, position, poll_size)?;
if records.is_empty() {
break;
}
for record in &records {
processed.push(record.offset);
position = record.offset + 1;
if u64::try_from(processed.len()).map_err(|_| bad("นับเกิน u64"))? >= crash_after
{
return Ok(RunOutcome { position, committed, processed });
}
}
committed = position;
commit(offsets, group, committed, now_ms)?;
}
Ok(RunOutcome { position, committed, processed })
}

position ขยับ​ทุก record ส่วน committed ขยับ​เมื่อ​จบ​ชุด​ของ poll หนึ่ง​ครั้ง​เท่านั้น บรรทัด return ตรง​กลาง​คือ​การ​ล้ม มัน​ออก​จาก function โดย​ไม่​ผ่าน​สอง​บรรทัด​ล่าง​สุด​ของ loop

demo สั่ง​ให้​มัน​ล้ม​หลัง​ประมวล​ผล​ครบ 12 ตัว โดย poll ครั้ง​ละ 5 record

expected/05-group.txt (ตัด​มา​บาง​ส่วน)
== at-least-once · อ่าน → ประมวลผล → เก็บตำแหน่ง ==
group kitchen · poll ครั้งละ 5 record · ล้มหลังประมวลผลครบ 12 ตัว
ตำแหน่ง (ในหน่วยความจำ) 12
offset ที่ commit (บน disk) 10
หน้าต่างการส่งซ้ำ 2 = 12 - 10
restart แล้วอ่าน offset จาก log ของ broker ได้ 10
record ที่ถูกประมวลผลซ้ำ 2 ตัว · offset [10, 11]
เท่ากับหน้าต่างพอดี · ไม่มี order หาย มีแต่ order ที่ทำซ้ำ

ตอน​ล้ม ตำแหน่ง​อยู่​ที่ 12 ส่วน​ค่าที่ commit ไว้​บน disk ยัง​เป็น 10 ผล​ลบ​ของ​สอง​ตัว​นี้​คือ 2 และ​มัน​ไม่ใช่​การ​ประมาณ มัน​คือ​จำนวน​ที่​จะ​ถูก​ทำ​ซ้ำ​จริง

บรรทัด​ถัด​มา​พิสูจน์​ข้อ​นั้น​ด้วย​การ​รัน​จริง ไม่ใช่​ด้วย​การ​คำนวณ demo restart ผู้​อ่าน​ขึ้น​มา​ใหม่ อ่าน​ค่าที่ commit ไว้​จาก log ของ broker ได้ 10 แล้ว​เดิน​ต่อ record ที่​ตรง​กับ​รอบ​แรก​มี​สอง​ตัว​พอดี คือ offset 10 กับ 11

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

sequenceDiagram
    participant C as ผู้อ่านของ group kitchen
    participant B as partition orders-0
    participant O as log ของ __offsets
    C->>B: fetch จากตำแหน่ง 10
    B-->>C: record 10 ถึง 14
    Note over C: ทำ order 10 กับ 11 เสร็จ · ตำแหน่งเดินไปที่ 12
    C--xC: ล้มก่อนถึงบรรทัด commit
    Note over O: ค่าที่เก็บไว้ล่าสุดยังเป็น 10
    C->>O: restart แล้วขอค่าที่ commit ไว้
    O-->>C: 10
    C->>B: fetch จากตำแหน่ง 10 อีกครั้ง
    Note over C: order 10 กับ 11 ถูกทำซ้ำ · เท่ากับ 12 ลบ 10

คำ​บรรยาย​ภาพ: ลูก​ศร​ที่​ย้อน​กลับ​มา​ที่​ตัว​เอง​คือ​การ​ล้ม ตำแหน่ง​ที่​เดิน​ไป​ถึง 12 อยู่​ใน​หน่วย​ความ​จำ​ของ process ที่​ตาย​ไป​แล้ว ส่วน​ค่าที่​ผู้​อ่าน​คน​ใหม่​หยิบ​ขึ้น​มา​ได้​คือ​ค่าที่ commit ไว้​บน disk เท่านั้น ระยะ​ระหว่าง​สอง​ค่า​นั้น​คือ order ที่​ถูก​ทำ​ซ้ำ

สลับ​สอง​บรรทัด แล้ว order หาย​จริง ไม่ใช่​แค่​ทำ​ซ้ำ

หัวข้อ​ที่​มีชื่อ​ว่า “สลับ​สอง​บรรทัด แล้ว order หาย​จริง ไม่ใช่​แค่​ทำ​ซ้ำ”

หัวข้อ​นี้​ย้าย commit ขึ้น​ไป​ก่อน​การ​ประมวล​ผล แล้ว​นับ order ที่​หาย​ไป

เอกสาร​ออกแบบ​ของ Kafka เขียน​อีก​ทาง​เลือก​ไว้​ก่อน​หน้า​นั้น​หนึ่ง​ข้อ ว่า “It can read the messages, then save its position in the log, and finally process the messages” และ​สรุป​ผล​ของ​มัน​ว่า “This corresponds to ‘at-most-once’ semantics as in the case of a consumer failure messages may not be processed”

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

src/group.rs · การ commit ที่​ย้าย​ขึ้น​มา​อยู่​ก่อน loop ประมวล​ผล (ตัด​มา​บาง​ส่วน)
let last = records.last().expect("ตรวจว่าไม่ว่างไปแล้ว").offset;
committed = last + 1;
commit(offsets, group, committed, now_ms)?;
for record in &records {

การ commit เกิด​ขึ้น​ก่อน​ที่ record ชุด​นั้น​จะ​ถูก​แตะ​เลย​สัก​ตัว ผล​ของ​การ​สลับ​ที่​พิมพ์​ออก​มา​แบบ​นี้

expected/05-group.txt (ตัด​มา​บาง​ส่วน)
== at-most-once · อ่าน → เก็บตำแหน่ง → ประมวลผล ==
ตำแหน่ง (ในหน่วยความจำ) 12
offset ที่ commit (บน disk) 15
record ที่หายไปเลย 3 ตัว · offset 12 ถึง 14
สลับสองบรรทัดแล้ว order หายจริง ไม่ใช่แค่ทำซ้ำ
นี่คือความต่างทั้งหมดระหว่างสองความหมาย · ไม่มีอะไรอื่นเลย

คราว​นี้​ค่าที่ commit ไว้​เดิน​นำ​ตำแหน่ง​จริง ผู้​อ่าน​คน​ใหม่​จะ​เริ่ม​ที่ 15 ส่วน order หมายเลข 12 ถึง 14 ไม่​เคย​ถูก​ประมวล​ผล และ​ไม่มี​ใคร​กลับ​ไป​หยิบ​มัน​อีก

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

⚠ ค่า​ปริยาย​ของ client เลือก​ข้าง​ให้​แล้ว และ​มัน​เลือก​ทาง​ที่​มี​ของ​หาย​ได้

enable.auto.commit ของ KafkaConsumer มี​ค่า​ปริยาย​เป็น true ผู้​อ่าน​ที่​ไม่​ได้​ตั้ง​อะไร​เลย​จึง commit ให้​เอง​เป็น​ระยะ​อยู่​เบื้อง​หลัง

เอกสาร​ของ​มัน​เตือน​ไว้​ตรง​ตัว​ว่า auto commit ให้ at-least-once ก็​ต่อ​เมื่อ​ทำ​ครบ​เงื่อนไข “you must consume all data returned from each call to poll(Duration) before any subsequent calls” และ​ถ้า​ไม่​ครบ “it is possible for the committed offset to get ahead of the consumed position, which results in missing records”

นั่น​คือ​รูป​เดียว​กับ at-most-once เป๊ะ คือ​ค่าที่ commit เดิน​นำ​ตำแหน่ง​จริง มัน​ไม่​ได้​มา​จาก​การ​สลับ​บรรทัด​ด้วย​มือ แต่​มา​จาก​การ​ปล่อย​ให้​ตัว commit เดิน​ตาม​นาฬิกา​ของ​มัน​เอง

หัวข้อ​นี้​เปิด partition ใบ​ที่​สอง แล้ว​บอก​ว่า key ของ record ใน​นั้น​คือ​อะไร

broker ไม่​ได้​เก็บ offset ที่ commit ไว้​ใน​ตาราง​พิเศษ มัน​เก็บ​เป็น record ธรรมดา​ใน log ของ​ตัว​เอง คอร์ส​นี้​ตั้ง​ชื่อ partition นั้น​ว่า __offsets

src/group.rs · ชื่อ​ของ partition ภายใน
/// ชื่อ partition ภายในของคอร์สนี้ · ของ Kafka ชื่อ `__consumer_offsets`
/// และมี **50 partition กับ replication factor 3** · ของ kaen-log มี partition เดียว
/// และ **ไม่มี replica เลย** · บทเรียนต้องพูดข้อนี้ ไม่ใช่ปล่อยผ่าน
pub const OFFSETS_TOPIC: &str = "__offsets";

ของ Kafka ชื่อ __consumer_offsets และ GroupCoordinatorConfig ตั้ง​ค่า​ปริยาย​ไว้​ที่ 50 partition กับ replication factor 3 ส่วน​คำ​อธิบาย​ของ​ค่า​แรก​เขียน​กำกับ​ไว้​ด้วย​ว่า “should not change after deployment”

kaen-log มี partition เดียว​และ​ไม่มี​สำเนา​ของ​มัน​เลย​สัก​ใบ นี่​ไม่ใช่​ราย​ละเอียด​เล็ก​น้อย มัน​แปล​ว่า disk ใบ​ที่​พัง พา offset ที่ commit ไว้​ของ​ทั้ง​สาม​กลุ่ม​ไป​ด้วย​พร้อม​กัน

expected/05-group.txt (ตัด​มา​บาง​ส่วน)
== partition สองใบ ==
folder topic cleanup.policy
data/orders-0 orders delete
data/__offsets-0 __offsets compact
log end offset ของ orders 20
`__offsets` ของ Kafka ชื่อ `__consumer_offsets` มี 50 partition และ RF 3
ของ kaen-log มี partition เดียว และไม่มี replica เลยสักตัว

สอง partition นี้​ต่าง​กัน​ที่ cleanup.policy ใบ​ของ​ผู้​ใช้​ตั้ง​เป็น delete ส่วน​ใบ​ของ broker ตั้ง​เป็น compact และ​ของ Kafka ก็​ตั้ง​แบบ​เดียวกัน GroupCoordinatorService.groupMetadataTopicConfigs ใส่ TopicConfig.CLEANUP_POLICY_COMPACT ให้ topic นั้น​ตอน​สร้าง

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

src/group.rs · key ของ record ที่​เก็บ offset
/// key คือ triple พอดี · ไม่มีอะไรเกินและไม่มีอะไรขาด
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct OffsetKey {
pub group: String,
pub topic: String,
pub partition: i32,
}

รูป​ของ key ลอก​มา​จาก OffsetCommitKey.json ของ Kafka ที​ละ​ช่อง schema นั้น​ประกาศ validVersions ไว้​ที่ "0-1" และ​มี​สาม field พอดี คือ group เป็น string topic เป็น string และ partition เป็น int32

ที่​ลอก​มา​คือ​ราย​ชื่อ​ของ field ไม่ใช่​การ encode ตัว kaen-log เขียน​สาม field นี้​ลง byte ด้วย​วิธี​ของ​มัน​เอง และ​ไม่​ได้​เข้า​กัน​ได้ที​ละ byte กับ client ของ Kafka ตาม​ที่​บท​ที่ 1 ประกาศ​ไว้​ตั้งแต่​ต้น

การ​ที่ key มี group อยู่​ข้าง​ใน​คือ​เหตุผล​ที่​สาม​กลุ่ม​อยู่​คนละ​ตำแหน่ง​บน log เดียวกัน​ได้ มัน​ไม่ใช่ feature ที่​เติม​เข้า​มา มัน​เป็น​ผล​ของ​การ​เลือก key

append_to_offsets_log ไม่​ได้​เขียน​ลง log ด้วย​ทาง​ลัด​ของ​ตัว​เอง มัน​เรียก​ทาง produce เส้น​เดียว​กับ​ที่ บท​ที่ 3 สร้าง​ไว้ จึง​ได้การ​แจก offset การ fsync และ​การ​ตอบ​กลับ​แบบ​เดียวกัน​ทั้งหมด

src/group.rs · record หนึ่ง​ตัว​ของ​การ commit
let record = Record {
offset: 0,
timestamp_ms: now_ms,
key: Some(key.encode()),
// value เป็น `None` คือ tombstone · เส้นทางเดียวกับที่คำสั่งลบใช้
value: value.map(OffsetValue::encode),
};

ช่อง offset เป็น​ศูนย์​ตอน​ส่ง เพราะ​บท​ที่ 1 ตรึง​ข้อ​นี้​ไว้​แล้ว​ว่า broker เป็น​คน​แจก​ลำดับ ไม่ใช่​ผู้​ส่ง แม้​ผู้​ส่ง​ครั้ง​นี้​จะ​เป็น​ตัว broker เอง​ก็ตาม

หัวข้อ​นี้​อ่าน log ของ offset ทั้ง​ก้อน​กลับ​มา​เป็น​ตาราง แล้ว​ลบ key หนึ่ง​ตัว​ทิ้ง

__offsets เป็น log ที่​ต่อ​ท้าย​อย่าง​เดียว​เหมือน​กับ orders ทุก​ประการ การ​จะ​รู้​ว่า​ตอน​นี้​แต่ละ​กลุ่ม​อ่าน​ถึง​ไหน จึง​ต้อง​อ่าน​ทั้ง log แล้ว​เก็บ​ค่า​สุดท้าย​ของ​แต่ละ key

src/group.rs · replay ทั้ง log แล้ว​เหลือ​ค่า​สุดท้าย​ต่อ key
/// replay ทั้ง log แล้วเก็บค่าสุดท้ายต่อ key · tombstone ลบ key นั้นออก
///
/// ใช้ `BTreeMap` ไม่ใช่ `HashMap` เพราะ demo พิมพ์ตารางนี้ออกมา และลำดับของ
/// `HashMap` ไม่แน่นอนระหว่างการรันแต่ละครั้ง
pub fn load_offsets(offsets: &Partition) -> io::Result<BTreeMap<OffsetKey, OffsetValue>> {
let mut out = BTreeMap::new();
let log = offsets.log_guard();
for segment in log.segments() {
for (_, batch) in segment.read_all_batches()? {
for record in batch.records {
// record ที่ไม่มี key ผิดกฎบน topic ที่ compact · Kafka ปฏิเสธตั้งแต่ produce
let Some(raw_key) = record.key.as_deref() else { continue };
let key = OffsetKey::decode(raw_key)?;
match record.value.as_deref() {
Some(raw) => {
out.insert(key, OffsetValue::decode(raw)?);
}
None => {
out.remove(&key);
}
}
}
}
}
Ok(out)
}

สอง branch ล่าง​สุด​คือ​ทั้งหมด​ของ​ความ​หมาย value ที่​มี​ของ​แปล​ว่า​ทับ​ค่า​เดิม ส่วน value ที่​เป็น None แปล​ว่า​ลบ key นั้น​ออก​จาก​ผล ซึ่ง​ก็​คือ tombstone ที่​บท​ที่ 1 นิยาม​ไว้​แล้ว ว่า​เป็น​ความ​ยาว​ลบ​หนึ่ง​บน wire

การ​ลบ​จึง​ไม่ใช่​การ​ไป​แก้​ของ​เก่า มัน​คือ​การ​ต่อ​ท้าย record ใหม่​หนึ่ง​ตัว​ที่​ไม่มี value

expected/05-group.txt (ตัด​มา​บาง​ส่วน)
== ลบ offset ของกลุ่มหนึ่งทิ้ง · tombstone เส้นทางเดียวกับบทที่ 6 ==
key ที่ยังมีชีวิตก่อนลบ 3 · หลังลบ 2
log ยาวขึ้นหนึ่ง record แต่ key ที่ยังมีชีวิตน้อยลงหนึ่งตัว
log end offset ของ __offsets 7
บทที่ 6 เป็นคนเก็บกวาด record พวกนี้ ด้วยตัวทำความสะอาดตัวเดียวกับ orders

สอง​บรรทัด​กลาง​คือ​ความ​ย้อน​แย้ง​ที่​ทำให้​ต้อง​มี​บท​ที่ 6 log ยาว​ขึ้น​ทุก​ครั้ง​ที่​มี​คน​ลบ​ของ ส่วน​จำนวน​ของ​ที่​ยัง​มี​ชีวิต​กลับ​น้อย​ลง

บท​ที่ 6 · retention กับ compaction คือ​คน​ที่มา​เก็บ​กวาด​ส่วน​ต่าง​นั้น และ​มัน​ใช้​ตัว​ทำความ​สะอาด​ตัว​เดียว​กับ​ที่​ใช้​กับ orders ไม่มี​กรณี​พิเศษ​ให้ partition ภายใน

หัวข้อ​นี้ commit ให้​สาม​กลุ่ม​ที่​สาม​ค่า แล้ว​อ่าน​ทั้ง​ตาราง​กลับ​ออก​มา

demo เขียน commit ของ​สาม​กลุ่ม​ลง __offsets ที​ละ​ตัว แล้ว​อ่าน​ทั้ง log กลับ​มา​เป็น​ตาราง

expected/05-group.txt (ตัด​มา​บาง​ส่วน)
== สามกลุ่ม อ่าน log เดียวกัน ที่สาม offset ==
commit ของ kitchen เก็บที่ offset 3 ของ __offsets
commit ของ courier เก็บที่ offset 4 ของ __offsets
commit ของ analytics เก็บที่ offset 5 ของ __offsets
group topic partition offset ถัดไปที่จะอ่าน
analytics orders 0 0
courier orders 0 4
kitchen orders 0 7
การอ่านไม่กินของใคร · สามกลุ่มอยู่คนละตำแหน่งบน log เดียวกันได้ตลอดไป

สาม​แถว​บน​คือ record สาม​ตัว​ใน __offsets เรียง​ตาม​ลำดับ​ที่​เขียน​ลง​ไป สาม​แถว​ล่าง​คือ​ผล​ของ​การ replay ซึ่ง​เรียง​ตาม​ชื่อ​กลุ่ม เพราะ BTreeMap เรียง​ตาม key ให้

ตัวเลข​ที่​น่า​สนใจ​อยู่​ท้าย​แถว analytics ยัง​อยู่​ที่ 0 คือ​ยัง​ไม่​ได้​เริ่ม​อ่าน courier อยู่​ที่ 4 และ kitchen อยู่​ที่ 7 ทั้ง​สาม​อ่าน log ก้อน​เดียวกัน​อยู่

เอกสาร​ออกแบบ​ของ Kafka อธิบาย​ว่า​ทำไม​สิ่ง​นี้​ถึง​ถูก โดย​เทียบ​กับ​ระบบ​คิว​ที่​เก็บ​สถานะ​ไว้​ที่ broker ที​ละ​ข้อความ ระบบ​แบบ​นั้น “must keep multiple states about every single message” คือ​ทั้ง​การ​ล็อก​ไม่​ให้​แจก​ซ้ำ และ​การ​มาร์ค​ว่า​บริโภค​แล้ว

kaen-log ไม่​เก็บ​สถานะ​ราย​ข้อความ​สัก​ตัว มัน​เก็บ​จำนวนเต็ม​หนึ่ง​ตัว​ต่อ​หนึ่ง triple และ​การ​อ่าน​ไม่​ทำให้​ของ​หาย​ไป​จาก​ใคร

ประโยค​ที่​เอกสาร​ออกแบบ​เขียน​ไว้​คือ topic ถูก​แบ่ง​เป็น partition “each of which is consumed by exactly one consumer within each subscribing consumer group at any given time”

ประโยค​เดียวกัน​บอก​ต่อว่า​ผล​ของ​มัน​คือ “the position of a consumer in each partition is just a single integer, the offset of the next message to consume” และ​ปิด​ท้าย​ว่า “This makes the equivalent of message acknowledgements very cheap”

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

ข้อ​สรุป​นั้น​เป็นการ​อนุมาน​จาก​กติกา​ข้าง​บน ไม่ใช่​ประโยค​ที่​เอกสาร​ของ Kafka เขียน​ไว้​เอง คอร์ส​นี้​ค้น​แล้ว​ไม่​เจอ​ประโยค​แบบ​นั้น จึง​เสนอ​มัน​ใน​ฐานะ​ข้อ​สรุป ไม่ใช่​ใน​ฐานะ​คำ​อ้าง

หัวข้อ​นี้​ให้​สมาชิก​รุ่น​เก่า​ลอง commit แล้ว​ดู​ว่า​อะไร​ปฏิเสธ​มัน

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

เครื่อง​มือ​ที่​ใช้​กัน​มัน​คือ generation fencinggeneration fencingการ​กัน​สมาชิก​รุ่น​เก่า​ไม่​ให้​บันทึก offset ทับ ด้วย​เลข​รุ่น​ของ group ที่​เพิ่ม​ขึ้น​ทุก​ครั้ง​ที่​จัด​สมาชิก​ใหม่ ซึ่ง​เป็น​จำนวนเต็ม​ตัว​เดียว​ที่​เพิ่ม​ขึ้น​ทุก​ครั้ง ที่​กลุ่ม​จัด​สมาชิก​ใหม่

src/group.rs · สถานะ​ของ​กลุ่ม​หนึ่ง​กลุ่ม
/// สมาชิกเก็บเป็น `Vec` ไม่ใช่ `HashMap` · ลำดับของ `HashMap` ไม่แน่นอน และ demo
/// ตัวนี้พิมพ์รายชื่อสมาชิกออกมา
pub struct Group {
pub id: String,
pub state: GroupState,
/// การกันผีเป็น `i32` ตัวเดียว · ตรงกับ `GenerationIdOrMemberEpoch` บน wire
pub generation: i32,
pub members: Vec<String>,
pub last_heartbeat_ms: Vec<(String, i64)>,
}

ความ​กว้าง​ของ​ช่อง​นั้น​ไม่ใช่​รสนิยม OffsetCommitRequest.json ของ Kafka ประกาศ field ชื่อ GenerationIdOrMemberEpoch เป็น int32 มี​ค่า​ปริยาย​เป็น -1 และ​คำ​อธิบาย​ของ​มัน​บอก​ว่า​มัน​คือ “The generation of the group if using the classic group protocol or the member epoch if using the consumer protocol”

ตัว​ตัดสิน​ใจ​ของ kaen-log อ่าน​สถานะ​ก่อน แล้ว​ค่อย​อ่าน​เลข​รุ่น

src/group.rs · ด่าน​ของ​การ commit
/// **การ rebalance ปิดการเขียน ไม่ได้ปิดการอ่าน** · `PREPARING_REBALANCE`
/// ยังรับ commit จาก generation ก่อนหน้าได้ ส่วน `COMPLETING_REBALANCE` ไม่รับ
/// · การ **อ่าน** offset ทำได้ในทุกสถานะที่ยังมีชีวิต
pub fn check_commit(&self, member: &str, generation: i32) -> Result<(), GroupError> {
match self.state {
GroupState::CompletingRebalance => return Err(GroupError::RebalanceInProgress),
GroupState::Dead => return Err(GroupError::UnknownMemberId),
_ => {}
}
if generation != self.generation {
return Err(GroupError::IllegalGeneration);
}
if !self.members.iter().any(|m| m == member) {
return Err(GroupError::UnknownMemberId);
}
Ok(())
}

คำ​อธิบาย​เหนือ function ตัว​นี้​เขียน​กฎ​ของ Kafka ไว้ ไม่ใช่​กฎ​ที่ code ข้าง​ล่าง​มัน​บังคับ​ครบ​ทุก​ข้อ ส่วน​ที่​ไม่​ตรง​กัน​อยู่​ใน callout ท้าย​หัวข้อ​นี้

รูป​ของ match ลอก​มา​จาก ClassicGroupState ของ Kafka ซึ่ง​เขียน​สิ่ง​ที่​แต่ละ​สถานะ​ทำ​ไว้​เป็น​รายการ COMPLETING_REBALANCE เขียน​ว่า “respond to offset commits with REBALANCE_IN_PROGRESS” ส่วน DEAD เขียน​ว่า “respond to offset commit with UNKNOWN_MEMBER_ID”

demo ยิง​หก​กรณี​เข้าไป​ที่​ด่าน​นี้ แล้ว​พิมพ์​ผล​ของ​ทั้ง​การ​เขียน​และ​การ​อ่าน​คู่​กัน

expected/05-group.txt (ตัด​มา​บาง​ส่วน)
== กันผีด้วย i32 ตัวเดียว ==
group kitchen · สถานะ Stable · generation 2 · สมาชิก ["member-a", "member-b"]
group ที่ยังไม่มีสมาชิกเลยอยู่ในสถานะ Empty
กรณี commit fetch
สมาชิกจริง generation ปัจจุบัน ผ่าน ผ่าน
ผีจาก generation ก่อน ILLEGAL_GENERATION ผ่าน
สมาชิกที่ไม่รู้จัก UNKNOWN_MEMBER_ID ผ่าน
กำลังเตรียม rebalance ผ่าน ผ่าน
กำลังปิด rebalance REBALANCE_IN_PROGRESS ผ่าน
group ตายแล้ว UNKNOWN_MEMBER_ID UNKNOWN_MEMBER_ID
rebalance ปิดการ **เขียน** ไม่ได้ปิดการ **อ่าน**
PreparingRebalance ยังรับ commit จาก generation ปัจจุบันได้ · CompletingRebalance ไม่รับ
การอ่าน offset ผ่านได้ทุกสถานะที่ยังมีชีวิต · ตกเฉพาะตอน group ตายแล้ว

column ขวา​สุด​คือ​ข้อ​ที่​คน​มอง​ข้าม​บ่อย​ที่สุด การ​อ่าน​ผ่าน​ทุก​แถว​ยกเว้น​แถว​สุดท้าย การ rebalance ปิด​การ​เขียน ไม่​ได้​ปิด​การ​อ่าน

ClassicGroupState พูด​ข้อ​นี้​ด้วย​ความ​สม่ำเสมอ​ที่​หา​ยาก ทั้ง​ห้า​สถานะ​เขียน​บรรทัด “allow offset fetch requests” ไว้​เหมือน​กัน​หมด รวม​ทั้ง DEAD ที่​ปฏิเสธ​การ commit ไป​แล้ว

และ kaen-log ไม่​ได้​ตาม​ข้อ​สุดท้าย​นั้น แถว​ล่าง​สุด​ของ​ตาราง​แสดง​ว่าการ​อ่าน​ตก​ด้วย​ตอน​ที่ group ตาย​แล้ว ซึ่ง​ต่าง​จาก Kafka ที่​ยัง​ยอม​ให้​อ่าน ตาราง​เดียวกัน​ที่​ใช้​แสดง​กฎ จึง​ฟ้อง​ข้อ​ที่ kaen-log ไม่​ได้​ทำ​ตาม​ด้วย

kaen-log เข้ม​กว่า Kafka ตรง​เลข​รุ่น และ​ข้อ​นั้น​สังเกต​ได้​จาก​ตาราง​เดียวกัน

PREPARING_REBALANCE ของ Kafka เขียน​ว่า “allow offset commits from previous generation” คือ​มัน​ยอมรับ commit จาก​รุ่น ก่อน​หน้า ระหว่าง​ที่​กำลัง​เตรียม​จัด​สมาชิก​ใหม่

check_commit ของ kaen-log ไม่​ทำ​แบบ​นั้น มัน​เทียบ​เลข​รุ่น​เท่า​กัน​เป๊ะ​ใน​ทุก​สถานะ​ที่​ยัง​มี​ชีวิต แถว “กำลัง​เตรียม rebalance” ใน​ตาราง​ข้าง​บน​จึง​ผ่าน เพราะ​กรณี​นั้น​ใช้ generation ปัจจุบัน ไม่ใช่​เพราะ kaen-log ยอมรับ​รุ่น​เก่า

ความ​ต่าง​ข้อ​นี้​ทำให้ kaen-log ปฏิเสธ commit ที่ Kafka จะ​รับ และ​ผล​คือ​ผู้​อ่าน​ที่​กำลัง​จะ​ถูก​จัด​ใหม่ เสีย commit สุดท้าย​ไป ซึ่ง​ขยาย​หน้าต่าง​การ​ส่ง​ซ้ำ​ให้​กว้าง​ขึ้น ไม่​ได้​ทำให้ order หาย

หัวข้อ​นี้​บอก​ว่าการ​จัด​สมาชิก​ใหม่​หยุด​อะไร และ​คอร์ส​นี้​ไม่มี​ตัวเลข​อะไร​ให้

การ​จัด​สมาชิก​ใหม่​ไม่​ได้​แพง​เพราะ​การ​คำนวณ​ว่า​ใคร​ถือ partition ไหน มัน​แพง​เพราะ ช่วง CompletingRebalance ปิด commit ของ​ทั้ง​กลุ่ม​ไว้ ตาราง​ข้าง​บน​แสดง​แถว​นั้น​ไว้​แล้ว ส่วน​แถว PreparingRebalance ยัง​ผ่าน ราคา​จึง​เก็บ​เฉพาะ​ช่วง​หลัง ไม่ใช่​ตลอด​การ​จัด​สมาชิก​ใหม่

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

คอร์ส​นี้​ไม่มี​ตัวเลข​ของ​การ rebalance ให้​เลย ไม่มี​เวลา​ที่​วัด​ได้ ไม่มี​ตัว​คูณ​ของ protocol รุ่น​ใหม่ และ​ไม่มี​อะไร​ใน expected/ ที่​จะ​เอา​มาร​อง​รับ​ตัวเลข​แบบ​นั้น​ได้

ตัว​คูณ​ที่​เจอ​บ่อย​ตาม​บทความ​ของ​ผู้​ขาย​ไม่มี​ที่มา​แบบ​ปฐม​ภูมิ คอร์ส​นี้​จึง​ไม่​พิมพ์​มัน ข้อ​อ้าง​ที่​วัด​ไม่​ได้ ไม่​ควร​มี​ตัวเลข​ติด​มา​ด้วย

หัวข้อ​นี้​ประกาศ​เส้น​ขอบเขต​ของ coordinator ที่​บท​นี้​เพิ่ง​สร้าง

  • ไม่มี protocol ของ​กลุ่ม​บน​สาย ไม่มี JoinGroup ไม่มี SyncGroup ไม่มี heartbeat จริง และ​ไม่มี​การ​เลือก​ผู้นำ​ของ​กลุ่ม สมาชิก​เข้า​กลุ่ม​ด้วย​การ​เรียก method ใน​กระบวนการ​เดียวกัน
  • สถานะ​ของ​กลุ่ม​อยู่​ใน​หน่วย​ความ​จำ​ล้วน Group ไม่​เคย​ถูก​เขียน​ลง disk ของ Kafka เก็บ metadata ของ​กลุ่ม​ไว้​ใน topic เดียว​กับ offset แต่​คอร์ส​นี้​ไม่​ได้​ตรวจ​รูป​ของ​มัน จึง​ไม่​อธิบาย​ว่า​มัน​หน้าตา​อย่างไร
  • ไม่มี​การ​หมด​อายุ​ของ offset ที่ commit ไว้ ของ Kafka มี และ​นาฬิกา​เริ่ม​เดิน​ตอน​ที่​กลุ่ม​ว่าง​ลง ตาม GroupCoordinatorConfig ส่วน __offsets ของ kaen-log เก็บ​ค่า​ไว้​จนกว่า​จะ​มี​คน​ลบ​ด้วย tombstone
  • coordinator ไม่​ได้​ถูก​เลือก​จาก​ใคร ของ Kafka หา coordinator จาก​การ hash ชื่อ​กลุ่ม แล้ว mod ด้วย​จำนวน partition ของ topic นั้น ตาม​ที่ partitionFor เขียน​ไว้​ว่า Utils.abs(groupId.hashCode()) % numPartitions การ​ล้ม​ของ coordinator จึง​เป็น​เรื่อง​เดียว​กับ การ​ล้ม​ของ leader ของ partition ซึ่ง​เป็น​กลไก​ที่​บท​ที่ 8 ถึง​จะ​มี
  • __offsets ไม่มี​สำเนา ใน​ขณะ​ที่​ค่า​ปริยาย​ของ Kafka คือ replication factor 3

ข้อ​สุดท้าย​มี​ผล​กับ​ข้อ​อ้าง​ของ​ทั้ง​บท offset ที่ commit แล้ว​ของ kaen-log รอด​จาก​การ​ที่ process ตาย มัน​ไม่​ได้​รอด​จาก​การ​ที่ disk ตาย และ​ไม่มี​บท​ไหน​ก่อน​บท​ที่ 8 ที่​อ้าง​แบบ​นั้น​ได้

บท​ที่ 6 · retention กับ compaction จะ​รับ __offsets ก้อน​นี้​ไป​ทำความ​สะอาด และ​ตรง​นั้น​คือ​จุด​ที่ tombstone กับ delete horizon จะ​ได้​ทำงาน​จริง​กับ log ที่​บท​นี้​เพิ่ง​สร้าง


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • KafkaConsumer.java tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — นิยาม​ของ​ตำแหน่ง​กับ committed position ประโยค​ที่​ว่า​ความ​ต่าง​ของ​สอง​ค่า​นี้ ให้​ผู้​อ่าน​คุม​ได้​เอง​ว่า​เมื่อ​ไหร่​ถือว่า record ถูก​บริโภค ประโยค​ที่​บอก​ว่า offset ไม่​รับ​ประกัน​ว่า​ติด​กัน และ​ประโยค​ที่​บอก​ว่า​ตำแหน่ง​ที่​ตกลง​ไป​ใน​ช่อง​ว่าง​จะ​ได้ record ตัว​ถัด​ไป​ที่ offset สูง​กว่า ประโยค​ตัว​หนา​เรื่อง record ตัว​ถัด​ไป และ​คำ​เตือน​เรื่อง auto commit ที่​ทำให้ record หาย​ได้
  • design.html tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — สอง​ย่อหน้า​ที่​นิยาม at-most-once กับ at-least-once ด้วย​ลำดับ​ของ​ขั้น​ตอน​ล้วน ประโยค​หนึ่ง partition ต่อ​หนึ่ง​ผู้​อ่าน​ใน​หนึ่ง​กลุ่ม และ​ย่อหน้า​ที่​บอก​ว่า​คิว​ต้อง​เก็บ​สถานะ​หลาย​ชั้น ต่อ​หนึ่ง​ข้อความ
  • ClassicGroupState.java tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — รายการ​ของ​แต่ละ​สถานะ ทั้ง​บรรทัด​ที่​ปฏิเสธ commit และ​บรรทัด “allow offset fetch requests” ที่​ปรากฏ​ใน​ทุก​สถานะ
  • OffsetCommitKey.json tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — สาม field ของ key คือ group topic และ partition กับ validVersions ที่​เป็น "0-1"
  • OffsetCommitRequest.json tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — field GenerationIdOrMemberEpoch ที่​เป็น int32 มี​ค่า​ปริยาย -1 พร้อม​คำ​อธิบาย​ของ​มัน
  • GroupCoordinatorService.java tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — groupMetadataTopicConfigs ที่​ใส่ CLEANUP_POLICY_COMPACT ให้ topic ของ offset และ partitionFor ที่ hash ชื่อ​กลุ่ม​แล้ว mod ด้วย​จำนวน partition
  • GroupCoordinatorConfig.java tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — ค่า​ปริยาย 50 partition กับ replication factor 3 ของ topic นั้น คำ​อธิบาย​ที่​ว่า​ไม่​ควร​เปลี่ยน​จำนวน partition หลัง deploy และ​การ​ที่ offset ที่ commit ไว้​มี​วัน​หมด​อายุ
  • ConsumerConfig.java tag 4.0.0 (ตรวจ​แล้ว 2026-09-03) — enable.auto.commit ที่​ประกาศ​ค่า​ปริยาย​เป็น true

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

ข้อ 1 / 3

ผู้อ่านตัวหนึ่งล้มลง ตำแหน่งในหน่วยความจำอยู่ที่ 12 ส่วนค่าที่ commit ไว้บน disk เป็น 10 ผู้อ่านตัวใหม่ขึ้นมาแทน จะเกิดอะไรกับ order สองตัวที่อยู่ระหว่างสองเลขนั้น