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:

AspekQueue databaseMessage broker
ThroughputRibuan job per detik dengan pollingRatusan ribu hingga jutaan pesan per detik
Pola distribusiPoint-to-point, satu job satu workerPub/sub, satu pesan bisa sampai ke banyak queue
Cara kerjaWorker polling berkalaBroker mendorong pesan ke consumer
Fitur routingHanya ORDER BY dan WHEREExchange, binding key, prioritas, dead-letter bawaan
Kemampuan querySQL penuh (inspeksi mudah)Perlu alat bantu khusus broker
Beban operasionalRendah, tidak ada server baruTinggi, ada server baru yang harus diamankan dan diawasi
Cocok untukMonolit, volume menengahVolume 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
<?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:

text
{
    "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:

  1. id unik per pesan — dipakai untuk melacak perjalanan pesan di log banyak layanan dan sebagai kunci idempotency di sisi consumer (chapter Idempotent Jobs ).
  2. 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.
  3. headers membawa metadata teknis yang boleh berubah selama perjalanan: di sini jumlah percobaan (x-attempts) yang bertambah setiap kali consumer me-retry pesan.
  4. body adalah payload domain murni — struktur yang sama dengan kolom payload pada tabel jobs dari chapter Job Queues .
  5. created_at memakai 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 exchangeAturan pencocokan bindingContoh pemakaian
directBinding key harus sama persis dengan routing keyorder.created ke queue invoice
fanoutMenyalin ke semua queue terikat, routing key diabaikanBroadcast sinyal flush cache
topicRouting key dicocokkan ke pola: * tepat satu kata, # nol atau lebih kataorder.# menangkap semua event pesanan
headersMencocokkan header pesan, bukan routing keyRouting 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
<?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:

text
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 (#) -> MENERIMA

Mari kita bedah cara kerja simulasi tersebut:

  1. cocokRoutingKey() memecah routing key dan binding key menjadi kata-kata yang dipisah titik, lalu mencocokkannya kata demi kata lewat cocokDenganBinding().
  2. * hanya menggantikan tepat satu kata — binding *.created cocok dengan order.created dan user.created, tetapi tidak dengan order.paid karena kata keduanya berbeda.
  3. # menggantikan nol atau lebih kata — binding order.# cocok dengan semua routing key yang diawali order., dan binding # sendirian cocok dengan apa pun. Cabang rekursif pada loop mencoba setiap titik potong yang mungkin.
  4. 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
<?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
<?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:

yaml
# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            async_amqp:
                dsn: "%env(MESSENGER_AMQP_DSN)%"
        routing:
            App\Message\KirimEmailMessage: async_amqp

Bagian 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:

SituasiPilihanAlasan
Kurang dari 1.000 job per menit, satu aplikasiQueue databaseTidak ada infrastruktur baru, inspeksi lewat SQL
Perlu pub/sub ke banyak layananRabbitMQ (topic exchange)Fan-out dikerjakan broker, producer tetap sederhana
Volume sangat tinggi dan butuh replay eventKafkaLog terpersisten bisa dibaca ulang oleh consumer baru
Tim kecil tanpa personil operasional khususQueue databaseBroker 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.

Referensi #