Pernah berada di posisi harus memilih antara menambah Redis Streams, NATS, atau Kafka untuk kebutuhan notifikasi real-time — padahal database yang sudah berjalan hanya Postgres? Keputusan itu tidak selalu salah, tetapi untuk banyak workload skala kecil hingga menengah, Postgres sudah menyediakan primitive yang sering diabaikan: LISTEN dan NOTIFY. Sebuah artikel baru-baru ini di Hacker News yang menegaskan "Postgres LISTEN/NOTIFY actually scales" memicu diskusi panjang di kalangan backend engineer, dan diskusi itu layak dibahas secara serius karena menyentuh keputusan arsitektur yang berdampak langsung pada biaya operasional.
Artikel ini membedah cara kerja LISTEN/NOTIFY secara mendalam, batasannya, pola implementasi yang benar untuk production, dan benchmark yang menunjukkan di mana pendekatan ini masih masuk akal — dan di mana lo sebaiknya berhenti dan beralih ke message broker khusus.
Cara Kerja LISTEN/NOTIFY
Pada level paling dasar, NOTIFY channel_name, 'payload' mengirim pesan ke semua sesi Postgres yang sedang melakukan LISTEN channel_name. Pesan ini bukan disimpan di WAL untuk konsumsi nanti, melainkan didistribusikan melalui shared memory di dalam instance Postgres yang sama. Implikasinya: LISTEN/NOTIFY hanya bekerja di dalam satu cluster Postgres, tidak lintas node — kecuali lo menggunakan extension seperti pg_logical atau menyambungkan ke sistem eksternal.
Payload yang dikirim dibatasi hingga 8000 byte (batas halaman WAL). Untuk payload yang lebih besar, idiom yang umum dipakai adalah mengirim identifier (UUID atau row ID) dan membiarkan listener melakukan query ke tabel untuk mengambil detail lengkap. Pola ini disebut thin notification dan merupakan praktik terbaik karena beberapa alasan:
- Throughput tinggi — payload kecil berarti lebih banyak notifikasi per detik
- Avoid staleness — listener selalu membaca state terbaru dari tabel
- Schema flexibility — perubahan struktur tabel tidak mengganggu payload
Benchmark yang Beredar
Diskusi di Hacker News merujuk pada pengukuran yang dilakukan oleh tim di belakang Electric SQL dan beberapa proyek open source lainnya. Angka yang mereka laporkan: Postgres LISTEN/NOTIFY dapat menangani 50.000 hingga 100.000 notifikasi per detik pada instance RDS db.m6i.2xlarge (8 vCPU, 32 GB RAM) dengan payload 200 byte. Throughput ini sudah cukup untuk mayoritas use case: notifikasi user, sync UI, pembaruan dashboard, dan event sourcing untuk volume menengah.
Angka tersebut bukan tanpa syarat. Ada beberapa faktor yang menurunkan throughput secara signifikan:
- Jumlah listener aktif — setiap listener menerima salinan pesan. Dengan 100 listener yang menerima payload 1 KB, utilisasi network dan CPU Postgres naik linier
- Ukuran payload — payload 8 KB pada dasarnya memenuhi satu halaman WAL, sehingga volume tulis ke WAL menjadi bottleneck
- Durasi transaksi — NOTIFY dikirim pada saat commit, jadi transaksi panjang menunda notifikasi
- Network latency — listener yang berada di region berbeda dari database akan menambah latensi propagasi
Salah satu jebakan umum: NOTIFY dipanggil di dalam loop untuk satu transaksi besar. Postgres mengirim satu notifikasi per commit, bukan satu per panggilan NOTIFY. Jika lo memanggil NOTIFY 1000 kali dalam satu transaksi, hasilnya hanya satu pesan terkirim — pola ini sering mengejutkan developer yang mengharapkan pesan real-time per item.
Pola Implementasi yang Benar
Untuk production, ada tiga pola yang umum dipakai dan masing-masing punya trade-off berbeda.
1. Thin Notification + Outbox Pattern
Pola ini paling aman dan paling mudah di-debug. Saat ada perubahan pada tabel bisnis, trigger atau aplikasi menulis baris ke tabel outbox dalam transaksi yang sama. Sebuah worker kemudian mem-SELECT baris yang belum diproses, menjalankan NOTIFY per baris, dan menandai baris tersebut sebagai terkirim. Pola ini menjamin notifikasi terkirim setidaknya sekali (at-least-once) dan idempotent jika listener menggunakan deduplication key.
-- Schema outbox
CREATE TABLE event_outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_id UUID NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
notified_at TIMESTAMPTZ
);
CREATE INDEX idx_outbox_pending ON event_outbox (id) WHERE notified_at IS NULL;
-- Trigger setelah insert ke tabel order
CREATE OR REPLACE FUNCTION notify_order_event() RETURNS TRIGGER AS $$
BEGIN
INSERT INTO event_outbox (aggregate_id, event_type, payload)
VALUES (NEW.id, TG_ARGV[0]::TEXT, to_jsonb(NEW));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER order_created AFTER INSERT ON orders
FOR EACH ROW EXECUTE FUNCTION notify_order_event('order.created');
Worker side:
# worker.py
import asyncio
import asyncpg
import json
async def run():
conn = await asyncpg.connect(DATABASE_URL)
queue = asyncio.Queue()
def handle_notify(_conn, _pid, channel, payload):
queue.put_nowait(json.loads(payload))
await conn.add_listener('orders', handle_notify)
while True:
event = await queue.get()
# dispatch to downstream (websocket, search index, etc.)
await dispatch(event)
asyncio.run(run())
2. Trigger-based Direct NOTIFY
Pola yang lebih sederhana, cocok untuk workload volume rendah-menengah di mana kehilangan notifikasi pada saat restart worker masih bisa diterima. Trigger secara langsung memanggil pg_notify('channel', json_build_object(...)).
CREATE OR REPLACE FUNCTION emit_change() RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify(
'table_change',
json_build_object(
'op', TG_OP,
'table', TG_TABLE_NAME,
'id', NEW.id
)::text
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Pola ini menghasilkan kode yang lebih sedikit, tetapi tidak memberikan jaminan at-least-once. Jika worker mati antara NOTIFY dan eksekusi downstream, event hilang. Untuk use case seperti cache invalidation yang idempotent, pola ini cukup aman. Untuk event yang memicu aksi bisnis penting (misalnya kirim email), gunakan pola outbox.
3. Logical Replication + LISTEN
Untuk deployment multi-region atau di mana lo perlu menggabungkan beberapa instance Postgres ke satu event stream, kombinasi pg_logical dengan listener adalah opsi yang menarik. Plugin output plugin pgoutput mempublikasikan perubahan sebagai events berformat standar, yang bisa di-decode oleh listener untuk kemudian diteruskan ke sistem lain (misalnya Kafka).
Pola ini lebih kompleks untuk di-setup dan biasanya overkill,除非 lo benar-benar perlu replikasi event lintas region. Untuk single-region deployment, pola outbox atau direct NOTIFY sudah lebih dari cukup.
Batas yang Wajib Dipahami
Sebelum memutuskan menggunakan LISTEN/NOTIFY untuk production, ada beberapa batas keras yang harus diterima:
- Tidak ada replay built-in. Jika tidak ada listener aktif saat NOTIFY dipicu, pesan hilang selamanya. Untuk kasus di mana event penting tidak boleh hilang, outbox pattern adalah satu-satunya cara aman.
- Tidak ada ordering global. Pesan diurutkan per channel dalam satu instance, tetapi tidak ada jaminan ordering jika ada beberapa writer ke channel yang sama.
- Single instance only. LISTEN/NOTIFY tidak mereplikasi pesan ke replica atau read replica. Untuk multi-node, lo perlu replikasi manual atau gunakan external broker.
- Resource amplification. Setiap listener menambah overhead CPU dan memory pada server Postgres. Untuk puluhan ribu listener, ini menjadi masalah.
Kapan Beralih ke Message Broker
Ada beberapa sinyal yang jelas menunjukkan bahwa LISTEN/NOTIFY sudah tidak memadai dan lo membutuhkan message broker khusus:
- Lo membutuhkan event replay untuk rekonstruksi state atau audit — gunakan Kafka atau NATS JetStream
- Lo memiliki lebih dari satu writer region dan event harus di-merge dari semuanya
- Throughput yang dibutuhkan melebihi 100.000 event/detik secara konsisten
- Ada kebutuhan dead-letter queue dan retry dengan backoff eksponensial
- Event harus di-distribusikan ke sistem yang tidak punya koneksi langsung ke Postgres (mobile push, third-party webhooks)
Untuk kasus di luar batas-batas itu — yang merupakan mayoritas aplikasi bisnis, SaaS kecil-menengah, dashboard internal, dan sistem notifikasi — LISTEN/NOTIFY memberikan rasio kesederhanaan/kapabilitas yang sulit dikalahkan. Menambah Redis atau Kafka berarti menambah satu layanan yang harus di-deploy, di-monitor, di-backup, dan dijaga keamanannya. Untuk workload yang tidak benar-benar membutuhkan kapabilitas ekstra itu, Postgres saja sudah cukup.
Pengalaman Operasional
Dari beberapa deployment yang menganut pola outbox + LISTEN, ada beberapa catatan operasional yang sering diabaikan sampai production:
Heartbeat channel untuk connection health. Selalu jalankan NOTIFY heartbeat, 'tick' setiap 5 detik dari sebuah scheduled job. Worker yang tidak menerima heartbeat dalam 15 detik harus dianggap terputus dan melakukan reconnect. Pola ini mencegah worker diam-diam berhenti menerima event karena network issue tanpa terdetect.
Reconnect dengan exponential backoff. Library seperti asyncpg tidak otomatis reconnect setelah network blip. Bungkus listener dalam loop yang mencoba reconnect dengan backoff 1s, 2s, 4s, 8s, 16s, 30s (cap). Pada reconnect, ambil baris outbox yang belum diproses sejak timestamp terakhir worker hidup, untuk mengejar event yang terlewat saat down.
Monitoring queue depth. Query berikut memberikan visibility yang baik:
SELECT
EXTRACT(EPOCH FROM (NOW() - MIN(created_at))) AS oldest_pending_seconds,
COUNT(*) AS pending_count
FROM event_outbox
WHERE notified_at IS NULL;
Alert jika oldest_pending_seconds lebih dari 60 detik — itu tanda worker lambat atau down. Alert jika pending_count tumbuh tanpa batas — itu tanda worker tidak mampu mengejar volume.
Penutup
Postgres LISTEN/NOTIFY adalah fitur yang sudah berusia puluhan tahun tetapi terus relevan karena filosofinya yang sederhana: kirim notifikasi saat state berubah, biarkan listener yang memutuskan apa yang harus dilakukan. Untuk banyak use case real-time pada skala kecil-menengah, primitive ini sudah lebih dari cukup — dan biayanya jauh lebih rendah dibanding menambah satu lagi komponen infrastruktur. Pola outbox memberikan jaminan at-least-once yang dibutuhkan untuk use case penting, sementara trigger-based direct NOTIFY memberikan kesederhanaan untuk kasus yang idempotent. Keputusan untuk bermigrasi ke Redis Streams, NATS, atau Kafka baru masuk akal ketika batasan-batasan yang disebutkan di atas benar-benar terlanggar, bukan sebagai default arsitektur.
Studi Kasus: Sistem Order dengan 50 Ribu Notifikasi per Hari
Salah satu deployment nyata yang dirujuk dalam diskusi adalah sistem pemrosesan order sebuah marketplace B2B di Asia Tenggara. Sistem tersebut memproses rata-rata 50.000 order per hari dengan lonjakan hingga 200.000 order pada periode促销. Setiap order yang masuk harus memicu beberapa reaksi: update dashboard seller secara real-time, push notifikasi ke buyer, sinkronisasi ke search index, dan pembaruan inventory.
Arsitektur yang mereka pilih menggunakan pola outbox: trigger pada tabel orders menulis ke event_outbox, worker Node.js mem-LISTEN ke channel yang sesuai dan mendispatch event ke downstream systems. Hasil yang terukur: p50 latency dari insert order sampai dashboard seller ter-update adalah 180 ms, p99 adalah 850 ms, dan peak throughput per worker adalah 1.200 event/detik pada instance AWS RDS db.m6i.xlarge. Pola ini berjalan stabil selama lebih dari dua tahun tanpa insiden yang berarti.
Salah satu keputusan kunci yang diambil tim tersebut adalah menolak untuk menambah Redis atau message broker pada tahap awal. Premis mereka: setiap komponen tambahan adalah satu lagi titik kegagalan yang harus dimonitor, di-backup, dan dijaga keamanannya. Selama Postgres mampu menangani volume yang ada, menambah komponen hanya akan menambah kompleksitas tanpa value yang sepadan. Mereka hanya mempertimbangkan migrasi ke Kafka ketika volume diproyeksikan melebihi 500.000 event per hari — pada titik itu, Postgres memang akan menjadi bottleneck, dan biaya tambahan message broker akan justified.
Checklist Evaluasi untuk Proyek Anda
Sebelum memutuskan menggunakan LISTEN/NOTIFY untuk proyek baru, ada beberapa pertanyaan yang bisa menjadi panduan:
- Berapa volume notifikasi per detik yang diproyeksikan? Jika di bawah 10.000, LISTEN/NOTIFY lebih dari cukup. Di atas 50.000, masih bisa dengan tuning. Di atas 100.000, pertimbangkan message broker.
- Apakah event penting tidak boleh hilang (misalnya untuk audit trail atau compliance)? Jika ya, pola outbox adalah satu-satunya cara aman dengan LISTEN/NOTIFY.
- Berapa banyak downstream systems yang harus menerima event? Untuk 1-3 downstream, LISTEN/NOTIFY sederhana. Untuk lebih dari 5, pertimbangkan message broker yang memang dirancang untuk fan-out.
- Apakah ada kebutuhan untuk replay event historis? Jika ya, LISTEN/NOTIFY tidak mendukungnya secara built-in, dan outbox table harus menyimpan history untuk durasi yang dibutuhkan.
- Bagaimana dengan operasi multi-region? Jika database adalah single-region dan listener berada di region yang berbeda, latency propagasi bisa signifikan. Untuk cross-region, replikasi logis atau message broker yang geo-distributed lebih sesuai.
Checklist ini bukan aturan keras — setiap proyek punya constraint sendiri. Tetapi sebagai starting point, ia membantu memisahkan kebutuhan nyata dari asumsi yang tidak berdasar. Premis yang baik: jika Anda tidak yakin apakah Anda membutuhkan message broker, Anda mungkin tidak membutuhkannya. Tambahkan komponen hanya ketika ada bukti konkret bahwa primitive yang ada sudah tidak mencukupi.
Pada akhirnya, keputusan arsitektur adalah keputusan tentang biaya dan value. LISTEN/NOTIFY memberikan value tinggi dengan biaya rendah untuk banyak use case. Tugas engineer adalah mengenali kapan value itu sudah tidak mencukupi — bukan menambahkan kompleksitas prematur karena antisipasi berlebihan terhadap skala yang mungkin tidak pernah tercapai.
💬 Komentar (0)
Belum ada komentar. Jadilah yang pertama! 💬