Queue database menyelesaikan masalah dengan perkakas yang sudah kita miliki: satu tabel, satu worker, tanpa server baru. Namun begitu volume naik dan jumlah layanan bertambah, muncul kebutuhan yang tidak bisa dipenuhi sebuah tabel: mengirim satu pesan ke banyak konsumen sekaligus (pola pub/sub), mendorong pesan ke worker tanpa polling, dan melaju di ratusan ribu pesan per detik. Kebutuhan tersebut diisi oleh message broker — server khusus yang tugas satu-satunya adalah menerima, merutekan, dan menyerahkan pesan dari producer ke consumer. Pada chapter ini kita pelajari konsep inti broker melalui RabbitMQ (exchange, queue, binding), membandingkan Redis dan Kafka sebagai alternatif, lalu menutup dengan panduan memilih antara queue database dan broker untuk aplikasi PHP Anda.
Kapan Queue Database Tidak Lagi Cukup #
Perbedaan mendasar kedua pendekatan terletak pada siapa yang bekerja. Pada queue database, worker aktif menarik (polling) baris dari tabel secara berkala; pada broker, server broker yang aktif mendorong pesan kepada consumer yang terdaftar. Perbedaan kedua ada pada pola distribusi: tabel hanya mengizinkan satu worker mengklaim satu baris (point-to-point), sedangkan broker mampu menyalin satu pesan ke banyak queue sekaligus (fan-out). Perbandingan lengkapnya dapat dilihat pada tabel berikut:
| Aspek | Queue database | Message broker |
|---|---|---|
| Throughput | Ribuan job per detik dengan polling | Ratusan ribu hingga jutaan pesan per detik |
| Pola distribusi | Point-to-point, satu job satu worker | Pub/sub, satu pesan bisa sampai ke banyak queue |
| Cara kerja | Worker polling berkala | Broker mendorong pesan ke consumer |
| Fitur routing | Hanya ORDER BY dan WHERE | Exchange, binding key, prioritas, dead-letter bawaan |
| Kemampuan query | SQL penuh (inspeksi mudah) | Perlu alat bantu khusus broker |
| Beban operasional | Rendah, tidak ada server baru | Tinggi, ada server baru yang harus diamankan dan diawasi |
| Cocok untuk | Monolit, volume menengah | Volume tinggi, layanan terpisah, arsitektur event-driven |
Tabel tersebut jangan dibaca sebagai vonis bahwa queue database itu buruk. Justru karena beban operasionalnya rendah, queue database hampir selalu menjadi titik awal yang tepat — dan sebagian besar aplikasi tidak pernah melewatinya. Broker menjadi keharusan ketika salah satu baris di kolom kiri mulai terasa: polling membebani database, pesan harus dikonsumsi beberapa layanan berbeda, atau volume pesan menembus batas nyaman tabel.
Amplop Pesan (Message Envelope) #
Broker tidak memahami domain bisnis kita; yang dipindahkannya hanyalah byte. Karena itu setiap pesan dibungkus dalam struktur standar yang disebut envelope — metadata perjalanan di sekeliling isi sesungguhnya. Envelope memisahkan lima hal: identitas pesan untuk pelacakan, routing_key untuk pengarahan, headers untuk metadata teknis, body untuk data domain, dan stempel waktu pembuatan:
<?php
$dibuatPada = new DateTimeImmutable("2026-01-15 08:00:00", new DateTimeZone("Asia/Jakarta"));
$envelope = [
"id" => "msg-7f3a2b",
"routing_key" => "order.created",
"headers" => [
"content_type" => "application/json",
"x-attempts" => 0,
],
"body" => [
"orderId" => 1042,
"pelanggan" => "[email protected]",
"total" => 750000,
],
"created_at" => $dibuatPada->format(DateTimeInterface::ATOM),
];
echo json_encode($envelope, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), "\n";Output yang Dihasilkan:
{
"id": "msg-7f3a2b",
"routing_key": "order.created",
"headers": {
"content_type": "application/json",
"x-attempts": 0
},
"body": {
"orderId": 1042,
"pelanggan": "[email protected]",
"total": 750000
},
"created_at": "2026-01-15T08:00:00+07:00"
}Mari kita bedah kelima bidangnya satu per satu:
idunik per pesan — dipakai untuk melacak perjalanan pesan di log banyak layanan dan sebagai kunci idempotency di sisi consumer (chapter Idempotent Jobs ).routing_key(order.created) adalah alamat logis pesan, bukan nama queue tujuan — bagaimana routing key diterjemahkan menjadi queue adalah urusan exchange, yang kita bahat segera setelah ini.headersmembawa metadata teknis yang boleh berubah selama perjalanan: di sini jumlah percobaan (x-attempts) yang bertambah setiap kali consumer me-retry pesan.bodyadalah payload domain murni — struktur yang sama dengan kolompayloadpada tabeljobsdari chapter Job Queues .created_atmemakai format ATOM (DateTimeInterface::ATOM) yang memuat zona waktu (+07:00), sehingga konsumen di zona waktu mana pun bisa mengurutkan pesan dengan benar.
RabbitMQ: Exchange, Queue, dan Binding #
RabbitMQ mengimplementasikan model AMQP yang memisahkan tiga peran: producer tidak pernah mengirim pesan langsung ke queue. Producer menerbitkan pesan ke exchange dengan sebuah routing key; exchange meneruskannya ke queue yang terikat (binding) padanya; consumer membaca dari queue. Ikatan antara exchange dan queue dinyatakan dengan binding key yang aturan pencocokannya bergantung pada tipe exchange:
Diagram tersebut memperlihatkan kekuatan pola pub/sub: satu pesan order.created diterbitkan sekali, namun sampai ke tiga queue sekaligus karena ketiganya cocok dengan binding yang berbeda. Layanan invoice hanya peduli pesanan baru, layanan email peduli semua event *.created, dan layanan analitik ingin segala hal tentang pesanan. Producer tidak perlu tahu ketiga layanan itu ada — penambahan consumer baru cukup dilakukan dengan membuat queue dan binding-nya, tanpa menyentuh kode producer. Empat tipe exchange bawaan RabbitMQ dirangkum berikut:
| Tipe exchange | Aturan pencocokan binding | Contoh pemakaian |
|---|---|---|
direct | Binding key harus sama persis dengan routing key | order.created ke queue invoice |
fanout | Menyalin ke semua queue terikat, routing key diabaikan | Broadcast sinyal flush cache |
topic | Routing key dicocokkan ke pola: * tepat satu kata, # nol atau lebih kata | order.# menangkap semua event pesanan |
headers | Mencocokkan header pesan, bukan routing key | Routing berdasarkan tipe konten |
Tipe topic adalah yang paling sering dipakai karena polanya ekspresif namun mudah dibaca. Aturan pencocokannya sendiri bisa kita simulasi dengan PHP murni — hasilnya identik dengan keputusan yang diambil broker di dunia nyata:
<?php
function cocokDenganBinding(array $kunci, array $pola): bool
{
while (true) {
if ($pola === []) {
return $kunci === [];
}
$bagian = array_shift($pola);
if ($bagian === "#") {
if ($pola === []) {
return true; // '#' di posisi akhir menyerap sisa kunci
}
for ($i = 0; $i <= count($kunci); $i++) {
if (cocokDenganBinding(array_slice($kunci, $i), $pola)) {
return true;
}
}
return false;
}
if ($kunci === []) {
return false;
}
$teks = array_shift($kunci);
if ($bagian !== "*" && $bagian !== $teks) {
return false;
}
}
}
function cocokRoutingKey(string $routingKey, string $bindingKey): bool
{
return cocokDenganBinding(explode(".", $routingKey), explode(".", $bindingKey));
}
$bindings = [
"invoice_service" => "order.created",
"email_service" => "*.created",
"analytics" => "order.#",
"audit" => "#",
];
$routingKeys = ["order.created", "order.paid", "user.created"];
foreach ($routingKeys as $routingKey) {
echo "routing key '{$routingKey}':\n";
foreach ($bindings as $service => $bindingKey) {
$status = cocokRoutingKey($routingKey, $bindingKey) ? "MENERIMA" : "tidak cocok";
echo " {$service} ({$bindingKey}) -> {$status}\n";
}
}Output yang Dihasilkan:
routing key 'order.created':
invoice_service (order.created) -> MENERIMA
email_service (*.created) -> MENERIMA
analytics (order.#) -> MENERIMA
audit (#) -> MENERIMA
routing key 'order.paid':
invoice_service (order.created) -> tidak cocok
email_service (*.created) -> tidak cocok
analytics (order.#) -> MENERIMA
audit (#) -> MENERIMA
routing key 'user.created':
invoice_service (order.created) -> tidak cocok
email_service (*.created) -> MENERIMA
analytics (order.#) -> tidak cocok
audit (#) -> MENERIMAMari kita bedah cara kerja simulasi tersebut:
cocokRoutingKey()memecah routing key dan binding key menjadi kata-kata yang dipisah titik, lalu mencocokkannya kata demi kata lewatcocokDenganBinding().*hanya menggantikan tepat satu kata — binding*.createdcocok denganorder.createddanuser.created, tetapi tidak denganorder.paidkarena kata keduanya berbeda.#menggantikan nol atau lebih kata — bindingorder.#cocok dengan semua routing key yang diawaliorder., dan binding#sendirian cocok dengan apa pun. Cabang rekursif pada loop mencoba setiap titik potong yang mungkin.- Ketiga routing key diuji terhadap empat binding, dan hasilnya persis seperti diagram topologi di atas: satu pesan bisa diterima beberapa queue sekaligus, satu queue bisa menolak pesanan yang tidak relevan.
Redis sebagai Broker #
Redis menawarkan dua jalur menjadi broker. Jalur pertama memanfaatkan struktur list: producer menjalankan LPUSH untuk menaruh pesan di ekor, consumer menjalankan BRPOP yang memblokir hingga pesan tersedia — blocking pop inilah yang menghilangkan polling. Jalur kedua memanfaatkan streams: log append-only dengan consumer groups, di mana tiap grup mencatat offset masing-masing, pesan yang dikonsumsi harus di-acknowledge, dan pesan tanpa ack tercatat di pending entries list — mekanisme yang fungsinya serupa visibility timeout pada tabel jobs kita. Berikut wujud producer dan consumer dengan ekstensi Redis:
<?php
// Membutuhkan server Redis yang berjalan (tidak tersedia di lingkungan belajar ini)
$redis = new Redis();
$redis->connect("127.0.0.1", 6379);
$envelope = json_encode(["type" => "kirim_email", "payload" => ["orderId" => 1042]]);
// producer: taruh pesan di list
$redis->lPush("email_queue", $envelope);
// consumer: blokir sampai 5 detik menunggu pesan tersedia
[$namaQueue, $pesanMentah] = $redis->brPop(["email_queue"], 5);
$envelopeDiterima = json_decode($pesanMentah, true);List sederhana namun miskin fitur: tidak ada ack, tidak ada pengambilalihan pesan milik consumer yang mati, sehingga pesan bisa hilang atau justru diproses dua kali. Streams hadir untuk menutup kekurangan tersebut dengan konsep consumer group dan pending list. Dokumentasi resmi Redis merinci kedua struktur data ini — tautannya tersedia di bagian Referensi.
Kafka Sekilas #
Kafka mengambil model yang sama sekali berbeda: bukan menghapus pesan setelah dikonsumsi, Kafka menyimpan setiap pesan dalam log append-only yang terpartisi. Consumer melacak posisi bacanya sendiri (offset), sehingga pesan yang sama bisa dibaca ulang kapan pun — kemampuan yang disebut replay. Ciri ini membuat Kafka cocok sebagai tulang punggung event streaming: sistem analitik bisa “memutar ulang” seluruh sejarah event, consumer baru bisa menyusul dari awal, dan event yang diproses dengan salah bisa dihitung ulang. Bayarannya adalah kompleksitas operasional yang paling berat di antara semua opsi — cluster Kafka menuntut pengelolaan broker, partisi, dan retensi yang cermat. Untuk aplikasi PHP biasa, Kafka relevan ketika perusahaan sudah menjalankan platform streaming dan aplikasi kita tinggal menempel padanya.
Pustaka PHP untuk Broker #
Bicara dengan broker dari PHP dilakukan lewat pustaka klien. Untuk RabbitMQ, pustaka standarnya adalah php-amqplib; untuk pola yang lebih tinggi lagi, Symfony Messenger menyembunyikan seluruh detail transport di balik message bus. Producer dengan php-amqplib terlihat seperti ini:
<?php
// composer require php-amqplib/php-amqplib
// Membutuhkan server RabbitMQ yang berjalan (tidak tersedia di lingkungan belajar ini)
require __DIR__ . "/vendor/autoload.php";
use PhpAmqpLib\Connection\AMQPStreamConnection;
use PhpAmqpLib\Message\AMQPMessage;
$connection = new AMQPStreamConnection("localhost", 5672, "guest", "guest");
$channel = $connection->channel();
// deklarasikan topologi: exchange topic, queue, dan binding
$channel->exchange_declare("order_events", "topic", false, true, false);
$channel->queue_declare("email_service", false, true, false, false);
$channel->queue_bind("email_service", "order_events", "*.created");
$envelope = json_encode(["orderId" => 1042, "pelanggan" => "[email protected]"]);
$channel->basic_publish(new AMQPMessage($envelope), "order_events", "order.created");
$channel->close();
$connection->close();Tiga pemanggilan deklarasi di awal (exchange_declare, queue_declare, queue_bind) adalah wujud langsung diagram topologi pada bagian RabbitMQ — keduanya bersifat idempoten, jadi aman dijalankan setiap kali aplikasi start. Symfony Messenger membungkus semuanya menjadi konfigurasi deklaratif:
# config/packages/messenger.yaml
framework:
messenger:
transports:
async_amqp:
dsn: "%env(MESSENGER_AMQP_DSN)%"
routing:
App\Message\KirimEmailMessage: async_amqpBagian routing memetakan message class ke transport: KirimEmailMessage berjalan asinkron lewat AMQP, sementara message class lain yang tidak terdaftar dijalankan sinkron di dalam request. Inilah keunggulan pola Messenger — berpindah dari transport doctrine (queue database) saat development ke AMQP di produksi dilakukan tanpa menyentuh kode domain.
Memilih antara Queue Database dan Broker #
Keputusan akhirnya pragmatis. Tabel berikut merangkum situasi umum beserta pilihan yang paling masuk akal:
| Situasi | Pilihan | Alasan |
|---|---|---|
| Kurang dari 1.000 job per menit, satu aplikasi | Queue database | Tidak ada infrastruktur baru, inspeksi lewat SQL |
| Perlu pub/sub ke banyak layanan | RabbitMQ (topic exchange) | Fan-out dikerjakan broker, producer tetap sederhana |
| Volume sangat tinggi dan butuh replay event | Kafka | Log terpersisten bisa dibaca ulang oleh consumer baru |
| Tim kecil tanpa personil operasional khusus | Queue database | Broker yang diawasi setengah hati lebih berbahaya daripada tabel |
Ringkasan #
Message broker mengambil alih tanggung jawab pengiriman pesan: exchange menerima pesan dari producer, binding menentukan queue mana yang berhak menerima, dan consumer membaca dari queue-nya masing-masing — satu pesan bisa sampai ke banyak layanan tanpa producer mengetahui satu pun dari mereka. RabbitMQ menawarkan model AMQP yang kaya routing, Redis menawarkan kesederhanaan list hingga kekuatan streams, dan Kafka menawarkan log yang bisa diputar ulang. Semua tetap dititipkan dalam envelope berisi id, routing key, headers, body, dan stempel waktu. Queue database tetap menjadi titik awal yang sah hingga volume benar-benar menuntut broker. Baik lewat tabel maupun broker, ada satu jenis pekerjaan yang belum tercakup: pekerjaan yang dipicu oleh jam, bukan oleh event — yang kita tangani pada chapter Scheduled Tasks.