← Semua pembelajaran / Go Nol → Enterprise
Fase 5 · Arsitektur Aplikasi

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.

Sumber asli pkg.go.dev Resmi Rangkuman ~8 menit baca

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

SQSPostgresRedis (asynq)
Sistem tambahanTerkelola AWSTidak adaElastiCache
Atomik dengan transaksi bisnisTidak — butuh outboxYaTidak
Dasbor bawaanKonsol AWSTulis sendiriYa
Job terjadwal / berulangEventBridgeKolom jalankan_padaBawaan
Skala sangat besarYaTerbatasBaik
Antrean surat mati (DLQ)BawaanTulis sendiriBawaan

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)
}
OperasiCara membuatnya idempoten
Kirim emailTabel penanda "sudah dikirim", atau idempotency key penyedia
Tagih pembayaranIdempotency key yang diteruskan ke penyedia
Buat barisINSERT ... ON CONFLICT DO NOTHING
Tambah angkaTulis nilai akhir, bukan += 1
Unggah berkasNama objek deterministik — tulis ulang tidak berbahaya

Yang harus dipantau

  1. Kedalaman antrean — menumpuk berarti worker kurang, atau ada job yang macet.
  2. Umur job tertua — metrik yang lebih jujur daripada jumlah; sepuluh job berumur sejam lebih buruk daripada seribu job berumur sedetik.
  3. Laju kegagalan per jenis — satu jenis yang selalu gagal biasanya bug, bukan gangguan.
  4. Isi DLQ — harus nol, dan setiap isinya butuh orang yang melihatnya.
  5. 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.