Benchmark Payment Gateway Bagian 6 : Ditemukan Saat Menulis Bagian 5, Dibongkar dan Dibangun Ulang
Seri ini seharusnya selesai di Bagian 5. Draft-nya sudah lengkap, angkanya sudah final, kesimpulannya sudah ditulis. Tapi ada satu paragraf soal trade-off “model detect-and-flag” yang, waktu ditinjau ulang, terasa mengganjal — dan pertanyaan yang muncul dari situ membongkar sesuatu yang lebih dalam daripada bug yang sudah dibahas di Bagian 4. Bagian ini isinya cerita soal itu: bagaimana proses menulis perbandingan arsitektur ini sendiri yang justru menemukan celah desain berikutnya, dan apa yang akhirnya dibongkar serta dibangun ulang karenanya.
Pertanyaan yang Muncul Saat Meninjau Draft
Draf awal Bagian 5 menulis begini soal bug pembayaran ganda di Bagian 4: “kadang salah, nanti dikoreksi” itu trade-off yang wajar untuk beberapa domain, tapi tidak untuk pelunasan pembayaran. Waktu ditinjau ulang, muncul keberatan yang masuk akal: kenapa harus menerima “kadang salah, nanti dikoreksi” sama sekali? Bukankah seharusnya bisa didesain supaya foolproof sejak awal, tanpa peduli serendah apa pun resource-nya?
Ini bukan pertanyaan retoris. Jawabannya membongkar bahwa perbaikan yang sudah dikerjakan di Bagian 4 — yang membetulkan PostgresProjectionSink supaya tidak lagi mencatat satu bankReference dua kali — itu benar, sudah diverifikasi, dan tetap benar sampai sekarang. Tapi itu tambalan di hilir, bukan perbaikan di akar masalahnya.
Kenapa Perbaikan di Bagian 4 Belum Cukup
Begini yang sebenarnya terjadi pada skenario race di Bagian 4: request thread membaca status tagihan secara lokal, melihat masih aktif, lalu langsung membalas ke bank bahwa pembayaran diterima. Baru belakangan, di thread lain, Kafka Streams memproses ulang dan menyadari tagihan itu sebenarnya sudah lunas duluan oleh pembayaran lain.
Perbaikan di PostgresProjectionSink membetulkan catatan di database supaya tidak lagi kontradiktif. Tapi jawaban yang sudah diterima bank — “ACCEPTED” — sudah terlanjur terkirim sebelum Kafka Streams sempat mengoreksi apa pun. Database-nya benar sekarang; jawaban ke bank yang sudah kadung salah tidak bisa ditarik lagi. Dua hal yang berbeda, dan perbaikan Bagian 4 cuma menyentuh salah satunya.
sequenceDiagram
participant Bank
participant Req as Request Thread
participant Streams as Kafka Streams
(otoritas asli)
participant DB as PostgreSQL
Req->>Req: Cek state lokal, masih ACTIVE
Req->>Bank: "ACCEPTED" (jawaban sudah terkirim)
Req->>Streams: PaymentReceivedEvent
Note over Streams: Belakangan: tagihan ternyata
sudah lunas duluan
Streams->>DB: DoubleSettlementDetectedEvent
Note over Bank: Bank sudah kadung tahu
"ACCEPTED" -- tidak bisa ditarik lagi
Perbaikan yang Sebenarnya : RocksDB Transaction Langsung di Request Thread
Supaya bank menerima jawaban yang akurat sejak awal, celahnya harus ditutup di sumbernya: jeda antara “request thread membaca status pembayaran (menghasilkan belum dibayar)” dan “Kafka Streams menentukan status pembayaran (sukses atau sudah terbayar oleh transaksi lain)” itu sendiri. Solusinya: satukan pencarian nomor VA, pengecekan status tagihan, dan penulisan saldo sebagai satu transaksi RocksDB yang atomic, dieksekusi langsung di request thread — bukan lagi “baca lokal, kirim event, biarkan diproses belakangan.”
Komponen barunya diberi nama ChargeSettlementStore — sebuah RocksDB TransactionDB yang dimiliki langsung oleh aplikasi, bukan lagi state store yang dikelola Kafka Streams. Pakai getForUpdate, yang memberi jaminan sama persis seperti SELECT FOR UPDATE di RDBMS: begitu transaksi commit, tidak ada request lain yang bisa membaca state yang sudah usang. Kafka baru terlibat setelah keputusan final diambil, semata untuk menyalurkan hasilnya ke PostgresProjectionSink — bukan lagi sebagai penentu keputusan.
flowchart TB
subgraph Sebelum["Sebelum"]
A1["Request Thread
baca lokal"] --> A2["Kirim Event"]
A2 --> A3["Kafka Streams
memutuskan belakangan"]
end
subgraph Sesudah["Sesudah"]
B1["Request Thread
transaksi RocksDB atomic"] --> B2["Keputusan final,
langsung"]
B2 --> B3["Kafka: sebar hasil
ke read model"]
end
A3 -.->|diperbaiki menjadi| B1
Kafka Streams-nya Sendiri Malah Dibuang
Ini konsekuensi yang tidak diduga sebelumnya. Begitu pembacaan dan penulisan sama-sama pindah ke ChargeSettlementStore, tiga state store yang tadinya dikelola Kafka Streams (charge-state-store, va-registry-store, idempotency-store — tautan ini menunjuk ke commit terakhir sebelum ketiganya dihapus) beserta logika pengambilan keputusan di topology-nya jadi benar-benar tidak dipakai lagi oleh siapa pun.
Pilihannya cuma dua: biarkan tetap ada sebagai salinan yang tidak dipakai (supaya label “Kafka Streams + RocksDB” di seri ini tetap utuh), atau dihapus sekalian karena sudah menjadi dead code. Opsi pertama justru mengulang persis pola anti-pattern yang berulang kali ditemukan sepanjang seri ini: dua tempat berbeda yang menyimpan state untuk hal yang sama, yang berpotensi tidak sinkron. Jadi dibuang saja. Kafka sendiri (broker, event log, konsumer untuk read model) tetap dipakai persis seperti sebelumnya — yang dibuang cuma peran Kafka Streams sebagai pemilik state store dan pengambil keputusan.
Verifikasi : Bukan Cuma Replay Event, Tapi Concurrency Sungguhan
Test deterministik di Bagian 4 (PostgresProjectionSinkTest) membuktikan perbaikan level-proyeksi. Caranya: mengirim ulang urutan event satu per satu, sekuensial, meniru persis urutan kejadian yang sudah diketahui menyebabkan bug. Ini belum menguji concurrency sungguhan — cuma satu urutan kejadian tertentu yang diputar ulang. Untuk perbaikan kali ini, dibutuhkan bukti yang lebih kuat: 50 thread Java sungguhan, dijalankan bersamaan, sama-sama mencoba membayar tagihan CLOSED yang baru saja dibuat, masing-masing dengan bankReference berbeda.
Kutipan dari ConcurrentPaymentSettlementIntegrationTest.concurrentPaymentsAgainstSameCharge_exactlyOneWins_noDoubleCounting():
List<Callable<PaymentOutcome>> tasks = IntStream.range(0, 50)
.mapToObj(i -> (Callable<PaymentOutcome>) () -> {
String bankReference = "CONC-REF-" + i + "-" + UUID.randomUUID();
var result = paymentApplicationService.processPayment(
"BSI", vaNumber, bankReference, totalAmount, Instant.now());
return result.outcome();
})
.toList();
List<Future<PaymentOutcome>> futures = executor.invokeAll(tasks);
Hasilnya: tepat 1 dari 50 thread diterima, 49 sisanya benar-benar ditandai sebagai pembayaran ganda, dan cumulativePaid akhir persis sama dengan nominal satu kali pembayaran — tidak ada yang dobel dihitung meski 50 thread berebut mengakses tagihan yang sama secara bersamaan. Buktinya jauh lebih kuat dibanding replay event yang sekadar mengulang satu urutan kejadian yang sudah diketahui. Di sini, 50 thread betul-betul berebut secara bersamaan, dan tetap tidak ada double payment.
Angka Benchmark Setelah Dibongkar Ulang
Pertanyaan wajar berikutnya: apakah pembongkaran arsitektur sebesar ini menambah latency? Dijawab dengan menjalankan ulang benchmark, dua kali, dengan lingkungan pengujian yang steril dari interferensi setiap kali:
| Metrik | Run 1 (arsitektur baru) | Run 2 (arsitektur baru) | Pembanding: run bersih sebelumnya |
|---|---|---|---|
| p99 latency | 9,96 ms | 9,85 ms | 9,41 ms / 8,50 ms |
| Virtual user puncak (dari 2.000) | 32 (tidak pernah keluar dari alokasi awal 100) | 33 (sama) | 122 / 100 |
| Audit akurasi | LULUS, 0 ketidaksesuaian | LULUS, 0 ketidaksesuaian | LULUS, 0 ketidaksesuaian |
Angkanya secara statistik tidak bisa dibedakan dari sebelum pembongkaran arsitektur ini. Masuk akal: alur kode program yang baru sama sekali tidak menyentuh Kafka untuk mengambil keputusan, jadi tidak ada round-trip tambahan yang perlu ditunggu. Perbaikan yang membereskan celah akurasi di sumbernya ini sama sekali tidak mengorbankan metrik yang sebenarnya penting.
Memetik Pelajaran dari Setiap Iterasi
Ini sudah ketiga kalinya pelajaran yang sama mengemuka dalam proyek ini, dan setiap tahapnya menyingkap pemahaman yang makin mendalam:
- Bagian 3: Hasil benchmark yang tampak spektakuler ternyata cuma artefak dari lingkungan pengujian yang terkontaminasi, hingga akhirnya diralat.
- Bagian 4: Ralat tersebut ternyata kebablasan karena ikut menyapu bersih isu akurasi — padahal “jarang terjadi” bukan berarti “tidak ada.” Akar masalahnya berhasil ditemukan dan ditambal di level proyeksi database.
- Bagian ini: Tambalan di Bagian 4 ternyata baru membereskan gejala di permukaan, belum menyentuh akar masalahnya. Hal ini baru terungkap saat proses menulis dan meninjau ulang penjelasannya — mencoba merumuskan alur berpikir secara logis untuk pembaca justru menyingkap celah yang tersembunyi.
Poin terakhir inilah yang paling menarik menurut saya. Celah arsitektur ini ditemukan bukan lewat proses coding atau testing, melainkan saat menjelaskannya kepada pembaca. Kadang-kadang, cara terbaik untuk menguji hasil kerja sendiri (maupun hasil kerja AI) bukanlah dengan menambah automated test, melainkan mencoba menuangkan alasannya secara runtut agar bisa dipahami orang awam. Jika ada bagian yang terasa janggal saat dijelaskan, kemungkinan besar memang ada masalah di sana.
Seluruh kode, panduan remediasi yang diperbarui, data benchmark mentah, serta test concurrency-nya dapat diakses secara publik di repository payment-gateway-evtsrc berlisensi Apache 2.0.
Seri ini sekarang benar-benar selesai — setidaknya sampai draf berikutnya ditinjau ulang. Dan benar saja, ketika merapikan daftar poin di Bagian 4 yang membahas perbaikan ini (khususnya poin “tandai semuanya lunas”), muncul satu pertanyaan lagi yang tak kalah menarik. Ceritanya berlanjut di Bagian 7. Terima kasih telah mengikuti seluruh rangkaian tulisan ini!