Retry yang kita bangun pada chapter sebelumnya punya efek samping yang tidak terlihat di contoh: setiap percobaan ulang adalah permintaan baru yang sampai di server, padahal permintaan pertama mungkin sudah sempat diproses sebelum jawabannya hilang di jaringan. Di sistem terdistribusi, pengiriman ulang bukan kejadian luar biasa — broker dengan jaminan at-least-once, klien yang tidak sabar, dan pengguna yang menekan tombol dua kali semuanya mengirim pesan yang sama berkali-kali. Idempotency adalah sifat operasi yang membuat pengulangan aman: operasi dijalankan sekali atau sepuluh kali, hasil akhirnya sama. Chapter ini membuktikan bedanya lewat service penagihan yang ditagih dua kali, lalu memperbaikinya dengan kunci idempotensi.
Definisi Idempotensi #
Operasi idempotent adalah operasi yang pemanggilan berulangnya meninggalkan keadaan yang sama dengan pemanggilan sekali. Matematis ditulis f(f(x)) = f(x) — hasil fungsi tidak berubah bila keluarannya dimasukkan kembali. HTTP membedakan method berdasarkan sifat ini secara eksplisit; RFC 9110 bahkan menyebut idempotensi sebagai alasan keamanan retry:
| Method HTTP | Idempotent? | Contoh efek berulang |
|---|---|---|
GET | ya | membaca data — tidak mengubah apa pun |
PUT | ya | PUT saldo=500 dua kali → saldo tetap 500 |
DELETE | ya | menghapus yang sudah terhapus → tetap terhapus |
POST | tidak secara bawaan | POST tagih 250000 dua kali → ditagih 500000 |
Perhatikan DELETE: keberhasilan kedua mungkin berbeda (404 karena sudah terhapus), tetapi keadaan sistem sama — definisi idempotensi menyangkut hasil akhir, bukan kesamaan respons byte-per-byte. Method POST, tempat sebagian besar perintah bisnis berada, tidak mendapat jaminan apa pun dari protokol; jaminan itu harus kita bangun sendiri.
Kunci Idempotensi dalam Kode #
Solusi standar untuk POST idempotent adalah idempotency key: klien membuat satu kunci unik per operasi (bukan per percobaan), mengirimkannya bersama setiap percobaan, dan server menyimpan hasil percobaan pertama per kunci. Program berikut membandingkan service penagihan tanpa dan dengan kunci:
import java.util.LinkedHashMap;
import java.util.Map;
/** Idempotency: kunci deduplikasi membuat retry aman untuk diulang. */
public class KunciIdempotensi {
/** Service penagihan rentan: setiap panggilan menagih ulang. */
static class PenagihanRentan {
private long totalTagihan = 0;
String tagih(String pesanan, long jumlah) {
totalTagihan += jumlah;
return "menagih " + pesanan + " sebesar Rp" + jumlah
+ ", total kini Rp" + totalTagihan;
}
}
/** Service penagihan idempotent: hasil pertama disimpan per kunci. */
static class PenagihanAman {
private final Map<String, String> hasilPerKunci = new LinkedHashMap<>();
private long totalTagihan = 0;
String tagih(String kunci, String pesanan, long jumlah) {
String tersimpan = hasilPerKunci.get(kunci);
if (tersimpan != null) {
return "DUPLIKAT -> " + tersimpan + " (tidak menagih ulang)";
}
totalTagihan += jumlah; // efek samping hanya terjadi sekali per kunci
String hasil = "menagih " + pesanan + " sebesar Rp" + jumlah
+ ", total kini Rp" + totalTagihan;
hasilPerKunci.put(kunci, hasil);
return hasil;
}
}
public static void main(String[] args) {
System.out.println("== TANPA kunci idempotensi: retry menagih dua kali ==");
PenagihanRentan rentan = new PenagihanRentan();
System.out.println(" percobaan 1 : " + rentan.tagih("pesanan-1001", 250_000));
System.out.println(" percobaan 2 : " + rentan.tagih("pesanan-1001", 250_000));
System.out.println();
System.out.println("== DENGAN kunci idempotensi: retry dikenali ==");
PenagihanAman aman = new PenagihanAman();
System.out.println(" percobaan 1 : "
+ aman.tagih("key-abc-1001", "pesanan-1001", 250_000));
System.out.println(" percobaan 2 : " // retry karena jawaban pertama hilang
+ aman.tagih("key-abc-1001", "pesanan-1001", 250_000));
System.out.println(" pesanan lain: "
+ aman.tagih("key-xyz-1002", "pesanan-1002", 175_000));
System.out.println();
System.out.println("== Idempotensi alami pada operasi set ==");
long saldo = 0;
for (int i = 1; i <= 3; i++) {
saldo = 500_000; // setSaldo: hasil akhir tidak bergantung jumlah pemanggilan
System.out.println(" setSaldo(500000) pemanggilan ke-" + i
+ " -> saldo = Rp" + saldo);
}
}
}Output yang Dihasilkan:
== TANPA kunci idempotensi: retry menagih dua kali ==
percobaan 1 : menagih pesanan-1001 sebesar Rp250000, total kini Rp250000
percobaan 2 : menagih pesanan-1001 sebesar Rp250000, total kini Rp500000
== DENGAN kunci idempotensi: retry dikenali ==
percobaan 1 : menagih pesanan-1001 sebesar Rp250000, total kini Rp250000
percobaan 2 : DUPLIKAT -> menagih pesanan-1001 sebesar Rp250000, total kini Rp250000 (tidak menagih ulang)
pesanan lain: menagih pesanan-1002 sebesar Rp175000, total kini Rp425000
== Idempotensi alami pada operasi set ==
setSaldo(500000) pemanggilan ke-1 -> saldo = Rp500000
setSaldo(500000) pemanggilan ke-2 -> saldo = Rp500000
setSaldo(500000) pemanggilan ke-3 -> saldo = Rp500000Mari kita bedah tiga blok utamanya:
PenagihanRentan: versi tanpa deduplikasi. Percobaan kedua — retry yang sah karena jawaban pertama hilang — menambahtotalTagihansekali lagi: Rp250.000 berlipat menjadi Rp500.000. Tidak ada yang salah dengan kodenya; yang salah adalah kontraknya.PenagihanAman.tagih(...): pemeriksaan pertama adalahhasilPerKunci.get(kunci). Bila kunci sudah tercatat, method mengembalikan hasil tersimpan dengan penandaDUPLIKAT— tanpa menyentuhtotalTagihan. Bila belum, efek samping dijalankan lalu hasilnya disimpan keLinkedHashMapsebelum dikembalikan.- Percobaan 3 (
key-xyz-1002): kunci berbeda adalah operasi berbeda — tagihan baru yang sah. Idempotensi mengunci operasi, bukan pesanan; dua tagihan yang memang dimaksudkan berbeda tetap dua tagihan. - Blok
setSaldo: idempotensi alami dari operasi set. Pemanggilan ketiga menghasilkan keadaan yang sama seperti pemanggilan pertama — inilah sebabnyaPUTdipandang idempotent oleh RFC 9110, dan sebabnya desain “tetapkan nilai” lebih ramah retry daripada “tambahkan delta”.
Kontrak Lengkap yang Perlu Dijaga #
Peta penyimpanan hasil di PenagihanAman sengaja dibuat sederhana; implementasi produksi menambah tiga ketentuan:
- Penyimpanan bersama efek samping. Pencatatan “kunci → hasil” harus terjadi dalam satu transaksi dengan aksi tagihnya — bila keduanya terpisah, kegagalan di tengah meninggalkan tagihan tanpa catatan, dan retry berikutnya menagih ulang. Pola transactional outbox pada chapter Event-Driven Architecture menyelesaikan masalah sepupu ini.
- Jendela waktu (TTL). Kunci yang disimpan selamanya memboroskan penyimpanan. Sistem pembayaran umumnya menyimpan kunci beberapa jam sampai beberapa hari — cukup melampaui seluruh jendela retry klien.
- Jaminan serialisasi. Dua percobaan yang tiba nyaris bersamaan tidak boleh lolos pemeriksaan keduanya sebelum salah satunya menulis. Di database relasional, ini konsekuensi alami dari constraint unik pada kolom kunci; di memori bersama, kunci
synchronizedatauConcurrentHashMap.computeIfAbsent(...)memainkan peran itu.
Dengan retry yang aman, kita kini punya kotak peralatan untuk masalah yang lebih besar: transaksi yang membentang lintas service, tempat commit dan rollback database lokal tidak lagi berlaku. Chapter berikutnya membahas mengapa ACID lintas service sulit, dan apa pilihan penggantinya.
Referensi #
- IETF RFC 9110: HTTP Semantics — Idempotent Methods
- microservices.io: Idempotent Consumer
- Oracle: ConcurrentHashMap (JDK 25)
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
KunciIdempotensi.java
Unduh berkasimport java.util.LinkedHashMap;
import java.util.Map;
/** Idempotency: kunci deduplikasi membuat retry aman untuk diulang. */
public class KunciIdempotensi {
/** Service penagihan rentan: setiap panggilan menagih ulang. */
static class PenagihanRentan {
private long totalTagihan = 0;
String tagih(String pesanan, long jumlah) {
totalTagihan += jumlah;
return "menagih " + pesanan + " sebesar Rp" + jumlah
+ ", total kini Rp" + totalTagihan;
}
}
/** Service penagihan idempotent: hasil pertama disimpan per kunci. */
static class PenagihanAman {
private final Map<String, String> hasilPerKunci = new LinkedHashMap<>();
private long totalTagihan = 0;
String tagih(String kunci, String pesanan, long jumlah) {
String tersimpan = hasilPerKunci.get(kunci);
if (tersimpan != null) {
return "DUPLIKAT -> " + tersimpan + " (tidak menagih ulang)";
}
totalTagihan += jumlah; // efek samping hanya terjadi sekali per kunci
String hasil = "menagih " + pesanan + " sebesar Rp" + jumlah
+ ", total kini Rp" + totalTagihan;
hasilPerKunci.put(kunci, hasil);
return hasil;
}
}
public static void main(String[] args) {
System.out.println("== TANPA kunci idempotensi: retry menagih dua kali ==");
PenagihanRentan rentan = new PenagihanRentan();
System.out.println(" percobaan 1 : " + rentan.tagih("pesanan-1001", 250_000));
System.out.println(" percobaan 2 : " + rentan.tagih("pesanan-1001", 250_000));
System.out.println();
System.out.println("== DENGAN kunci idempotensi: retry dikenali ==");
PenagihanAman aman = new PenagihanAman();
System.out.println(" percobaan 1 : "
+ aman.tagih("key-abc-1001", "pesanan-1001", 250_000));
System.out.println(" percobaan 2 : " // retry karena jawaban pertama hilang
+ aman.tagih("key-abc-1001", "pesanan-1001", 250_000));
System.out.println(" pesanan lain: "
+ aman.tagih("key-xyz-1002", "pesanan-1002", 175_000));
System.out.println();
System.out.println("== Idempotensi alami pada operasi set ==");
long saldo = 0;
for (int i = 1; i <= 3; i++) {
saldo = 500_000; // setSaldo: hasil akhir tidak bergantung jumlah pemanggilan
System.out.println(" setSaldo(500000) pemanggilan ke-" + i
+ " -> saldo = Rp" + saldo);
}
}
}