Apa itu Kafka Streams? #

Dalam era arsitektur modern yang berbasis peristiwa (event-driven), kecepatan dalam memproses informasi adalah pembeda utama antara sistem yang responsif dan sistem yang lamban. Pemrosesan data tradisional yang mengandalkan model batch (seperti pemrosesan data terjadwal setiap malam menggunakan Hadoop atau Spark) tidak lagi memadai untuk kasus penggunaan yang menuntut reaksi seketika, seperti pendeteksian transaksi fraud perbankan, rekomendasi e-commerce dinamis, atau pemantauan kesehatan armada server. Untuk menjembatani kebutuhan ini, Apache Kafka menyediakan pustaka pemrosesan aliran data bernama Kafka Streams. Melalui artikel ini, kita akan membedah secara mendalam filosofi dasar Kafka Streams, mengapa ia dirancang sebagai pustaka tertanam (embedded library), bagaimana arsitektur internal model threading-nya beroperasi, serta komparasi komprehensif terhadap framework pemrosesan besar seperti Apache Spark dan Apache Flink.


Filosofi Pustaka Tertanam vs Framework Terpusat #

Ketika kita membicarakan pemrosesan aliran data (stream processing), paradigma lama sering kali mengarahkan kita untuk memikirkan kluster komputasi terdistribusi yang besar dan rumit seperti Apache Spark Streaming, Apache Flink, atau Apache Storm. Framework-framework tersebut dirancang dengan model komputasi terpusat yang memerlukan kluster master-worker sendiri untuk berjalan. Di sisi lain, Kafka Streams mengambil arah desain yang sepenuhnya berlawanan.

1. Model Pustaka Java Sederhana (Embedded Library) #

Kafka Streams bukanlah sistem pemrosesan data dengan mesin eksekusi (execution engine) mandiri. Ia adalah sebuah pustaka Java klien biasa (kafka-streams.jar) yang kita impor ke dalam kode aplikasi kita.

  • Tidak Ada Kluster Tambahan: Kita tidak perlu menyewa, mengonfigurasi, memantau, dan mengelola kluster server tambahan seperti Mesos, YARN, atau Spark/Flink Standalone Manager. Aplikasi kita berjalan sebagai proses standar di sistem operasi.
  • Kompabilitas Deployment: Karena aplikasi yang menggunakan Kafka Streams hanyalah aplikasi Java biasa (baik dikemas sebagai JAR mandiri, aplikasi Spring Boot, maupun Quarkus), kita dapat mendeploy-nya dengan cara apa pun yang kita gunakan untuk mikroservis lainnya. Kita dapat membungkusnya ke dalam container Docker dan mendeploy-nya di Kubernetes, Nomad, VM AWS EC2, atau bahkan bare-metal.

2. Hubungan Simbiotik dengan Apache Kafka #

Kafka Streams didesain khusus dari bawah untuk bekerja secara eksklusif dengan Apache Kafka. Ia memanfaatkan seluruh kapabilitas bawaan Kafka untuk menyelesaikan tantangan sistem terdistribusi yang rumit.

  • State Management: Menyimpan state lokal menggunakan RocksDB dan mereplikasikannya ke broker Kafka melalui changelog topics.
  • Fault Tolerance: Mengandalkan arsitektur partisi Kafka dan mekanisme rebalance consumer group untuk mendistribusikan beban kerja kembali jika terjadi kegagalan instansi aplikasi.
  • Scalability: Skalabilitas horisontal dicapai hanya dengan menjalankan instance aplikasi baru dengan konfigurasi application.id yang sama. Kafka secara otomatis akan membagi partisi masukan ke instansi yang baru menyala.
flowchart TD
    subgraph SPARK ["Model Spark / Flink (Heavy Cluster)"]
        direction TB
        Master["Spark Master Node"]
        Worker1["Worker Node 1"]
        Worker2["Worker Node 2"]
        Master -. Mengelola tugas .-> Worker1
        Master -. Mengelola tugas .-> Worker2
        DataIn["Topik Kafka"] --> Master
        Worker1 --> DataOut["Topik Hasil"]
        Worker2 --> DataOut
    end

    subgraph KSTREAMS ["Model Kafka Streams (Embedded Library)"]
        direction TB
        App1["Aplikasi Klien (Spring Boot + KStreams)"]
        App2["Aplikasi Klien (Spring Boot + KStreams)"]
        Broker["Kluster Kafka (Broker)"]
        
        Broker -- "Membaca & Menulis Event secara langsung" --> App1
        Broker -- "Membaca & Menulis Event secara langsung" --> App2
    end
    
    style Master stroke:#d32f2f,stroke-width:2px
    style Worker1 stroke:#d32f2f,stroke-width:2px
    style Worker2 stroke:#d32f2f,stroke-width:2px
    style App1 stroke:#388e3c,stroke-width:2px
    style App2 stroke:#388e3c,stroke-width:2px
    style Broker stroke:#0288d1,stroke-width:2px

Arsitektur Internal: Model Threading dan Task Assignment #

Di balik kesederhanaan deployment-nya, Kafka Streams menyimpan arsitektur penanganan partisi dan threading yang sangat terstruktur. Pemahaman terhadap komponen-komponen ini sangat krusial agar kita dapat melakukan tuning performa aplikasi secara tepat di lingkungan produksi.

1. Relasi Partisi Terhadap Task (Task Assignment) #

Kafka Streams memecah topologi logika pemrosesan kita menjadi satu atau beberapa unit kerja paralel yang disebut StreamTask. Jumlah StreamTask ditentukan langsung oleh jumlah partisi maksimum dari topik-topik input yang dibaca oleh aplikasi kita.

Misalnya, jika aplikasi kita membaca dari Topic_A (memiliki 6 partisi) dan menulis ke Topic_B, Kafka Streams akan membuat tepat 6 buah StreamTask (Task 0 hingga Task 5).

  • Isolasi Data: Setiap Task memproses data dari satu partisi input tertentu secara eksklusif. Data dibaca dari partisi, diproses melalui rantai operator logika, dan dikirim ke partisi output.
  • State Store: Jika proses kita bersifat stateful (seperti agregasi data per menit), setiap Task akan memiliki State Store (RocksDB) lokal sendiri yang memuat data hanya dari partisi yang ditugaskan kepadanya. Ini mencegah konflik memori lintas thread karena tidak ada shared-state global di memori.

2. Utas Pemrosesan (StreamThread) #

Utas atau StreamThread adalah thread Java aktual yang mengeksekusi logika dari StreamTask. Kita dapat mengatur jumlah StreamThread per instansi aplikasi menggunakan parameter konfigurasi num.stream.threads (default adalah 1).

PETA HUBUNGAN THREAD DAN TASK:
-------------------------------------------------------------------
Aplikasi Instansi 1 (num.stream.threads = 2):
  ├── StreamThread 1 (Mengeksekusi Task 0, Task 1, Task 2)
  └── StreamThread 2 (Mengeksekusi Task 3, Task 4, Task 5)
-------------------------------------------------------------------

Jika salah satu StreamThread mati karena eksepsi yang tidak tertangani, Kafka Streams akan mendeteksi kematian tersebut dan secara otomatis memindahkan StreamTask yang menggantung ke thread lain yang masih hidup di dalam instansi tersebut atau ke instansi aplikasi lain di kluster melalui proses rebalance group.

3. Skalabilitas Horisontal Dinamis #

Mari kita tinjau skenario skalabilitas horisontal di mana kita memiliki topik input dengan 4 partisi, dan kita menyalakan aplikasi kita pada berbagai tingkat kepadatan instansi:

  • Skenario 1 (Instansi Tunggal): Kita menyalakan 1 instansi aplikasi dengan num.stream.threads=2. Maka 1 instansi tersebut akan memiliki 2 thread aktif, di mana masing-masing thread mengeksekusi 2 StreamTask secara paralel.
  • Skenario 2 (Dua Instansi): Kita menyalakan instansi kedua dengan konfigurasi application.id yang sama. Protokol rebalance Kafka akan terpicu secara dinamis. Instansi 1 sekarang memproses Task 0 dan Task 1, sedangkan Instansi 2 memproses Task 2 and Task 3. Proses migrasi task ini berjalan transparan di latar belakang tanpa kehilangan state lokal berkat replikasi changelog.
  • Skenario 3 (Instansi Berlebih): Kita menyalakan 5 instansi aplikasi untuk topik input bertotal 4 partisi. Karena jumlah partisi hanya 4, hanya ada 4 StreamTask yang bisa terbentuk. Instansi ke-5 akan berjalan dalam status idle (siaga) tanpa memproses data apa pun, siap mengambil alih tugas secara instan jika salah satu dari 4 instansi aktif mengalami crash.

Kode Penerapan: Anti-Pattern vs Solusi dengan Kafka Streams #

Untuk benar-benar memahami mengapa kita harus menggunakan Kafka Streams daripada mencoba membangun mesin pemrosesan data aliran sendiri menggunakan Kafka Consumer biasa, mari kita bandingkan kedua pendekatan tersebut melalui contoh implementasi nyata di bawah ini.

Masalah Kasus Penggunaan #

Bayangkan kita ingin membaca aliran data event transaksi belanja (purchase-events) yang dikirim dalam format JSON, memfilter transaksi yang bernilai di bawah $10, dan menuliskan data pembeli beserta nilai belanjanya ke topik tujuan (large-purchases).

Anti-Pattern: Menggunakan Pola Consumer Loop Manual #

Di bawah ini adalah kode Java yang mencoba mencapai tujuan tersebut secara manual dengan memanfaatkan consumer loop biasa.

// ANTI-PATTERN: Membangun logika stream processing manual dengan Consumer Loop biasa.
// ✗ Sangat sulit mengelola fault tolerance, scaling, state store, dan penanganan commit offset.
public class ManualStreamProcessor {
    public static void main(String[] args) {
        Properties props = new Properties();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        props.put(ConsumerConfig.GROUP_ID_CONFIG, "manual-processor-group");
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());

        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
        consumer.subscribe(Collections.singletonList("purchase-events"));

        Properties prodProps = new Properties();
        prodProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        prodProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        prodProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        KafkaProducer<String, String> producer = new KafkaProducer<>(prodProps);

        try {
            while (true) {
                // ✗ Pemanggilan blocking poll loop yang mematikan kinerja rebalance jika lambat diproses
                ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
                for (ConsumerRecord<String, String> record : records) {
                    String purchaseJson = record.value();
                    double amount = parseAmount(purchaseJson);

                    // Melakukan filtering manual
                    if (amount >= 10.0) {
                        ProducerRecord<String, String> outRecord = 
                            new ProducerRecord<>("large-purchases", record.key(), purchaseJson);
                        
                        // ✗ Pengiriman asinkron tanpa koordinasi transaksi atomik (potensi data loss/duplikasi)
                        producer.send(outRecord);
                    }
                }
                // ✗ Komit manual offset berpotensi memicu At-Least-Once ganda jika crash terjadi sebelum komit sukses
                consumer.commitSync();
            }
        } finally {
            consumer.close();
            producer.close();
        }
    }

    private static double parseAmount(String json) {
        // Logika parsing sederhana
        return 15.0; // Contoh simulasi nilai belanja
    }
}

Mengapa Kode Anti-Pattern di Atas Sangat Berbahaya? #

  1. Tidak Ada State Management: Jika kita ingin melakukan agregasi (misal, menghitung total belanja per pengguna selama 5 menit terakhir), kita harus membuat penyimpanan memori eksternal (seperti Redis) secara manual. Ini menambahkan ketergantungan infrastruktur baru dan memperlambat latensi karena harus mengakses jaringan lintas node.
  2. Potensi Duplikasi Data: Tidak ada jaminan pemrosesan transaksional atomik antara penulisan ke produser dan komit offset konsumen.
  3. Threading Rumit: Untuk menambah performa, kita harus secara manual menulis logika manajemen multi-thread Java di atas Consumer API, yang rawan menimbulkan kesalahan race condition atau kebocoran memori (memory leaks).

Solusi Praktis: Menggunakan Kafka Streams DSL API #

Di bawah ini adalah implementasi standar menggunakan Kafka Streams DSL API. Kode ini sangat ringkas, aman, terkelola secara otomatis oleh sistem internal, dan siap untuk diskalakan.

// BENAR: Menggunakan Kafka Streams DSL untuk mendefinisikan topologi pemrosesan aliran data.
// ✓ Jauh lebih aman, otomatis mengelola state store RocksDB, threading, dan fault-tolerance.
public class KafkaStreamsDslProcessor {
    public static void main(String[] args) {
        Properties config = new Properties();
        
        // ✓ Application ID bertindak sebagai Group ID konsumen dan namespace untuk state store lokal
        config.put(StreamsConfig.APPLICATION_ID_CONFIG, "purchase-filtering-service");
        config.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        
        // Konfigurasi tipe data serializer / deserializer default
        config.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.String().getClass().getName());
        config.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.String().getClass().getName());
        
        // Mengoptimalkan pemrosesan internal
        config.put(StreamsConfig.NUM_STREAM_THREADS_CONFIG, 2); // Menggunakan 2 thread paralel

        // Membuat builder topologi pemrosesan aliran
        StreamsBuilder builder = new StreamsBuilder();

        // ✓ Membaca input stream dari topik asal secara non-blocking
        KStream<String, String> purchases = builder.stream("purchase-events");

        // Mendefinisikan topologi filter asinkron menggunakan lambda expression
        purchases
            .filter((key, value) -> parseAmount(value) >= 10.0)
            // ✓ Menuliskan hasil pemrosesan secara langsung ke topik tujuan
            .to("large-purchases");

        // Membangun topologi logika fisik
        Topology topology = builder.build();
        
        // Menginisialisasi instansi Kafka Streams
        KafkaStreams streams = new KafkaStreams(topology, config);

        // Menambahkan shutdown hook agar aplikasi menutup koneksi secara bersih saat dimatikan
        Runtime.getRuntime().addShutdownHook(new Thread(streams::close));

        log.info("Memulai pemrosesan topologi aliran data...");
        streams.start();
    }

    private static double parseAmount(String json) {
        // Logika parsing JSON, misalnya menggunakan Jackson
        return 20.0;
    }
}

Agar kita tidak salah dalam memilih platform teknologi saat merancang sistem, kita perlu memetakan karakteristik Kafka Streams terhadap dua raksasa framework pemrosesan stream lainnya di dunia industri:

Dimensi Perbandingan Kafka Streams Apache Spark Streaming Apache Flink
Jenis Deployment Embedded Library (Pustaka Tersemat di Aplikasi) Dedicated Compute Cluster (Master-Worker) Dedicated Compute Cluster (Master-Worker)
Gaya Pemrosesan Event-at-a-time (Satu per satu rekaman) Micro-batching (Default) / Continuous Processing Event-at-a-time (Aliran kontinu murni)
Manajemen State Lokal (RocksDB tersemat per partisi) Terdistribusi (Checkpoints ke HDFS/S3) Lokal + Replikasi (RocksDB + State Checkpoints)
Sumber Data Hanya Apache Kafka Berbagai sumber (Kafka, HDFS, S3, RDBMS, dll) Berbagai sumber (Kafka, HDFS, S3, RDBMS, dll)
Kompleksitas Ops Sangat Rendah (Deploy seperti microservice biasa) Tinggi (Butuh tim operasi kluster sendiri) Sangat Tinggi (Butuh pemantauan kluster intensif)
Kebutuhan Memori Kecil (Sesuai ukuran JVM Heap + RocksDB lokal) Besar (Butuh resource JVM Executor yang masif) Sangat Besar (Butuh alokasi memori kluster terdedikasi)
Kasus Penggunaan Utama Mikroservis Event-Driven, CDC, ETL Real-time sederhana Analitik Big Data berskala petabyte secara berkala Analitik Event Real-Time rumit, pemrosesan event kompleks (CEP)

Kapan Memilih Kafka Streams? #

Untuk mempermudah pengambilan keputusan arsitektur di tim kita, berikut adalah diagram alur keputusan sederhana untuk memilih di antara opsi-opsi yang ada:

PILIH KAFKA STREAMS JIKA:
  ✓ Sumber data masukan dan tujuan keluaran utama kita adalah Apache Kafka.
  ✓ Kita ingin mendeploy aplikasi pemrosesan data kita ke Kubernetes/Docker seperti mikroservis lainnya.
  ✓ Tim pengembang kita sangat familiar dengan ekosistem Java/JVM.
  ✓ Kita ingin menghindari overhead biaya operasional dan pemeliharaan kluster eksternal baru.

PILIH APICHE SPARK / FLINK JIKA:
  ✗ Kita perlu menggabungkan data Kafka secara langsung dengan sumber data eksternal lain seperti Hadoop HDFS atau RDBMS dalam jumlah besar.
  ✗ Kita membutuhkan fitur pemrosesan event kompleks (Complex Event Processing / CEP) tingkat lanjut yang tidak didukung DSL dasar.
  ✗ Kita melakukan pemrosesan analitik batch skala raksasa yang tidak berbasis aliran event terus-menerus.

Ringkasan #

  • Kafka Streams — Pustaka klien Java tertanam (embedded library) yang digunakan untuk memproses data dari dan ke Apache Kafka secara real-time tanpa membutuhkan kluster komputasi tambahan.
  • Filosofi Lightweight — Tidak memerlukan instalasi server khusus; aplikasi yang menggunakan Kafka Streams dapat di-deploy layaknya mikroservis biasa menggunakan Docker atau Kubernetes.
  • StreamTask — Unit kerja paralel terkecil di Kafka Streams, di mana jumlahnya secara dinamis ditentukan oleh jumlah partisi maksimum dari topik masukan.
  • StreamThread — Utas pemrosesan data aktual di Java yang bertugas mengeksekusi logika komputasi dari satu atau beberapa StreamTask secara paralel.
  • RocksDB — Mesin database key-value lokal di luar memori heap JVM (off-heap) yang digunakan untuk menyimpan stateful data secara efisien pada setiap partisi.
  • Embedded vs Cluster — Kafka Streams memprioritaskan kesederhanaan operasional dan performa integrasi lokal, berbeda dengan Spark/Flink yang mengutamakan komputasi skala masif lintas berbagai jenis media penyimpanan.

← Sebelumnya: Anti-Pattern Integration   Berikutnya: Stream vs Table →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact