Handling Duplicate Message #
Ketika kita mengonfigurasi konsumen Apache Kafka menggunakan jaminan pemrosesan At-Least-Once (untuk memastikan tidak ada satu pun pesan bisnis yang hilang), konsekuensi logis yang tidak dapat dihindari adalah munculnya duplikasi pesan. Jaringan internet yang tidak stabil, kegagalan penulisan offset komit di tengah-tengah transaksi, atau proses rebalance dinamis adalah peristiwa biasa di kluster server terdistribusi. Menyerahkan urutan pemrosesan pesan sepenuhnya ke database hilir tanpa perlindungan khusus akan merusak konsistensi data bisnis kita (seperti mendebit saldo nasabah berkali-kali). Oleh karena itu, kita wajib membangun mekanisme Idempotent Consumer (Konsumen Idempoten) untuk menyaring pesan ganda sebelum memicu logika bisnis yang sensitif.
Mengapa Duplikasi Pesan Terjadi di Kafka? #
Sebelum merancang solusi, kita harus memahami dengan tepat bagaimana duplikasi pesan menyusup ke dalam sistem kita. Ada dua skenario klasik yang paling sering terjadi di lingkungan produksi:
Skenario 1: Kegagalan Pengiriman Komit Offset (Network Timeout) #
- Produser mengirimkan pesan ke Kafka broker dengan kunci unik transaksi
TX-999(Offset500). - Konsumen kita sukses menarik pesan tersebut melalui pemanggilan fungsi
.poll(). - Konsumen memproses transaksi, memotong saldo, dan menulis record transaksi sukses ke database bisnis kita.
- Masalah: Ketika konsumen mengirimkan sinyal
commitSync(Offset 500)kembali ke broker, koneksi jaringan terputus sementara atau broker coordinator mengalami jeda waktu. Broker tidak pernah menerima komit tersebut. - Setelah koneksi pulih, konsumen (atau konsumen baru pasca-rebalance) mendapati committed offset terakhir masih berada di angka
499. - Konsumen ditarik mundur untuk membaca kembali Offset
500(TX-999). Jika database tidak memiliki filter pelindung, saldo nasabah akan terpotong untuk kedua kalinya.
Skenario 2: Rebalance di Tengah Pemrosesan Batch #
- Konsumen memanggil
.poll()dan menerima 50 pesan sekaligus (Offset1000hingga1049). - Konsumen mulai memproses pesan satu per satu secara sinkron di thread utama.
- Setelah sukses memproses 30 pesan hingga Offset
1029, total waktu yang dihabiskan telah melampaui batas waktumax.poll.interval.ms(misalnya karena kueri database melambat). - Group Coordinator mendeteksi keterlambatan pemanggilan
.poll(), menganggap konsumen tersebut mati, dan memicu rebalance. - Partisi dipindahkan ke konsumen lain. Konsumen baru membaca offset komit terakhir yang valid di broker, yaitu Offset
999(karena komit batch sebelumnya baru akan dikirim setelah seluruh 50 pesan selesai diproses). - Konsumen baru memproses ulang seluruh 50 pesan dari Offset
1000, padahal 30 pesan pertama sebenarnya sudah sukses ditulis ke database oleh konsumen sebelumnya.
Mekanisme Idempotent Consumer (Konsumen Idempoten) #
Mekanisme Idempotent Consumer (sering disebut Deduplication Pattern) adalah pola arsitektur di mana aplikasi konsumen dirancang untuk menyaring pesan duplikat secara mandiri sehingga eksekusi berulang dari pesan yang sama tidak akan mengubah status akhir sistem melebihi eksekusi pertama.
Ada dua pendekatan utama yang bisa kita terapkan untuk mencapai idempotensi ini:
1. Database Unique Constraint / UPSERT (Idempotensi Alami) #
Jika entitas data bisnis kita secara alami memiliki pengenal unik (Natural Unique Key) yang dikirimkan oleh produser (misalnya order_id, invoice_number, atau payment_id), kita bisa memanfaatkan fitur keamanan bawaan database RDBMS (seperti PostgreSQL, MySQL) atau NoSQL (seperti MongoDB).
- Unique Constraints: Menandai kolom
order_iddi database sebagaiUNIQUE PRIMARY KEY. Jika konsumen mencoba menulis data yang sama untuk kedua kalinya, database akan memotong transaksi dan melemparkan eksepsi error pelanggaran integritas data (Integrity Constraint Violation). - UPSERT (Insert or Update): Jika kita tidak ingin melemparkan error melainkan memperbarui data yang ada, kita dapat menggunakan perintah UPSERT:
- PostgreSQL:
INSERT ... ON CONFLICT (order_id) DO UPDATE SET ...atauDO NOTHING. - MySQL:
INSERT INTO ... ON DUPLICATE KEY UPDATE ... - MongoDB: Menggunakan operasi
db.collection.updateOne()dengan parameterupsert: true.
- PostgreSQL:
2. Dedicated Deduplication Table (Tabel Deduplikasi Khusus) #
Dalam banyak skenario nyata, logika bisnis kita tidak sesederhana menyimpan data ke satu tabel. Kita mungkin harus memicu pemanggilan API pihak ketiga, mengirim email notifikasi ke pengguna, atau menjalankan serangkaian kueri kompleks yang tidak memiliki primary key tunggal.
Untuk menangani skenario ini, kita harus membuat Tabel Deduplikasi Khusus (biasanya bernama processed_events atau event_deduplication) di dalam database transaksional kita.
- Cara Kerja:
- Setiap kali produser mengirim pesan, ia wajib menyertakan ID UUID unik di dalam header atau payload pesan (disebut
Event ID). - Saat konsumen membaca pesan, ia membuka transaksi database baru (BEGIN TRANSACTION).
- Konsumen mencoba memasukkan
Event IDtersebut ke dalam tabelprocessed_events. - Jika proses insert sukses, konsumen melanjutkan eksekusi logika bisnis utama dan memperbarui database bisnis di dalam transaksi yang sama.
- Konsumen mengomit transaksi database (COMMIT TRANSACTION).
- Jika proses insert di langkah ke-3 gagal karena kunci duplikat, konsumen segera membatalkan transaksi (ROLLBACK), mengabaikan pesan tersebut, dan langsung melanjutkan ke pesan berikutnya.
- Setiap kali produser mengirim pesan, ia wajib menyertakan ID UUID unik di dalam header atau payload pesan (disebut
Alur Kerja Deteksi Pesan Duplikat #
Diagram alir di bawah ini memvisualisasikan bagaimana alur keputusan yang harus dilalui oleh aplikasi konsumen kita saat menyaring pesan menggunakan tabel deduplikasi:
flowchart TD
subgraph Alur_Pemrosesan
Start["1. Konsumen Memanggil poll()"] --> GetRecord["2. Dapatkan Pesan (Event ID: EV-888)"]
GetRecord --> StartTx["3. Buka Transaksi Database (BEGIN)"]
StartTx --> TryInsert["4. Coba Insert EV-888 ke tabel processed_events"]
TryInsert --> Success{"Apakah insert sukses?"}
Success -- "Ya (Belum Pernah Diproses)" --> ProcessBiz["5. Eksekusi Logika Bisnis Utama"]
ProcessBiz --> CommitTx["6. Komit Transaksi Database (COMMIT)"]
CommitTx --> CommitOffset["7. Komit Offset ke Broker Kafka"]
CommitOffset --> End["Selesai, Lanjut Pesan Berikutnya"]
Success -- "Tidak (Duplikat Terdeteksi)" --> RollbackTx["8. Batalkan Transaksi Database (ROLLBACK)"]
RollbackTx --> SkipMessage["9. Abaikan Pesan / Log Warning"]
SkipMessage --> CommitOffset
end
style Success stroke:#fbc02d,stroke-width:2px
style ProcessBiz stroke:#2e7d32,stroke-width:2px
style SkipMessage stroke:#c62828,stroke-width:2px
Deduplikasi Berbasis Redis (In-Memory Distributed Cache) #
Pada aplikasi dengan throughput sangat tinggi (misalnya memproses ratusan ribu logistik log per detik), melakukan kueri INSERT ke database relasional (RDBMS) untuk setiap pesan dapat memicu kemacetan performa (disk I/O bottlenecks).
Sebagai alternatif, kita bisa menggunakan penyimpanan memori terdistribusi seperti Redis sebagai filter deduplikasi cepat sebelum data dikirim ke RDBMS.
- Cara Kerja:
- Kita memanfaatkan perintah atomik Redis
SET key value EX seconds NX. - Parameter
NXmemastikan kunci hanya akan tersimpan jika belum ada di Redis. - Parameter
EXmemberikan batas waktu kedaluwarsa (Time-To-Live / TTL) secara otomatis agar memori Redis tidak membengkak tanpa batas. - Jika Redis mengembalikan status sukses, kita lanjutkan pemrosesan ke database. Jika gagal (kembalian
nullataufalse), pesan segera diabaikan karena terbukti duplikat.
- Kita memanfaatkan perintah atomik Redis
// CONTOH REDIS-BASED DEDUPLICATION DI JAVA (MENGGUNAKAN JEDIS)
String redisKey = "event:" + eventId;
// Simpan Event ID dengan TTL 24 jam (86400 detik) hanya jika key belum ada (NX)
String result = jedis.set(redisKey, "processed", new SetParams().nx().ex(86400));
if ("OK".equals(result)) {
// ✓ Aman: Event ID belum pernah diproses sebelumnya
executeDatabaseLogistics(record.value());
} else {
// ✗ Duplikat terdeteksi di cache memori Redis
log.warn("Mendeteksi pesan duplikat di Redis cache: {}", eventId);
}
- Trade-Off: Pendekatan ini tidak sepenuhnya transaksional secara atomik (karena Redis dan RDBMS berada di sistem terpisah). Jika penulisan database gagal setelah Redis sukses di-set, kita harus memiliki mekanisme compensating transaction atau menghapus key di Redis agar pesan dapat di-retry kembali.
Hubungan dengan Transactional Outbox Pattern #
Untuk menjamin keandalan data end-to-end, penanganan duplikasi di sisi konsumen sering kali dipasangkan dengan Transactional Outbox Pattern di sisi produser.
- Produser menulis data bisnis dan data event ke tabel
outboxlokal dalam satu transaksi database lokal. - Komponen Debezium atau CDC (Change Data Capture) membaca tabel outbox dan mempublikasikannya ke Kafka broker secara asinkron.
- Karena CDC menjamin pengiriman At-Least-Once ke broker, konsumen tetap wajib mengimplementasikan tabel deduplikasi.
- Sinergi ini memastikan bahwa dari titik produser hingga konsumen, data dijamin tidak akan hilang dan status akhir sistem terjamin konsisten tanpa ada duplikasi transaksi.
Kode Penerapan: Implementasi Idempotent Consumer #
Mari kita bandingkan secara langsung kode implementasi yang rentan terhadap duplikasi dengan kode solusi yang kokoh menggunakan RDBMS deduplication.
Java SDK Anti-Pattern: Penyimpanan Data Tanpa Proteksi Idempotensi #
Kode di bawah ini menunjukkan kesalahan umum pengembang yang berasumsi bahwa database insert biasa sudah cukup aman karena Kafka berjalan lancar.
// ANTI-PATTERN: Menulis data tanpa penyaringan keunikan pesan
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "order-processor");
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "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("order-events"));
try {
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
// 解析 JSON payload
Order order = parseJson(record.value());
// ✗ JANGAN: Menggunakan query insert mentah tanpa unique constraint atau checking.
// Jika terjadi rebalance atau network failure sebelum commitSync(),
// fungsi ini akan membuat baris order baru di database dengan ID yang sama,
// memicu duplikasi data di dashboard admin.
saveOrderToDatabase(order.getId(), order.getCustomerId(), order.getAmount());
}
if (!records.isEmpty()) {
consumer.commitSync();
}
}
} finally {
consumer.close();
}
Java SDK Solusi: Menerapkan Deduplication Table Transaksional #
Kode di bawah ini menunjukkan solusi yang direkomendasikan dengan membungkus proses insert ke tabel deduplikasi dan penulisan bisnis dalam satu transaksi database lokal menggunakan JDBC.
// BENAR: Mengamankan pemrosesan menggunakan transaksi database transaksional dan constraint
Properties props = new Properties();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ConsumerConfig.GROUP_ID_CONFIG, "order-processor");
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "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("order-events"));
try {
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
// Dapatkan Event ID unik dari header Kafka (atau dari JSON payload)
String eventId = getHeaderValue(record.headers(), "event_id");
Order order = parseJson(record.value());
if (eventId == null) {
log.error("Pesan tidak memiliki event_id unik, abaikan untuk keselamatan.");
continue;
}
try (Connection conn = dataSource.getConnection()) {
// ✓ 1. Mulai Transaksi Database Lokal
conn.setAutoCommit(false);
try {
// ✓ 2. Coba catat Event ID ke tabel deduplikasi processed_events.
// Kolom event_id adalah PRIMARY KEY.
try (PreparedStatement dedupStmt = conn.prepareStatement(
"INSERT INTO processed_events (event_id) VALUES (?)"
)) {
dedupStmt.setString(1, eventId);
dedupStmt.executeUpdate();
}
// ✓ 3. Jika berhasil, eksekusi logika bisnis utama pada transaksi yang sama
try (PreparedStatement bizStmt = conn.prepareStatement(
"INSERT INTO orders (id, customer_id, amount) VALUES (?, ?, ?)"
)) {
bizStmt.setString(1, order.getId());
bizStmt.setString(2, order.getCustomerId());
bizStmt.setDouble(3, order.getAmount());
bizStmt.executeUpdate();
}
// ✓ 4. Komit seluruh transaksi database secara utuh
conn.commit();
log.info("Sukses memproses event: {} dan menyimpan data bisnis.", eventId);
} catch (SQLIntegrityConstraintViolationException e) {
// ✓ 5. DETEKSI DUPLIKAT: Terjadi pelanggaran primary key di langkah 2.
// Segera batalkan transaksi database agar tidak menulis data ganda.
conn.rollback();
log.warn("Pesan duplikat terdeteksi untuk Event ID: {}. Mengabaikan pesan dengan aman.", eventId);
} catch (Exception ex) {
conn.rollback();
log.error("Terjadi error sistem, membatalkan transaksi.", ex);
throw ex; // Lemparkan eksepsi agar loop konsumen berhenti & retry dijalankan
}
} catch (SQLException sqle) {
log.error("Kegagalan koneksi database", sqle);
}
}
// ✓ 6. Selalu lakukan komit offset di akhir batch
if (!records.isEmpty()) {
consumer.commitSync();
}
}
} finally {
consumer.close();
}
Ringkasan #
- At-Least-Once Side-Effect — Penggunaan jaminan At-Least-Once di Apache Kafka memastikan data tidak pernah hilang, namun membawa risiko terjadinya duplikasi pesan akibat kegagalan komit offset.
- Idempotent Consumer Pattern — Pola arsitektur wajib pada sisi konsumen untuk memastikan pemrosesan pesan yang sama secara berulang tidak merusak status akhir database bisnis.
- Natural Unique Key — Pendekatan menggunakan kolom kunci unik bawaan dari data bisnis (seperti
order_id) dan mengandalkan Unique Constraint RDBMS untuk memotong duplikasi data.- Deduplication Table — Tabel pembantu khusus (
processed_events) untuk mencatat Event ID UUID unik sebelum mengeksekusi logika bisnis yang kompleks di dalam satu transaksi database lokal.- Non-Transactional Side-Effects — Operasi eksternal non-database (seperti kirim email atau potong saldo via pihak ketiga) harus diamankan dengan menyertakan Idempotency Key saat memanggil API luar.
- Transaksional Atomik — Keamanan deduplikasi dijamin dengan memastikan pencatatan event ID dan eksepsi bisnis dibungkus dalam blok transaksi
BEGIN ... COMMITyang sama.
← Sebelumnya: At-Least-Once vs At-Most-Once Berikutnya: Poison Message Strategy →