At-Least-Once vs At-Most-Once #
Ketika merancang arsitektur sistem berbasis peristiwa (event-driven system) menggunakan Apache Kafka, salah satu aspek paling krusial yang menentukan integritas data adalah jaminan pemrosesan pesan (message delivery semantics). Di sisi konsumen, kita dihadapkan pada dua pilihan jaminan pemrosesan mendasar: At-Least-Once (Setidaknya Sekali) dan At-Most-Once (Paling Banyak Sekali). Setiap pilihan membawa implikasi arsitektur yang sangat berbeda, memaksa kita untuk memilih trade-off di antara risiko duplikasi data bisnis atau risiko kehilangan data secara permanen. Tanpa pemahaman mendalam mengenai kapan offset dikomit ke broker relatif terhadap eksekusi logika bisnis, aplikasi kita rentan mengalami ketidakkonsistenan data yang sulit dilacak di lingkungan produksi.
Membedah Jaminan Pemrosesan: Sudut Pandang Konsumen #
Secara fundamental, jaminan pemrosesan pesan di sisi konsumen ditentukan oleh urutan kronologis antara dua aktivitas utama:
- Pemrosesan Data: Menjalankan logika bisnis aplikasi, seperti menulis data ke database, memanggil API eksternal, atau memperbarui cache memori.
- Komit Offset: Melaporkan kembali ke broker Kafka coordinator mengenai posisi baca tertinggi yang sukses diproses agar disimpan di topik internal
__consumer_offsets.
Karena kedua aktivitas ini merupakan operasi terdistribusi yang terpisah oleh jaringan I/O, kita tidak dapat melaksanakannya secara bersamaan dalam satu transaksi atomik global (kecuali jika kita menerapkan pola transaksi khusus yang akan dibahas pada bab terpisah). Oleh karena itu, urutan pelaksanaan kedua aktivitas ini melahirkan dua semantik utama.
At-Most-Once (Paling Banyak Sekali) #
Semantik At-Most-Once memberikan jaminan bahwa setiap pesan yang dikirim oleh broker akan diproses paling banyak satu kali oleh konsumen kita. Dalam semantik ini, ada kemungkinan pesan hilang dan tidak diproses sama sekali (data loss), tetapi sistem dijamin terbebas sepenuhnya dari risiko pesan diproses secara ganda (zero duplicates).
Urutan Operasi: Commit-First (Komit Dulu, Proses Kemudian) #
Untuk menerapkan At-Most-Once, konsumen harus beroperasi dengan alur sebagai berikut:
- Konsumen memanggil
.poll()dan memperoleh batch pesan (misalnya Offset100hingga109). - Konsumen segera mengirimkan komit offset untuk Offset
109ke broker Kafka. Broker mencatat Offset109sebagai posisi sukses dibaca. - Konsumen mulai memproses data logika bisnis untuk pesan-pesan tersebut secara lokal.
flowchart TD
Start["1. Ambil Batch Pesan (Offset 100-109)"] --> Commit["2. Segera Komit Offset 109 ke Broker"]
Commit --> Process["3. Mulai Proses Logika Bisnis Lokal"]
Process --> Success{"Apakah pemrosesan sukses?"}
Success -- "Ya" --> End["Selesai, Siap Poll Berikutnya"]
Success -- "Tidak / Crash" --> Crash["Aplikasi Mati Mendadak"]
Crash -. "Saat Aplikasi Pulih" .-> Recover["Mulai Membaca dari Offset 110"]
style Crash stroke:#c62828,stroke-width:2px
style Recover stroke:#2e7d32,stroke-width:2px
Skenario Kegagalan: Kehilangan Data (Data Loss) #
Misalkan saat memproses data pada langkah ke-3 (misalnya pada pesan dengan Offset 105), kontainer aplikasi kita mendadak mengalami crash karena kehabisan memori heap (OOM) atau mati listrik.
- Keadaan di Broker: Kafka coordinator menganggap grup konsumen kita telah sukses menyelesaikan pembacaan hingga Offset
109(karena komit telah dikirim dan diterima broker di langkah ke-2). - Saat Konsumen Pulih: Ketika instansi aplikasi dinyalakan kembali, ia akan melakukan kueri posisi baca terakhir ke broker. Broker mengarahkan konsumen untuk mulai membaca dari Offset
110. - Dampak: Pesan dari Offset
105hingga109hilang selamanya dari sistem pemrosesan kita. Pesan-pesan tersebut tidak akan pernah dibaca lagi, meninggalkan celah hilangnya data pada sistem hilir (downstream systems).
Use Case yang Tepat untuk At-Most-Once #
Meskipun kehilangan data terdengar menakutkan, At-Most-Once sangat berguna untuk skenario yang memprioritaskan kecepatan pemrosesan dan toleran terhadap hilangnya beberapa sampel data, seperti:
- Pengumpulan Clickstream: Menganalisis perilaku klik pengguna di situs web. Kehilangan satu atau dua klik tidak akan merusak analisis tren keseluruhan.
- Metrik Sensor IoT: Membaca data suhu atau kelembaban dari ribuan sensor setiap 5 detik. Jika satu data sensor hilang, data berikutnya yang datang 5 detik kemudian akan menggantikannya.
- Log Streaming (Logstash/Splunk): Mengalirkan data log server untuk monitoring performa umum.
At-Least-Once (Setidaknya Sekali) #
Semantik At-Least-Once memberikan jaminan bahwa setiap pesan yang dikirim oleh broker akan diproses setidaknya satu kali oleh konsumen kita. Dalam semantik ini, tidak ada satu pun pesan yang akan hilang dari sistem pemrosesan kita (zero data loss), namun terdapat risiko bahwa satu atau beberapa pesan akan diproses lebih dari sekali (duplikasi).
Urutan Operasi: Process-First (Proses Dulu, Komit Kemudian) #
Untuk menerapkan At-Least-Once, konsumen harus membalik urutan operasinya:
- Konsumen memanggil
.poll()dan memperoleh batch pesan (misalnya Offset100hingga109). - Konsumen menjalankan seluruh logika bisnis untuk pesan-pesan tersebut hingga selesai dan tersimpan sukses di database tujuan.
- Setelah seluruh pemrosesan dipastikan sukses tanpa error, konsumen baru mengirimkan komit offset untuk Offset
109ke broker Kafka.
flowchart TD
Start["1. Ambil Batch Pesan (Offset 100-109)"] --> Process["2. Jalankan Logika Bisnis & Simpan ke DB"]
Process --> Success{"Apakah pemrosesan sukses?"}
Success -- "Ya" --> Commit["3. Komit Offset 109 ke Broker"]
Success -- "Tidak / Crash" --> Crash["Aplikasi Mati Mendadak"]
Commit --> End["Selesai, Siap Poll Berikutnya"]
Crash -. "Saat Aplikasi Pulih" .-> Recover["Membaca Ulang dari Offset 100"]
style Crash stroke:#c62828,stroke-width:2px
style Recover stroke:#2e7d32,stroke-width:2px
Skenario Kegagalan: Duplikasi Data (Data Duplication) #
Misalkan setelah langkah ke-2 selesai (seluruh data hingga Offset 109 telah sukses ditulis ke database RDBMS), jaringan terputus sebelum langkah ke-3 terlaksana. Konsumen gagal mengirimkan sinyal komit offset ke broker coordinator.
- Keadaan di Broker: Kafka coordinator mencatat offset komit terakhir kita masih di angka
99. - Saat Konsumen Pulih (atau Terjadi Rebalance): Konsumen baru yang ditugaskan mengambil alih partisi tersebut akan menanyakan posisi terakhir ke broker. Broker mengarahkan konsumen untuk membaca kembali mulai dari Offset
100. - Dampak: Konsumen akan memproses ulang pesan dari Offset
100hingga109. Karena data tersebut sudah sempat tersimpan di database pada percobaan pertama, pemrosesan ulang ini akan memicu duplikasi entri data (misalnya transaksi pembayaran yang didebit dua kali) jika kita tidak mengimplementasikan mekanisme idempotensi di database.
Pengaruh Mekanisme Retry (Percobaan Ulang) Lokal pada Consumer Lag #
Ketika menggunakan At-Least-Once, kegagalan logika bisnis pada satu pesan akan menahan proses commit offset untuk seluruh batch pesan yang sedang dikonsumsi.
- Jika kita menerapkan mekanisme retry lokal dengan strategi jeda waktu (backoff retry), utas konsumen utama akan memblokir panggilan
.poll()berikutnya hingga proses retry selesai atau pesan dialihkan ke topik pembuangan (Dead Letter Queue). - Selama jeda waktu retry ini, committed offset di broker tidak akan bertambah. Indikator metrik Consumer Lag pada dashboard monitoring kita akan mendeteksi lonjakan lag yang tajam. Hal ini wajar karena kita mengutamakan keakuratan pemrosesan data daripada kecepatan aliran data.
Menjembatani Menuju Exactly-Once Semantics (EOS) #
Bagi pengembang yang menginginkan kesempurnaan sistem terdistribusi, pilihan ideal adalah Exactly-Once Semantics (EOS) — jaminan di mana data diproses tepat satu kali (tanpa data loss dan tanpa duplikasi).
- Di dalam ekosistem Kafka, Exactly-Once murni dicapai dengan mengintegrasikan produser transaksional dan konsumen transaksional.
- Pada konsumen transaksional, properti konfigurasi
isolation.levelmemainkan peran penting:read_uncommitted(Default): Konsumen akan langsung membaca seluruh pesan yang ditulis ke broker, termasuk pesan dari produser transaksional yang statusnya belum di-commit (masih menggantung) atau bahkan pesan transaksi yang di-abort/dibatalkan.read_committed: Konsumen hanya akan membaca pesan non-transaksional atau pesan transaksional yang sudah sukses dikomit oleh produser. Pesan dari transaksi yang dibatalkan akan dilewati oleh konsumen secara otomatis.
- Detail mendalam mengenai alur transaksi dua fase (two-phase commit) ini akan kita bahas secara komprehensif pada seri artikel Delivery Semantics berikutnya.
Tabel Komparasi Jaminan Pemrosesan #
Tabel di bawah ini merangkum trade-off strategis antara kedua jenis semantik pengiriman data:
| Kriteria | At-Most-Once | At-Least-Once |
|---|---|---|
| Urutan Utama | Commit offset $\rightarrow$ Jalankan logika bisnis | Jalankan logika bisnis $\rightarrow$ Commit offset |
| Risiko Utama | Kehilangan Data (Data Loss) | Duplikasi Data (Data Duplication) |
| Keamanan Data | Rendah (Pesan bisa terlewat) | Sangat Tinggi (Pesan dijamin diproses) |
| Overhead Idempotensi | Tidak butuh (karena tidak pernah duplikat) | Wajib (untuk menyaring pesan ganda) |
| Performa Throughput | Sedikit lebih tinggi (karena asinkron/cepat) | Sedikit lebih rendah (karena menunggu sukses) |
| Konfigurasi Utama | enable.auto.commit=true |
enable.auto.commit=false |
Penerapan Kode: Implementasi At-Most-Once vs At-Least-Once #
Mari kita lihat secara nyata bagaimana penulisan kode memengaruhi semantik pemrosesan yang dihasilkan.
Java SDK: Contoh Implementasi At-Most-Once (Commit-First) #
Untuk mencapai semantik At-Most-Once secara instan, kita bisa menggunakan komit otomatis bawaan (Auto-Commit) dengan waktu tunggu minimal, atau memanggil commitSync() sesaat setelah data ditarik dari fungsi .poll().
// CONTOH AT-MOST-ONCE: Mengutamakan pencegahan duplikasi daripada kehilangan data
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "iot-sensor-consumer");
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false"); // Matikan auto agar kontrol manual
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("sensor-telemetry"));
try {
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
if (!records.isEmpty()) {
// ✓ COMMIT FIRST: Segera laporkan kesuksesan posisi baca ke broker Kafka
// sebelum logika bisnis yang rentan crash dijalankan.
consumer.commitSync();
for (ConsumerRecord<String, String> record : records) {
// Jika baris ini crash atau melempar eksepsi, pesan di dalam batch
// akan terlewat selamanya karena broker sudah mencatat offset komit terbaru.
saveSensorReadingToMemoryCache(record.value());
}
}
}
} finally {
consumer.close();
}
Java SDK: Contoh Implementasi At-Least-Once (Process-First) #
Untuk menjamin tidak ada data yang hilang, kita wajib mematikan auto-commit (enable.auto.commit=false), menjalankan logika bisnis hingga tuntas di dalam blok try-catch, baru kemudian melakukan komit manual setelah dipastikan bebas dari eksepsi error.
// CONTOH AT-LEAST-ONCE: Menjamin tidak ada data yang hilang (Rekomendasi Utama)
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "billing-consumer");
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false"); // ✓ Wajib false
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("billing-events"));
try {
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
try {
// 1. Jalankan proses bisnis yang penting (tulis ke database transaksi)
executeBillingTransactionInDatabase(record.value());
} catch (Exception e) {
// Jika penulisan database gagal, hentikan pemrosesan batch segera.
// Jangan panggil commitSync(). Saat loop berputar kembali atau crash,
// data ini akan ditarik ulang dari broker untuk dicoba kembali.
log.error("Pemrosesan database gagal untuk offset: " + record.offset(), e);
throw new ProcessingFailedException("Gagal menulis ke DB", e);
}
}
// 2. ✓ COMMIT LAST: Hanya lakukan komit offset setelah seluruh record
// di dalam batch berhasil tersimpan di database tanpa ada error.
if (!records.isEmpty()) {
consumer.commitSync();
}
}
} catch (ProcessingFailedException pfe) {
// Penanganan error pemulihan (misal jeda waktu sebelum retry)
sleepAndRecover();
} finally {
consumer.close();
}
Mengapa At-Least-Once Mewajibkan Idempotensi? #
Jika kita memilih jaminan At-Least-Once (yang merupakan standar untuk aplikasi bisnis komersial), kita harus menerima kenyataan matematika terdistribusi: duplikasi pesan cepat atau lambat pasti akan terjadi. Jaringan internet tidak pernah sempurna. Koneksi terputus sesaat setelah database selesai ditulis adalah kejadian umum di server produksi.
Oleh karena itu, jika kita menggunakan semantik At-Least-Once, aplikasi konsumen kita wajib menerapkan mekanisme Idempotent Consumer (Konsumen Idempoten).
- Idempotensi berarti menjalankan suatu operasi berkali-kali dengan parameter yang sama akan menghasilkan efek sistem yang sama seperti saat menjalankannya pertama kali.
- Contoh taktik RDBMS menggunakan tabel deduplikasi:
-- Skema Tabel Deduplikasi untuk menyaring Event ID unik
CREATE TABLE processed_events (
event_id VARCHAR(255) PRIMARY KEY,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
// CONTOH PENANGANAN IDEMPOTENSI DI KODE KONSUMEN
try (Connection conn = dataSource.getConnection()) {
conn.setAutoCommit(false);
// ✓ Mencoba mencatat Event ID ke database terlebih dahulu.
// Jika Event ID sudah pernah diproses, constraint Primary Key akan
// melempar DuplicateKeyException, memotong pemrosesan transaksi ganda.
try (PreparedStatement stmt = conn.prepareStatement(
"INSERT INTO processed_events (event_id) VALUES (?)"
)) {
stmt.setString(1, record.key());
stmt.executeUpdate();
}
// Lanjutkan proses bisnis
updateAccountBalance(record.value());
conn.commit();
} catch (SQLIntegrityConstraintViolationException e) {
// Pesan terdeteksi duplikat, abaikan dengan aman tanpa memproses bisnis kembali
log.warn("Mendeteksi pesan duplikat dengan ID: {}, melewati proses.", record.key());
}
Ringkasan #
- At-Most-Once (Paling Banyak Sekali) — Jaminan pemrosesan di mana setiap pesan diproses maksimal satu kali. Menghilangkan risiko duplikasi tetapi membuka celah kehilangan data (data loss).
- Commit-First Pattern — Pendekatan At-Most-Once dicapai dengan melakukan komit offset ke broker sesaat setelah pesan diterima dari
.poll(), sebelum logika bisnis dijalankan.- At-Least-Once (Setidaknya Sekali) — Jaminan pemrosesan di mana pesan dijamin tidak akan hilang dari alur sistem, namun siap menerima risiko terjadinya duplikasi pesan (data duplication).
- Commit-Last Pattern — Pendekatan At-Least-Once dicapai dengan mematikan auto-commit dan baru memanggil
commitSync()setelah seluruh logika bisnis dipastikan sukses tersimpan di database.- Trade-Off Penting — Memilih jaminan semantik adalah trade-off bisnis: tentukan apakah aplikasi kita lebih toleran terhadap hilangnya sampel data kecil (At-Most-Once) atau duplikasi data sementara (At-Least-Once).
- Idempotency Mandate — Penggunaan At-Least-Once mewajibkan arsitektur aplikasi kita memiliki lapisan penanganan idempotensi pada penyimpanan database hilir untuk meredam dampak pemrosesan ganda.
← Sebelumnya: Rebalance Process Berikutnya: Duplicate Message →