Exactly-Once #
Dalam dunia rekayasa perangkat lunak terdistribusi, menjamin bahwa sebuah pesan diproses tepat satu kali (exactly-once) sering kali dianggap sebagai “Holy Grail” atau cawan suci yang mustahil dicapai. Berdasarkan teori akademis sistem terdistribusi (seperti teorema FLP Impossibility), sangat sulit untuk membedakan antara kegagalan jaringan sementara dengan crash permanen pada sebuah node tanpa adanya koordinasi terpusat yang mahal harganya. Namun, pada bulan Juni 2017, Apache Kafka merilis versi 0.11 yang memperkenalkan fitur Exactly-Once Semantics (EOS). Jaminan ini memastikan bahwa meskipun terjadi kegagalan jaringan, gangguan koneksi, broker crash, atau restart aplikasi secara mendadak di tengah pemrosesan, data yang kita kirimkan dari produser ke broker dan diproses oleh konsumen akan tercatat tepat satu kali secara konsisten. Pemrosesan tepat satu kali ini sangat kritis untuk aplikasi sensitif seperti pemrosesan transaksi keuangan, sistem inventaris pergudangan, dan kalkulasi logistik.
Fondasi Teoretis: Mengapa Exactly-Once Sangat Sulit Dicapai? #
Untuk memahami kejeniusan solusi EOS di Kafka, kita harus memahami mengapa model pengiriman ini secara historis sangat sulit dicapai. Masalah utama terletak pada Ketidakpastian Status Jaringan (Network State Uncertainty). Ketika sebuah pengirim (produser) mengirimkan pesan ke penerima (broker) dan jaringan terputus sebelum pengirim menerima balasan, pengirim dihadapkan pada dua kemungkinan yang tidak diketahui:
- Pesan tidak pernah sampai ke broker (data loss jika tidak dikirim ulang).
- Pesan sudah disimpan dengan aman oleh broker, namun paket ACK balik yang hilang di jalan (duplikasi jika dikirim ulang).
Di sistem tradisional, untuk menyelesaikan masalah ini, pengembang harus memilih antara menerima kehilangan data (At-Most-Once) atau menerima risiko data ganda (At-Least-Once) lalu menyaringnya secara manual menggunakan logika deduplikasi yang rumit di tingkat basis data.
Kafka memecahkan masalah ini dengan menyatukan tiga pilar arsitektur internal yang saling berkolaborasi untuk membentuk sistem Exactly-Once yang kohesif:
flowchart TD
EOS["Exactly-Once Semantics (EOS)"]
EOS --> P1["1. Idempotent Producer"]
EOS --> P2["2. Transactional API"]
EOS --> P3["3. Isolation Level (read_committed)"]
P1 -->|"Mencegah Duplikasi Jaringan"| PID["Identitas PID + Sequence Number"]
P2 -->|"Menulis Atomik Multi-Partisi"| TC["Transaction Coordinator + Transaction State Log"]
P3 -->|"Penyaringan Konsumen"| LSO["Last Stable Offset (LSO) + Commit Marker"]
style EOS stroke:#0288d1,stroke-width:2px
style P1 stroke:#2e7d32,stroke-width:2px
style P2 stroke:#2e7d32,stroke-width:2px
style P3 stroke:#2e7d32,stroke-width:2px
Ketiga komponen di atas bekerja secara berurutan. Idempotent Producer bertugas menjamin bahwa tidak ada pesan duplikat yang ditulis oleh produser ke satu partisi akibat pengiriman ulang di jaringan. Transactional API menjamin bahwa penulisan ke beberapa partisi dan topik yang berbeda (termasuk offset konsumen) berjalan secara atomik (semua sukses atau semua batal). Terakhir, Isolation Level di sisi konsumen menjamin bahwa aplikasi hilir tidak akan membaca data dari transaksi yang gagal atau sedang berjalan.
Pilar 1: Idempotent Producer (Idempotensi Produser) #
Idempotensi produser adalah dasar dari EOS. Tanpa idempotensi, tidak mungkin kita bisa membangun transaksi yang aman. Idempotensi menjamin bahwa berapa kali pun produser mengirim ulang pesan yang sama karena kehilangan ACK di jaringan, broker hanya akan menulis pesan tersebut tepat satu kali ke log disk fisik.
Cara Kerja Internal Idempotensi #
Ketika idempotensi diaktifkan (enable.idempotence=true), Kafka melakukan dua hal di balik layar:
1. Alokasi Producer ID (PID) #
Setiap kali produser diinisialisasi, ia akan meminta ID unik ke broker coordinator menggunakan panggilan API InitProducerId. Broker memberikan angka Producer ID (PID) dan menaikkan nilai Epoch produser. PID ini unik di dalam kluster Kafka dan berfungsi sebagai identitas unik produser tersebut.
2. Nomor Urut (Sequence Number) #
Untuk setiap partisi tujuan, produser akan memberikan Sequence Number yang dimulai dari 0 dan bertambah 1 untuk setiap pesan yang dikirimkan. Nomor urut ini disematkan pada header pesan biner bersama dengan PID.
Di sisi broker, memori broker aktif memelihara peta status (state map) untuk setiap pasangan (PID, Partition) yang mencatat nomor urut terakhir yang sukses ditulis (Last Sequence Number). Ketika broker menerima pesan baru, ia memeriksa nomor urut pesan tersebut:
- Jika
Sequence Number == Last Sequence + 1: Pesan valid, broker menulis pesan ke disk dan memperbaruiLast Sequencemenjadi nomor urut baru. - Jika
Sequence Number <= Last Sequence: Broker menyadari bahwa pesan ini adalah duplikat akibat pengiriman ulang produser. Broker membuang pesan tersebut secara senyap (tidak menulis ke disk) namun tetap mengirimkan ACK sukses ke produser agar produser tidak cemas dan tidak terus mencoba mengirim ulang. - Jika
Sequence Number > Last Sequence + 1: Menunjukkan adanya pesan yang terlewat (out-of-sequence), yang mengindikasikan adanya data loss di jaringan sebelum pesan tiba. Broker menolak pesan tersebut dan mengirimkan errorOutOfOrderSequenceExceptionke produser.
sequenceDiagram
autonumber
participant Prod as Produser (PID=100)
participant Broker as Kafka Broker (State: PID 100 -> LastSeq 5)
Note over Prod, Broker: "ProduceRequest dengan Seq 6 (Normal)"
Prod->>Broker: ProduceRequest (PID=100, Seq=6, Data A)
Broker->>Broker: Cek Seq: 6 == LastSeq 5 + 1 (Valid!)
Broker->>Broker: Tulis Data A ke Disk
Note over Broker: Update State: PID 100 -> LastSeq 6
Broker-->>Prod: ACK Sukses
Note over Prod, Broker: "ProduceRequest dengan Seq 6 (Duplikat akibat Retry)"
Prod->>Broker: ProduceRequest (PID=100, Seq=6, Data A)
Broker->>Broker: Cek Seq: 6 <= LastSeq 6 (DUPLIKAT!)
Note over Broker: Pesan dibuang secara senyap dari penulisan disk
Broker-->>Prod: ACK Sukses (Klien menganggap pengiriman berhasil)
Batasan Idempotensi Produser #
Meskipun sangat kuat, idempotensi produser memiliki dua batasan penting:
- Terikat pada Sesi Produser (Single-Session): Jika aplikasi produser restart total, ia akan dialokasikan PID baru oleh broker. Broker menganggap ini adalah produser baru, sehingga duplikasi dari sesi produser lama yang tertahan di antrean jaringan tidak dapat disaring lagi.
- Terikat pada Partisi Tunggal (Single-Partition): Idempotensi hanya bekerja untuk menjamin tidak ada duplikasi pada satu partisi dari satu topik. Ia tidak dapat menjamin transaksi atomik lintas partisi atau lintas topik. Untuk mengatasi batasan ini, kita membutuhkan Transactional API.
Pilar 2: Transactional API (API Transaksi) #
Untuk mengatasi batasan idempotensi, Kafka menyediakan Transactional API. Ini memungkinkan produser untuk mengirimkan sekelompok pesan ke berbagai partisi dan topik secara atomik. Jaminan atomisitas ini mengikuti prinsip All-or-Nothing: semua pesan dalam transaksi sukses terkomit, atau tidak ada satu pun pesan yang terlihat oleh konsumen jika transaksi dibatalkan (aborted).
Protokol transaksi Kafka dikoordinasikan oleh komponen internal broker yang disebut Transaction Coordinator. Coordinator ini melacak status transaksi di dalam topik log internal yang aman bernama __transaction_state.
Siklus hidup transaksi Kafka melibatkan langkah-langkah berikut:
- Inisialisasi: Produser mendaftarkan
transactional.idyang statis ke Transaction Coordinator. Ini memberikan jaminan Zombie Fencing (fencing produser lama jika ada dua instansi produser dengan ID transaksi yang sama berjalan bersamaan). - Memulai Transaksi: Produser memanggil
beginTransaction()untuk memulai blok transaksi baru secara lokal. - Mengirim Pesan: Produser mengirimkan pesan ke berbagai partisi topik. Di balik layar, Transaction Coordinator mencatat partisi mana saja yang terlibat dalam transaksi ini ke topik
__transaction_state. - Menulis Commit/Abort Marker: Ketika produser memanggil
commitTransaction(), Transaction Coordinator menulis catatan penutup PrepareCommit ke__transaction_state, lalu menyebarkan pesan khusus bernama Commit Marker (atau Abort Marker jika transaksi dibatalkan) ke seluruh partisi topik fisik yang terlibat.
KONTEN LOG PARTISI DENGAN COMMIT MARKER:
┌─────────────────────┬─────────────────────┬─────────────────────┐
│ Offset 200: Pesan A │ Offset 201: Pesan B │ Offset 202: COMMIT │
│ (Tx Active) │ (Tx Active) │ (Commit Marker) │
└─────────────────────┴─────────────────────┴─────────────────────┘
│
(Konsumen read_committed)
│
▼
(Hanya membaca data setelah Offset 202)
Pilar 3: Isolation Level read_committed di Sisi Konsumen #
Jaminan penulisan atomik di broker tidak akan berguna jika konsumen langsung memproses setiap baris data yang masuk tanpa memeriksa status transaksi data tersebut. Oleh karena itu, di sisi konsumen hilir, kita wajib mengonfigurasi properti:
isolation.level=read_committed
Secara default, properti ini bernilai read_uncommitted, yang berarti konsumen langsung melahap semua data di log partisi tanpa memperdulikan apakah data tersebut berasal dari transaksi yang sukses, sedang berjalan, atau telah dibatalkan (aborted).
Bagaimana read_committed Bekerja? #
Ketika konsumen diatur ke read_committed, pustaka klien konsumen akan melakukan penyaringan di sisi klien menggunakan informasi dari broker:
- Konsumen membaca log partisi secara berurutan.
- Jika ia menemukan pesan transaksional, ia akan memeriksa apakah pesan tersebut diikuti oleh Commit Marker di offset berikutnya.
- Jika ia menemukan Commit Marker, pesan didekode dan diteruskan ke perulangan logika bisnis aplikasi kita.
- Jika ia menemukan Abort Marker, konsumen secara otomatis melompati pesan-pesan dari transaksi yang batal tersebut secara senyap. Aplikasi kita tidak akan pernah melihat isi pesan tersebut.
- Menahan LSO (Last Stable Offset): Jika ada transaksi yang masih berjalan (ongoing) di partisi tersebut, konsumen tidak akan diizinkan membaca pesan apa pun yang berada di luar LSO, meskipun ada pesan non-transaksional baru yang ditulis setelah transaksi ongoing itu dimulai. Ini memastikan bahwa konsumen tidak akan melompati status transaksi yang belum pasti.
Kode Penerapan Java SDK: Konfigurasi EOS Terbaik #
Berikut adalah contoh kode Java yang membandingkan penulisan produser non-idempotent (anti-pattern untuk EOS) dengan produser idempotent yang aman untuk lingkungan produksi.
import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.Properties;
public class ExactlyOnceProducerComparison {
public static void main(String[] args) {
Properties baseProps = new Properties();
baseProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
baseProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
baseProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
// =====================================================================
// 1. ANTI-PATTERN: PRODUSER NON-IDEMPOTENT (Rentan Duplikasi)
// =====================================================================
Properties unsafeProps = new Properties(baseProps);
unsafeProps.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "false");
unsafeProps.put(ProducerConfig.ACKS_CONFIG, "1"); // Hanya menunggu leader
unsafeProps.put(ProducerConfig.RETRIES_CONFIG, 3);
KafkaProducer<String, String> unsafeProducer = new KafkaProducer<>(unsafeProps);
// JANGAN gunakan konfigurasi di atas jika Anda memproses pembayaran saldo!
unsafeProducer.send(new ProducerRecord<>("payments", "user_10", "{\"amount\": 50000}"));
unsafeProducer.close();
// =====================================================================
// 2. BENAR: PRODUSER IDEMPOTENT (Aman Dari Duplikasi Jaringan)
// =====================================================================
Properties safeProps = new Properties(baseProps);
// ✓ Mengaktifkan jaminan idempotensi di tingkat partisi tunggal
safeProps.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true");
// Catatan Penting: Saat enable.idempotence diset ke true, Kafka client
// secara otomatis akan memvalidasi dan memaksa parameter berikut:
// - ACKS_CONFIG akan dipaksa menjadi "all" (durabilitas maksimum)
// - RETRIES_CONFIG akan dipaksa menjadi Integer.MAX_VALUE
// - MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION dipaksa maksimal 5 (menjaga urutan pesan)
KafkaProducer<String, String> safeProducer = new KafkaProducer<>(safeProps);
// Aman dari risiko duplikasi akibat switch jaringan mati sesaat
safeProducer.send(new ProducerRecord<>("payments", "user_10", "{\"amount\": 50000}"), new Callback() {
@Override
public void onCompletion(RecordMetadata metadata, Exception exception) {
if (exception != null) {
// Terjadi kesalahan fatal non-retriable (misalnya kegagalan otorisasi ACL)
System.err.println("✗ Gagal mengirim transaksi secara idempotent: " + exception.getMessage());
} else {
System.out.println("✓ Transaksi sukses disimpan di offset: " + metadata.offset());
}
}
});
safeProducer.close();
}
}
Kapan Memilih Semantik Exactly-Once? #
Untuk membantu arsitek sistem memilih tingkat jaminan pengiriman data yang tepat, gunakan panduan praktis berikut:
TETAP gunakan Exactly-Once jika:
✓ Aplikasi mengolah data finansial, pembukuan akuntansi, billing, atau transfer uang.
✓ Anda melakukan agregasi data yang sensitif (seperti menghitung total inventaris barang secara real-time).
✓ Anda menggunakan Kafka Streams untuk pemrosesan stateful (windowing join/aggregation) lintas topik.
✓ Duplikasi pesan sama sekali tidak dapat ditoleransi oleh database target.
JANGAN paksakan Exactly-Once (Gunakan At-Least-Once) jika:
✗ Throughput super tinggi (misalnya memproses >10.000.000 metrik IoT per detik) adalah tujuan utama.
✗ Latensi end-to-end yang konsisten di bawah 5 milidetik adalah keharusan (overhead transaksi menghambat hal ini).
✗ Sistem database hilir Anda sudah idempoten (lebih efisien menggunakan deduplikasi di sisi DB).
Ringkasan #
- Exactly-Once Semantics (EOS) — Jaminan pengiriman terkuat di mana pesan dipastikan masuk ke log broker dan selesai dieksekusi di konsumen tepat satu kali, bebas dari kehilangan data maupun duplikasi.
- Idempotent Producer — Komponen dasar EOS yang mencegah duplikasi pesan akibat retries di jaringan melalui penyematan Producer ID (PID) dan Sequence Number pada header paket data.
- Sequence Number Filtering — Mekanisme broker untuk membuang paket duplikat secara senyap jika nomor urut pesan yang diterima kurang dari atau sama dengan nomor urut terakhir yang tercatat di memori broker.
- Transactional API - Sekumpulan fungsi API untuk menjamin atomisitas penulisan lintas topik dan partisi (All-or-Nothing) yang dikoordinasikan oleh Transaction Coordinator melalui topik
__transaction_state.- Isolation Level read_committed — Penyetelan wajib di sisi konsumen agar secara aktif menyaring dan membuang data dari transaksi yang dibatalkan (aborted) serta menahan pembacaan di batas Last Stable Offset (LSO).
- Zombie Fencing — Protokol pengamanan untuk mengisolasi dan menolak akses tulis dari instansi produser lama (split-brain) setelah produser baru dengan ID transaksi yang sama mendaftarkan diri ke broker.
- Overhead Performa — Transaksi EOS menyebabkan sedikit penurunan throughput dan peningkatan latensi akibat penulisan commit marker tambahan dan penahanan LSO di broker.