Kembali ke jurnalCATATAN FAJAR
Software Engineering6 menit baca

Benchmark Kafka vs Redis Streams: Throughput dan Latency

Tes Kafka dan Redis Streams saya memisahkan throughput dari latency dan menjelaskan dengan gamblang batasan dari sebuah benchmark lokal.

BAGIAN 15 DARI 16Kafka vs Redis Streams
  1. 01Kafka vs Redis Streams: Bikin Stock Ticker Dua Kali
  2. 02Kafka Partition dan Key: Menjaga Simbol Tetap Berurutan
  3. 03Dasar-Dasar Redis Streams: XADD, Entry ID, dan MAXLEN
  4. 04Kafka Consumer Group: Lag, Draining, dan Rebalancing
  5. 05Consumer Group Redis Streams: Competing Consumer dan PEL
  6. 06At-Least-Once di Kafka: Retry dan Dead Letter Queue
  7. 07Redis Streams: Message Nyangkut, XAUTOCLAIM, dan Dead Letter
  8. 08Exactly-Once di Kafka: Idempotent Producer dan Transaction
  9. 09Redis Pub/Sub vs Streams: Ephemeral atau Durable
  10. 10Co-Partitioning: Key Sama, Partition Sama, di Kedua Topic
  11. 11Menulis Custom Partition Assigner KafkaJS untuk Local Join
  12. 12Kafka ISR vs Redis Cluster: Replication dan Failover
  13. 13Stateful Streams: Trigger Edge vs Level dan Window OHLC
  14. 14Membangun Live Market Dashboard dengan SSE dan Next.js
  15. 15Benchmark Kafka vs Redis Streams: Throughput dan LatencyKamu di sini
  16. 16Kafka atau Redis Streams: Gimana Cara Memilihnya
Di artikel ini 8 bagian

Sebuah benchmark butuh lebih dari sekadar angka throughput. Hardware, ukuran payload, ukuran batch, warmup, dan aktivitas lain di mesin itu memengaruhi hasilnya. Tanpa kondisi-kondisi itu, sebuah perbandingan susah ditafsirkan.

Setelah membangun stock ticker yang sama dua kali, saya ingin membandingkan Kafka dan Redis Streams di mesin saya. Saya menulis test harness untuk mengukur throughput dan latency.

Artikel ini menjelaskan harness tersebut beserta batasannya. Di sini nggak ada hasil pengukuran throughput atau latency. Hasil dari laptop saya bakal butuh tes lanjutan sebelum bisa jadi dasar keputusan di production.

Dua jalan masuk ke function yang sama

Ada dua entry point yang memanggil runBenchmark(). CLI menerima jumlah message dan ukuran payload, dengan default 15.000 message dan 64 byte. Dashboard mengirim nilai yang dipilih ke sebuah API route, yang memanggil function yang sama di server:

typescript
export async function POST(req: Request): Promise<Response> {
  const body = await req.json();
  const result = await runBenchmark(body.messages ?? 15000, body.payloadBytes ?? 64);
  return Response.json(result);
}

Kedua jalur meng-clamp input-nya sebelum melakukan apa pun:

typescript
const n = Math.max(1000, Math.min(50000, Math.floor(messages)));
const bytes = Math.max(8, Math.min(4096, Math.floor(payloadBytes)));
const m = Math.min(1200, n); // how many of those n messages get a latency sample

Jumlah message berkisar dari 1.000 sampai 50.000, dan ukuran payload dari 8 sampai 4.096 byte. Harness mengukur latency untuk paling banyak 1.200 message supaya durasi fase itu terbatas.

Preset di dashboard berkisar dari 5.000 sampai 50.000 message dan dari 16 sampai 1.024 byte. Semuanya tetap di dalam batas input yang sama.

Apa yang diukur, dan dengan urutan apa

Dua dimensi, masing-masing dijalankan terhadap kedua sistem, secara berurutan:

mermaid
sequenceDiagram
    participant H as Harness
    participant K as Kafka
    participant R as Redis
    H->>K: throughput run "batched producer.send"
    H->>K: latency run "producer + fresh consumer"
    H->>R: throughput run "pipelined XADD"
    H->>R: latency run "XADD + blocking XREAD"
    Note over H: one phase at a time, never concurrent

Harness menjalankan kedua fase Kafka lebih dulu, baru kemudian kedua fase Redis. Run yang berurutan menghindari persaingan langsung antara kedua tes untuk CPU, Docker network bridge, dan akses disk. Aktivitas lain di laptop saya tetap bisa memengaruhi hasil keduanya.

Throughput produce

Secara struktur, kedua function throughput melakukan hal yang sama: kirim n message dalam batch berisi 1.000, ukur waktu seluruh loop, lalu bagi.

typescript
// Kafka: batched producer.send(), keyed across all 6 partitions
const producer = kafka.producer({ idempotent: true });
const BATCH = 1000;
const start = performance.now();
for (let i = 0; i < n; i += BATCH) {
  const end = Math.min(n, i + BATCH);
  const messages = [];
  for (let j = i; j < end; j++) messages.push({ key: `k${j % 6}`, value });
  await producer.send({ topic, messages });
}
const perSec = Math.round(n / ((performance.now() - start) / 1000));
typescript
// Redis: pipelined XADD, same batch size
const redis = createRedis();
const BATCH = 1000;
const start = performance.now();
for (let i = 0; i < n; i += BATCH) {
  const end = Math.min(n, i + BATCH);
  const pipe = redis.pipeline();
  for (let j = i; j < end; j++) pipe.xadd(key, "*", "p", value);
  await pipe.exec();
}
const perSec = Math.round(n / ((performance.now() - start) / 1000));

Run Kafka memutar key k0 sampai k5 di sebuah topic dengan 6 partition. Run ini sudah mencakup paralelisme di level partition yang dijelaskan sebelumnya. Run Redis menulis ke satu stream key.

Perbedaan ini membatasi perbandingannya. Eksperimen lanjutan bisa membagi traffic Redis ke beberapa stream key, dengan hash tag untuk key yang saling berkaitan. Harness yang sekarang nggak melakukan itu.

Producer Kafka memakai idempotent: true dan acks=all. Setup Compose default-nya punya satu node dan membuat topic benchmark dengan replicationFactor: 1. Jadi, "all in-sync replicas" artinya satu replica. Tes ini nggak mengukur acknowledgment di beberapa broker.

Latency end to end

Throughput mengukur jumlah message per satuan waktu. Latency mengukur waktu antara operasi kirim dan saat message diterima. Untuk mengukur latency, saya menjalankan producer dan consumer secara bersamaan. Saya mencatat waktu kirim di setiap message dan menghitung waktu yang berlalu saat message itu diterima.

Untuk Kafka, harness membuat consumer group baru (bench-lat-<timestamp>) dan subscribe ke topic-nya. Harness menunggu 900ms sebelum message pertama. Delay tetap ini mengurangi race saat startup di tes saya, tapi nggak membuktikan kalau consumer-nya udah siap. Setiap message membawa timestamp kirimnya di sebuah header:

typescript
await producer.send({
  topic,
  messages: [{ key: `k${i % 6}`, value, headers: { t: String(performance.now()) } }],
});

// in the consumer's eachMessage handler
const sent = Number(message.headers?.t?.toString() ?? "0");
latencies.push(performance.now() - sent);

Writer Redis mengirim entry dengan XADD. Reader memakai blocking connection terpisah. Kalau nggak, blocking read bakal menunda command lain di connection itu. Reader mengirim call XREAD ... BLOCK 1000 mentah dari $ (cuma entry baru). Writer menaruh waktu kirim di sebuah field entry:

typescript
await writer.xadd(key, "*", "t", String(performance.now()), "p", value);

// read loop, dedicated blocking connection
const res = await reader.call("XREAD", "COUNT", "500", "BLOCK", "1000", "STREAMS", key, lastId);
// for each entry returned: latencies.push(performance.now() - Number(map.t))

Pengukuran Redis memakai XREAD, bukan XREADGROUP, jadi pengelolaan pending entries list nggak ikut terukur. Kedua loop latency punya timeout 20 detik. Keduanya mengirim satu message setiap kali, jadi nggak mengukur latency batch.

Percentile, dihitung apa adanya

Begitu harness punya daftar sampel latency, satu function bersama dijalankan di hasil kedua sistem:

typescript
function percentiles(samples: number[]): Percentiles {
  const s = [...samples].sort((a, b) => a - b);
  const at = (q: number) => s[Math.min(s.length - 1, Math.floor(q * s.length))];
  const sum = s.reduce((a, b) => a + b, 0);
  return {
    count: s.length,
    avgMs: sum / s.length,
    p50Ms: at(0.5),
    p95Ms: at(0.95),
    p99Ms: at(0.99),
    minMs: s[0],
    maxMs: s[s.length - 1],
  };
}

Function at(q) mengurutkan sampel lalu memilih index floor(q * length). Ini estimasi percentile berbasis index. Hasilnya bisa beda dari interpolasi dan dari konvensi nearest rank, yang memakai ceil(q * length) - 1 untuk quantile positif. Pakai estimator yang sama saat membandingkan hasil.

Harness nggak membuang sampel warmup sama sekali. Biaya startup, misalnya setup connection dan koordinasi consumer group, tetap ikut di mean dan percentile. Dengan sampel yang sedikit, beberapa message yang lambat bisa mengubah mean secara signifikan.

Kenapa bentuk hasilnya cenderung berbeda

Perbedaan arsitektur ini bisa menjelaskan hasil tes. Tapi perbedaan ini nggak menggantikan pengukuran dari deployment yang dituju.

mermaid
graph TD
    subgraph Kafka
        KP["Producer batches records"] --> KL["Leader log segment (page cache)"]
        KL --> KF["fsync per broker flush policy"]
        KF --> KA["acks=all: wait for in-sync replicas"]
    end
    subgraph Redis
        RP["Client pipelines XADD"] --> RE["Single-threaded event loop"]
        RE --> RM["In-memory stream (RAM)"]
        RM --> RA["Async AOF append (everysec)"]
    end

Kafka meng-append record ke log segment dan memakai page cache dari sistem operasi. Biasanya broker nggak menyinkronkan setiap record ke disk. Karena itu, setting replication dan acknowledgment penting untuk durability.

Harness saya mengirim batch berisi 1.000 record per call producer.send(). Batching ini mengurangi overhead request. Jangan berasumsi setting producer Java seperti linger.ms dan batch.size berlaku juga di KafkaJS.

Redis menyimpan data stream di memory dan mengeksekusi command secara berurutan di main command thread-nya. Persistence tergantung konfigurasi. Setup Compose saya memakai AOF dengan appendfsync everysec, yang membuka celah kehilangan data setelah terjadi failure. Pipelining mengurangi pertukaran di network dengan mengirim beberapa command sekaligus.

Kedua sistem punya biaya storage dan replication yang berbeda. Ukur keduanya dengan payload, batching, dan setting durability yang dibutuhkan. Arsitektur saja nggak bisa memastikan mana yang bakal lebih cepat untuk workload itu.

Catatan yang selalu saya sertakan di setiap run

Ada beberapa hal yang bukan cakupan benchmark ini, dan jangan sampai disalahartikan begitu:

  • Topologi lokal: setup Compose default punya satu node KRaft dengan topic benchmark di replicationFactor: 1. Profile tiga broker yang terpisah memakai replicationFactor: 3 untuk tes leader failure. Benchmark ini nggak memakai profile itu maupun Redis Cluster.
  • Overhead client: KafkaJS dan ioredis berbagi proses Node.js dengan harness. Garbage collection, JSON.stringify, dan scheduling promise memengaruhi pengukuran. Hasilnya mengukur "Kafka lewat KafkaJS" dan "Redis lewat ioredis," termasuk biaya dari client-nya.
  • Network lokal: client terhubung lewat port mapping Docker ke localhost. Setup ini nggak merepresentasikan delay network atau failure antar host, availability zone, atau region.
  • Single run: harness nggak punya fase warmup atau beban berkelanjutan yang diulang. Setiap run mengukur satu mesin dalam kondisinya saat itu.

Tes untuk deployment sebaiknya memvariasikan concurrency producer dan consumer, jumlah broker, dan sharding Redis. Tes itu juga sebaiknya mencakup perilaku sinkronisasi disk yang dibutuhkan dan payload di atas 4.096 byte kalau aplikasinya butuh. Jalankan cukup lama untuk melihat efek garbage collection dan page cache pada tail latency.

Membaca output tanpa membohongi diri sendiri

Saya melihat distribusinya dulu sebelum rata-ratanya. p50 yang dekat dengan mean dan p99 yang jauh menandakan ada request lambat di bagian tail. Saya membandingkan jumlah message dan ukuran payload yang sama, lalu mengulang run-nya. Satu run 15.000 message dengan browser dan IDE yang terbuka cuma menguji setup lokal saya.

Pakai pengukuran dari hardware, payload, dan beban yang representatif sebelum mengambil keputusan deployment. Harness lokal membantu mengeksplorasi perilaku sistem, tapi hasilnya butuh konteks tambahan itu.

Post terakhir memakai perilaku dari seri ini untuk membandingkan di mana masing-masing sistem cocok dipakai.

TOPIK

SELANJUTNYA DI SERI INIKafka atau Redis Streams: Gimana Cara Memilihnya

MAKASIH UDAH BACA

Gimana menurutmu?

Reaksi atau obrolan, dua-duanya selalu ditunggu.

Memuat reaksi…

Bagikan

Memuat komentar...

LANJUT JELAJAH

Satu pikiran bawa ke pikiran lain.

Semua tulisan
Kembali ke semua tulisanSatu catatan, pelan-pelan.