Exactly-once #
Dalam diskusi arsitektur sistem berbasis antrean pesan (message queuing system), jaminan pengiriman Exactly-Once (Tepat Sekali) adalah jaminan yang paling diinginkan oleh para developer. Jaminan ini menjanjikan bahwa setiap pesan tidak akan pernah hilang, dan pada saat yang sama, tidak akan pernah diproses lebih dari satu kali oleh aplikasi konsumen.
Namun, di dunia nyata sistem terdistribusi, klaim mengenai jaminan exactly-once native sering kali menyesatkan. Secara teoritis dan praktis, menjamin pesan fisik mengalir di jaringan kabel tepat satu kali adalah hal yang mustahil jika kita dihadapkan pada kegagalan hardware dan jaringan. Meskipun demikian, kita dapat merancang arsitektur sistem kita sedemikian rupa agar efek bisnis dari pemrosesan pesan tersebut terjadi tepat satu kali (exactly-once processing/effect). Artikel ini akan membedah batasan teoretis sistem terdistribusi melalui Teorema Dua Jenderal, memilah perbedaan fundamental antara pengiriman fisik dan pemrosesan logis, merumuskan strategi pencapaian efek tepat sekali, serta menyajikan implementasi kode konsumen transaksional dalam bahasa Go.
Keterbatasan Teoretis Sistem Terdistribusi: Teorema Dua Jenderal #
Untuk memahami mengapa exactly-once delivery murni adalah kemustahilan fisik, kita harus mempelajari masalah klasik dalam ilmu komputer: Two Generals Problem (Masalah Dua Jenderal).
Studi Kasus Masalah Dua Jenderal #
Bayangkan dua jenderal tentara (Jenderal A dan Jenderal B) yang ingin menyerang satu kota musuh yang sama. Mereka hanya bisa mengalahkan musuh jika mereka menyerang secara bersamaan. Jika hanya salah satu jenderal yang menyerang, pasukan jenderal tersebut akan hancur. Satu-satunya media komunikasi di antara kedua jenderal adalah melalui kurir yang harus melewati wilayah musuh, yang berarti kurir tersebut berisiko tertangkap (komunikasi tidak andal).
flowchart LR
A["Jenderal A<br>'Menyerang besok jam 8!'"] -- "Kurir (Jaringan Tidak Andal)" --> B["Jenderal B"]- Jenderal A mengirim kurir ke Jenderal B dengan pesan: “Menyerang besok jam 8 pagi”.
- Kurir berhasil sampai. Namun, Jenderal B tahu bahwa Jenderal A tidak tahu apakah kurir tersebut berhasil sampai atau tertangkap. Jenderal B harus mengirim kurir konfirmasi (ACK) kembali ke Jenderal A.
- Jenderal B mengirim kurir balik: “Pesan diterima, saya setuju menyerang jam 8”.
- Kurir konfirmasi berhasil sampai ke Jenderal A. Namun, sekarang Jenderal A sadar bahwa Jenderal B tidak tahu apakah kurir konfirmasinya selamat atau ditangkap musuh. Jika Jenderal B berpikir konfirmasinya tidak sampai, Jenderal B tidak akan berani menyerang karena takut Jenderal A batal menyerang.
- Jenderal A harus mengirim kurir konfirmasi atas konfirmasi tersebut (ACK-of-ACK) ke Jenderal B.
- Proses ini berulang tanpa batas.
Dalam matematika sistem terdistribusi, telah terbukti bahwa tidak ada jumlah konfirmasi yang pasti yang dapat menjamin kedua belah pihak 100% sepakat mengenai status komunikasi melalui media yang tidak andal.
Dalam arsitektur RabbitMQ, broker bertindak sebagai Jenderal A dan konsumen bertindak sebagai Jenderal B. Ketika terjadi kegagalan jaringan sejenak tepat saat konsumen mengirimkan konfirmasi ACK ke broker, broker tidak dapat membedakan apakah konsumen crash sebelum memproses data, ataukah proses sukses tetapi ACK hilang di jalan. Demi keamanan data, broker harus memilih opsi teraman: mengirim ulang pesan tersebut (requeue), yang secara otomatis membatalkan jaminan exactly-once delivery.
Exactly-Once Delivery vs Exactly-Once Processing (Effect) #
Meskipun kita tidak bisa mencegah pesan dikirim lebih dari satu kali oleh jaringan (At-Least-Once), kita dapat menjamin bahwa aplikasi kita hanya mengeksekusi efek samping dari pesan tersebut tepat satu kali. Kita harus memisahkan dua konsep ini secara tegas:
1. Exactly-Once Delivery (Fisik) #
Broker menjamin pesan fisik hanya mengalir di kabel jaringan tepat satu kali dari soket broker ke soket konsumen. Ini adalah kemustahilan teoretis di jaringan yang tidak andal karena jika terjadi connection drop di tengah-tengah pengiriman paket TCP, broker wajib melakukan pengiriman ulang demi mencegah data hilang.
2. Exactly-Once Processing / Effect (Logis) #
Broker melakukan pengiriman ulang (duplikasi pesan di jaringan diizinkan terjadi), namun aplikasi konsumen kita memiliki mekanisme untuk mendeteksi duplikasi tersebut dan memastikan logika bisnis (seperti pemotongan saldo, pembaruan inventaris) hanya dieksekusi sekali. Ini adalah target realistis yang wajib kita implementasikan di tingkat kode aplikasi.
Strategi Arsitektur untuk Mencapai Exactly-Once Effect #
Untuk mewujudkan efek tepat sekali pada sistem kita, kita harus menerapkan pola desain transaksional yang ketat di sisi produsen dan konsumen:
1. Sisi Produsen: Transactional Outbox Pattern #
Untuk mencegah produsen menerbitkan pesan gantung (misal: pesanan disimpan di database, tetapi proses kirim pesan ke RabbitMQ gagal), kita menggunakan Transactional Outbox Pattern:
- Aplikasi produsen menulis data bisnis utama dan data pesan yang akan dikirim (di tabel
outbox) dalam satu transaksi database lokal yang sama secara atomik. - Proses latar belakang (background worker) secara berkala membaca tabel
outbox, mempublikasikan pesan ke RabbitMQ dengan Publisher Confirms aktif. - Setelah menerima konfirmasi sukses dari RabbitMQ, worker menandai status pesan di tabel outbox sebagai
sentatau menghapusnya.
2. Sisi Konsumen: Pola Komit Atomik & Manual ACK setelah Commit #
Di sisi penerima, kita harus mengikat pencatatan keunikan pesan dengan transaksi database utama secara atomik:
- Mulai Transaksi Database: Saat menerima pesan, buka transaksi database relasional baru (
BEGIN TRANSACTION). - Verifikasi & Tulis Deduplikasi: Lakukan query untuk memeriksa apakah ID unik pesan tersebut sudah ada di tabel log deduplikasi (misalnya tabel
processed_messages). Jika sudah ada, langsung lakukan rollback transaksi dan kirimkan ACK ke broker untuk membuang pesan duplikat tersebut. Jika belum ada, masukkan ID pesan tersebut ke tabel log deduplikasi. - Eksekusi Logika Bisnis: Jalankan pembaruan data bisnis (misalnya potong saldo rekening) dalam transaksi yang sama.
- Commit Transaksi: Selesaikan transaksi secara atomik (
COMMIT). Di titik ini, pencatatan keunikan pesan dan efek bisnis dikunci secara bersamaan. Jika database down di tengah jalan, seluruh operasi otomatis di-rollback tanpa ada efek samping parsial. - Kirimkan Sinyal ACK: Setelah commit sukses, panggil
d.Ack(false)untuk menghapus pesan dari RabbitMQ.
flowchart TD
A["Menerima Pesan (Delivery)"] --> B["Buka Transaksi Database"]
B --> C["Pencatatan ID Pesan & Logika Bisnis"]
C --> D{"Sukses Commit Database?"}
D -->|Tidak| E["Rollback DB & Requeue Pesan"]
D -->|Ya| F["Kirim Manual ACK ke Broker"]Jika aplikasi konsumen crash tepat setelah langkah 4 (Commit sukses) tetapi sebelum langkah 5 (ACK dikirim):
- RabbitMQ akan me-requeue pesan dan mengirimkannya kembali.
- Pada percobaan kedua, ketika konsumen mencoba melakukan langkah 2, upaya penulisan ID pesan ke tabel deduplikasi akan dibatalkan secara atomik karena bentrok dengan Unique Constraint kunci utama database yang sudah tertulis pada percobaan pertama.
- Konsumen mendeteksi bentrokan ini, langsung melompati logika bisnis, dan memanggil ACK untuk membersihkan antrean dengan aman tanpa ada pemrosesan ganda.
Implementasi Kode Go: Konsumen Transaksional Atomik #
Berikut adalah contoh implementasi konsumen transaksional atomik dalam bahasa Go menggunakan database relasional SQL (menggunakan driver standar) untuk mendemonstrasikan pola komit atomik guna mencapai efek exactly-once.
package main
import (
"context"
"database/sql"
"errors"
"log"
"os"
"os/signal"
"syscall"
"time"
_ "github.com/lib/pq" // Driver PostgreSQL
amqp "github.com/rabbitmq/amqp091-go"
)
const (
amqpURL = "amqp://guest:guest@localhost:5672/"
queueName = "transactional-orders"
dbDSN = "postgres://postgres:postgres@localhost:5432/shop?sslmode=disable"
)
type OrderMsg struct {
OrderID string `json:"order_id"`
UserID string `json:"user_id"`
Amount float64 `json:"amount"`
}
func main() {
// Inisialisasi Koneksi PostgreSQL
db, err := sql.Open("postgres", dbDSN)
if err != nil {
log.Fatalf("Gagal terhubung ke database: %s", err)
}
defer db.Close()
// Membuka Koneksi TCP ke RabbitMQ
conn, err := amqp.Dial(amqpURL)
if err != nil {
log.Fatalf("Gagal terhubung ke RabbitMQ: %s", err)
}
defer conn.Close()
ch, err := conn.Channel()
if err != nil {
log.Fatalf("Gagal membuka channel: %s", err)
}
defer ch.Close()
// Deklarasi Antrean Durable
_, err = ch.QueueDeclare(
queueName,
true, // durable
false,
false,
false,
nil,
)
if err != nil {
log.Fatalf("Gagal deklarasi antrean: %s", err)
}
// Membatasi prefetch QoS
err = ch.Qos(5, 0, false)
if err != nil {
log.Fatalf("Gagal menyetel QoS: %s", err)
}
msgs, err := ch.Consume(
queueName,
"",
false, // autoAck: false (manual ACK untuk kontrol transaksional)
false,
false,
false,
nil,
)
if err != nil {
log.Fatalf("Gagal mendaftarkan konsumen: %s", err)
}
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
for d := range msgs {
messageID := d.MessageId
if messageID == "" {
log.Printf("[ERROR] Pesan tidak memiliki MessageId. Mengabaikan pemrosesan demi keamanan data.")
d.Reject(false) // Buang pesan tanpa requeue
continue
}
// Memulai Proses Transaksi Database Atomik
processErr := executeTransactionalProcessing(ctx, db, messageID, d.Body)
if processErr != nil {
if errors.Is(processErr, errDuplicateMessage) {
// PESAN DUPLIKAT TERDETEKSI: Langsung kirim ACK untuk menghapusnya dari antrean
log.Printf("[INFO] Duplikasi terdeteksi untuk ID: %s. Melompati logika bisnis dan meng-ACK pesan.", messageID)
d.Ack(false)
} else {
// KESALAHAN INFRASTRUKTUR SEMENTARA: Lakukan requeue agar dicoba lagi
log.Printf("[ERROR] Kegagalan database: %s. Melakukan requeue pesan...", processErr)
d.Nack(false, true) // requeue = true
}
continue
}
// 5. Mengirimkan ACK hanya setelah COMMIT database 100% SUKSES
err = d.Ack(false)
if err != nil {
log.Printf("[ERROR] Transaksi DB sukses commit, namun gagal mengirim ACK: %s", err)
// Data di DB aman, RabbitMQ akan mengirim ulang pesan karena ACK tidak sampai.
// Namun pada pengiriman kedua, baris insert ke tabel processed_messages
// akan mendeteksi duplikasi key dan langsung meng-ACK pesan tanpa efek samping ganda.
} else {
log.Printf("✓ Transaksi selesai secara atomik untuk ID: %s", messageID)
}
}
}()
<-sigChan
log.Println("[*] Memulai graceful shutdown...")
cancel()
time.Sleep(1 * time.Second)
}
var errDuplicateMessage = errors.New("pesan duplikat terdeteksi")
// Fungsi pemroses transaksi dengan pola komit atomik
func executeTransactionalProcessing(ctx context.Context, db *sql.DB, messageID string, body []byte) error {
// 1. Memulai Transaksi SQL
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
// Pastikan kita selalu melakukan rollback jika terjadi kepanikan atau error sebelum commit
defer tx.Rollback()
// 2. Mencoba Mencatat ID Pesan ke Tabel Deduplikasi
// Tabel processed_messages harus memiliki primary key atau unique constraint pada kolom message_id
queryDeduplicate := `INSERT INTO processed_messages (message_id, processed_at) VALUES ($1, NOW())`
_, err = tx.ExecContext(ctx, queryDeduplicate, messageID)
if err != nil {
// Periksa apakah error disebabkan oleh bentrokan constraint unik (duplicate key)
if isUniqueConstraintViolation(err) {
return errDuplicateMessage
}
return err
}
// 3. Eksekusi Logika Bisnis Utama dalam Transaksi yang Sama
// Contoh: Mengurangi saldo pengguna
queryBalance := `UPDATE users SET balance = balance - 150000 WHERE id = $1 AND balance >= 150000`
res, err := tx.ExecContext(ctx, queryBalance, "user_id_dummy")
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil || rows == 0 {
return fmt.Errorf("gagal memotong saldo, dana tidak cukup atau user tidak ditemukan")
}
// 4. Melakukan Commit Transaksi secara Atomik
err = tx.Commit()
if err != nil {
return err
}
return nil
}
func isUniqueConstraintViolation(err error) bool {
// Di sistem produksi, parsing kode error PostgreSQL (misal code '23505' untuk unique_violation)
return true // Sederhanakan untuk contoh ilustrasi
}
Anti-Pattern vs Solusi Praktis di Lingkungan Produksi #
Hindari kesalahan asumsi fatal berikut saat berhadapan dengan konsep exactly-once:
Anti-Pattern: Menggunakan Redis Non-Transaksional Sebagai Deduplicator yang Terpisah dari Database Bisnis #
Menerima pesan, melakukan verifikasi dan penulisan kunci deduplikasi ke Redis Cache, lalu melakukan penulisan logika bisnis utama ke database PostgreSQL secara terpisah tanpa pengawasan transaksi atomik terpadu.
Mengapa ini salah? #
Redis dan PostgreSQL adalah dua sistem penyimpanan independen yang tidak saling mengetahui status transaksi masing-masing. Jika penulisan kunci ke Redis sukses, tetapi kemudian penulisan data transaksi bisnis ke database PostgreSQL mengalami kegagalan (misalnya karena bentrok tipe data atau PostgreSQL mendadak restart), database PostgreSQL akan di-rollback namun kunci deduplikasi di Redis tetap terlanjur tersimpan sebagai processed. Saat RabbitMQ mengirim ulang pesan karena mendeteksi kegagalan, upaya kedua akan langsung diblokir oleh Redis karena kunci sudah ada, sehingga data transaksi bisnis kita tidak akan pernah ditulis. Ini memicu inkonsistensi data yang parah.
- Solusi: Selalu simpan kunci log deduplikasi pesan di dalam mesin database yang sama dengan mesin database logika bisnis utama kita. Gunakan transaksi ACID database relasional untuk mengamankan penulisan log deduplikasi pesan dan data bisnis utama secara bersama-sama di dalam satu blok komit atomik tunggal.
Ringkasan #
- Exactly-Once Delivery Fisik Mustahil — Ketidakpastian transmisi jaringan (Teorema Dua Jenderal) mencegah jaminan bahwa pesan fisik hanya mengalir satu kali di kabel jaringan tanpa risiko data hilang.
- Exactly-Once Processing Target Nyata — Kita mengizinkan terjadinya duplikasi pengiriman fisik (At-Least-Once), namun kita merancang kode aplikasi agar efek samping bisnis hanya tereksekusi tepat satu kali.
- Pola Komit Atomik Konsumen — Buka transaksi database relasional baru, masukkan ID pesan unik ke tabel deduplikasi (dengan unique constraint), eksekusi data bisnis, lakukan commit, lalu panggil manual ACK.
- Dampak Rollback Otomatis — Jika proses di tengah transaksi database gagal, seluruh penulisan di-rollback dan pesan dikembalikan ke antrean tanpa efek samping parsial yang merusak data.
- Outbox Pattern di Produsen — Sisi penerbit juga harus menggunakan outbox pattern terintegrasi transaksi database lokal untuk menjamin pesan tidak dikirim gantung tanpa komit data utama.
- Bukan Fitur Konfigurasi — Jaminan exactly-once adalah hasil dari desain arsitektural menyeluruh di tingkat aplikasi, bukan fitur instan yang dapat aktif hanya lewat centang konfigurasi broker.
← Sebelumnya: At-least-once Berikutnya: Publisher Confirms →