Retention & Compaction
แนวคิดในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “แนวคิดในหนึ่งประโยค”Kafka ไม่เก็บข้อมูลตลอดไปโดย default — cleanup policy ของ topic ตัดสินว่า record เก่าจะถูก ลบ หลังหมดหน้าต่าง retention หรือถูก compact ให้เหลือแค่ ค่าล่าสุดต่อ key
Retention: ลบตามเวลาหรือขนาด
หัวข้อที่มีชื่อว่า “Retention: ลบตามเวลาหรือขนาด”ด้วย policy ค่าเริ่มต้น cleanup.policy=delete Kafka ลบ log segment เก่าเมื่อข้าม threshold:
# section: topiccleanup.policy=deleteretention.ms=604800000retention.bytes=5368709120050 GiB คือ 50 * 1024^3 = 53687091200 byte พอดี — ไม่ใช่ 53687456000 ตัวเลขที่พบในโน้ตเก่าบางที่ซึ่งผิดไป 364,800 byte (~0.0007% ใกล้พอที่จะดูถูกแต่ผิดพอที่จะไม่ผ่านการเช็คแบบเป๊ะ) Retention บังคับใช้กับทั้ง segment ไม่ใช่ record เดี่ยว ๆ และเกี่ยวกับ อายุหรือขนาด — ไม่เกี่ยวว่าใครอ่านข้อมูลไปแล้วหรือยัง consumer ที่ตามหลังเกินหน้าต่าง retention จะพลาด record ที่หมดอายุไป นี่คือ policy ที่ถูกต้องสำหรับ event stream ที่ event เก่าไม่มีค่า: clickstream, log, metric
พิสูจน์: ลบตามเวลา segment จริง ก่อนและหลัง
หัวข้อที่มีชื่อว่า “พิสูจน์: ลบตามเวลา segment จริง ก่อนและหลัง”log.retention.check.interval.ms คือ interval การ poll ของ broker เองสำหรับ retention (default จริง 300000 ms / 5 นาที — ยืนยันแบบสดและเปลี่ยนแบบ per-request ไม่ได้):
/opt/kafka/bin/kafka-configs.sh --bootstrap-server $BOOTSTRAP --describe --entity-type brokers --entity-name 1 --all 2>&1 | grep "log.retention.check.interval.ms"# Confirm it truly cannot be changed without a broker restart -- a real error, not a guess:/opt/kafka/bin/kafka-configs.sh --bootstrap-server $BOOTSTRAP --alter --entity-type brokers --entity-name 1 --add-config log.retention.check.interval.ms=1000 2>&1 | tail -3 || trueผลจริง (scratch broker ของตัวเอง):
log.retention.check.interval.ms=300000 sensitive=false synonyms={DEFAULT_CONFIG:log.retention.check.interval.ms=300000}Error while executing config command with args '--bootstrap-server localhost:9092 --alter --entity-type brokers --entity-name 1 --add-config log.retention.check.interval.ms=1000'java.util.concurrent.ExecutionException: org.apache.kafka.common.errors.InvalidRequestException: Cannot update these configs dynamically: [log.retention.check.interval.ms]เพราะ interval นั้นเป็นระดับ broker ทั้งตัว static และ 5 นาทีโดย default การรอให้ลบจริงภายในหน้าต่าง 60 วินาทีของ harness คอร์สนี้ทำไม่ได้จริงบน broker default ของ harness ตัว scratch broker ของคอร์สนี้เองถูกสั่ง start ด้วย KAFKA_LOG_RETENTION_CHECK_INTERVAL_MS=1000 (1 วินาที) โดยเฉพาะเพื่อให้การพิสูจน์นี้ทำได้จริง — ทุกอย่างด้านล่างนี้คือ transcript จริงจาก broker นั้น ไม่ใช่ fence ที่ harness นี้รันซ้ำ:
# topic: retention-time-demo, retention.ms=5000, segment.ms=3000
# right after producing 5 records:$ ls /tmp/kafka-logs/retention-time-demo-0/00000000000000000000.index 00000000000000000000.log (380 bytes) 00000000000000000000.timeindex ...
# after segment.ms elapses and 5 more records force a roll:$ ls /tmp/kafka-logs/retention-time-demo-0/00000000000000000000.* (inactive, 380 bytes) 00000000000000000005.* (active)
# after retention.ms(5s) + check interval(1s) elapse:$ ls /tmp/kafka-logs/retention-time-demo-0/00000000000000000000.index.deleted 00000000000000000000.log.deleted 00000000000000000000.timeindex.deleted00000000000000000005.index.deleted 00000000000000000005.log.deleted 00000000000000000005.timeindex.deleted00000000000000000010.log (new, empty active segment) 00000000000000000010.timeindex ...ทั้งสอง segment ที่ไม่ active (offset 0 กับ 5 ที่เก็บ record ที่ผลิตทั้ง 10 ตัว) ถูกเปลี่ยนชื่อเป็น .deleted เมื่อมันแก่เกิน retention.ms — การลบทางกายภาพจริง ๆ เกิดขึ้น log.segment.delete.delay.ms ทีหลัง (default จริง 60000 ms ยืนยันด้วยวิธีเดียวกัน: kafka-configs.sh --describe --entity-type brokers --all | grep log.segment.delete.delay.ms) segment active ใหม่ว่างเปล่า (offset 10) ถูก roll มาแทน เพราะ log ต้องมี segment ที่เขียนได้เปิดอยู่เสมอ แม้ทุกอย่างข้างหลังเพิ่งถูกลบไป
พิสูจน์: ลบตามขนาด อิสระจากเวลา
หัวข้อที่มีชื่อว่า “พิสูจน์: ลบตามขนาด อิสระจากเวลา”ด้วย retention.bytes ตั้งเล็กและ retention.ms ยาว (เพื่อไม่ให้เวลาเป็นคำอธิบายได้) ผลิต 4 batch พร้อม segment.ms บังคับ roll ระหว่างแต่ละ batch และเจอ floor จริงของ segment.bytes ระหว่างทาง:
$ kafka-topics.sh --create --topic retention-size-demo --partitions 1 \ --config retention.ms=600000 --config retention.bytes=300 \ --config segment.bytes=400 --config segment.ms=3000Error while executing topic command : Invalid value 400 for configuration segment.bytes: Value must be at least 1048576org.apache.kafka.common.config.ConfigException: Invalid value 400 for configuration segment.bytes: Value must be at least 1048576
# retried with the real floor, segment.bytes=1048576 (1 MiB) -- segment.ms still forces small,# frequent rolls regardless, so the 1 MiB byte ceiling never actually matters here:$ kafka-topics.sh --create --topic retention-size-demo --partitions 1 \ --config retention.ms=600000 --config retention.bytes=300 \ --config segment.bytes=1048576 --config segment.ms=3000Created topic retention-size-demo.
# 4 batches of 5 records each, 4s apart (segment.ms=3000 forces a roll between each):$ ls /tmp/kafka-logs/retention-size-demo-0/ | grep -v deleted00000000000000000015.log (415 bytes) # only the LAST batch's segment survives$ du -cb /tmp/kafka-logs/retention-size-demo-0/*.log415 total$ ls /tmp/kafka-logs/retention-size-demo-0/*.deleted | wc -l11 # 3 full segments (offsets 0, 5, 10), 3 files each (.log/.index/.snapshot or .timeindex)segment.bytes เองมี floor จริงที่บังคับใช้อยู่ที่ 1 MiB — ลองค่าเล็กกว่านี้จะถูก broker ปฏิเสธตั้งแต่ก่อนสร้าง topic ด้วยซ้ำ เป็น error ตอน config ไม่ใช่ no-op เงียบ ๆ ด้วย floor นั้น segment.ms=3000 คือตัวที่บังคับ roll segment เล็ก ๆ ถี่ ๆ จริง ๆ ในที่นี้ (ไม่ใช่ segment.bytes ที่ record ทดสอบเล็ก ๆ ไม่มีทางเข้าใกล้เลย เมื่อมี 4 segment เต็มแล้ว retention (retention.bytes=300) ลบ 3 segment เก่าสุดทันทีที่มันกลายเป็น inactive และ check interval รัน — เหลือแค่ segment ที่ active ล่าสุด ที่ 415 byte ซึ่ง เกิน เพดาน 300 byte นี่ไม่ใช่บั๊ก: retention ไม่เคยตัด segment active ดังนั้นขนาดรวมของ partition อาจเกิน retention.bytes ได้มากสุดหนึ่ง segment ณ เวลาใดก็ตาม การกำหนด retention.bytes เป็นเพดานแบบ hard cap โดยไม่คิดถึง headroom นี้ เป็นความเข้าใจผิดที่พบบ่อย
Compaction: เก็บค่าล่าสุดต่อ key
หัวข้อที่มีชื่อว่า “Compaction: เก็บค่าล่าสุดต่อ key”ตั้ง cleanup.policy=compact แล้ว Kafka เปลี่ยน strategy ทั้งหมด แทนที่จะทิ้งข้อมูลเก่าตามอายุ มันเก็บ record ล่าสุดของแต่ละ key และ garbage-collect ค่าเก่าของ key นั้นไปเรื่อย ๆ:
before compaction: (a,1)(b,1)(a,2)(c,1)(b,2)(a,3)after compaction: (c,1)(b,2)(a,3) ← latest value per key survivesสิ่งนี้เปลี่ยน topic ให้เป็น snapshot ของ state ปัจจุบันตาม key ที่ durable และ replay ได้ — เหมาะกับ changelog, configuration, และ state store ที่ Kafka Streams เก็บ consumer ใหม่เอี่ยมอ่าน topic ที่ compact ทั้งหมดแล้วสร้าง state ล่าสุดของทุก key ขึ้นใหม่ได้
พิสูจน์: compaction ข้ามขอบเขต segment พร้อม tombstone ก่อน/หลังจริง
หัวข้อที่มีชื่อว่า “พิสูจน์: compaction ข้ามขอบเขต segment พร้อม tombstone ก่อน/หลังจริง”min.cleanable.dirty.ratio=0.01 ทำให้ cleaner thread พร้อมรันแทบจะทันที (default จริง: 0.5); delete.retention.ms คุมว่า tombstone อยู่นานแค่ไหนหลัง compaction เห็นมันครั้งแรก สคริปต์นี้ผลิตสามรอบของ key เดิมสามตัว (a, b, c) พร้อม segment roll จริงระหว่างแต่ละรอบ tombstone สำหรับ key a ระหว่างรอบ 2 กับ 3 แล้ว dump ทุก segment:
TOPIC="${TOPIC_PREFIX:-}compact-demo"/opt/kafka/bin/kafka-topics.sh --bootstrap-server $BOOTSTRAP --create --topic "$TOPIC" --partitions 1 --if-not-exists \ --config cleanup.policy=compact --config segment.bytes=1048576 --config segment.ms=3000 \ --config min.cleanable.dirty.ratio=0.01 --config delete.retention.ms=3000
produce_round() { for k in a b c; do printf "%s:round%s-value-for-%s\n" "$k" "$1" "$k"; done | \ /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server $BOOTSTRAP --topic "$TOPIC" \ --property parse.key=true --property key.separator=:}
produce_round 1sleep 4produce_round 2sleep 4# tombstone for key a -- null.marker makes the literal token NULL a real null valueprintf "a:NULL\n" | /opt/kafka/bin/kafka-console-producer.sh --bootstrap-server $BOOTSTRAP --topic "$TOPIC" \ --property parse.key=true --property key.separator=: --property null.marker=NULLsleep 4produce_round 3echo "waiting for segment roll + cleaner pass..."sleep 20echo "=== segments ==="ls "/tmp/kafka-logs/${TOPIC}-0/" | grep -v deletedecho "=== dump-log ==="for f in /tmp/kafka-logs/${TOPIC}-0/*.log; do echo "-- $f --" /opt/kafka/bin/kafka-dump-log.sh --files "$f" --print-data-log 2>&1 | grep -E "offset:"doneผลจริงจากสคริปต์นี้เป๊ะ ๆ รันกับ broker Kafka 4.3.1:
=== segments ===00000000000000000000.index00000000000000000000.log00000000000000000000.timeindex00000000000000000007.index00000000000000000007.log00000000000000000007.snapshot00000000000000000007.timeindexleader-epoch-checkpointpartition.metadata=== dump-log ===-- /tmp/kafka-logs/compact-demo-0/00000000000000000000.log --Log starting offset: 0| offset: 4 CreateTime: ... keySize: 1 valueSize: 18 ... key: b payload: round2-value-for-b| offset: 5 CreateTime: ... keySize: 1 valueSize: 18 ... key: c payload: round2-value-for-c| offset: 6 CreateTime: ... keySize: 1 valueSize: -1 ... key: a-- /tmp/kafka-logs/compact-demo-0/00000000000000000007.log --Log starting offset: 7| offset: 7 CreateTime: ... keySize: 1 valueSize: 18 ... key: a payload: round3-value-for-a| offset: 8 CreateTime: ... keySize: 1 valueSize: 18 ... key: b payload: round3-value-for-b| offset: 9 CreateTime: ... keySize: 1 valueSize: 18 ... key: c payload: round3-value-for-cทุกอย่างจาก รอบ 1 หายไปหมด — cleaner ยุบมันไปแล้วตอนที่รันนี้ เพราะค่ารอบ 2 ของ key เดียวกันใหม่กว่า รอบ 2’s b กับ c รอดอยู่ (offset 4, 5) เพราะยังไม่มีอะไรมาแทนที่; a ของรอบ 2 ถูกแทนที่ด้วย tombstone (offset 6, valueSize: -1 ไม่มี payload — เครื่องหมายจริงบนดิสก์สำหรับ “key นี้ถูกลบแล้ว” ไม่ใช่ record ประเภทพิเศษ) รอบ 3 อยู่ใน segment ที่ยัง active โดยไม่ถูกแตะ (offset 7 เป็นต้นไป) — รวมถึงค่า ใหม่ ของ a ที่เขียน หลัง tombstone ซึ่งกลายเป็นค่าปัจจุบันใหม่ได้ถูกต้องโดยไม่ถูก tombstone บล็อก ตัว tombstone เอง (offset 6) ยังอยู่ตรงนี้: มันไม่ถูกลบทันทีที่มีอะไรมาแทนที่ — มันอยู่รอดอย่างน้อย delete.retention.ms นับจากตอนที่ cleaner ประมวลผลมันครั้งแรก เพื่อให้ consumer ที่ตามหลังยังมีโอกาสเห็นว่า “key นี้ถูกลบแล้ว” ก่อนที่ marker เองจะหายไป
ตามเอกสารทางการ (kafka.apache.org/documentation/#compaction) log compaction รับประกัน เรียงตามนี้: consumer ที่อ่านจากจุดเริ่มต้นของ log จะเห็นอย่างน้อย state สุดท้ายของทุก key ตามลำดับที่เขียน; ลำดับของ record ไม่เคย เปลี่ยนภายใน partition มีแค่บาง record ถูกลบออก; offset ของ record ไม่เคยเปลี่ยน; และ — กลไกที่พิสูจน์ตรง ๆ ข้างต้น — หัวของ log (segment ที่ active อยู่ตอนนี้) ไม่เคยถูก compact ดังนั้น key ที่เขียนเมื่อเร็ว ๆ นี้รับประกันว่ามองเห็นได้ ไม่ถูก compact จนกว่าจะแก่ไปอยู่ใน segment เก่ากว่า
รวม policy: compact,delete
หัวข้อที่มีชื่อว่า “รวม policy: compact,delete”cleanup.policy=compact,delete compact ตาม key และ ยังลบข้อมูลเก่ามาก ๆ ออกตาม retention.ms/retention.bytes — มีประโยชน์เมื่อต้องการ snapshot ที่ rebuild ได้ตาม key แต่ยังต้องการเพดานสัมบูรณ์ว่าแม้แต่ค่าล่าสุดต่อ key จะอยู่ได้นานแค่ไหน (เช่นสไตล์ GDPR “ต้องไม่เก็บเกิน N วัน” บวกกับ “เก็บล่าสุดต่อ key”)
# section: topiccleanup.policy=compact,deleteretention.ms=2592000000min.cleanable.dirty.ratio=0.5เลือก policy
หัวข้อที่มีชื่อว่า “เลือก policy”delete— record คือประเด็น ประวัติสำคัญ ค่าปัจจุบันต่อ key ไม่สำคัญ Event streamcompact— ค่าล่าสุดของ key คือประเด็น ต้องการ snapshot ที่ rebuild ได้ State, changelog, config topiccompact,delete— ทั้งคู่ เมื่อแม้แต่ snapshot ล่าสุดต่อ key ก็ต้องการเพดานอายุที่ชัดเจน
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”🐛 หา bug ในโค้ดนี้
# AI-generated config for a Kafka Streams changelog / state topiccleanup.policy=compactsegment.bytes=1048576min.cleanable.dirty.ratio=0.5retention.ms=86400000เฉลย
โค้ดนี้ดูสมเหตุสมผลตอนมองผ่าน ๆ — compact สำหรับ state topic คือ policy family ที่ถูกต้อง และ key อื่น ๆ ล้วนเป็นชื่อ config จริงที่ใช้ได้ บั๊กคือ retention.ms=86400000 (1 วัน) ที่ตั้งอยู่บน topic ที่ cleanup.policy เป็น แค่ compact ไม่ใช่ compact,delete retention.ms กับ retention.bytes ถูกอ่านโดย delete cleaner; ด้วย policy compact ล้วน ๆ ไม่มีอะไรใน path การลบ segment ของ Kafka สนใจมันเลย — เงียบ ๆ ไม่มีผล ไม่ใช่ error ไม่ใช่ warning คนที่เขียนนี้น่าจะตั้งใจหนึ่งในสองอย่าง: cleanup.policy=compact,delete (compact ตาม key แต่จำกัดอายุรวมด้วย) หรือไม่มีบรรทัด retention.ms เลย (compact ล้วน ไม่มีเพดานอายุ พิสูจน์ข้างต้นว่าเป็น cleaner ตัวเดียวที่รันเมื่อ policy เป็นแค่ compact) ปล่อยไว้แบบนี้ compile ได้ deploy ได้ และได้ topic ที่ดูเหมือนตั้งค่าไว้สำหรับทั้งสองพฤติกรรม ทั้งที่จริงได้แค่อย่างเดียว — บั๊กแบบที่มองไม่เห็นจนกว่าใครจะถามว่า “ทำไม key เก่าที่ยังอยู่ไม่ถูกตัดตามอายุอย่างที่ config บอก?”
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”delete กับ compact ตอบคำถามที่ต่างกันสิ้นเชิงสองข้อ และ cleanup.policy ของ topic บอกว่า topic นี้ถามคำถามไหน delete ถามว่า “ข้อมูลนี้ เก่า แค่ไหน?” — ไม่เคยดู key เลย และ record สอง record ที่มี key เดียวกันก็เป็นแค่ record ที่ไม่เกี่ยวกันสองตัวสำหรับมัน compact ถามว่า “ค่า ปัจจุบัน ของ key นี้คืออะไร?” — ไม่เคยดูอายุเลย (จนกว่าจะเติม ,delete) และตำแหน่งของ record ในเวลาไม่สำคัญเทียบกับว่ามี record ใหม่กว่าที่ key เดียวกันหรือไม่ การอ่าน cleanup.policy ของ topic ควรบอกทันทีว่า mental model ไหนใน 2 แบบนี้ใช้ได้ — พิสูจน์ข้างต้นแล้วว่าทั้งคู่ทำงานและพังต่างกันโดยสิ้นเชิง
จุดแลกเปลี่ยน (Trade-offs)
หัวข้อที่มีชื่อว่า “จุดแลกเปลี่ยน (Trade-offs)”delete— คิดตามได้ง่าย (อายุ/ขนาดล้วน ๆ) แต่ consumer ที่ตามหลังเกินหน้าต่าง retention จะพลาดข้อมูลแบบเงียบ ๆ — พิสูจน์ข้างต้น: ทั้ง segment หายไป (.deletedแล้วหายจริง) โดยไม่มีสัญญาณต่อ record เลยcompact— ให้ snapshot ที่ rebuild ได้ตาม key ตลอดไป แต่ “ตลอดไป” ไม่ใช่ “ทันที” — พิสูจน์ข้างต้น ค่าที่ถูกแทนที่อาจอยู่ใน segment inactive สักพักจนกว่า dirty-ratio threshold และ backoff interval ของ cleaner จะอนุญาตให้ pass อีกครั้ง และ segment active ไม่ถูก compact เลย ดังนั้นค่าเก่าของ key อาจอยู่ร่วมกับ tombstone หรือค่าใหม่ของตัวเองได้ในหน้าต่างเวลาที่บางทีน่าแปลกใจcompact,delete— ปิดช่องว่าง “ตลอดไป” ด้วยเพดานอายุที่ชัดเจน แลกกับการเสียคุณสมบัติ “snapshot durable ไม่ว่าจะเก่าแค่ไหน” ที่compactล้วน ๆ ให้ — ค่าล่าสุดของ key เก่ามาก ๆ อาจหมดอายุไปได้ในที่สุดแม้ไม่มีอะไรเคยเขียนทับมันเลยmin.cleanable.dirty.ratioที่ก้าวร้าว (พิสูจน์ข้างต้นด้วย0.01) — ทำให้ compaction รันแทบต่อเนื่อง ดีสำหรับ demo และ topic ที่ค่าซ้ำเก่าต้องหายไปเร็ว แลกกับ I/O ของ cleaner ที่ต่อเนื่อง; default จริง (0.5) แลกสิ่งนั้นกับงาน background ที่น้อยกว่ามาก แลกกับค่าเก่าที่ค้างอยู่นานกว่า
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
retention.ms/retention.bytesใช้กับ topic ที่ compact ด้วย จำกัดว่าค่าล่าสุดต่อ key เก่าได้แค่ไหน” พิสูจน์ข้างต้นแล้วว่าไม่จริง (SpotTheBug): ด้วย policycompactล้วน ๆretention.msไม่มีผล — ไม่มีอะไรอ่านมันเลย มีแค่compact,deleteเท่านั้นที่รวมทั้งสองพฤติกรรม - “Compaction เกิดขึ้นทันทีที่มีค่าใหม่ของ key ถูกเขียน” พิสูจน์ข้างต้นแล้วว่าไม่จริง: ค่า
aของรอบ 2 ที่ถูกแทนที่หายไปก็ต่อเมื่อ cleaner รันจริง (ถูกจำกัดโดยmin.cleanable.dirty.ratioและlog.cleaner.backoff.ms) และข้อมูลรอบ 1 ก็รอดจนถึงตอนนั้นเหมือนกัน — compaction คือ background pass เป็นระยะ ไม่ใช่ side effect แบบ synchronous ของsend() - “Tombstone ลบ key ทันที” พิสูจน์ข้างต้นแล้วว่าไม่จริง: ตัว tombstone record เอง (offset 6,
valueSize: -1) ยังอยู่ใน dump หลังจากค่าที่มันแทนที่หายไปแล้ว — มันอยู่ต่อไปเป็นเวลาdelete.retention.msโดยเฉพาะเพื่อให้ consumer ที่ตามหลังยังเห็นการลบ - “Segment active ก็ถูก compact เหมือน segment อื่น แค่เป็นตัวสุดท้าย” พิสูจน์ข้างต้นแล้วว่าไม่จริง: record ของรอบ 3 ใน segment active ไม่ถูกแตะเลยแม้แต่ byte เดียว หลังจาก cleaner pass สองครั้งแยกกันได้ยุบค่าของรอบ 1 กับ 2 ใน segment เก่าไปแล้ว — segment active ถูกยกเว้นโดยหลักการ ไม่ใช่แค่ “ยังไปไม่ถึง”
- “Retention ลบตรงตามหน้าต่าง retention เป๊ะถึงวินาที” พิสูจน์ข้างต้นแล้วว่าไม่จริง: การลบเกิดขึ้นก็ต่อเมื่อ
log.retention.check.interval.mspoll ครั้งถัดไป (default ทุก 5 นาที) และกระทบแค่ segment ที่ roll ไปแล้วเท่านั้น — record อาจอยู่เกินretention.msที่ตั้งไว้ได้มากสุด check interval บวกเวลาที่ segment ของมันใช้ roll