ข้ามไปยังเนื้อหา

Batching กับ Compression

producer รวม record เป็น batch และ compress ตามต้องการ ดังนั้นการรอเพิ่มอีกนิดกับ batch ที่ใหญ่ขึ้นจึงซื้อ throughput ที่สูงขึ้นมาก — เป็นแนวคิดที่ทุก client ของ Kafka ทำ แม้ปุ่มปรับจริงจะต่างกัน

producer ไม่ได้ส่งทีละ record แต่สะสม record ต่อ partition เข้าเป็น batch แล้วส่ง batch เป็น request เดียว มีสองค่ามาตรฐานที่กำหนดรูปร่างของ batch — นี่คือ ชื่อ config ของ Java client / librdkafka ที่คุณจะเจอใน official docs และ client ส่วนใหญ่:

  • batch.size — ขนาดสูงสุดเป็น byte ของ batch เดียว batch จะถูกส่งเมื่อเต็มถึงขนาดนี้ (หรือเมื่อ linger.ms หมดเวลา)
  • linger.ms — producer จะ รอ record เพิ่มนานแค่ไหนก่อนส่ง batch ที่ยังไม่เต็ม ค่า default คือ 0 (ส่งให้เร็วที่สุด) เพิ่มเป็น เช่น 10 จะให้ record สะสมได้มากขึ้น เกิด batch ที่เต็มกว่า

batch ที่ใหญ่ขึ้นหมายถึง request น้อยลง compress ได้ดีขึ้น และ throughput สูงขึ้น — แลกกับ latency ที่เพิ่มไม่กี่ millisecond ต่อ record นี่คือคันโยกหลัก

# Wait up to 10 ms to fill a batch, and allow bigger batches for throughput
linger.ms=10
batch.size=65536

KafkaJS ไม่มีปุ่ม linger.ms หรือ batch.size — ยืนยันจาก type definition และ source ของ [email protected] เอง: ไม่มีชื่อไหนโผล่ใน ProducerConfig เลย แต่จะรวม message ที่คุณส่งใน send() ครั้งเดียวเข้าด้วยกันเองแล้วส่งพร้อมกัน — คุณคุม batching ด้วยวิธีจัดกลุ่ม message เข้า send() แต่ละครั้ง (เช่น buffer record เองแล้วส่งเป็น array เดียว) ไม่ใช่ด้วย linger timer

# librdkafka config keys (@confluentinc/kafka-javascript's native surface).
# Not path-commented: batch.num.messages is a real librdkafka-only key with
# no entry on kafka.apache.org's own config reference, so this course's
# harness (which checks properties fences against that official reference)
# cannot validate it — verified real instead by actually using it: this
# course's probe ran a producer with exactly these three keys successfully.
linger.ms=10
batch.size=65536
batch.num.messages=10000

compression ถูกทำ ต่อ batch ดังนั้น batch ที่ใหญ่กว่าจึง compress ได้ดีกว่า ทุก client เลือก codec บนเส้นความเร็วต่ออัตราการย่อเดียวกัน:

  • lz4 — เร็วมาก อัตราย่อดี เป็นตัวเลือก default ที่นิยม
  • zstd — อัตราย่อเยี่ยม ยังเร็วอยู่ เหมาะเมื่อ network หรือ storage เป็นคอขวด
  • snappy — เร็ว อัตราย่อพอประมาณ
  • gzip — อัตราย่อดีสุด แต่กิน CPU มากสุดและช้าสุด
  • none — ไม่ compress (ค่า default)

ใน Java client นี่คือ property compression.type ใน KafkaJS compression เป็น option ต่อการ send ที่ใช้ CompressionTypes:

import { Kafka, CompressionTypes } from 'kafkajs'
// KafkaJS ships GZIP natively; snappy/lz4/zstd need an extra codec
// package registered via CompressionCodecs before you can use them.
await producer.send({
topic: 'orders',
compression: CompressionTypes.GZIP,
messages,
})

ผลลัพธ์จริงจาก kafka-producer-perf-test.sh กับ broker ของบทนี้เอง — 100,000 record ขนาด 200 byte ต่ออัน throughput ไม่จำกัด (--throughput -1), acks=1, linger.ms=5, batch.size=65536:

scripts/batching-and-compression/perf-test.sh
/opt/kafka/bin/kafka-topics.sh --bootstrap-server "$BOOTSTRAP" --create --if-not-exists --topic perf-none --partitions 3 --replication-factor 1
/opt/kafka/bin/kafka-topics.sh --bootstrap-server "$BOOTSTRAP" --create --if-not-exists --topic perf-lz4 --partitions 3 --replication-factor 1
echo "=== no compression ==="
/opt/kafka/bin/kafka-producer-perf-test.sh --topic perf-none --num-records 100000 --record-size 200 \
--throughput -1 --producer-props bootstrap.servers="$BOOTSTRAP" acks=1 linger.ms=5 batch.size=65536
echo "=== lz4 compression ==="
/opt/kafka/bin/kafka-producer-perf-test.sh --topic perf-lz4 --num-records 100000 --record-size 200 \
--throughput -1 --producer-props bootstrap.servers="$BOOTSTRAP" acks=1 linger.ms=5 batch.size=65536 compression.type=lz4

ผลลัพธ์จริงแบบตัดให้สั้น (เครื่องนี้ single-broker ดังนั้นตัวเลขสัมบูรณ์ให้มองเป็น noise ของฮาร์ดแวร์ในเครื่อง — สิ่งที่สำคัญคือ การเปรียบเทียบ):

=== no compression ===
100000 records sent, 317460.317460 records/sec (60.55 MB/sec), 6.18 ms avg latency, 144.00 ms max latency, 5 ms 50th, 13 ms 95th, 18 ms 99th, 19 ms 99.9th.
=== lz4 compression ===
100000 records sent, 303951.367781 records/sec (57.97 MB/sec), 6.10 ms avg latency, 154.00 ms max latency, 5 ms 50th, 16 ms 95th, 28 ms 99th, 31 ms 99.9th.

วัดจริง: อัตราการย่อบน JSON ที่สมจริง ผ่าน kafka-dump-log.sh

หัวข้อที่มีชื่อว่า “วัดจริง: อัตราการย่อบน JSON ที่สมจริง ผ่าน kafka-dump-log.sh”

record order ที่เป็น JSON สมจริง ซ้ำ ๆ 500 อัน (แบบที่ service จริงส่งกันจริง ๆ — ชื่อ field ซ้ำ ค่าคล้ายกัน) ส่งครั้งหนึ่งแบบไม่ compress และอีกครั้งด้วย CompressionTypes.GZIP:

src/batching-and-compression/compression-ratio-demo.ts
// @run
import { Kafka, CompressionTypes } from 'kafkajs';
const brokers = [process.env.KAFKA_BROKERS ?? 'localhost:9092'];
const prefix = process.env.TOPIC_PREFIX ?? '';
const kafka = new Kafka({ clientId: 'compression-probe', brokers });
function order(i: number) {
return JSON.stringify({
orderId: `order-${i}`,
customerId: `customer-${i % 50}`,
status: 'PENDING',
items: [
{ sku: 'WIDGET-001', qty: 2, price: 19.99 },
{ sku: 'WIDGET-002', qty: 1, price: 9.99 },
],
createdAt: '2026-09-29T00:00:00.000Z',
});
}
async function send(topic: string, compression: number) {
const admin = kafka.admin();
await admin.connect();
await admin.createTopics({ topics: [{ topic, numPartitions: 1, replicationFactor: 1 }] });
await admin.disconnect();
const producer = kafka.producer();
await producer.connect();
const messages = Array.from({ length: 500 }, (_, i) => ({ key: `k${i}`, value: order(i) }));
await producer.send({ topic, messages, compression });
await producer.disconnect();
}
async function main() {
await send(`${prefix}orders-json-none`, CompressionTypes.None);
await send(`${prefix}orders-json-gzip`, CompressionTypes.GZIP);
console.log('sent 500 realistic JSON order records, both uncompressed and gzip');
process.exit(0);
}
main();
scripts/batching-and-compression/dump-compression-ratio.sh
NONE_LOG=$(find /tmp/kafka-logs -maxdepth 1 -type d -name '*orders-json-none-0' | head -1)
GZIP_LOG=$(find /tmp/kafka-logs -maxdepth 1 -type d -name '*orders-json-gzip-0' | head -1)
echo "=== uncompressed batch ==="
/opt/kafka/bin/kafka-dump-log.sh --files "$NONE_LOG"/00000000000000000000.log | head -3
echo "=== gzip batch ==="
/opt/kafka/bin/kafka-dump-log.sh --files "$GZIP_LOG"/00000000000000000000.log | head -3

ผลลัพธ์จริง — ทั้งคู่เป็น record batch เดียว (500 record รวมกันในการเรียก send() ครั้งเดียว) ดังนั้น field size: ของ batch เองคือขนาดบนดิสก์จริง ๆ:

=== uncompressed batch ===
baseOffset: 0 lastOffset: 499 count: 500 ... size: 108177 magic: 2 compresscodec: none ...
=== gzip batch ===
baseOffset: 0 lastOffset: 499 count: 500 ... size: 4741 magic: 2 compresscodec: gzip ...

108,177 byte → 4,741 byte — ลดลง 22.8 เท่า (เล็กลง 95.6%) สำหรับ JSON สมจริงที่ซ้ำ ๆ บทเรียนจากการวัดทั้งสองครั้งรวมกัน: อัตราการย่อคือคุณสมบัติของ ความซ้ำซ้อนใน payload จริงของคุณ ไม่ใช่เปอร์เซ็นต์ตายตัวที่ codec ไหนก็รับประกันได้ — วัดกับข้อมูลของคุณเอง แบบเดียวกับที่บทนี้วัดกับ payload สมจริงและ payload สุ่มแยกกัน

compression.type ตั้งได้สองที่ และทำงานร่วมกัน:

  • ฝั่ง producer (compression.type ใน Java client / compression: CompressionTypes.GZIP ต่อ-send ใน KafkaJS) — producer compress batch ก่อนส่ง
  • ระดับ topic (compression.type เป็น topic config ค่า default คือ producer) — คุมว่า broker จะทำอะไร producer (ค่า default) แปลว่า “เก็บ codec ที่ producer ใช้ไว้แบบเดิม อย่าไปแตะ” การตั้ง compression.type ของ topic เป็น codec เฉพาะ (เช่น lz4) จะทำให้ broker compress ซ้ำ ทุก batch ให้เป็น codec นั้น ตอนเขียนลง ไม่ว่า producer จะส่งอะไรมาก็ตาม — มีประโยชน์เพื่อบังคับมาตรฐานข้าม producer ที่คุณควบคุมไม่ได้ทั้งหมด แลกกับ CPU ของ broker ที่เพิ่มขึ้น
config/batching-and-compression/topic-compression.properties
# section: topic
compression.type=producer

เพราะ send() เป็น asynchronous record จึงนั่งอยู่ใน buffer ในหน่วยความจำก่อน I/O layer จะส่งออก ใน Java client buffer.memory จำกัดขนาดรวมของ buffer นั้น เมื่อ buffer เต็ม send() จะ block (นานสุดตาม max.block.ms) เป็นการใส่ back-pressure เพื่อไม่ให้ producer ที่เร็วเกินไปกินหน่วยความจำจนหมด KafkaJS ไม่มี setting buffer แบบนี้ให้ปรับ ไม่ได้ pre-buffer record ก่อน send() แบบที่ Java client ทำ จึงไม่มีคู่ buffer.memory/max.block.ms ให้จูน

Java client ยังคุมทั้งเส้นทางด้วยเส้นตาย: delivery.timeout.ms (ค่า default 120000) คือ เส้นตายรวม ตั้งแต่ตอน send() คืนค่าจนถึงตอน record ถูก acknowledge — ครอบคลุมทั้ง delay ของ batching, การส่งผ่าน network และ retry ทั้งหมด ควรตั้งให้ >= request.timeout.ms + linger.ms ถ้าส่ง record ไม่สำเร็จภายในเวลานี้ การ send จะ fail ถาวร

# 32 MiB send buffer; when full, send() applies back-pressure and blocks
buffer.memory=33554432
# Total deadline covering batching + retries; must be >= request.timeout.ms + linger.ms
delivery.timeout.ms=120000
request.timeout.ms=30000

KafkaJS บรรลุเป้าหมายเดียวกัน — จำกัดว่า send จะ retry ได้นานแค่ไหน — ด้วยวิธีต่างออกไป: ผ่าน retry policy ระดับ client (retries, initialRetryTime, maxRetryTime) ซึ่งอยู่ใน Error Handling & Retries ไม่มี deadline เดี่ยว ๆ retry จะหยุดเองเมื่อจำนวนครั้งใน policy หมด ในทางกลับกัน native librdkafka surface ของ @confluentinc/kafka-javascript expose delivery.timeout.ms ของ Java client ตรง ๆ เพราะใช้ config surface ของ librdkafka ร่วมกับ ecosystem C/C++/Java

flowchart LR
  send["send() adds record to buffer"] --> buf["'buffer.memory': full means back-pressure"]
  buf --> batch["Batch fills to 'batch.size' or 'linger.ms' elapses"]
  batch --> comp["Compress batch: lz4 / zstd / snappy / gzip"]
  comp --> io["I/O thread sends; retries allowed"]
  io --> deadline["Must finish within 'delivery.timeout.ms' (120000)"]
Record ถูก batch, compress แล้วระบายออกภายในเส้นตายของการส่ง

ใน Java client ถ้าจะดัน throughput ขึ้น เพิ่ม linger.ms อีกนิด (5-20 ms) ขยาย batch.size และเปิด compression แบบ lz4 หรือ zstd — สามอย่างนี้ทำงานร่วมกัน เพราะรอมากขึ้นทำให้ batch เต็มกว่า และ batch ที่เต็มกว่าก็ compress ได้ดีกว่า เผื่อ buffer.memory ให้พอสบายเพื่อไม่ให้ back-pressure มาสะดุด และทำให้ delivery.timeout.ms ยาวพอจะรองรับ retry ตอน broker สะดุด ใน KafkaJS เพราะไม่มีปุ่ม batching ให้จูน ให้เน้นงาน throughput ที่: ส่ง message array ที่ใหญ่ขึ้นต่อการ send() หนึ่งครั้ง เลือก compression: CompressionTypes.GZIP (หรือ codec LZ4/ZSTD ที่ลงทะเบียนไว้) และตั้ง retry policy ให้กว้างพอจะทนต่อ broker สะดุด ใน @confluentinc/kafka-javascript ปุ่มเทียบเท่า Java client ครบชุด (linger.ms, batch.size, batch.num.messages, compression.codec) ใช้ได้ตรง ๆ

🐛 หา bug ในโค้ดนี้

src/batching-and-compression/high-throughput-producer.ts
import { Kafka, CompressionTypes } from 'kafkajs';
// AI comment: "tuned for high throughput — larger batches, less overhead"
const kafka = new Kafka({
clientId: 'events-app',
brokers: ['localhost:9092'],
});
async function sendEvents(events: Record<string, unknown>[]) {
const producer = kafka.producer();
await producer.connect();
await producer.send({
topic: 'events',
compression: CompressionTypes.LZ4, // "lz4 is fast, great for throughput"
messages: events.map((e) => ({ value: JSON.stringify(e) })),
});
await producer.disconnect();
}
เฉลย

โค้ดนี้ดูเหมือนคำแนะนำการจูนที่ดีและมี comment กำกับชัดเจน — lz4 เป็นตัวเลือกทั่วไปที่ถูกต้องจริงบน Java client และ comment ก็ฟังดูเหมือนมาจาก performance guide จริง ๆ บั๊กจริง: นี่คือ KafkaJS ไม่ใช่ Java client และ KafkaJS ship มาแค่ GZIP ตัวเดียว (ยืนยันจาก source ด้านบน) — Snappy/LZ4/ZSTD จะ throw KafkaJSNotImplemented('LZ4 compression not implemented') จนกว่าจะติดตั้งและลงทะเบียน codec package แยกกับ CompressionCodecs ก่อน sendEvents จะ throw ตั้งแต่ครั้งแรกที่เรียก ไม่ได้ค่อย ๆ เสื่อมประสิทธิภาพแบบเงียบ ๆ — แต่โค้ดฐานที่ pattern นี้ถูก copy เข้าไป (จาก tutorial หรือตัวอย่างของ client อื่นที่ adapt มาโดยไม่เช็ก) จะไม่รู้สาเหตุจนกว่าจะรันจริง วิธีแก้คือใช้ codec ที่ KafkaJS ship มาจริง หรือติดตั้งและลงทะเบียน package LZ4 ตัวจริง:

// Option A: use what KafkaJS ships natively
compression: CompressionTypes.GZIP,
// Option B: install kafkajs-lz4, then register it once at startup
import LZ4 from 'kafkajs-lz4';
import { CompressionCodecs } from 'kafkajs';
CompressionCodecs[CompressionTypes.LZ4] = new LZ4().codec;

ทั้ง batching และ compression แลก เวลานิดหน่อยฝั่ง client กับ งานที่ broker/network/disk ต้องทำต่อ record น้อยลงมาก — แต่ทำงานคนละชั้นและไม่ต้องพึ่งกันเอง batching คือเรื่อง เมื่อไร ที่ request จะออกจาก client (สะสมไว้ก่อน vs ส่งทันที) compression คือเรื่อง กี่ byte ที่ request นั้นต้องเสียเมื่อมันออกไปแล้ว KafkaJS ยุบการตัดสินใจเรื่อง “เมื่อไร” ลงเหลือแค่ “คุณใส่ message กี่อันใน send() array เดียว” (ไม่มีการสะสมด้วย timer เลย) ในขณะที่ยังให้การตัดสินใจเรื่อง “กี่ byte” ต่อการเรียกผ่าน compression Java client และ librdkafka แยกทั้งสองการตัดสินใจออกเป็นปุ่มอิสระที่จูนได้ต่อเนื่อง (linger.ms/batch.size สำหรับเมื่อไร, compression.type สำหรับกี่ byte) คันโยกสองอันเดียวกัน แต่ระดับความละเอียดในการควบคุมต่างกัน

  • การ batch ตาม call ของ send() ใน KafkaJS — ไม่ต้องตั้งค่าอะไรเลย แต่ไม่มีทางจูนเส้น latency/throughput แยกจากวิธีที่โค้ดแอปของคุณบังเอิญจัดกลุ่ม message เข้า send()
  • linger.ms/batch.size/batch.num.messages ของ @confluentinc/kafka-javascript — ควบคุมอิสระเต็มที่ แลกกับปุ่มจูนที่แอปของคุณต้องตั้งให้ถูก
  • GZIP (codec เดียวที่ KafkaJS มีให้โดยไม่ต้องลงอะไรเพิ่ม) — ใช้ได้ทุกที่ไม่ต้องพึ่ง dependency เพิ่ม แต่เป็น codec ที่ช้าที่สุด กิน CPU มากที่สุดในสี่ตัว — โอเคสำหรับ topic throughput ปานกลาง เป็นคอขวดจริงเมื่อปริมาณสูงมาก
  • LZ4/Snappy/ZSTD ผ่าน @confluentinc/kafka-javascript หรือ codec package ที่ลงทะเบียนกับ KafkaJS — compress เร็วขึ้นชัดเจน แลกกับ native dependency เพิ่ม (confluent client) หรือ plugin package ที่ดูแลน้อยกว่า (KafkaJS codec plugin)
  • การ override compression.type ระดับ topic — บังคับ codec ให้สม่ำเสมอข้าม producer ที่คุณควบคุมไม่ได้ทั้งหมด แลกกับ CPU ของ broker ที่เสียต่อเนื่องทุก batch ที่ต้อง compress ซ้ำ
  • “KafkaJS มี linger.ms และ batch.size เหมือน client อื่น ๆ แค่ชื่อต่างนิดหน่อย” ไม่ — ยืนยันจาก source และ type แล้ว: ทั้งสองไม่มีอยู่ใน KafkaJS เลย batching เป็นแบบ implicit ขับเคลื่อนล้วน ๆ ด้วยจำนวน message ที่คุณส่งเข้า send() หนึ่งครั้ง
  • “compression ทำให้เร็วขึ้นเสมอ” ไม่ — วัดตรง ๆ ด้านบนแล้ว: บน payload byte สุ่มที่ย่อไม่ได้ lz4 เสีย throughput ไปราว 4% เทียบกับไม่ compress เพราะต้นทุน CPU ของ compressor แทบไม่ได้ขนาดที่ลดลงกลับมาเลย
  • “CompressionTypes.LZ4 (หรือ Snappy/ZSTD) ใช้ได้เลยใน KafkaJS เหมือน GZIP” ไม่ — พิสูจน์จาก source และจากกรณี SpotTheBug ด้านบนแล้ว: มีแค่ GZIP เท่านั้นที่ bundle มา ตัวอื่นจะ throw KafkaJSNotImplemented จนกว่าจะติดตั้งและลงทะเบียน codec package แยกก่อน
  • “ค่า compression.type ของ topic คือค่าที่ producer ใช้ compress” ไม่ — ค่าระดับ topic คุมว่า broker จะทำอะไรหลังรับ batch (ค่า default producer: ปล่อยไว้แบบเดิม) ส่วนค่าฝั่ง producer เอง (property compression.type หรือ option compression ต่อ-send ของ KafkaJS) คือสิ่งที่ compress byte จริง ๆ ตอนส่งออก
linger.ms และ batch.size ใน Java client แลกอะไรกับอะไร
KafkaJS expose linger.ms หรือ batch.size ไหม แล้ว @confluentinc/kafka-javascript expose อะไรแทน
KafkaJS ship compression codec ตัวไหนมาให้ในตัว ยืนยันจาก source ของมันเอง
ตอนรัน kafka-producer-perf-test.sh จริงของบทนี้ด้วย payload default (byte สุ่ม) เกิดอะไรขึ้นตอนเปิด lz4 compression
บน payload JSON สมจริงที่ซ้ำ ๆ ของบทเดียวกัน (record order 500 อันใน batch เดียว) kafka-dump-log.sh แสดงอัตราการย่อของ gzip เท่าไร
ค่า topic-level compression.type=producer (ค่า default) แปลว่าอะไรจริง ๆ
delivery.timeout.ms (ค่า default 120000) ใน Java client คุมอะไร และ KafkaJS บรรลุเป้าหมายเดียวกันยังไง