Benchmark Payment Gateway Bagian 4 : "Nggak Boleh Double Payment, Walaupun Resource Habis"
Di Bagian 3, dokumen ralat telah diterbitkan: hasil benchmark yang semula tampak dramatis (penurunan performa signifikan pada event-sourced beserta anomali pembayaran ganda) dipastikan sebagai efek samping rebutan resource dengan aplikasi lain. Begitu lingkungan pengujian dibersihkan, kedua anomali tersebut tak lagi muncul.
Dokumen ralat di repositori saat itu menyimpulkan bahwa kedua temuan dinyatakan tidak valid — baik dari aspek performa maupun anomali pembayaran ganda. Namun saat meninjau kembali laporan tersebut, muncul satu hal yang mengganjal. Saya mengajukan pertanyaan reflektif kepada Claude Code:
“The reproducible double payment bug, the app should not do double payment however low the resource is, correct?”
Pertanyaan sederhana ini langsung menohok inti masalah dan membongkar kekeliruan logika yang sempat terlewatkan.
Evaluasi Ulang Cakupan Ralat
Terdapat perbedaan mendasar antara dua jenis klaim yang sempat dicampuradukkan:
- Klaim Pengukuran Performa (p99, throughput, jumlah virtual user): Nilai-nilai ini murni mencerminkan kondisi riil saat eksekusi berlangsung. Begitu lingkungan pengujian terinterferensi, seluruh data yang dihasilkan otomatis tidak valid dan harus ditarik.
- Klaim Koreksi Data / Integrity (sebuah
bankReferencetersimpan dua kali): Fenomena ini mengindikasikan adanya jalur eksekusi kode yang memungkinkan kondisi keliru tersebut terjadi. Jalur kode yang bermasalah tetap ada, baik servernya sedang dalam kondisi load tinggi ataupun sedang santai. Keterbatasan sumber daya sekadar mempercepat munculnya race condition.
Hasil pengujian yang bersih hanya menunjukkan bahwa race condition tersebut jarang terpicu dalam skenario biasa — padahal ada bug di kode program. Dua klaim ini memiliki konsekuensi yang amat berbeda — terlebih bagi sistem yang mengelola transaksi keuangan.
Menelusuri Akar Masalah
Penyebab utamanya bermuara pada PostgresProjectionSink, yakni komponen yang bertugas memproyeksikan event dari Kafka ke tabel PostgreSQL untuk kebutuhan query. Komponen ini ternyata memiliki dua metode independen yang tidak saling memverifikasi:
sequenceDiagram
participant Req as Request Thread
participant Streams as Kafka Streams
(single writer)
participant Sink as Projection Sink
Req->>Req: Cek status tagihan (RocksDB)
masih ACTIVE, lolos validasi
Req->>Sink: PaymentReceivedEvent
Sink->>Sink: projectPaymentReceivedBatch()
langsung insert baris "diterima"
Streams->>Streams: Proses event yang sama
ternyata tagihan SUDAH lunas
oleh pembayaran lain yang menang duluan
Streams->>Sink: DoubleSettlementDetectedEvent
(bankReference yang sama)
Sink->>Sink: projectDoubleSettlementBatch()
insert baris BARU, tidak cek yang lama
Note over Sink: Hasil: 2 baris untuk 1 bankReference
satu "diterima", satu "ganda"
Alur kejadiannya adalah sebagai berikut: request thread membaca status tagihan secara lokal dari RocksDB, mendapati statusnya masih aktif, lalu menyetujui transaksi secara optimis. Namun Kafka Streams — sebagai penentu keputusan akhir — baru memproses event tersebut belakangan dan mendapati bahwa tagihan telah lunas lebih dulu oleh transaksi lain yang lebih dulu diproses dalam antrean partition yang sama. Kafka Streams kemudian menandai transaksi yang telat ini dengan menerbitkan DoubleSettlementDetectedEvent.
Masalah utama berada pada PostgresProjectionSink — tautan ini menunjuk persis ke commit terakhir sebelum perbaikan, jadi bug-nya masih terlihat apa adanya. Metode projectPaymentReceivedBatch() langsung memasukkan baris “diterima” begitu menangkap PaymentReceivedEvent, tanpa menunggu konfirmasi dari Kafka Streams. Ketika event koreksi (DoubleSettlementDetectedEvent dengan bankReference serupa) tiba, metode projectDoubleSettlementBatch() memasukkan baris baru tanpa memverifikasi keberadaan data bankReference sebelumnya. Akibatnya, satu transaksi berakhir sebagai dua baris data yang saling bertentangan di database.
Solusi Perbaikan
Langkah pembenahan dilakukan dengan menyesuaikan projectDoubleSettlementBatch() — klik link untuk melihat versi yang sudah diperbaiki — agar memverifikasi keberadaan data bankReference terlebih dahulu. Jika data sudah ada, status baris tersebut diperbaiki langsung di tempat (ditandai sebagai pembayaran ganda dan akumulasi saldonya dikoreksi).
Perbaikan ini cukup untuk membuat catatan di database kembali konsisten. Tapi ada hal lain yang masih harus diperbaiki: respons HTTP yang sudah kadung dikirim ke bank. Pada skenario concurrent payment, bank sudah menerima balasan “ACCEPTED” pada saat request thread lolos validasi — jauh sebelum Kafka Streams memproses ulang dan memutuskan bahwa pembayaran itu sebetulnya double settlement. Perbaikan di PostgresProjectionSink membenahi catatan pembukuannya, bukan jawaban yang sudah terlanjur diberikan ke klien. Dua hal yang berbeda, dan hanya salah satunya yang tercakup oleh perbaikan di atas.
Perbaikan yang Lebih Mendasar: Transaksi Atomic di RocksDB
Supaya bank benar-benar menerima jawaban yang akurat sejak awal (bukan cuma database yang dikoreksi belakangan), akar masalahnya harus dibereskan di titik asalnya: jeda antara “request thread membaca status pembayaran (menghasilkan belum dibayar)” dan “Kafka Streams menentukan status pembayaran (sukses atau sudah terbayar oleh transaksi lain).” Caranya: satukan pencarian, validasi, dan penulisan sebagai satu transaksi RocksDB yang atomic, dieksekusi langsung di request thread itu sendiri — begini bentuknya setelah benar-benar diimplementasikan di ChargeSettlementStore.applyPayment():
- Cari
chargeId(id tagihan) dari nomor VA. - Ambil semua VA aktif di semua bank untuk id tagihan tersebut.
- Tandai VA yang menerima pembayaran sebagai lunas (PAID), tandai VA bank lain untuk id tagihan tersebut dengan status batal (CANCELLED) — bukan ikut ditandai lunas, sebab tidak ada uang yang benar-benar masuk lewat VA tersebut.
Ketiga langkah ini dieksekusi dalam satu transaksi yang sama. RocksDB sudah mendukung transaksi optimistic maupun pessimistic yang bisa dipakai persis untuk pola ini, memberi jaminan yang sama seperti SELECT FOR UPDATE di RDBMS: begitu transaksi commit, tidak ada request lain yang bisa membaca state usang. Kafka baru dilibatkan setelah keputusan final diambil, semata untuk menyalurkan hasilnya ke downstream — bukan lagi sebagai penentu keputusan itu sendiri.
Pola ini juga tidak mengorbankan latency: tidak ada round-trip sinkron lewat Kafka yang perlu ditunggu, karena keputusannya sudah selesai murni di RocksDB lokal sebelum event apa pun dikirim.
Satu syarat supaya ini tetap benar saat aplikasi di-scale out ke banyak instance: kunci partition harus didesain supaya satu chargeId (dan VA pembayarannya dari semua bank) selalu jatuh ke partition yang sama, sehingga transaksi RocksDB di atas selalu punya akses ke seluruh state tagihan itu di satu tempat, tanpa perlu koordinasi lintas instance. Kafka Streams sendiri sudah menyediakan API queryMetadataForKey() yang bisa dipakai untuk mengetahui instance mana yang memegang partition suatu key — pola yang biasa dipakai untuk meneruskan (forward) request ke instance yang tepat, bukan sesuatu yang eksotis atau perlu dibangun dari nol.
Untuk kebutuhan durability lintas site (bukan sekadar lintas instance), pola standarnya adalah replication factor 3, acks=all, dan min.insync.replicas=2 — sebuah pesan baru dianggap berhasil hanya setelah tersimpan di minimal 2 dari 3 replica, sehingga kehilangan satu broker tidak berarti kehilangan data (RPO=0). Konsekuensinya, latency penulisan jadi terikat pada round-trip time ke site yang paling jauh — kondisi ini juga ditemui di RDBMS bila harus dideploy dengan replikasi master-slave lintas site, bukan cuma terjadi di replikasi Kafka.
Karakteristik latency ini bukan keterbatasan bawaan Event Sourcing maupun Kafka Streams, melainkan murni akibat pola implementasi yang dipilih. Pola umum—seperti validasi lokal yang dilanjutkan append event—memang rawan memicu race condition jika tidak didesain secara matang. Berbeda dengan RDBMS yang memberikan jaminan konsistensi ini “gratis” lewat SELECT FOR UPDATE, Event Sourcing membutuhkan kehati-hatian ekstra. Inti perbandingannya bukan soal “bisa atau tidak bisa”, melainkan antara “fitur bawaan yang umum digunakan” versus “kehati-hatian lebih pada saat mendesain aplikasi”.
Catatan editorial: bagian ini awalnya ditulis sebagai proposal — “begini caranya kalau mau dibereskan sampai ke akarnya.” Setelah seri ini ditulis dan ditinjau ulang, proposalnya benar-benar diimplementasikan. Ceritanya, lengkap dengan angka benchmark ulang, ada di Bagian 6.
Verifikasi Tanpa Tergantung Load Test
Masalah ini murni soal logika alur kode, jadi unit test deterministik jauh lebih efektif membuktikannya ketimbang mengulang-ulang pengujian k6.
Sebagai gantinya, disusun pengujian unit yang mengeksekusi langsung metode consumeDomainEvents() dengan urutan event yang secara presisi mereplikasi kondisi race condition: mengirimkan PaymentReceivedEvent, lalu dilanjutkan dengan DoubleSettlementDetectedEvent untuk bankReference yang sama. Pengujian ini sepenuhnya terisolasi tanpa memerlukan Kafka, concurrency, maupun ketergantungan waktu.
List<PaymentProjectionEntity> payments = paymentRepo.findByChargeId(chargeId);
assertThat(payments)
.as("satu bankReference tidak boleh tercatat sebagai pembayaran diterima DAN pembayaran ganda sekaligus")
.hasSize(1);
assertThat(payments.get(0).isDoubleSettlement())
.as("keputusan Kafka Streams yang belakangan harus menang atas penerimaan optimis di awal")
.isTrue();
Sebelum perbaikan diterapkan, kasus uji ini konsisten gagal 100% (Expected size: 1 but was: 2). Setelah pembenahan diterapkan, pengujian berhasil 100%. Metode ini memberikan bukti yang meyakinkan — mengingat probabilitas kemunculan anomali ini di kondisi nyata hanya berkisar 1 dari 26.000 transaksi.
Dampak Perbaikan Terhadap Latensi
Apakah penambahan query verifikasi pada jalur penulisan database ini berdampak pada latensi?
Pengujian menunjukkan latensi utama tidak terpengaruh karena pemisahan arsitektur secara struktural: PostgresProjectionSink beroperasi pada konsumer Kafka secara asinkron, setelah respon HTTP dikirimkan kembali ke pihak bank. Nilai latency p50/p95/p99 pada k6 murni ditentukan oleh request thread, yang tidak melalui kode program PostgresProjectionSink.
Aspek yang berpotensi terdampak hanyalah projection lag — yaitu seberapa jauh data read model di PostgreSQL tertinggal dari write path. Aspek ini dipantau secara langsung melalui endpoint /api/admin/debug/projection-lag yang diperbarui setiap ~9 detik selama eksekusi 2.000 TPS. Hasil pemantauan menunjukkan lag tetap di bawah 10 milidetik sepanjang pengujian.
Evaluasi Ketahanan Sistem
Ketiadaan error di lingkungan lokal sebatas menunjukkan frekuensi kemunculannya yang rendah. Sistem pembayaran yang memiliki celah kesalahan saat beban puncak memerlukan perhatian serius — sebab kondisi beban tinggi merupakan hal biasa di lingkungan produksi. GC pause, lonjakan CPU, serta prapemrosesan disk yang lambat adalah peristiwa rutin di server produksi.
Bagian penutup dari seri ini akan menyajikan rekapitulasi data akhir setelah seluruh pembenahan rampung, beserta keputusan akhir yang diambil — termasuk ulasan menarik: jika RDBMS menjadi pilihan utama secara operasional, lantas apa peranan dari repositori event-sourced yang telah dibangun?
Stay tuned …