At-Most-Once #
Dalam arsitektur sistem terdistribusi, menjamin bahwa data terkirim dan diproses dengan benar merupakan salah satu tantangan terbesar. Apache Kafka menawarkan beberapa tingkat jaminan pengiriman data yang dikenal sebagai Delivery Semantics. Salah satu model jaminan yang paling mendasar adalah At-Most-Once (Paling Banyak Sekali). Dalam model ini, sebuah pesan akan dikirim atau diproses paling banyak satu kali. Ini berarti pesan bisa saja hilang di tengah jalan dan tidak pernah sampai atau diproses oleh sistem hilir, namun pesan tersebut dijamin tidak akan pernah diproses lebih dari satu kali. Jaminan ini sangat ideal untuk skenario di mana duplikasi data jauh lebih berbahaya atau lebih mahal biayanya dibandingkan kehilangan beberapa baris data, atau ketika latensi super rendah menjadi prioritas utama sistem kita.
Konsep Dasar dan Filosofi At-Most-Once #
Semantik pengiriman At-Most-Once beroperasi dengan filosofi “kirim dan lupakan” (fire-and-forget) di sisi produser, atau “komit dulu, proses kemudian” (commit-first) di sisi konsumen. Untuk memahami mengapa tingkat jaminan ini ada, kita harus memahami trade-off mendasar dalam sistem terdistribusi antara keandalan (durabilitas), kecepatan (throughput/latensi), dan konsistensi.
Dalam ekosistem Kafka, secara default, jika kita tidak melakukan konfigurasi khusus, sistem akan beroperasi mendekati At-Least-Once. Namun, untuk beberapa aplikasi tertentu, biaya untuk mendeteksi, menyaring, dan menangani duplikasi pesan sangatlah tinggi. Pemrosesan pesan duplikat dapat merusak status internal aplikasi atau menyebabkan inkonsistensi jika operasi yang dilakukan tidak bersifat idempoten. Dalam kondisi seperti ini, pengembang sistem sering kali memilih untuk mengorbankan sebagian kecil pesan yang hilang demi menjaga kecepatan pemrosesan dan kesederhanaan logika aplikasi tanpa overhead penanganan duplikasi.
sequenceDiagram
autonumber
participant Prod as Produser (App)
participant Broker as Kafka Broker
participant Cons as Konsumen (App)
Note over Prod, Broker: "Sisi Produser: acks=0 (Fire and Forget)"
Prod->>Broker: ProduceRequest (Pesan A)
Note over Prod: Produser langsung menganggap sukses tanpa menunggu respons
Broker--xProd: ACK Hilang / Broker Crash (Pesan A hilang secara fisik)
Note over Broker, Cons: "Sisi Konsumen: Commit-First (At-Most-Once)"
Cons->>Broker: poll() menarik pesan B (Offset 200)
Cons->>Broker: CommitOffsetRequest (Offset 200)
Broker-->>Cons: Commit ACK (Offset 200 tersimpan)
Note over Cons: Konsumen mulai memproses pesan B
Note over Cons: CRASH! (OOM / Listrik padam saat proses berjalan)
Note over Cons: Restart konsumen -> Membaca dari Offset 201 (Pesan B hilang!)
Diagram di atas mengilustrasikan bagaimana data dapat hilang di kedua sisi (produser dan konsumen) ketika kita menerapkan semantik At-Most-Once. Di sisi produser, hilangnya data terjadi karena kita tidak menunggu konfirmasi dari broker. Di sisi konsumen, data hilang karena kita menandai data tersebut sebagai “selesai diproses” (melalui komit offset) sebelum logika bisnis kita benar-benar berhasil mengeksekusinya.
Konfigurasi di Sisi Produser (Producer Client) #
Untuk memaksa produser Kafka beroperasi dalam mode At-Most-Once, kita harus menonaktifkan semua mekanisme pengiriman ulang (retries) dan tidak meminta konfirmasi penerimaan dari broker. Konfigurasi utama yang harus disetel pada properti produser meliputi:
1. acks=0
#
Properti acks (acknowledgements) mengontrol berapa banyak replika broker yang harus menerima pesan sebelum produser menganggap pengiriman tersebut sukses.
- Cara Kerja: Ketika disetel ke
0, produser akan mengirimkan ProduceRequest ke broker dan langsung mengembalikan status sukses ke utas aplikasi utama tanpa menunggu konfirmasi sama sekali dari broker leader. - Dampak: Jika broker leader mengalami crash tepat setelah paket data tiba di soket jaringan tetapi sebelum ditulis ke log disk, pesan tersebut akan hilang selamanya. Namun, latensi pengiriman menjadi sangat rendah karena tidak ada waktu tunggu jaringan (network round-trip) untuk ACK.
2. retries=0
#
Secara default, pustaka klien Kafka akan mencoba mengirim ulang pesan secara otomatis jika terjadi kegagalan sementara (seperti kesalahan jaringan atau pemilihan ulang leader partisi baru).
- Cara Kerja: Dengan menyetel
retries=0, kita melarang produser melakukan pengiriman ulang jika terjadi error. - Dampak: Jika terjadi gangguan koneksi jaringan sesaat, produser langsung menyerah dan melempar eksepsi ke aplikasi, atau mengabaikannya tergantung pada penanganan callback kita. Ini memastikan bahwa tidak akan ada pengiriman ulang yang berpotensi menghasilkan duplikat di broker akibat ACK yang hilang.
3. max.in.flight.requests.per.connection=1
#
Meskipun retries sudah disetel ke 0, membatasi jumlah request yang berjalan di jaringan secara paralel membantu menjaga urutan pesan jika terjadi error jaringan yang tidak terduga pada koneksi soket TCP tertentu.
Konfigurasi di Sisi Konsumen (Consumer Client) #
Di sisi konsumen, semantik At-Most-Once dicapai dengan mengubah urutan operasi komit offset. Ada dua cara utama untuk mengimplementasikan hal ini:
Pendekatan A: Auto-Commit Berbasis Waktu (Time-based Auto-Commit) #
Ini adalah metode termudah namun kurang presisi. Kita mengandalkan thread latar belakang konsumen untuk mengirimkan komit offset secara berkala ke broker tanpa memperdulikan status pemrosesan data di utas utama.
Konfigurasi yang diperlukan:
enable.auto.commit=true: Mengaktifkan fitur komit otomatis oleh konsumen.auto.commit.interval.ms=1000: Menyuruh konsumen untuk mengirimkan offset terakhir yang ditarik ke broker setiap 1 detik.
Mengapa ini menghasilkan At-Most-Once?
Ketika kita memanggil fungsi consumer.poll(Duration), konsumen akan menarik sejumlah pesan (misalnya offset 100 hingga 150). Utas latar belakang akan secara otomatis mengirimkan komit untuk offset 150 pada interval waktu berikutnya. Jika aplikasi utama kita sedang memproses pesan offset 120 dan tiba-tiba mengalami crash, offset 150 sudah terlanjur dikomit ke broker. Saat aplikasi dinyalakan kembali, ia akan mulai membaca dari offset 151. Pesan 121 hingga 150 hilang tanpa pernah selesai diproses.
Pendekatan B: Komit Manual Sebelum Pemrosesan (Commit-First Pattern) #
Pendekatan ini memberikan kontrol penuh kepada kode kita. Kita menonaktifkan auto-commit dan secara eksplisit melakukan komit secara sinkron segera setelah pesan berhasil ditarik dari fungsi .poll().
Konfigurasi yang diperlukan:
enable.auto.commit=false
Langkah operasi di dalam kode:
- Panggil
consumer.poll()untuk mengambil batch data. - Panggil
consumer.commitSync()secara instan untuk menandai bahwa offset batch tersebut telah diterima. - Mulai jalankan perulangan logika bisnis untuk memproses data.
flowchart TD
Poll["Poll Data"] --> Commit["Commit Offset ke Broker"] --> Process["Proses Logika Bisnis"] --> Done["Selesai"]
Process -- "Aplikasi Crash" --> Lost["Data Setelah Commit Hilang!"]
Jika aplikasi mengalami kegagalan di tengah-tengah langkah ke-3, database atau sistem eksternal kita tidak akan menerima data tersebut, namun broker sudah mencatat bahwa data telah dikonsumsi. Setelah sistem pulih, pemrosesan akan berlanjut ke batch berikutnya, menghindari duplikasi dengan risiko kehilangan data batch yang gagal.
Analisis Skenario Kegagalan (Data Loss Deep Dive) #
Mari kita bedah secara kronologis bagaimana kegagalan sistem dapat menyebabkan data loss pada implementasi At-Most-Once di lingkungan produksi.
Skenario 1: Kegagalan Broker saat Produser acks=0
#
- Aplikasi bisnis memanggil
producer.send(record). - Pustaka klien Kafka mengemas pesan dan mengirimkannya melalui soket TCP ke IP Broker Leader.
- Klien langsung mengembalikan objek
RecordMetadatasukses ke aplikasi kita. - Tepat pada milidetik yang sama, server fisik Broker Leader mati mendadak karena gangguan listrik. Pesan baru saja tiba di buffer memori kernel OS server broker dan belum sempat disalin ke memori JVM Kafka atau ditulis ke disk menggunakan page cache.
- Kafka controller mendeteksi matinya leader dan menunjuk broker follower sebagai leader baru.
- Karena follower belum sempat mereplikasi pesan tersebut, pesan tersebut hilang selamanya. Produser tidak tahu dan tidak akan mengirim ulang karena
acks=0dan thread produser sudah melanjutkan ke tugas berikutnya.
Skenario 2: Crash Konsumen saat Menggunakan Pola Commit-First #
- Konsumen membaca 5 records dari partisi 0:
[offset 50, 51, 52, 53, 54]. - Konsumen segera memanggil
consumer.commitSync(). Broker menyimpan offset55di topik internal__consumer_offsets. - Konsumen mulai memproses record offset
50(sukses disimpan ke database RDBMS). - Konsumen mulai memproses record offset
51. Di tengah pemrosesan, server mengalami Out Of Memory (OOM) error atau kabel jaringan terputus. Utas JVM mati seketika. - Setelah server dinyalakan kembali, instansi konsumen baru bergabung kembali ke grup. Group coordinator menugaskan kembali partisi 0 ke konsumen ini.
- Konsumen memanggil
.poll()pertama kali. Broker melihat offset terakhir yang dikomit adalah55. Broker mengirimkan data mulai dari offset55. - Akibat: Record
51,52,53, dan54tidak pernah diproses lagi oleh aplikasi kita.
Kasus Penggunaan Praktis (Real-World Use Cases) #
Semantik At-Most-Once bukanlah pola yang buruk; ini adalah pilihan arsitektur yang sengaja diambil untuk skenario tertentu. Berikut adalah beberapa use case nyata di mana pola ini sangat direkomendasikan:
1. Pelacakan Clickstream Pengguna (Clickstream Analytics) #
Ketika kita melacak setiap klik pengguna di situs e-commerce untuk menganalisis tren navigasi atau memberikan rekomendasi produk secara real-time. Jika dari 1.000.000 klik ada 5 klik yang hilang karena gangguan jaringan sementara, hasil analisis statistik kita tidak akan berubah secara signifikan. Sebaliknya, jika kita menggunakan At-Least-Once, duplikasi data klik akan mengacaukan perhitungan konversi corong (funnel conversion rate) dan mempersulit visualisasi alur halaman pengguna.
2. Telemetri Sensor IoT Berfrekuensi Tinggi #
Bayangkan sebuah sensor suhu pada mesin pabrik yang mengirimkan status suhu setiap 100 milidetik. Jika satu paket data suhu hilang pada pukul 10:00:00.100, data baru yang dikirim pada pukul 10:00:00.200 (100 milidetik kemudian) akan langsung menggantikannya. Dalam skenario ini, mengirim ulang data suhu yang usang (karena delay jaringan) justru akan mengacaukan analisis real-time dan membuang-buang bandwidth. Kita hanya peduli pada kondisi suhu paling mutakhir.
3. Agregasi Log Aplikasi Skala Besar #
Sistem manajemen log terpusat (seperti Elasticsearch/Kibana via Logstash atau fluentd) memproses jutaan baris log per detik dari ribuan server. Kehilangan satu baris log debug dari aplikasi tidak sekritis jika server log harus mengalami hambatan performa akibat mengelola sistem pengiriman ulang log yang gagal, yang dapat menyebabkan memori server jebol karena kelebihan beban antrean (backpressure).
Kode Penerapan Java SDK: Anti-Pattern vs Solusi Aman #
Mari kita pelajari contoh implementasi kode Java untuk memahami perbedaan penerapan semantik pengiriman data ini secara aman dan menghindari jebakan kesalahan penempatan logika bisnis.
1. Sisi Produser (Java SDK) #
import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.Properties;
public class AtMostOnceProducerExample {
public static void main(String[] args) {
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
// =====================================================================
// KONFIGURASI PENTING AT-MOST-ONCE
// =====================================================================
// ✓ Setel acks ke 0 agar produser tidak menunggu konfirmasi dari broker
props.put(ProducerConfig.ACKS_CONFIG, "0");
// ✓ Matikan retries otomatis untuk mencegah pengiriman ulang duplikat
props.put(ProducerConfig.RETRIES_CONFIG, 0);
// ✓ Batas in-flight request untuk meminimalkan risiko pengiriman out-of-order
props.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 1);
KafkaProducer<String, String> producer = new KafkaProducer<>(props);
// ANTI-PATTERN: Menggunakan acks=0 untuk transaksi finansial penting
// Jika jaringan putus, saldo pengguna berkurang di database kita tetapi
// pesan transfer tidak pernah sampai ke Kafka, dan aplikasi tidak tahu.
try {
String transactionMsg = "{\"userId\":\"usr_99\",\"amount\":150000}";
// JANGAN lakukan ini jika pesan bersifat kritikal!
producer.send(new ProducerRecord<>("bank-transfers", "usr_99", transactionMsg));
System.out.println("✓ Transaksi dikirim (acks=0). Berbahaya jika koneksi hilang!");
} catch (Exception e) {
// Blok catch ini hampir tidak pernah dipicu untuk kesalahan jaringan
// karena send() dengan acks=0 langsung mengembalikan sukses.
System.err.println("✗ Error terdeteksi di tingkat lokal klien: " + e.getMessage());
}
// BENAR: Menggunakan acks=0 untuk data sensor suhu ruangan
// Jika satu data hilang, tidak berdampak buruk pada pendinginan AC.
for (int i = 0; i < 5; i++) {
String sensorPayload = "{\"temp\": 24.5, \"timestamp\": " + System.currentTimeMillis() + "}";
producer.send(new ProducerRecord<>("room-temperature", "sensor_01", sensorPayload), new Callback() {
@Override
public void onCompletion(RecordMetadata metadata, Exception exception) {
if (exception != null) {
// Callback ini hanya dipicu jika ada error serialisasi lokal
// sebelum pesan menyentuh kartu jaringan.
System.err.println("✗ Gagal mengirim metrik sensor lokal: " + exception.getMessage());
}
}
});
}
producer.close();
}
}
2. Sisi Konsumen (Java SDK) #
Berikut adalah implementasi konsumen yang menggunakan pola Commit-First untuk menjamin semantik At-Most-Once.
import org.apache.kafka.clients.consumer.*;
import org.apache.kafka.common.serialization.StringDeserializer;
import java.time.Duration;
import java.util.Collections;
import java.util.Properties;
public class AtMostOnceConsumerExample {
public static void main(String[] args) {
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "telemetry-aggregator");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
// =====================================================================
// KONFIGURASI PENTING AT-MOST-ONCE (MANUAL COMMIT FIRST)
// =====================================================================
// ✓ Matikan komit otomatis agar kita bisa mengontrol kapan komit dikirim
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "latest");
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Collections.singletonList("room-temperature"));
try {
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
if (!records.isEmpty()) {
// ✓ LANGKAH 1: Langsung komit offset sebelum memproses data!
// Ini menetapkan semantik At-Most-Once.
try {
consumer.commitSync();
System.out.println("✓ Offset berhasil dikomit ke broker (Commit-First).");
} catch (CommitFailedException e) {
System.err.println("✗ Gagal komit offset: " + e.getMessage());
// Pada At-Most-Once, jika komit gagal, kita boleh memilih untuk tetap lanjut
// atau melompati data ini demi mencegah pemrosesan ulang.
continue;
}
// ✓ LANGKAH 2: Proses data setelah offset aman dikomit
for (ConsumerRecord<String, String> record : records) {
try {
// Simulasi pemrosesan data (misalnya parsing JSON dan komputasi)
processTelemetryData(record.value());
} catch (Exception e) {
// JANGAN biarkan error di sini memicu loop pembacaan ulang pesan yang sama.
// Karena offset sudah dikomit di langkah 1, saat iterasi poll berikutnya
// data yang gagal ini tidak akan ditarik lagi oleh broker.
System.err.println("✗ Gagal memproses metrik offset " + record.offset() + ": " + e.getMessage());
}
}
}
}
} finally {
consumer.close();
}
}
private static void processTelemetryData(String json) {
// Logika bisnis telemetri ringan
System.out.println("Memproses telemetri: " + json);
}
}
Kapan Memilih At-Most-Once? #
Untuk membantu arsitek sistem dalam memilih semantik yang tepat, gunakan panduan praktis berikut:
TETAP gunakan At-Most-Once jika:
✓ Latensi transmisi jaringan ultra rendah adalah kebutuhan mutlak aplikasi.
✓ Duplikasi pesan dapat menyebabkan kerusakan data yang mahal di database hilir.
✓ Data bersifat kontinu dan memiliki siklus penggantian nilai yang cepat (real-time stream).
✓ Sistem hilir kita tidak bersifat idempoten dan kita ingin mematikan mekanisme retry.
JANGAN gunakan At-Most-Once jika:
✗ Setiap pesan bernilai tinggi dan hilangnya satu pesan saja dapat melanggar regulasi keuangan.
✗ Kita sedang membangun sistem manajemen inventaris, sistem billing, atau pemrosesan pesanan.
✗ Kita membutuhkan konsistensi data yang mutlak antar microservices.
Ringkasan #
- At-Most-Once (Paling Banyak Sekali) — Jaminan pengiriman di mana setiap pesan diproses sekali saja atau tidak sama sekali, meminimalkan risiko duplikasi dengan konsekuensi adanya potensi kehilangan data (data loss).
- Fire-and-Forget — Pola pengiriman produser menggunakan properti
acks=0danretries=0yang langsung mengembalikan status sukses tanpa menunggu kepastian penyimpanan dari broker leader.- Commit-First Pattern — Metode pengosongan antrean di sisi konsumen dengan langsung mengomit offset terakhir ke broker sebelum mengeksekusi logika pemrosesan data.
- High-Throughput & Low-Latency — Keuntungan utama At-Most-Once karena tidak adanya overhead jaringan untuk proses koordinasi acknowledgements dan transaksi multi-partisi.
- Use Case Clickstream & IoT — Sangat cocok untuk sistem telemetri sensor, pelacakan navigasi web, dan pengumpulan log agregat di mana sampel data yang hilang tidak merusak statistik global.
- Bahaya Transaksi Finansial — Penggunaan semantik At-Most-Once sangat dilarang untuk data bernilai tinggi seperti transfer saldo bank atau order e-commerce karena risiko kegagalan sistem dapat menghilangkan uang pengguna secara permanen.
← Sebelumnya: Poison Message Strategy Berikutnya: At-Least-Once →