Pekerjaan latar & antrean
Goroutine cukup untuk pekerjaan yang boleh hilang. Untuk email, invoice, dan pemrosesan pembayaran, kamu butuh antrean yang bertahan melewati restart — dan job yang aman kalau dijalankan dua kali.
Intisari
go func()untuk pekerjaan latar berarti pekerjaan hilang saat container dimatikan. ECS melakukannya setiap deploy.- Pilihan antrean: SQS (terkelola, cocok untuk AWS), Postgres (satu sistem lebih sedikit), atau Redis (asynq, dasbor bawaan).
- Setiap antrean menjamin at-least-once, jadi setiap job wajib idempoten.
- Pola outbox: tulis niat kirim ke database di transaksi yang sama, kirim dari worker terpisah.
- Worker adalah binari terpisah (
cmd/worker), di-deploy sebagai service ECS sendiri — supaya bisa diskalakan terpisah dari web.
Kenapa bukan goroutine saja
// ❌ Terlihat cukup, sampai deploy pertama
func (h *Handler) Daftar(w http.ResponseWriter, r *http.Request) {
u, _ := h.svc.Buat(r.Context(), req)
go h.email.KirimSelamatDatang(u) // hilang kalau container mati sekarang
w.WriteHeader(http.StatusCreated)
}
| Yang hilang dengan goroutine telanjang |
|---|
| Ketahanan — SIGTERM saat deploy membunuh pekerjaan yang belum selesai |
| Retry — kegagalan sementara berarti gagal permanen |
| Kejelasan — tidak ada yang tahu ada berapa yang gagal |
| Batas — seribu permintaan = seribu panggilan SMTP bersamaan |
| Penjadwalan — "kirim pengingat besok" tidak mungkin |
Memilih antrean
| SQS | Postgres | Redis (asynq) | |
|---|---|---|---|
| Sistem tambahan | Terkelola AWS | Tidak ada | ElastiCache |
| Atomik dengan transaksi bisnis | Tidak — butuh outbox | Ya | Tidak |
| Dasbor bawaan | Konsol AWS | Tulis sendiri | Ya |
| Job terjadwal / berulang | EventBridge | Kolom jalankan_pada | Bawaan |
| Skala sangat besar | Ya | Terbatas | Baik |
| Antrean surat mati (DLQ) | Bawaan | Tulis sendiri | Bawaan |
Rekomendasi: mulai dari Postgres kalau volumenya di bawah beberapa ribu job per menit — nol infrastruktur baru, dan satu-satunya pilihan yang bisa menaruh job di transaksi yang sama dengan perubahan datanya. Pindah ke SQS saat volumenya besar atau saat kamu butuh DLQ dan retry terkelola tanpa menulisnya sendiri (Fase 10).
Antrean di Postgres
CREATE TABLE pekerjaan (
id bigserial PRIMARY KEY,
jenis text NOT NULL,
muatan jsonb NOT NULL,
status text NOT NULL DEFAULT 'menunggu',
percobaan int NOT NULL DEFAULT 0,
maks_percobaan int NOT NULL DEFAULT 5,
jalankan_pada timestamptz NOT NULL DEFAULT now(),
kunci_sampai timestamptz,
error_terakhir text,
dibuat timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_pekerjaan_siap ON pekerjaan (jalankan_pada)
WHERE status = 'menunggu';
-- Mengambil job tanpa dua worker mengambil yang sama:
-- SKIP LOCKED membuat worker lain melewati baris yang sudah terkunci.
UPDATE pekerjaan
SET status = 'berjalan',
kunci_sampai = now() + interval '5 minutes',
percobaan = percobaan + 1
WHERE id = (
SELECT id FROM pekerjaan
WHERE status = 'menunggu' AND jalankan_pada <= now()
ORDER BY jalankan_pada
FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING id, jenis, muatan;
FOR UPDATE SKIP LOCKED adalah inti dari antrean berbasis SQL. Tanpa itu, sepuluh
worker akan saling menunggu di baris yang sama dan throughput-mu jadi sama dengan satu worker.
Pola outbox
// Satu transaksi: pesanan tersimpan DAN niat kirim tercatat.
// Keduanya terjadi, atau tidak sama sekali.
err := DalamTx(ctx, db, func(tx *sql.Tx) error {
if err := simpanPesanan(ctx, tx, p); err != nil {
return err
}
return antreanTulis(ctx, tx, "email.konfirmasi", map[string]any{
"pesanan_id": p.ID,
"email": p.Email,
})
})
Ini menyelesaikan masalah yang sering tidak disadari. Kalau kamu mengirim ke SQS di dalam transaksi lalu transaksinya gagal, pesan sudah terlanjur terkirim — pelanggan menerima email konfirmasi untuk pesanan yang tidak ada. Kalau kamu mengirim setelah commit, container bisa mati di antara keduanya — pesanan ada, email tidak pernah terkirim. Outbox menghilangkan kedua celah: satu transaksi database, lalu worker terpisah yang membaca outbox dan meneruskannya ke SQS.
Worker sebagai binari terpisah
// cmd/worker/main.go
func jalankan(ctx context.Context) error {
cfg, _ := config.Muat()
pool, _ := platform.BukaPool(ctx, cfg.DSN)
defer pool.Close()
w := pekerjaan.NewWorker(pool, log,
pekerjaan.DenganKonkurensi(10),
pekerjaan.DenganJeda(time.Second))
w.Daftar("email.konfirmasi", kirimKonfirmasi)
w.Daftar("invoice.buat", buatInvoice)
w.Daftar("gambar.ubah_ukuran", ubahUkuran)
return w.Jalankan(ctx) // berhenti rapi saat ctx dibatalkan
}
func (w *Worker) prosesSatu(ctx context.Context, j Pekerjaan) {
// Setiap job punya pemulih panic sendiri (Fase 1) — kalau tidak,
// satu job rusak mematikan seluruh worker.
defer func() {
if r := recover(); r != nil {
w.log.ErrorContext(ctx, "panic di job", "jenis", j.Jenis,
"id", j.ID, "panic", r, "stack", string(debug.Stack()))
w.gagalkan(ctx, j, fmt.Errorf("panic: %v", r))
}
}()
// Anggaran waktu per job, lebih pendek dari kunci_sampai.
ctx, batal := context.WithTimeout(ctx, 4*time.Minute)
defer batal()
if err := w.penangan[j.Jenis](ctx, j.Muatan); err != nil {
w.gagalkan(ctx, j, err) // jadwalkan ulang dengan backoff
return
}
w.selesaikan(ctx, j)
}
Idempotensi bukan opsional
// Job AKAN berjalan dua kali. Worker bisa mati setelah pekerjaan selesai
// tapi sebelum sempat menandainya selesai.
func kirimKonfirmasi(ctx context.Context, m Muatan) error {
// Kunci idempotensi: aman diulang, tidak menghasilkan email kedua.
kunci := fmt.Sprintf("email:konfirmasi:%d", m.PesananID)
baru, err := penanda.Klaim(ctx, kunci, 24*time.Hour)
if err != nil {
return err
}
if !baru {
return nil // sudah pernah dikirim — sukses, tanpa efek
}
return email.Kirim(ctx, m.Email, "Konfirmasi pesanan", isi)
}
| Operasi | Cara membuatnya idempoten |
|---|---|
| Kirim email | Tabel penanda "sudah dikirim", atau idempotency key penyedia |
| Tagih pembayaran | Idempotency key yang diteruskan ke penyedia |
| Buat baris | INSERT ... ON CONFLICT DO NOTHING |
| Tambah angka | Tulis nilai akhir, bukan += 1 |
| Unggah berkas | Nama objek deterministik — tulis ulang tidak berbahaya |
Yang harus dipantau
- Kedalaman antrean — menumpuk berarti worker kurang, atau ada job yang macet.
- Umur job tertua — metrik yang lebih jujur daripada jumlah; sepuluh job berumur sejam lebih buruk daripada seribu job berumur sedetik.
- Laju kegagalan per jenis — satu jenis yang selalu gagal biasanya bug, bukan gangguan.
- Isi DLQ — harus nol, dan setiap isinya butuh orang yang melihatnya.
- Durasi pemrosesan p95 — kalau mendekati
kunci_sampai, job akan diproses dobel.
Latihan: bangun antrean Postgres sederhana dengan FOR UPDATE SKIP LOCKED, satu
jenis job, dan worker terpisah. Jalankan dua worker sekaligus dan buktikan tidak ada job yang diproses
dua kali. Lalu matikan satu worker di tengah pemrosesan dan buktikan job-nya diambil worker lain setelah
kunci_sampai lewat — dan bahwa penangannya idempoten.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.