Jawabannya: Konflik Bukan Error

Menambah unique key membuat error duplicate key di mana-mana, dan itu bukan tanda solusinya salah. Ini urutan perbaikannya, lengkap dengan angka hasil pengukuran tiap varian.

Yang paling menarik dari lab ini bukan jawabannya, tapi urutan gagalnya. Karena urutan itulah kenapa banyak orang menyimpulkan "unique key nggak menyelesaikan apa-apa" — padahal constraint-nya benar, dan database-nya benar.

Poin Penting

Menambah UNIQUE menyelesaikan dobelnya, lalu memunculkan masalah baru yang justru benar.

  • Konflik "sudah ada" bukan kegagalan: dari sudut pandang pengirim, itu hasil yang sukses.
  • Klaimnya harus satu statement (INSERT ... ON CONFLICT DO NOTHING RETURNING), bukan periksa dulu lalu tulis.
  • Jawaban pengiriman pertama disimpan dan dikembalikan apa adanya.
  • Angka yang berubah dihitung database, bukan dibaca-ubah-tulis di aplikasi.

Hasil pengukuran

Semua angka di sini dari run pengukuran, bukan perkiraan: satu database bersih untuk tiap varian. Pemeriksaannya 21. Angka di kolom terakhir: berapa yang masih merah.

varian merah yang masih merah
kondisi awal 11 dari 21 dobel, saldo, jawaban, dan event_id yang dipakai ulang
(1) + UNIQUE (event_id) di database, handler tidak diubah 7 dari 21 dijawab 2xx, jawaban, saldo
(2) + klaim ON CONFLICT DO NOTHING, tapi konflik dijawab pesan 1 dari 21 body pengiriman ulang beda
(3) + jawaban pengiriman pertama disimpan di baris klaimnya 1 dari 21 saldo
(4) + saldo dihitung database, bukan dibaca-ubah-tulis di Python 0 dari 21 —
alternatif untuk (2): periksa dulu di aplikasi, baru INSERT 3 dari 21 26–29 dari 30 pengiriman bersamaan dijawab 500

1. Yang salah bukan kodenya, tapi asumsinya

Kode awalnya nggak punya penjagaan apa pun, dan komentarnya menjelaskan kenapa penulisnya merasa itu cukup: "endpoint ini membalas 200 setelah transaksinya commit, jadi satu event sampai ke sini tepat satu kali."

Dua kejadian di situ dianggap satu. Kenyataannya session.commit() dan jawaban 200 yang sampai ke gateway adalah dua kejadian terpisah, dan yang kedua bisa gagal sendirian. Kalau gagal, gateway cuma tahu satu hal: dia belum menerima balasan.

2. Unique key memperbaiki gejalanya, lalu memunculkan masalah baru — dan masalah baru itu benar

Tambahkan UNIQUE (event_id). Dobelnya berhenti. Yang muncul:

TEXT
status: [200, 500, 500, 500]
1 dari 30 pengiriman bersamaan dijawab 2xx
sqlalchemy.exc.IntegrityError: (psycopg2.errors.UniqueViolation)
duplicate key value violates unique constraint "ledger_entries_event_id_key"

Ini yang bikin orang mengira unique key itu bukan solusi. Padahal yang salah adalah reaksi kita terhadap konfliknya: kita memperlakukannya sebagai kegagalan, padahal dari sudut pandang gateway, "event ini sudah pernah saya kirim dan sudah pernah diterima" adalah hasil yang sukses.

Perhatikan juga apa yang tidak rusak waktu itu: barisnya tetap satu, saldonya tetap benar. Jadi duplicate key bukan masalah data — dia masalah jawaban. Gateway nggak pernah tahu percobaan pertamanya berhasil, jadi dia kirim lagi, dapat 500 lagi, kirim lagi. Yang bertambah bukan barisnya, tapi error-nya.

3. Keputusannya pindah ke database, dan jawabannya disimpan

Dua perubahan, dan dua-duanya soal tempat menyimpan:

  • Klaim eventnya dipindah ke satu statement, bukan "periksa dulu lalu tulis":

    SQL
    INSERT INTO processed_events (event_id, request_fingerprint)
    VALUES ($1, $2)
    ON CONFLICT (event_id) DO NOTHING
    RETURNING event_id;

    Kosong = bukan kita yang dapat. Ini "periksa dan tulis" dalam satu operasi, jadi nggak ada jendela waktu yang bisa dilewati dua-duanya. Nggak ada except yang perlu ditulis, karena konflik yang bisa diprediksi bukan pengecualian.

  • Jawaban pengiriman pertama disimpan di baris klaimnya, dan pengiriman ulang mengembalikannya apa adanya.

Kalau kamu memilih "periksa dulu di aplikasi, baru INSERT", itu masih merah. Bentuk ini kelihatan setara dan di tes manual memang hijau; di bawah beban, 30 pengiriman membaca tabelnya bersamaan, semuanya melihat "belum ada", semuanya menulis. 26–29 di antaranya ditangkap unique key dan jadi 500. Hasil akhirnya nggak dobel, tapi endpoint-mu berteriak di hampir semua pengiriman bersamaan.

4. Saldo bukan soal idempotensi, tapi ketemu di lab yang sama

account.balance = account.balance + nominal kelihatan seperti penjumlahan. Yang sebenarnya terjadi: baca nilai dari database, kirim balik nilai barunya. Tiga puluh transaksi yang membaca nilai yang sama akan menulis nilai yang sama — 40.000 dari 300.000 yang terselamatkan.

Di SQL, satu statement UPDATE accounts SET balance = balance + $1 WHERE id = $2 nggak punya masalah itu: database yang memegang barisnya selama update. Pelajaran yang sama dengan tiga langkah sebelumnya, dalam bentuk paling murni: kalau datanya ada di database, keputusannya juga di database.

Saya baru benar-benar paham bagian ini waktu retry-nya datang dari worker, bukan dari HTTP. Retry yang salah bukan cuma soal satu request dijawab dua kali: dia bisa datang jam tiga pagi, dari worker lain, untuk pekerjaan yang sudah selesai. Obatnya sama, dan sejak itu saya nggak pernah lagi menganggap "dijalankan sekali" sebagai jaminan.

Jebakan yang paling menggoda

PYTHON
try:
    session.flush()
except IntegrityError:
    pass

Terlihat aman karena baris gandanya memang nggak jadi masuk. Yang nggak kelihatan: di Postgres, statement yang gagal membatalkan seluruh transaksi. Statement berikutnya gagal dengan 25P02: current transaction is aborted, dan kalau kamu menangkap error itu juga lalu mengembalikan 200, kamu baru saja bilang "beres" untuk pekerjaan yang nggak terjadi. Kalau tetap mau ditangani, butuh SAVEPOINT supaya yang dibatalkan cuma statement itu.

Jebakan kedua: memisahkan klaim dari pekerjaannya. Kalau klaimnya di-commit lebih dulu, ada jendela waktu di mana server boleh mati — dan sesudah itu eventnya tercatat sudah diproses sementara ledger dan saldonya kosong, dan semua pengiriman ulang sesudahnya dianggap duplikat. Dobel kelihatan di laporan; hilang nggak kelihatan.

Yang belum dicakup lab ini

  • Efek samping ke luar database. Kalau handler-mu juga mengirim email atau memanggil API pihak ketiga, satu transaksi nggak menolong: email nggak bisa di-rollback. Itu pola outbox.
  • Fakta yang datang dari dua jalur. Di lab ini satu pembayaran cuma punya satu jalur. Begitu ada job rekonsiliasi yang menutup pembayaran yang webhook-nya telat, semua kunci yang dipakai di lab ini berhenti bekerja — dan yang bekerja tinggal kunci yang berasal dari domainnya sendiri. Itu lab berikutnya.

Biar makin jago ngoding.

Yuk gabung bareng temen-temen developer lainnya buat dapet update teknologi, tips, dan tutorial santai tiap minggu.

Biar Nggak Kudet

Update teknologi, tips ngoding, dan insight santai langsung ke email kamu tiap minggu.

Santai, anti spam. Bisa berhenti langganan kapan aja.

Baca Selanjutnya

Lihat semua

© 2026 Inva.dev.