Pada sistem donasi skala kampanye publik, lonjakan trafik bisa datang tiba-tiba setelah liputan media, figur publik, atau momentum sosial tertentu. Dalam kondisi seperti ini, backend bukan hanya harus cepat, tetapi juga benar: donasi tidak boleh tercatat ganda, kartu tidak boleh terdebit dua kali, progres donasi harus relatif segar, dan saldo atau total kampanye harus konsisten.

Masalah utamanya biasanya bukan sekadar menerima lebih banyak request, melainkan menjaga konsistensi lintas HTTP request, database, queue, worker, cache, dan payment gateway. Kunci desainnya adalah memisahkan jalur tulis yang kritikal, memakai idempotency key, deduplikasi job, distributed lock secara selektif, pola outbox/inbox, serta strategi retry dan dead-letter queue yang jelas.

Artikel ini membahas desain praktis backend donasi yang bisa diterapkan tim kecil, dengan fokus pada alur request -> DB -> queue -> worker -> cache, termasuk skema tabel, failure mode umum, metrik observabilitas, dan checklist implementasi bertahap.

Arsitektur dasar queue donasi andal

Prinsip dasarnya: jangan melakukan semua hal sinkron di request utama. Request pengguna cukup memvalidasi input, membuat catatan awal transaksi secara atomik, lalu menjadwalkan proses lanjutan lewat queue. Dengan begitu, sistem lebih tahan terhadap lonjakan trafik dan lebih mudah dikontrol saat ada kegagalan parsial.

Alur yang direkomendasikan

  1. Client mengirim permintaan donasi dengan idempotency key.
  2. API menyimpan record donasi awal di database dalam status misalnya PENDING.
  3. Dalam transaksi yang sama, API menulis event ke tabel outbox.
  4. Publisher atau relay membaca outbox dan mengirim job ke queue.
  5. Worker memproses job: memanggil payment gateway atau menunggu webhook, lalu memperbarui status donasi.
  6. Worker memperbarui agregat kampanye atau menulis event lanjutan.
  7. Cache untuk progres donasi di-refresh atau di-invalidasi berdasarkan event yang sudah sukses.

Model ini memisahkan sumber kebenaran di database dari data turunan di queue dan cache. Ini penting karena queue dan cache pada dasarnya bersifat eventual, sedangkan transaksi finansial memerlukan titik kebenaran yang jelas.

Komponen dan tanggung jawab

  • Database relasional: sumber kebenaran untuk donasi, status pembayaran, ledger, dan idempotency record.
  • Queue broker: menahan beban dan memisahkan kerja berat dari request sinkron.
  • Worker: menjalankan proses asinkron, retry, dan kompensasi terbatas.
  • Cache: menyajikan progres kampanye dan statistik baca cepat.
  • Lock service atau primitive lock di Redis/DB: mencegah race pada resource tertentu.

Gunakan lock seperlunya. Lock terlalu luas akan menurunkan throughput, tetapi tanpa lock pada titik kritis, Anda berisiko mengalami double count atau update yang saling menimpa.

Idempotensi untuk mencegah double charge dan double count

Di sistem donasi, idempotensi bukan fitur tambahan. Ini fondasi. Request bisa dikirim ulang karena timeout, tombol ditekan dua kali, mobile app retry otomatis, load balancer memutus koneksi, atau client tidak menerima respons meski server sebenarnya sudah memproses transaksi.

Idempotency key pada request donasi

Setiap percobaan pembuatan donasi perlu membawa idempotency key unik, biasanya dikirim lewat header atau field request. Server menyimpan key ini bersama konteks permintaan yang relevan, misalnya:

  • id donor atau sesi anonim
  • campaign_id
  • amount
  • currency
  • request hash
  • status hasil sebelumnya

Jika request yang sama datang lagi dengan key yang sama, server mengembalikan hasil sebelumnya atau melanjutkan ke state yang benar, bukan membuat donasi baru.

Skema tabel idempotency yang sederhana

CREATE TABLE idempotency_keys (
  id BIGSERIAL PRIMARY KEY,
  idempotency_key VARCHAR(128) NOT NULL,
  scope VARCHAR(64) NOT NULL,
  request_hash VARCHAR(128) NOT NULL,
  resource_type VARCHAR(64),
  resource_id BIGINT,
  status VARCHAR(32) NOT NULL,
  response_code INT,
  response_body TEXT,
  created_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL,
  UNIQUE (scope, idempotency_key)
);

scope membantu membatasi key, misalnya per endpoint atau per merchant account. request_hash dipakai untuk mendeteksi misuse: jika key yang sama dikirim dengan payload berbeda, server sebaiknya menolak karena itu bukan retry yang sah.

Idempotensi di level payment provider

Kalau payment gateway mendukung idempotency, gunakan juga di sana. Namun jangan bergantung penuh pada provider. Sistem internal tetap harus menyimpan korelasi antara:

  • donation_id
  • payment_intent_id atau referensi eksternal
  • idempotency_key
  • status internal dan status eksternal

Alasannya sederhana: provider bisa mengirim webhook berulang, respons sinkron bisa timeout, atau status eksternal bisa berubah terlambat. Sistem Anda tetap perlu memastikan satu donasi hanya dihitung sekali.

Mencegah double count pada agregat kampanye

Double charge adalah masalah pembayaran; double count adalah masalah akuntansi internal. Donasi yang sama bisa terhitung dua kali jika event diproses ulang atau worker duplicate berjalan bersamaan.

Praktik yang aman:

  • Simpan ledger event atau campaign contribution record dengan unique constraint pada donation_id dan jenis kontribusi.
  • Agregat kampanye diperbarui hanya jika insert ledger berhasil.
  • Jika event yang sama datang lagi, insert gagal secara aman karena unique constraint, dan worker menandainya sebagai duplicate yang sudah pernah diproses.

Desain tabel dan event: sumber kebenaran yang jelas

Untuk sistem donasi yang andal, hindari menyimpan hanya satu tabel besar tanpa jejak status. Pisahkan entitas inti agar perubahan state dapat diaudit dan dipulihkan.

Contoh entitas inti

CREATE TABLE donations (
  id BIGSERIAL PRIMARY KEY,
  donor_id BIGINT,
  campaign_id BIGINT NOT NULL,
  amount NUMERIC(18,2) NOT NULL,
  currency CHAR(3) NOT NULL,
  status VARCHAR(32) NOT NULL,
  payment_reference VARCHAR(128),
  idempotency_key VARCHAR(128) NOT NULL,
  created_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL,
  UNIQUE (idempotency_key)
);

CREATE TABLE donation_ledger (
  id BIGSERIAL PRIMARY KEY,
  donation_id BIGINT NOT NULL,
  campaign_id BIGINT NOT NULL,
  entry_type VARCHAR(32) NOT NULL,
  amount NUMERIC(18,2) NOT NULL,
  created_at TIMESTAMP NOT NULL,
  UNIQUE (donation_id, entry_type)
);

CREATE TABLE campaign_totals (
  campaign_id BIGINT PRIMARY KEY,
  confirmed_amount NUMERIC(18,2) NOT NULL DEFAULT 0,
  donation_count BIGINT NOT NULL DEFAULT 0,
  updated_at TIMESTAMP NOT NULL
);

CREATE TABLE outbox_events (
  id BIGSERIAL PRIMARY KEY,
  aggregate_type VARCHAR(64) NOT NULL,
  aggregate_id BIGINT NOT NULL,
  event_type VARCHAR(64) NOT NULL,
  payload_json TEXT NOT NULL,
  published_at TIMESTAMP,
  created_at TIMESTAMP NOT NULL
);

CREATE TABLE inbox_events (
  id BIGSERIAL PRIMARY KEY,
  source VARCHAR(64) NOT NULL,
  message_id VARCHAR(128) NOT NULL,
  received_at TIMESTAMP NOT NULL,
  processed_at TIMESTAMP,
  UNIQUE (source, message_id)
);

Mengapa perlu outbox dan inbox

Outbox mencegah celah klasik: data di database sudah commit, tetapi job ke queue gagal dikirim. Jika API menulis perubahan domain dan outbox event dalam satu transaksi database, relay dapat mengirim event itu belakangan tanpa kehilangan fakta bisnis.

Inbox dipakai di sisi konsumen atau worker untuk mencatat message yang sudah diterima. Jika message yang sama datang lagi karena broker at least once, worker dapat mendeteksi duplikasi dan tidak memprosesnya dua kali.

Queue yang paling umum dipakai di sistem produksi biasanya menjamin at least once delivery, bukan exactly once. Karena itu, idempotensi di level konsumen adalah keharusan.

Locking dan deduplikasi job tanpa membunuh throughput

Tidak semua race condition harus diselesaikan dengan lock global. Pilih granularity yang tepat.

Kapan perlu distributed lock

Distributed lock berguna ketika satu resource tidak boleh diproses paralel dalam jendela waktu tertentu, misalnya:

  • rekonsiliasi satu donation_id oleh dua worker
  • refresh progres kampanye yang mahal dan rawan stampede
  • transisi state yang hanya boleh dijalankan satu kali

Contoh kunci lock:

  • donation:{id}:capture
  • campaign:{id}:aggregate
  • webhook:{provider}:{event_id}

Trade-off lock di Redis vs lock di database

  • Lock di Redis: cepat dan cocok untuk koordinasi singkat. Namun perlu TTL yang benar agar lock tidak menggantung jika worker mati.
  • Lock di database: lebih dekat ke data dan lebih kuat untuk operasi transaksional tertentu, tetapi bisa menambah kontensi dan memperlambat jalur panas.

Untuk update donasi individual, sering kali kombinasi unique constraint + update bersyarat + transaksi lebih aman dan sederhana daripada lock eksplisit yang luas.

Deduplikasi job

Jangan asumsikan satu job hanya akan ada satu kali di queue. Deduplikasi sebaiknya dilakukan di dua tempat:

  1. Sebelum enqueue: jika memungkinkan, gunakan key turunan dari event bisnis, misalnya donation-confirmed:{donation_id}.
  2. Saat consume: cek inbox table atau unique write agar proses ulang menjadi no-op.

Dengan begitu, walaupun producer atau broker mengirim duplikat, efek bisnis tetap tunggal.

Contoh pseudocode worker yang aman

function handleDonationConfirmed(message) {
  if (inboxExists(message.source, message.id)) {
    return; // duplicate message
  }

  beginTransaction();
  try {
    insertInbox(message.source, message.id);

    donation = findDonationForUpdate(message.donation_id);
    if (donation.status !== 'CONFIRMED') {
      updateDonationStatus(donation.id, 'CONFIRMED');
    }

    inserted = insertLedgerIfAbsent({
      donation_id: donation.id,
      campaign_id: donation.campaign_id,
      entry_type: 'CONFIRMED_DONATION',
      amount: donation.amount
    });

    if (inserted) {
      incrementCampaignTotals(donation.campaign_id, donation.amount, 1);
      insertOutboxEvent('campaign', donation.campaign_id, 'CampaignTotalUpdated', {...});
    }

    commit();
  } catch (e) {
    rollback();
    throw e;
  }
}

Poin pentingnya bukan sintaks, melainkan urutannya: catat inbox, kunci atau amankan row yang relevan, lakukan write idempoten, lalu baru publikasikan efek turunannya.

Retry, dead-letter queue, dan klasifikasi kegagalan

Retry yang buta sering justru memperparah insiden. Bedakan kegagalan yang layak diulang dari yang harus dihentikan.

Kegagalan yang biasanya boleh di-retry

  • timeout jaringan ke payment provider
  • HTTP 5xx dari layanan downstream
  • broker queue sementara tidak tersedia
  • kontensi lock sementara

Gunakan exponential backoff dan, bila perlu, jitter agar worker tidak menyerbu dependency secara bersamaan.

Kegagalan yang sebaiknya tidak di-retry tanpa perubahan data

  • payload tidak valid
  • mata uang tidak didukung
  • signature webhook tidak valid
  • foreign key tidak ditemukan karena referensi bisnis salah

Kasus semacam ini lebih cocok masuk ke dead-letter queue atau status gagal permanen untuk investigasi manual atau proses kompensasi.

Kapan job harus masuk dead-letter queue

Masukkan ke DLQ jika:

  • sudah melewati batas retry
  • error termasuk kategori non-retriable
  • terdeteksi anomali state yang butuh intervensi manusia

DLQ bukan tempat membuang masalah. DLQ harus punya proses operasional: dashboard, alarm, runbook, dan kemampuan replay yang aman setelah akar masalah diperbaiki.

Kesalahan umum pada retry

  • retry tanpa idempotensi sehingga efek bisnis terduplikasi
  • retry terlalu agresif hingga memperparah outage dependency
  • mengulang seluruh workflow, padahal cukup mengulang langkah tertentu
  • tidak mencatat alasan retry dan jumlah percobaan

Cache progress donasi: cepat dibaca, tetap masuk akal

Progress kampanye biasanya dibaca jauh lebih sering daripada ditulis. Karena itu cache sangat membantu, tetapi cache bukan sumber kebenaran. Saat trafik melonjak, tantangan utamanya adalah menjaga progress tetap cepat tanpa menampilkan angka yang menyesatkan terlalu lama.

Strategi cache yang praktis

  • Read-through cache untuk halaman kampanye.
  • TTL pendek untuk progress jika update sangat sering.
  • Event-driven invalidation saat donasi terkonfirmasi.
  • Precomputed aggregates di tabel terpisah untuk menghindari query sum besar berulang.

Pendekatan yang sering efektif: worker yang berhasil menambah ledger juga menulis event CampaignTotalUpdated. Consumer cache kemudian menghapus atau memperbarui key seperti campaign:{id}:progress.

Mencegah cache stampede

Ketika cache expired bersamaan dan ribuan request datang, database bisa diserbu ulang. Beberapa mitigasi:

  • gunakan TTL dengan sedikit variasi acak
  • pakai single-flight atau lock singkat saat regenerasi cache
  • sajikan data stale beberapa detik jika regenerasi gagal

Untuk halaman donasi publik, slightly stale but consistent biasanya lebih baik daripada real-time palsu yang rawan salah hitung.

Apa yang tidak boleh dicache terlalu percaya diri

  • status final pembayaran yang belum dikonfirmasi
  • saldo internal yang dipakai untuk keputusan finansial
  • hasil retry atau webhook yang masih dalam state balapan

Gunakan cache untuk akselerasi baca, bukan untuk menggantikan logika konsistensi transaksi.

Failure mode umum pada backend donasi

1. Request timeout, tetapi pembayaran sebenarnya sukses

Ini salah satu kasus paling umum. Client mengira gagal, lalu mengirim ulang. Solusinya:

  • gunakan idempotency key di API internal
  • pakai idempotency juga ke payment provider jika tersedia
  • sediakan endpoint polling status berdasarkan donation_id atau key

2. Webhook datang sebelum respons sinkron diproses

Urutan event dari provider tidak selalu seperti yang diharapkan. Karena itu, status internal harus didesain sebagai state machine yang menerima event datang tidak berurutan selama transisinya valid.

3. Job berhasil sebagian

Misalnya status donasi sudah berubah menjadi confirmed, tetapi cache gagal di-update. Ini harus dianggap aman jika database sudah benar. Cache bisa dipulihkan lewat event replay atau refresh periodik.

4. Dua worker memproses donasi yang sama

Ini terjadi karena redelivery, retry, atau bug producer. Pencegahannya:

  • unique constraint pada ledger atau inbox
  • update bersyarat berdasarkan status lama
  • lock pada resource kecil jika memang perlu

5. Total kampanye tidak cocok dengan transaksi detail

Agregat yang dipelihara terpisah bisa drift karena bug atau operasi manual. Siapkan job rekonsiliasi berkala yang membandingkan campaign_totals dengan hasil agregasi dari donation_ledger.

Jika harus memilih, prioritaskan kebenaran ledger detail. Agregat bisa dibangun ulang; transaksi detail yang hilang jauh lebih sulit dipulihkan.

Observabilitas: metrik yang benar untuk antrian donasi

Sistem queue donasi sulit dioperasikan tanpa observabilitas yang cukup. Jangan hanya memantau CPU dan memory.

Metrik inti

  • queue depth per jenis job
  • job age / queue lag: berapa lama job menunggu sebelum diproses
  • success rate dan retry rate
  • dead-letter count
  • duplicate message rate
  • idempotency hit rate
  • payment confirmation latency
  • cache hit ratio untuk endpoint campaign progress
  • lock contention dan lock timeout
  • drift antara campaign total dan ledger sum

Logging dan tracing yang berguna

Pastikan setiap alur membawa correlation ID atau trace ID yang mengaitkan:

  • HTTP request
  • record donasi
  • message queue
  • panggilan ke payment provider
  • webhook callback

Tanpa korelasi ini, debugging double charge atau missing progress akan memakan waktu sangat lama.

Alert yang layak dipasang

  • queue lag melewati ambang operasional
  • lonjakan retry pada job payment
  • DLQ bertambah terus
  • idempotency conflict meningkat tajam
  • rasio cache miss melonjak setelah deploy
  • drift agregat terdeteksi di rekonsiliasi

Implementasi bertahap untuk tim kecil

Tim kecil tidak harus membangun semua pola kompleks sejak hari pertama. Yang penting adalah urutan prioritas yang benar.

Tahap 1: amankan jalur kritikal

  1. Tambahkan idempotency key di endpoint pembuatan donasi.
  2. Simpan status donasi di database dengan state yang eksplisit.
  3. Gunakan queue untuk pekerjaan non-kritis terhadap latensi request.
  4. Pastikan webhook payment diverifikasi dan disimpan idempoten.

Tahap 2: cegah duplikasi efek bisnis

  1. Tambahkan tabel ledger dengan unique constraint.
  2. Perbarui total kampanye berdasarkan insert ledger yang sukses.
  3. Implementasikan inbox table untuk consumer penting.

Tahap 3: hilangkan celah publish event

  1. Tambahkan outbox table di transaksi domain.
  2. Buat relay publisher sederhana yang membaca outbox dan menandai event yang sudah terkirim.
  3. Pastikan replay outbox aman dan tidak membuat efek ganda.

Tahap 4: optimalkan performa baca

  1. Tambahkan cache untuk progres kampanye.
  2. Pakai invalidasi berbasis event, bukan hanya TTL panjang.
  3. Mitigasi stampede dengan single-flight atau stale-while-revalidate sederhana.

Tahap 5: tambah kontrol operasional

  1. Pasang metrik queue, retry, DLQ, dan drift agregat.
  2. Buat runbook untuk replay job, rekonsiliasi, dan penanganan webhook duplicate.
  3. Lakukan uji beban dan uji chaos terbatas pada dependency eksternal.

Checklist praktis sebelum menghadapi lonjakan kampanye

  • Endpoint donasi menerima dan menyimpan idempotency key.
  • Payload dengan key sama tetapi isi berbeda ditolak.
  • Status donasi punya transisi yang jelas: misalnya PENDING, PROCESSING, CONFIRMED, FAILED, CANCELED.
  • Webhook provider diverifikasi, dicatat, dan diproses idempoten.
  • Ledger kontribusi punya unique constraint agar donasi tidak dihitung dua kali.
  • Agregat kampanye tidak menjadi satu-satunya sumber kebenaran.
  • Outbox dipakai untuk event penting setelah commit database.
  • Consumer penting memakai inbox atau mekanisme deduplikasi setara.
  • Retry dibedakan antara error sementara dan error permanen.
  • DLQ memiliki dashboard, alarm, dan prosedur replay.
  • Cache progres kampanye bisa di-invalidasi oleh event konfirmasi.
  • Ada rekonsiliasi berkala antara ledger dan total kampanye.
  • Semua request, job, dan webhook memiliki correlation ID.

Penutup

Queue donasi andal bukan sekadar memilih broker yang cepat atau menambah jumlah worker. Yang jauh lebih penting adalah mendesain sistem supaya aman terhadap retry, duplikasi, urutan event yang kacau, dan kegagalan parsial. Dalam praktiknya, kombinasi idempotensi, deduplikasi job, locking yang selektif, outbox/inbox, cache yang disiplin, dan observabilitas yang memadai adalah fondasi untuk menjaga saldo dan progres kampanye tetap konsisten saat trafik melonjak.

Jika tim Anda masih kecil, mulailah dari jalur pembayaran dan pencatatan yang paling kritikal: idempotency key, state transaksi yang eksplisit, ledger idempoten, lalu outbox. Setelah itu baru optimalkan cache, throughput worker, dan pengalaman real-time. Sistem donasi bisa mentoleransi progres yang terlambat beberapa detik; yang tidak bisa ditoleransi adalah donasi terdebit dua kali atau total kampanye yang salah.