Kafka Streams
แนวคิดในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “แนวคิดในหนึ่งประโยค”Kafka Streams คือ Java library — ไม่ใช่คลัสเตอร์แยก — ที่ฝังเข้าไปในแอปของคุณเองเพื่อแปลง, รวม, และ join topic ของ Kafka ในฐานะ stream ต่อเนื่อง โดยมี state และ exactly-once guarantee ให้จัดการเอง
KStream, KTable และ duality
หัวข้อที่มีชื่อว่า “KStream, KTable และ duality”Kafka Streams ให้มุมมองของ topic สองแบบ:
KStream— ลำดับ event ที่ไม่มีขอบเขตและเป็นอิสระต่อกัน ทุก record คือข้อเท็จจริงใหม่ (“user คลิก”) ไม่มีอะไรถูกเขียนทับKTable— changelog ที่ตีความเป็นค่าล่าสุด ต่อ key แต่ละ record อัปเดตแถวของ key นั้น เหมือนUPSERTเข้าตาราง
นี่คือ stream-table duality: ตารางคือ snapshot ที่ได้จากการ replay stream ของการอัปเดต และ stream คือลำดับของการเปลี่ยนแปลงที่ได้จากการดูตาราง topic เดียวกันอ่านได้สองแบบขึ้นอยู่กับสิ่งที่ต้องการ — feed ของ event ที่วิ่งอยู่ หรือ state ปัจจุบันต่อ key
flowchart LR s["KStream (events)"] -->|"aggregate / reduce"| t["KTable (latest per key)"] t -->|"toStream()"| s2["KStream (changes)"]
Stateless vs stateful operations
หัวข้อที่มีชื่อว่า “Stateless vs stateful operations”Operation แบ่งเป็นสองตระกูล:
- Stateless — แต่ละ record จัดการตัวเอง:
map,filter,flatMap,branchไม่ต้องจำ record ก่อนหน้า - Stateful — ผลลัพธ์ขึ้นกับ record ที่เห็นมาก่อนหน้า:
aggregate,count,reduce,join, และ windowing (จัดกลุ่ม record เป็นช่วงเวลา) ต้องมีที่เก็บผลลัพธ์ที่กำลังสะสม
“ที่เก็บ” นั้นคือ state store — key-value store ในเครื่อง (RocksDB เป็นค่าเริ่มต้น) ที่อยู่ใน instance ของแอปตัวเอง เพื่อรอดจากการ crash ทุก state store ถูก back ด้วย changelog topic ใน Kafka: ทุกการอัปเดตถูกเขียนไปที่ changelog ด้วย ดังนั้นถ้า instance รีสตาร์ทที่อื่น Streams จะสร้าง store ขึ้นใหม่โดย replay changelog นั้น state ในเครื่องจึงคงทนโดยไม่ต้องจัดการฐานข้อมูลเอง
Topology นับคำ พิสูจน์ด้วยการรัน TopologyTestDriver จริง
หัวข้อที่มีชื่อว่า “Topology นับคำ พิสูจน์ด้วยการรัน TopologyTestDriver จริง”นี่คือตัวอย่างคลาสสิก — นับว่าแต่ละคำปรากฏกี่ครั้งใน input topic นี่ไม่ใช่สมมติฐาน: compile ด้วย javac ธรรมดาและ รัน ด้วย java กับ kafka-streams-4.3.1.jar / kafka-streams-test-utils-4.3.1.jar จริง (บวก rocksdbjni, jackson-databind และเพื่อน ๆ) ที่ดึงมาจาก Maven Central โดยตรงด้วย URL — ไม่มี gradle ไม่มี maven ตามข้อจำกัด toolchain ของคอร์สนี้
import org.apache.kafka.common.serialization.Serdes;import org.apache.kafka.streams.StreamsBuilder;import org.apache.kafka.streams.StreamsConfig;import org.apache.kafka.streams.TestInputTopic;import org.apache.kafka.streams.TestOutputTopic;import org.apache.kafka.streams.Topology;import org.apache.kafka.streams.TopologyTestDriver;import org.apache.kafka.streams.kstream.KStream;import org.apache.kafka.streams.kstream.KTable;import org.apache.kafka.streams.test.TestRecord;
import java.util.Arrays;import java.util.List;import java.util.Properties;
public class WordCountTopologyTest { public static void main(String[] args) { StreamsBuilder builder = new StreamsBuilder();
// Read the input topic as a stream of (key, line) events KStream<String, String> lines = builder.stream("text-input");
KTable<String, Long> counts = lines .flatMapValues(line -> Arrays.asList(line.toLowerCase().split("\\W+"))) // split into words .groupBy((key, word) -> word) // re-key by the word itself .count(); // stateful: running total per word -> a KTable
counts.toStream().to("word-counts"); // write the changelog of counts out
Topology topology = builder.build(); System.out.println(topology.describe());
Properties props = new Properties(); props.put(StreamsConfig.APPLICATION_ID_CONFIG, "wordcount-test"); props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "dummy:9092"); props.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.StringSerde.class); props.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.StringSerde.class);
// TopologyTestDriver: an in-memory harness -- no broker needed to unit-test a topology try (TopologyTestDriver driver = new TopologyTestDriver(topology, props)) { TestInputTopic<String, String> input = driver.createInputTopic( "text-input", Serdes.String().serializer(), Serdes.String().serializer()); TestOutputTopic<String, Long> output = driver.createOutputTopic( "word-counts", Serdes.String().deserializer(), Serdes.Long().deserializer());
input.pipeInput(null, "the quick brown fox"); input.pipeInput(null, "the lazy fox sleeps");
List<TestRecord<String, Long>> results = output.readRecordsToList(); for (TestRecord<String, Long> r : results) { System.out.println("word-counts: " + r.key() + " = " + r.value()); } System.out.println("TOTAL RECORDS: " + results.size()); } }}Output จริง (ตัดให้สั้น) จาก javac -cp <jars> WordCountTopologyTest.java && java -cp <jars>:. WordCountTopologyTest บน Mac เครื่องนี้:
Topologies: Sub-topology: 0 Source: KSTREAM-SOURCE-0000000000 (topics: [text-input]) --> KSTREAM-FLATMAPVALUES-0000000001 Processor: KSTREAM-FLATMAPVALUES-0000000001 (stores: []) --> KSTREAM-KEY-SELECT-0000000002 ... Sink: KSTREAM-SINK-0000000005 (topic: KSTREAM-AGGREGATE-STATE-STORE-0000000003-repartition) Sub-topology: 1 Source: KSTREAM-SOURCE-0000000007 (topics: [KSTREAM-AGGREGATE-STATE-STORE-0000000003-repartition]) Processor: KSTREAM-AGGREGATE-0000000004 (stores: [KSTREAM-AGGREGATE-STATE-STORE-0000000003]) Sink: KSTREAM-SINK-0000000009 (topic: word-counts)
word-counts: the = 1word-counts: quick = 1word-counts: brown = 1word-counts: fox = 1word-counts: the = 2word-counts: lazy = 1word-counts: fox = 2word-counts: sleeps = 1TOTAL RECORDS: 8สิ่งจริงสองอย่างที่พิสูจน์ได้: (1) count() compile ออกมาเป็น สอง sub-topology เชื่อมกันด้วย repartition topic ภายใน (...-repartition) — groupBy เปลี่ยน key ใหม่เป็นคำ ซึ่งไม่รับประกันว่าจะตกอยู่ partition เดียวกับ input Streams จึงแทรกขั้น repartition ให้อัตโนมัติ; (2) the และ fox ปล่อยออกมา สองครั้ง (ครั้งละหนึ่งครั้งต่อบรรทัด input ที่อัปเดต count ของมัน) เพราะ changelog ของ KTable ปล่อยทุก การอัปเดต ไม่ใช่แค่ยอดรวมสุดท้าย — 8 record output จาก 2 บรรทัด input ไม่ใช่ “ยอดสุดท้าย” 6 คำที่ไม่ซ้ำกัน ตรงกับ stream-table duality ในหัวข้อก่อนหน้าแบบจับต้องได้
KIP-1071: group.protocol=streams ใหม่ — GA พร้อม feature set ที่จำกัด ยืนยันแล้ว
หัวข้อที่มีชื่อว่า “KIP-1071: group.protocol=streams ใหม่ — GA พร้อม feature set ที่จำกัด ยืนยันแล้ว”// Turn on end-to-end exactly-once for the whole topologyprops.put(StreamsConfig.PROCESSING_GUARANTEE_CONFIG, "exactly_once_v2");
// The new broker-side rebalance protocol for Streams groups (KIP-1071)props.put(StreamsConfig.GROUP_PROTOCOL_CONFIG, "streams");ยืนยันสดจาก broker นี้: kafka-configs.sh --entity-type brokers --entity-default --describe --all แสดง group.coordinator.rebalance.protocols = [classic, consumer, share, streams] — streams เป็น rebalance protocol จริงที่เปิดใช้งานบน apache/kafka:4.3.1 ตรงกับ kafka-features.sh describe ที่แสดง Feature: streams.version FinalizedVersionLevel: 1
Co-partitioning: สิ่งที่เอกสารสัญญา กับสิ่งที่การรันจริงหนึ่งครั้งแสดงให้เห็น
หัวข้อที่มีชื่อว่า “Co-partitioning: สิ่งที่เอกสารสัญญา กับสิ่งที่การรันจริงหนึ่งครั้งแสดงให้เห็น”Stream-stream join (KStream.join(...)) ต้องการให้ input topic ทั้งสองเป็น co-partitioned — จำนวน partition เท่ากัน, partitioning strategy เดียวกัน — เพื่อให้ partition ที่ N ของ topic ซ้ายต้อง join กับ partition ที่ N ของ topic ขวาเท่านั้น Kafka Streams DSL developer guide ระบุชัดเจน: “The input topics of the join (left side and right side) must have the same number of partitions… During the partition assignment step, i.e. at runtime, Kafka Streams verifies whether the number of partitions for both sides of a join are the same. If they are not, a TopologyBuilderException (runtime exception) is being thrown.” class exception ใน jar 4.3.1 ปัจจุบันคือ org.apache.kafka.streams.errors.TopologyException ไม่ใช่ชื่อเก่า TopologyBuilderException ที่เอกสารยังใช้อยู่ — ยืนยันจากการ decompile CopartitionedTopicsEnforcer.class จาก kafka-streams-4.3.1.jar จริง ซึ่งมี format string ตรงตัว "%sTopics not co-partitioned: [%s]" ที่ throw เป็น TopologyException
Exactly-once และ streams protocol
หัวข้อที่มีชื่อว่า “Exactly-once และ streams protocol”Stream processing ที่อ่าน, อัปเดต state, และเขียน มีโอกาส ผิดพลาด ได้ง่ายเมื่อเกิดความล้มเหลว — อาจนับซ้ำได้ Kafka Streams ทำให้เรื่องนี้เป็นแค่ config บรรทัดเดียว เมื่อตั้ง processing.guarantee เป็น exactly_once_v2 Streams จะห่อทุกรอบ read-process-write (รวมถึง changelog ของ state store และ offset ของ consumer) ไว้ใน transaction เดียวของ Kafka ดังนั้นการ retry จะไม่มีทางอัปเดตซ้ำสองครั้ง — กลไก transaction เดียวกันกับที่พิสูจน์แบบ end-to-end ใน Exactly-Once Processing เพียงแต่ขับเคลื่อนโดย library Streams แทนการเรียก txn.sendOffsets() เอง
คำตอบของ JS ecosystem: ไม่มีตัวที่ maintain อยู่
หัวข้อที่มีชื่อว่า “คำตอบของ JS ecosystem: ไม่มีตัวที่ maintain อยู่”Kafka Streams เป็น library เฉพาะ JVM โดยการออกแบบ (state store, การ replicate changelog, และ rebalance protocol ล้วน implement อยู่ใน Java client) npm package ที่ชื่อ kafka-streams ตรงตัว — การเขียน KStream/KTable ใหม่ใน JS — มีอยู่จริงแต่ถูกทิ้งร้างไปแล้วโดยพฤตินัย: npm view kafka-streams time.modified รายงาน 2022-06-19 ซึ่งเก่ากว่าสี่ปีก่อนบทนี้จะถูกเขียน โดยไม่มี flag deprecated แต่ก็ไม่มีความเคลื่อนไหวเช่นกัน ไม่มี Node.js เทียบเท่า Kafka Streams ที่ maintain อยู่จริง ตัวเลือกจากโค้ด Node.js คือ plain consumer (ค่าเริ่มต้นของคอร์สนี้), เรียกไปยังเซอร์วิส Streams บน JVM, หรือใช้เครื่องมือของบทถัดไป Kafka Connect เมื่องานเป็นการย้ายข้อมูลแทนการแปลง
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”🐛 หา bug ในโค้ดนี้
// Design note for the payments pipeline (implementation is the Java Streams app).
// "left-orders" has 3 partitions, "right-payments" has 6 partitions -- Kafka Streams// will refuse to start the join and throw immediately if they don't match, so it's// safe to just wire the join up and let the framework catch any partition mismatch// before it ships to production.
export const joinConfig = {leftTopic: 'left-orders',rightTopic: 'right-payments',windowMs: 5000,};เฉลย
คำอ้างเรื่องความปลอดภัยในคอมเมนต์ — “Kafka Streams will refuse to start … and throw immediately if they don’t match” — คือสมมติฐานเดียวกับที่บทนี้พิสูจน์แล้วว่าไม่น่าเชื่อถือ DSL developer guide อธิบายพฤติกรรม fail-fast นั้นจริง แต่การรันจริงบน apache/kafka:4.3.1 ที่ join สอง topic ที่ partition ไม่ตรงกันจริง (3 กับ 5) reach RUNNING โดย ไม่มี exception และ assign task แบบเงียบ ๆ ที่ไม่มีทางเห็นทั้งสองฝั่งของ join เลย การพึ่งพา framework ให้จับกรณีนี้ไม่ได้รับการพิสูจน์ว่าปลอดภัยบนเวอร์ชันนี้ ทางแก้ไม่ใช่การเปลี่ยนโค้ด join เอง — แต่เป็นการแก้ กระบวนการ: ตรวจว่า left-orders กับ right-payments มีจำนวน partition เท่ากันด้วย kafka-topics.sh --describe (หรือตรวจด้วย admin client ใน CI) ก่อน deploy เพราะการ์ดที่ runtime ที่คอมเมนต์นี้สมมติไว้พึ่งไม่ได้
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”คุณค่าทั้งหมดของ Kafka Streams คือการเปลี่ยนปัญหา distributed systems ที่ยากสามข้อ — state ในเครื่องที่รอดจาก crash, read-process-write แบบ exactly-once, และการ rebalance instance ของ worker — ให้กลายเป็นแค่การเรียก library แทนที่จะต้องเขียนโค้ดเอง ทุกการพิสูจน์ในบทนี้แสดงจุดที่ abstraction นี้แข็งแรง (การรัน TopologyTestDriver จริงที่ให้ผลตรงกับที่ stream-table duality ทำนายไว้เป๊ะ, exactly_once_v2 transaction guarantee จริงที่ใช้ร่วมกับโมดูล delivery-and-transactions) และจุดที่มันใหม่กว่าและแคบกว่าที่เห็น (ข้อจำกัดที่ระบุชัดของ KIP-1071 และ safety net ของ co-partitioning ที่ไม่ทำงานในการรันจริงที่ทำซ้ำได้หนึ่งครั้ง) ถือว่า “Kafka Streams จัดการให้” เป็นจริงสำหรับ state และ exactly-once และ verify-เอง สำหรับอะไรก็ตามที่ release notes ระบุว่าเป็น GA “พร้อม feature set ที่จำกัด”
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”- TopologyTestDriver — พิสูจน์แล้วด้านบนว่าเป็นวิธี unit-test output ของ topology ได้แม่นยำโดยไม่ต้องมี broker แลกกับการไม่ทดสอบ partition assignment จริงหรือการบังคับ co-partitioning (ช่องว่างนั้นคือสิ่งที่หัวข้อ co-partitioning ด้านบนเจอพอดีเมื่อทดสอบกับ broker จริงแทน)
group.protocol=streams(KIP-1071) — task assignment แบบรวมศูนย์ฝั่ง broker และ observability ที่ดีขึ้น แลกกับข้อจำกัดที่ระบุชัดของ 4.2 (ไม่มี warm-up task, ไม่มี rack-aware placement, ไม่มี static membership, ย้ายโปรโตคอลแบบ offline เท่านั้น)exactly_once_v2— guarantee จริงที่ใช้งานได้ (กลไกเดียวกับ Exactly-Once Processing) แลกกับ overhead ของ transaction coordinator ทุก commit interval- Kafka Streams vs Kafka Connect vs plain consumer — Streams เหมาะกับ logic แบบ transform/join/aggregate จริงที่มี state ใช้ Connect (บทถัดไป) เมื่องานเป็นการย้ายข้อมูลล้วน ๆ และใช้ plain consumer เมื่อต้องการภาษาอื่นหรือควบคุมเองเต็มที่
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
count()บน KTable ปล่อย record เดียวต่อ key พร้อมยอดรวมสุดท้าย” พิสูจน์แล้วว่าผิดด้านบน: การรันจริงปล่อย 8 record จาก 2 บรรทัด input เพราะ changelog ของ KTable ปล่อยทุกการอัปเดตระหว่างทาง ไม่ใช่แค่ตัวสุดท้าย — consumer ของword-countsเห็นประวัติการอัปเดตทั้งหมด ไม่ใช่ snapshot - “Kafka Streams throw ทันทีเสมอถ้าสอง topic ที่ join กันมี partition ไม่เท่ากัน” พิสูจน์แล้วว่าผิดด้านบน (SpotTheBug) ด้วยการรันจริงที่ทำซ้ำได้บน 4.3.1 ที่ reach
RUNNINGทั้งที่ partition ไม่ตรงกันและไม่มี exception — พฤติกรรม fail-fast ตามเอกสารไม่ทำงานในการรันนี้ - “
group.protocol=streamsเป็น GA เต็มรูปแบบไม่มีเงื่อนไข เพราะ release notes บอกว่า GA” คำพูดของ release announcement เองคือ “GA with a limited feature set” — sticky assignment เท่านั้น, ไม่มี warm-up/rack-aware/static-membership, ย้ายโปรโตคอลแบบ offline เท่านั้น ยืนยันผ่าน KIP-1071 wiki - “มี Node.js Kafka Streams ที่ maintain อยู่จริง” npm package
kafka-streamsมีอยู่จริงแต่ publish ล่าสุดคือปี 2022 — ถูกทิ้งร้างไปแล้วโดยพฤตินัย ยืนยันผ่านnpm view