SSD Nodes Learn 🎉 VPS เริ่ม $5.50/เดือน
คู่มือ Matt Connorโดย Matt Connor

เลือก Message Queue ตัวไหนดีบน VPS เครื่องเดียว

เปรียบเทียบ NATS, RabbitMQ และ Kafka บนเซิร์ฟเวอร์เดียว วิเคราะห์การใช้หน่วยความจำ การจัดการเมื่อระบบรีสตาร์ท และเหตุผลที่ Postgres อาจเป็นตัวเลือกที่ดีกว่าสำหรับงานของคุณ

คำตอบสั้นๆ สำหรับเซิร์ฟเวอร์เดียว

การใช้ message queue บน VPS เครื่องเดียวเป็นการตัดสินใจเรื่องการรับประกันการส่งข้อมูล ไม่ใช่เรื่องความเร็ว บนเซิร์ฟเวอร์เครื่องเดียว ตัว broker มักไม่ใช่คอขวด เพราะโค้ดแอปพลิเคชัน ฐานข้อมูล และดิสก์ของคุณมักจะถึงขีดจำกัดก่อน ให้เลือกเครื่องมือที่คุณยอมรับพฤติกรรมเมื่อเกิดความล้มเหลวได้ จากนั้นจึงวัดผลจากเซิร์ฟเวอร์ที่คุณใช้งานจริง

สี่ทางเลือก เรียงตามลำดับที่ผู้อ่านส่วนใหญ่ควรพิจารณา:

  • ใช้ฐานข้อมูลที่คุณใช้งานอยู่แล้ว Postgres ร่วมกับ SELECT ... FOR UPDATE SKIP LOCKED เป็น job queue ที่ใช้งานได้จริง และไม่ต้องเพิ่มกระบวนการทำงานใหม่ให้ต้องคอยตรวจสอบ
  • ใช้ RabbitMQ เมื่อข้อความแต่ละรายการเป็นหน่วยงานที่ต้องมีการตอบรับ (acknowledge) มีการลองใหม่ตามจำนวนครั้งที่กำหนด แล้วจึงนำไปพักไว้ในที่ที่มนุษย์สามารถตรวจสอบได้
  • ใช้ NATS เมื่อข้อความคือเหตุการณ์ที่ระบบหลายส่วนต้องตอบสนอง ให้เปิดใช้งาน JetStream สำหรับเหตุการณ์ที่ต้องคงอยู่หลังการรีสตาร์ท
  • ใช้ Kafka เมื่อเครื่องมือปลายทางรองรับเฉพาะโปรโตคอลของ Kafka เท่านั้น บนเซิร์ฟเวอร์เครื่องเดียว นี่แทบจะเป็นเหตุผลเดียวที่เหลืออยู่

ส่วนที่เหลือของคู่มือนี้จะอธิบายเหตุผลประกอบ: แต่ละทางเลือกใช้หน่วยความจำและดิสก์เท่าใดบน VPS ขนาดเล็ก, จะเกิดอะไรขึ้นเมื่อเซิร์ฟเวอร์รีบูต และคำสั่งที่แม่นยำในการตรวจสอบ backlog ก่อนที่ผู้ใช้ของคุณจะรู้สึกถึงปัญหา

ความหมายที่แท้จริงของการรับประกันการส่งข้อความ

At most once หมายความว่า broker ส่งข้อความออกไปแล้วจะลืมข้อความนั้นทันที หากไม่มี consumer เชื่อมต่ออยู่ หรือ consumer ทำงานค้างไว้แล้วหยุดทำงานไป ข้อความนั้นจะสูญหายไปโดยไม่มีการรายงานสถานะใดๆ

At least once หมายความว่า consumer จะส่งการยืนยัน (ack) กลับมาหลังจากประมวลผลงานสำเร็จ จนกว่า ack จะมาถึง broker จะเก็บข้อความนั้นไว้และส่งให้อีกครั้ง การส่งซ้ำเป็นเหตุผลว่าทำไม handler ของคุณต้องเป็น idempotent: การประมวลผลข้อความเดิมซ้ำสองครั้งต้องไม่ทำให้เกิดการตัดบัตรซ้ำซ้อน การรับประกันแบบ Exactly once ตั้งแต่ต้นทางถึงปลายทางไม่ใช่สิ่งที่ broker มอบให้ได้ แต่เป็นสิ่งที่เกิดขึ้นได้จากการใช้ unique key ในฐานข้อมูลของคุณเอง

Replay เป็นคุณสมบัติที่แยกออกมาต่างหาก คิว (queue) จะลบข้อความทิ้งทันทีที่ได้รับ ack แต่ log จะเก็บข้อความไว้ตามระยะเวลาที่กำหนด (retention window) ทำให้ consumer ใหม่สามารถเริ่มอ่านจากจุดเริ่มต้นและไล่ดูประวัติทั้งหมดได้ Kafka และ NATS JetStream จัดเป็น log ส่วน RabbitMQ จัดเป็นคิว ความแตกต่างนี้ส่งผลต่อการออกแบบสถาปัตยกรรมมากกว่าเรื่อง throughput เสียอีก

Dead lettering คือสิ่งที่เกิดขึ้นกับข้อความที่ประมวลผลล้มเหลวซ้ำๆ หากไม่มีกลไกนี้ ข้อความที่เป็นพิษ (poison message) จะวนลูปอยู่ตลอดเวลา และลูปดังกล่าวจะทำให้ดูเหมือนว่า worker กำลังทำงานหนัก แทนที่จะแสดงให้เห็นว่ามีข้อผิดพลาดเกิดขึ้น

เริ่มต้นด้วย Postgres และให้ตัวจัดการคิวพิสูจน์ประสิทธิภาพของมัน

ภาระงานของแอปพลิเคชันเดี่ยวส่วนใหญ่มีงานเบื้องหลังเพียงไม่กี่พันงานต่อวัน ซึ่งสามารถจัดเก็บไว้ในตารางข้อมูลได้

CREATE TABLE job (
  id        bigserial   PRIMARY KEY,
  payload   jsonb       NOT NULL,
  run_after timestamptz NOT NULL DEFAULT now(),
  attempts  int         NOT NULL DEFAULT 0
);
CREATE INDEX job_ready_idx ON job (run_after, id);

เวิร์กเกอร์จะทำการจองงานหนึ่งรายการภายในทรานแซกชัน

BEGIN;
SELECT id, payload
  FROM job
 WHERE run_after <= now()
 ORDER BY id
   FOR UPDATE SKIP LOCKED
 LIMIT 1;
-- run the work, then remove the row
DELETE FROM job WHERE id = $1;
COMMIT;

FOR UPDATE SKIP LOCKED คือหัวใจสำคัญของเทคนิคนี้ มันจะล็อกแถวข้อมูลที่ส่งคืนและข้ามแถวที่ทรานแซกชันอื่นล็อกไว้แล้ว ทำให้เวิร์กเกอร์สองตัวไม่มีทางจองงานเดียวกัน หากเวิร์กเกอร์เกิดการแครช Postgres จะยกเลิกทรานแซกชันนั้น ทำให้การล็อกถูกปลดออกและแถวข้อมูลนั้นจะกลับมาปรากฏให้เวิร์กเกอร์ตัวถัดไปจองได้อีกครั้ง คุณจะได้การส่งมอบงานแบบ at-least-once, การลองใหม่โดยการเพิ่มค่า attempts และตารางสำหรับงานที่ล้มเหลว (dead letter table) ทั้งหมดนี้อยู่บนความทนทานของข้อมูลที่คุณจ่ายเงินซื้ออยู่แล้ว ส่วนงานที่ค้างอยู่สามารถตรวจสอบได้ด้วยคิวรีเดียว: SELECT count(*) FROM job WHERE run_after <= now();

จุดที่วิธีการนี้เริ่มใช้งานไม่ได้ผล เนื่องจากการจองและลบงานแต่ละครั้งคือการเขียนข้อมูลลงดิสก์ อัตราการทำงานที่สูงจึงทิ้งแถวข้อมูลที่ตายแล้ว (dead row versions) ไว้เบื้องหลัง และตารางคิวงานเป็นกรณีคลาสสิกที่ทำให้เกิด bloat จน autovacuum จัดการไม่ทัน งานที่ใช้เวลานานจะยิ่งทำให้ปัญหานี้แย่ลง เพราะทรานแซกชันที่เปิดค้างไว้ตลอดระยะเวลาการทำงานจะขัดขวางการทำ vacuum horizon ของฐานข้อมูลทั้งระบบ การทำ polling เพิ่มความหน่วง และการใช้ LISTEN ร่วมกับ NOTIFY จะช่วยลดการ polling ได้แต่ไม่ได้ลดการเขียนข้อมูล เมื่อตารางงานกลายเป็นตารางที่มีการใช้งานหนักที่สุด หรือเมื่อบริการที่สองต้องการเหตุการณ์เดียวกัน คุณควรย้ายงานนี้ออกไป ทางเลือกดังกล่าวมีผลต่อวิธีการติดตั้งฐานข้อมูล ดังนั้นควรตัดสินใจให้เรียบร้อยว่า ฐานข้อมูลจะรันใน Docker หรือบนโฮสต์ ก่อนที่จะเพิ่มตัวจัดการคิว (broker) เข้าไปข้างๆ

Redis เป็นอีกสิ่งหนึ่งที่คุณอาจใช้งานอยู่แล้ว Redis Streams มอบกลุ่มผู้บริโภค (consumer groups) พร้อมด้วย XADD และ XREADGROUP, รายการงานที่ค้างอยู่ต่อกลุ่ม และ XAUTOCLAIM เพื่อดึงงานกลับมาจากผู้บริโภคที่หยุดทำงานไป มันมีขนาดเล็กและรวดเร็ว ข้อควรระวังตามความเป็นจริงบนเซิร์ฟเวอร์เครื่องเดียวคือ ด้วยการตั้งค่า appendfsync everysec แบบปกติ ไฟดับอาจทำให้ข้อมูลที่เขียนในช่วงประมาณหนึ่งวินาทีสูญหายได้ ซึ่งถือว่ายอมรับได้สำหรับงานประเภท cache invalidation แต่ไม่เหมาะสมสำหรับงานด้านการชำระเงิน หากแอปพลิเคชันของคุณเป็นกระบวนการเดียวที่สร้างขึ้นรอบๆ SQLite ในการใช้งานจริงบน VPS รูปแบบการจองและลบงานแบบเดียวกันนี้ยังคงใช้งานได้ แม้ว่า SQLite จะไม่มีคำสั่งเทียบเท่ากับ SKIP LOCKED และเวิร์กเกอร์ทุกตัวจะต้องรอคิวกันที่ write lock เดียวก็ตาม

NATS core: การกำหนดเส้นทางตามหัวข้อโดยไม่มีหน่วยความจำ

docker run -d --name nats \
  -p 4222:4222 -p 127.0.0.1:8222:8222 \
  nats:2.14 -m 8222

ณ เดือนสิงหาคม 2026 เซิร์ฟเวอร์รุ่นปัจจุบันคือ 2.14 โดย -m 8222 จะเป็นการเปิดใช้งานพอร์ตตรวจสอบสถานะผ่าน HTTP ซึ่งโดยปกติจะปิดไว้และไม่มีการยืนยันตัวตน ดังนั้นควรผูกพอร์ตนี้ไว้กับ localhost ตามที่ระบุไว้ข้างต้น

Core NATS เป็นระบบส่งข้อความแบบ at most once และไม่มีการจัดเก็บข้อมูลใดๆ ผู้ส่ง (publisher) จะส่งข้อความไปยังหัวข้อ (subject) เช่น orders.created และผู้รับ (subscriber) ทุกรายที่มีตัวกรองตรงกับหัวข้อนั้นจะได้รับสำเนาข้อความ หากไม่มีผู้รับรายใดสมัครสมาชิกไว้ ข้อความนั้นจะถูกทิ้งไปและผู้ส่งจะไม่ได้รับข้อผิดพลาดใดๆ เนื่องจากงานของผู้ส่งถือว่าเสร็จสิ้นทันทีที่เซิร์ฟเวอร์รับข้อมูลนั้นไว้ สำหรับกลุ่มคิว (queue group) ซึ่งประกอบด้วยผู้รับหลายรายที่ใช้ชื่อกลุ่มเดียวกัน เซิร์ฟเวอร์จะเลือกสมาชิกเพียงหนึ่งรายต่อหนึ่งข้อความเพื่อกระจายภาระงานโดยไม่ต้องจัดเก็บคิวไว้ในระบบ

การใช้ทรัพยากรของระบบขึ้นอยู่กับสถานะการสมัครสมาชิกและบัฟเฟอร์การเขียนสำหรับแต่ละการเชื่อมต่อ ดังนั้นระบบจึงติดตามจำนวนการเชื่อมต่อแทนที่จะเป็นปริมาณข้อความ และไม่มีข้อมูลสะสมบนดิสก์ พฤติกรรมเมื่อรีสตาร์ทจึงเป็นไปตามหลักการนี้ คือข้อความที่กำลังส่งอยู่จะสูญหายไป ลูกข่ายจะเชื่อมต่อใหม่ด้วยตนเอง และไม่มีขั้นตอนการกู้คืนข้อมูลที่ต้องรอ

เนื่องจากไม่มีคิวค้าง (backlog) ให้ตรวจสอบ จึงควรเฝ้าระวังการสูญหายของข้อความแทน เมื่อผู้รับอ่านข้อมูลจากซ็อกเก็ตช้ากว่าที่เซิร์ฟเวอร์เขียนข้อมูลให้ บัฟเฟอร์ของเซิร์ฟเวอร์สำหรับลูกข่ายรายนั้นจะเต็ม หากลูกข่ายยังประมวลผลไม่ทันตามกำหนดเวลาการเขียน เซิร์ฟเวอร์จะปิดการเชื่อมต่อทั้งหมดและเพิ่มค่าตัวนับ

curl -s http://localhost:8222/varz | jq '.slow_consumers, .connections, .in_msgs, .out_msgs'

หากค่า slow_consumers เพิ่มขึ้นอย่างต่อเนื่อง หมายความว่ามีการทิ้งข้อความเกิดขึ้น ดังนั้นควรตั้งค่าการแจ้งเตือนแทนการตรวจสอบเพียงครั้งเดียว Core NATS เหมาะสำหรับข้อความที่มีอายุสั้น เช่น ข้อมูลตัวชี้วัด (metric), การอัปเดตสถานะการปรากฏตัว (presence update) หรือการยกเลิกความถูกต้องของแคช (cache invalidation) ซึ่งเหตุการณ์ถัดไปจะเข้ามาแทนที่ข้อมูลเดิมอยู่แล้ว

NATS JetStream: durable streams และการเล่นซ้ำข้อมูลในกระบวนการเดียวกัน

JetStream ไม่ใช่ผลิตภัณฑ์แยกต่างหาก แต่เป็นระบบย่อยที่รวมอยู่ใน binary เดียวกัน ซึ่งเปิดใช้งานได้ด้วย flag เพียงตัวเดียว

docker run -d --name nats \
  -p 4222:4222 -p 127.0.0.1:8222:8222 \
  -v nats-data:/data \
  nats:2.14 -js -sd /data -m 8222

-sd /data ใช้สำหรับกำหนดไดเรกทอรีจัดเก็บข้อมูล หากไม่ระบุ JetStream จะจัดเก็บข้อมูลไว้ที่ /tmp ซึ่งมีความทนทานตามชื่อเรียกของมัน คุณสามารถสร้าง stream ได้ด้วย CLI ซึ่งรวมอยู่ในอิมเมจ nats-box

docker run --rm -it --network host natsio/nats-box:latest \
  nats stream add ORDERS \
    --subjects 'orders.>' \
    --storage file \
    --retention limits \
    --max-age 72h \
    --max-bytes=1073741824 \
    --discard old \
    --defaults

ขีดจำกัดทุกรายการมีความสำคัญอย่างยิ่งสำหรับเซิร์ฟเวอร์ขนาดเล็ก --storage file คือสิ่งที่ยังคงอยู่หลังจากการ crash เนื่องจาก stream ที่อยู่ในหน่วยความจำจะไม่ถูกบันทึกไว้ --max-bytes=1073741824 เป็นการจำกัดขนาดของ stream ไว้ที่ 1 GiB โดยระบุเป็นจำนวนไบต์ และ --discard old จะลบข้อความที่เก่าที่สุดทิ้งเมื่อถึงขีดจำกัด แทนที่จะปฏิเสธการเขียนข้อมูลใหม่ หากไม่กำหนดขีดจำกัดไว้ ผู้ส่งข้อมูลที่ทำงานผิดปกติอาจทำให้ดิสก์เต็ม ซึ่งจะส่งผลให้ฐานข้อมูลของคุณหยุดทำงานไปด้วยเนื่องจากใช้ดิสก์ร่วมกัน

durable consumer จะรักษาตำแหน่งของตนเองใน stream ไว้และคงอยู่แม้จะมีการรีสตาร์ท ให้ตั้งค่า --max-deliver บน consumer เพื่อให้ข้อความที่ล้มเหลวซ้ำๆ หยุดการส่งใหม่โดยไม่มีกำหนด เมื่อข้อความถูกส่งจนครบจำนวนครั้งที่กำหนด JetStream จะประกาศ advisory บน $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.> และการสมัครรับข้อมูลในหัวข้อนั้นคือวิธีที่คุณจะสร้างเส้นทางสำหรับ dead letter ซึ่งเป็นฟีเจอร์ที่ RabbitMQ มีให้ใช้งาน นี่คือขั้นตอนการทำงานจริงที่คุณต้องเขียนขึ้นเอง

หากต้องการดูข้อมูลที่ค้างอยู่ ให้รัน nats stream report เพื่อดูจำนวนข้อความที่จัดเก็บไว้ และ nats consumer report ORDERS เพื่อดูการตอบรับที่ยังค้างอยู่และข้อความที่ยังไม่ได้ประมวลผลต่อ consumer หนึ่งตัว จำนวนข้อความที่ยังไม่ได้ประมวลผลคือค่าที่คุณควรตั้งค่าการแจ้งเตือนไว้ ค่าใช้จ่ายของดิสก์สามารถตรวจสอบได้ด้วย du -sh ในไดเรกทอรีจัดเก็บข้อมูล ซึ่งข้อมูลจะเพิ่มขึ้นเรื่อยๆ จนกว่าขีดจำกัดการเก็บรักษาจะเข้ามาจัดการลบข้อมูลออก

RabbitMQ: ยืนยันการรับข้อความแต่ละรายการและจัดการข้อความที่ล้มเหลว

docker run -d --name rabbitmq \
  -p 5672:5672 -p 127.0.0.1:15672:15672 \
  -v rabbitmq-data:/var/lib/rabbitmq \
  rabbitmq:4-management

ณ เดือนสิงหาคม 2026 ซีรีส์ปัจจุบันคือ 4.3 พอร์ต 5672 คือ AMQP (advanced message queuing protocol) และ 15672 คืออินเทอร์เฟซสำหรับจัดการ ให้คงอินเทอร์เฟซไว้ที่ localhost และเข้าถึงผ่าน SSH tunnel เท่านั้น

ประกาศคิวโดยตั้งค่าอาร์กิวเมนต์ x-queue-type เป็น quorum โดยค่าเริ่มต้นยังคงเป็น classic คิวประเภท Quorum จะมีความทนทาน (durable) เสมอและเขียนข้อมูลลงดิสก์ก่อนดำเนินการอื่นใด ดังนั้นบนโหนดเดียวคุณจะได้พฤติกรรมที่ชัดเจนเพียงรูปแบบเดียว แทนที่จะเป็นเมทริกซ์ของตัวเลือกแบบทนทานและแบบชั่วคราว ให้กำหนดปลายทางสำหรับ dead letter ด้วยนโยบาย

docker exec rabbitmq rabbitmqctl set_policy DLX ".*" \
  '{"dead-letter-exchange":"my-dlx", "dead-letter-routing-key":"my-routing-key"}' \
  --apply-to queues --priority 7

ข้อความจะถูกส่งไปยัง dead letter ด้วยเหตุผล 4 ประการ: ผู้บริโภค (consumer) ปฏิเสธข้อความด้วย basic.reject หรือ basic.nack โดยตั้งค่า requeue เป็น false, TTL (time to live) ของข้อความนั้นหมดอายุ, คิวมีขนาดเกินขีดจำกัด หรือข้อความนั้นเกินขีดจำกัดการส่งของ quorum queue ขีดจำกัดดังกล่าวถูกตั้งไว้ที่ 20 ตั้งแต่ RabbitMQ 4.0 เป็นต้นไป ดังนั้นตัวจัดการ (handler) ที่ส่งข้อยกเว้นและ nack จะทำการลองใหม่ 20 ครั้งแล้วส่งข้อความไปยัง dead letter exchange แทนที่จะวนลูปไม่สิ้นสุด

หน่วยความจำคือจุดที่ RabbitMQ มักสร้างความประหลาดใจบน VPS ขนาดเล็ก ค่าเริ่มต้นของ high watermark คือ 0.6 ของ RAM ที่มีอยู่ และเมื่อโหนดใช้งานเกินค่านี้ RabbitMQ จะบล็อกทุกการเชื่อมต่อที่กำลังเผยแพร่ (publishing) แอปพลิเคชันของคุณจะไม่ได้รับข้อผิดพลาด แต่จะได้รับคำสั่งเผยแพร่ที่ไม่ตอบสนอง ซึ่งในโค้ดของคุณจะดูเหมือนโปรแกรมค้าง ใน log ตอนเริ่มต้นระบบจะแสดงตัวเลขที่โหนดคำนวณไว้:

Memory high watermark set to 1024 MiB (1073741824 bytes) of 8192 MiB (8589934592 bytes) total

การแจ้งเตือนเรื่องดิสก์จะบล็อกผู้เผยแพร่ในลักษณะเดียวกันเมื่อพื้นที่ว่างลดลงต่ำกว่า 50 MB ตามค่าเริ่มต้น Quorum queues จะมีการคำนวณเพิ่มเติมของตัวเอง โดยเอกสารระบุว่าต้องใช้ metadata ในหน่วยความจำอย่างน้อย 32 ไบต์ต่อข้อความ หรือประมาณ 1 MB ต่อ 30,000 ข้อความ และแนะนำให้มี RAM อย่างน้อย 3 เท่าของขนาด write-ahead log ที่ใช้งานจริง ค่าเริ่มต้นของ WAL คือ 512 MiB ดังนั้นคำแนะนำนี้เพียงอย่างเดียวก็ต้องการ RAM ถึง 1.5 GB บนเซิร์ฟเวอร์ขนาด 2 GB ให้ปรับลดค่านี้ใน rabbitmq.conf แทนที่จะหวังว่าค่าเริ่มต้นจะใช้งานได้

raft.wal_max_size_bytes = 64000000
vm_memory_high_watermark.relative = 0.5

Backlog ประกอบด้วยตัวเลข 2 ชุด ซึ่งทั้งคู่จะบอกคุณว่าคุณกำลังเผชิญกับความล้มเหลวประเภทใด

docker exec rabbitmq rabbitmqctl list_queues name messages messages_ready messages_unacknowledged

messages_ready คือการรอผู้บริโภค ส่วน messages_unacknowledged คือข้อความที่ถูกส่งไปแล้วแต่ไม่ได้รับการยืนยัน (acked) จำนวน unacknowledged ที่เพิ่มขึ้นในขณะที่จำนวน ready คงที่ หมายความว่า worker ของคุณรับงานไปแล้วแต่ไม่ดำเนินการให้เสร็จสิ้น ซึ่งเป็นบั๊กที่ต่างจากการที่คิวประมวลผลไม่ทันเพียงอย่างเดียว

Kafka บนเครื่องเดียว และจุดที่มันไม่สมเหตุสมผล

KAFKA_CLUSTER_ID="$(bin/kafka-storage.sh random-uuid)"
bin/kafka-storage.sh format --standalone -t $KAFKA_CLUSTER_ID -c config/server.properties
bin/kafka-server-start.sh config/server.properties

นี่คือคู่มือเริ่มต้นใช้งานฉบับย่อสำหรับ Kafka 4.3.1 ซึ่งเป็นเวอร์ชันปัจจุบัน ณ เดือนสิงหาคม 2026 โดยทำงานในโหมด KRaft (Kafka Raft ซึ่งเป็น controller ในตัวที่เข้ามาแทนที่ ZooKeeper ตั้งแต่ Kafka 4.0) สำหรับเวอร์ชัน container คือ apache/kafka:4.3.1

สคริปต์เริ่มต้นจะตั้งค่า export KAFKA_HEAP_OPTS="-Xmx1G -Xms1G" ให้โดยอัตโนมัติหากคุณไม่ได้กำหนดค่าเอง ส่งผลให้ broker จอง Java heap ขนาด 1 GB ก่อนที่จะจัดเก็บข้อความแม้แต่รายการเดียว และยังต้องการ RAM ว่างเพิ่มเติมจากส่วนนี้สำหรับ page cache ที่ใช้ในการอ่านข้อมูล บน VPS ขนาด 2 GB แอปพลิเคชันของคุณจึงต้องแย่งชิงทรัพยากรที่เหลืออยู่กับ JVM

การตั้งค่า retention คือสิ่งที่น่าประหลาดใจถัดมา โดย log.retention.hours มีค่าเริ่มต้นที่ 168 ซึ่งหมายถึง 7 วัน และ log.retention.bytes มีค่าเริ่มต้นที่ -1 ซึ่งหมายถึงไม่มีการจำกัดขนาด Kafka จะเก็บข้อความไว้ตลอดช่วงเวลาดังกล่าวไม่ว่า consumer ทุกตัวจะอ่านข้อความไปแล้วหรือไม่ก็ตาม นี่คือฟีเจอร์ที่คุณต้องการ แต่บนดิสก์ขนาดเล็กมันอาจกลายเป็นสาเหตุที่ทำให้ระบบล้มเหลวได้ ดังนั้นควรตั้งค่าจำกัดขนาดเป็นไบต์ต่อ topic ก่อนที่จะเกิดปัญหา

มาถึงส่วนที่ต้องพูดตามตรง การมี broker เพียงตัวเดียวหมายถึง replication factor เท่ากับ 1 ดังนั้น acks=all จึงหมายถึงการทำ fsync บนดิสก์เพียงลูกเดียว คุณจะได้รับความทนทานของข้อมูลเท่ากับเครื่องเดียว แต่ต้องแลกกับต้นทุนการทำงานของ JVM broker บวกกับ controller การแบ่ง partition ช่วยเพิ่มการประมวลผลแบบขนานในระดับ broker ซึ่งคุณไม่มี ส่วนฟีเจอร์อย่างการทำ replication, rack awareness และฟีเจอร์อื่นๆ สำหรับกลุ่มเซิร์ฟเวอร์จะไม่มีผลใช้งาน JetStream สามารถให้การเล่นซ้ำข้อมูลที่ทนทานได้ในเครื่องเดียวกันโดยใช้หน่วยความจำน้อยกว่ามาก เหตุผลเพียงสองประการที่ยังคงสนับสนุนการใช้ Kafka ในกรณีนี้คือ เครื่องมือปลายทางรองรับเฉพาะโปรโตคอล Kafka เท่านั้น (เช่น การทำ change data capture ด้วย Debezium หรือตัวโหลดข้อมูลสำหรับการวิเคราะห์) หรือคุณกำลังจำลองโครงสร้างการผลิตในขนาดเล็ก การวางแผนที่จะขยายไปสู่คลัสเตอร์คือการวางแผนซื้อเครื่องเพิ่ม และจนกว่าจะถึงตอนนั้น สิ่งที่คุณต้องแลกก็เหมือนกับการ รัน k3s บนโหนดเดียว ที่คุณต้องแบกรับความซับซ้อนของคลัสเตอร์เพื่อแลกกับความน่าเชื่อถือของโหนดเดียว

Backlog ใน Kafka คือ consumer lag

bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group my-group

ให้อ่านค่าในคอลัมน์ LAG ซึ่งคำนวณจาก LOG-END-OFFSET ลบด้วย CURRENT-OFFSET สำหรับแต่ละ partition หากค่า lag เพิ่มขึ้นใน partition เดียวในขณะที่ partition อื่นคงที่ แสดงว่าเกิดปัญหา key ไม่กระจายตัว เนื่องจากข้อความทั้งหมดที่มี key เดียวกันจะถูกจัดเก็บลงใน partition เดียวกัน และ consumer เพียงตัวเดียวจะต้องรับหน้าที่ประมวลผลข้อความเหล่านั้นทั้งหมด

เกิดอะไรขึ้นเมื่อเซิร์ฟเวอร์รีสตาร์ท

Core NATS จะสูญเสียข้อมูลทั้งหมดที่อยู่ระหว่างการประมวลผลและกลับมาทำงานได้ทันที เนื่องจากไม่มีข้อมูลที่ต้องกู้คืน ส่วน JetStream จะโหลด stream และตำแหน่งของ consumer กลับมาจากไดเรกทอรีจัดเก็บข้อมูล ทำให้ consumer สามารถทำงานต่อจาก offset เดิมได้ สำหรับ RabbitMQ จะกู้คืน quorum queues จากดิสก์ ในขณะที่ classic transient queues และข้อความใดก็ตามที่ส่งโดยไม่ได้ระบุโหมด persistent delivery จะสูญหายไป ส่วน Kafka จะทำการ replay log segments เมื่อเริ่มต้นระบบ และหากเกิดการปิดระบบที่ไม่ปกติ (unclean shutdown) การสแกนเพื่อกู้คืนอาจใช้เวลาหลายนาทีบนดิสก์ขนาดเล็กก่อนที่ broker จะยอมรับการเชื่อมต่อ

มีสองสิ่งที่ควรตั้งค่าไว้ตั้งแต่ต้น อย่างแรกคือการกำหนด restart policy (restart: unless-stopped) ให้กับ container หรือเปิดใช้งาน systemd unit เพื่อให้ broker กลับมาทำงานได้เองหลังจากรีบูตเพื่ออัปเกรด kernel อย่างที่สองคือการจัดการลำดับการทำงาน เนื่องจาก broker ที่พร้อมใช้งานหลังจากแอปพลิเคชันของคุณ 20 วินาทีจะปฏิเสธการเชื่อมต่อในช่วงแรก และ client library บางตัวอาจเลือกที่จะปิดตัวลงแทนที่จะพยายามเชื่อมต่อใหม่ คุณควรควบคุมการเริ่มแอปพลิเคชันโดยอิงจากสถานะของ broker ด้วย การใช้ healthchecks ใน Compose เพื่อหน่วงเวลาบริการที่ขึ้นต่อกันจนกว่า broker จะพร้อมใช้งาน

ต้นทุนบน VPS ของคุณเอง วัดผลจริงแทนการอ้างอิงตัวเลข

ตัวเลข throughput ที่เผยแพร่ทั่วไปวัดจากฮาร์ดแวร์ที่คุณไม่ได้ใช้งาน ซึ่งมักเป็นเซิร์ฟเวอร์แบบหลายคอร์ที่ใช้ NVMe ในเครื่อง ให้ถือว่าตัวเลขเหล่านั้นเป็นค่าสูงสุดที่เป็นไปได้ และคุณควรวัดผลบนเซิร์ฟเวอร์ของคุณเอง

docker stats --no-stream
free -m
sudo du -sh /var/lib/docker/volumes/*/_data

ให้รันการทดสอบในขณะที่ broker ไม่มีการทำงาน และรันอีกครั้งภายใต้ปริมาณงานจริงของคุณ ช่องว่างระหว่างสองค่านี้คือตัวเลขที่จะตัดสินว่า broker นั้นสามารถทำงานร่วมกับแอปพลิเคชันของคุณได้หรือไม่ สำหรับค่า throughput ขั้นต่ำโดยประมาณ ให้ใช้เครื่องมือสร้างโหลด (load generator) ของแต่ละโปรเจกต์เองแทนการอ้างอิงจากบล็อกของผู้อื่น ได้แก่ nats bench pub test --msgs 100000 --clients 2 สำหรับ NATS, bin/kafka-producer-perf-test.sh สำหรับ Kafka และ PerfTest สำหรับ RabbitMQ การรันเครื่องมือสร้างโหลดบน VPS เดียวกันเป็นการวัดผลทั้ง broker และตัวสร้างโหลดไปพร้อมกัน ซึ่งสามารถทำได้ตราบใดที่คุณระบุรายละเอียดนี้เมื่อรายงานผลตัวเลข

มีข้อจำกัดหนึ่งประการที่ใช้กับทุกระบบ ตัวเลือกการจัดเก็บข้อมูลแบบทนทาน (durable option) ทุกตัวต้องรอการทำ fsync ดังนั้นบน VPS ที่ใช้ที่เก็บข้อมูลแบบเชื่อมต่อผ่านเครือข่าย (network-attached storage) ตัวดิสก์จะเป็นตัวกำหนดขีดจำกัด และการเปลี่ยนไปใช้ broker ตัวอื่นก็จะไม่ช่วยให้ค่านี้ดีขึ้น

สามภาระงานและคิวข้อความที่แต่ละงานต้องการ

  1. งานเบื้องหลังสำหรับเว็บแอปพลิเคชัน เช่น การส่งอีเมล, การปรับขนาดรูปภาพ หรือการส่ง webhooks ให้เริ่มต้นด้วย Postgres และ SKIP LOCKED จากนั้นให้ย้ายไปใช้ RabbitMQ พร้อมกับ quorum queues เมื่อคุณต้องการการยืนยันรับข้อความ (ack) รายข้อความ, การจำกัดจำนวนการส่ง และคิวสำหรับข้อความที่ส่งไม่สำเร็จ (dead letter queue) ซึ่งคุณสามารถตรวจสอบได้โดยไม่ต้องเขียนตรรกะเหล่านั้นเอง หรือเมื่อตารางเก็บงานกลายเป็นตารางที่มีการใช้งานหนักที่สุดในฐานข้อมูล
  2. เหตุการณ์ที่บริการภายในหลายตัวต้องตอบสนอง โดยที่ข้อความที่สูญหายสามารถถูกแทนที่ด้วยข้อความใหม่ได้อย่างรวดเร็ว ให้ใช้ Core NATS โดยใช้ subjects เป็นรูปแบบการกำหนดเส้นทางและใช้ queue groups ในกรณีที่ต้องการแบ่งเบาภาระงาน เพิ่ม JetStream stream สำหรับกลุ่ม subjects จำนวนจำกัดที่จำเป็นต้องคงอยู่หลังการรีสตาร์ท ส่วนที่เหลือให้เก็บไว้ในหน่วยความจำ
  3. บันทึกเหตุการณ์ที่ผู้บริโภค (consumers) อ่านตั้งแต่ต้น เพื่อใช้เป็นเส้นทางการตรวจสอบ (audit trail), การสร้าง read model ขึ้นใหม่ หรือการส่งข้อมูลไปยังระบบวิเคราะห์ในภายหลัง ให้ใช้ JetStream ร่วมกับที่เก็บข้อมูลแบบไฟล์และกำหนดขีดจำกัดของไบต์ไว้อย่างชัดเจน เลือกใช้ Kafka เฉพาะเมื่อเครื่องมือปลายทางต้องการโปรโตคอลของ Kafka เท่านั้น และยอมรับการใช้ JVM heap เพื่อแลกกับความเข้ากันได้ดังกล่าว

ต้นทุนของการเลือกผิดพลาดบนเซิร์ฟเวอร์เครื่องเดียวนั้นไม่ใช่เรื่องของปริมาณงาน (throughput) แต่คือการกู้คืนระบบตอนตีสาม เมื่อคุณจำเป็นต้องทราบว่าข้อความเหล่านั้นยังคงอยู่หรือไม่ จงเลือกโดยคำนึงถึงจุดนั้นเป็นสำคัญ

FAQ

ฉันสามารถรัน Kafka บน VPS ขนาด 2 GB ได้หรือไม่?

มันสามารถเริ่มทำงานได้ แต่จะค่อนข้างจำกัด bin/kafka-server-start.sh จะตั้งค่า KAFKA_HEAP_OPTS="-Xmx1G -Xms1G" ไว้หากคุณไม่ได้กำหนดค่าทับ ซึ่งจะทำให้ JVM จองหน่วยความจำ 1 GB ก่อนที่จะจัดเก็บข้อความใดๆ และ Kafka ยังต้องอาศัยหน่วยความจำที่เหลือสำหรับ page cache หากคุณรันแอปพลิเคชันและฐานข้อมูลบนเครื่องเดียวกัน คุณจะเริ่มใช้งาน swap นอกจากนี้คุณยังต้องใช้ replication factor 1 ซึ่งหมายความว่า acks=all จะเป็นการทำ fsync บนดิสก์เพียงลูกเดียว คุณจึงต้องแบกรับต้นทุนการทำงานของ Kafka โดยไม่ได้คุณสมบัติเรื่องความทนทานของข้อมูล (durability) ที่แท้จริง NATS JetStream ให้ความสามารถในการ replay ข้อมูลที่ทนทานได้บนฮาร์ดแวร์เดียวกันโดยใช้หน่วยความจำน้อยกว่ามาก

ฉันจำเป็นต้องใช้ message queue หากฉันใช้ Postgres อยู่แล้วหรือไม่?

บ่อยครั้งที่ไม่จำเป็น การอ่านตารางด้วย job ร่วมกับ SELECT ... FOR UPDATE SKIP LOCKED ภายใน transaction ช่วยให้มั่นใจได้ว่าข้อความจะถูกส่งอย่างน้อยหนึ่งครั้ง (at-least-once delivery) รองรับการทำงานแบบขนานที่ปลอดภัย มีการลองใหม่ (retries) และมีตารางสำหรับ dead letter โดยไม่ต้องดูแลบริการเพิ่มเติมและใช้การสำรองข้อมูลที่คุณมีอยู่แล้ว สัญญาณที่บ่งบอกว่าควรแยกออกมาคือเมื่อตารางคิวกลายเป็นภาระการเขียนที่หนักที่สุดจน autovacuum ทำงานไม่ทัน, งานที่ใช้เวลานานทำให้ transaction ค้างและขัดขวางการ vacuum ทั้งฐานข้อมูล หรือเมื่อมีบริการที่สองต้องการอ่านเหตุการณ์เดียวกันอย่างอิสระ

ฉันควรใช้ NATS JetStream หรือ RabbitMQ สำหรับงานเบื้องหลัง (background jobs)?

เลือก RabbitMQ หากคุณต้องการการยืนยันการรับข้อความรายข้อความ (per-message acknowledgement), การจำกัดจำนวนการส่ง และการจัดการ dead letter เป็นฟีเจอร์ในตัว Quorum queues มีความทนทานเสมอ โดย RabbitMQ 4.0 จะตั้งค่าการจำกัดการส่งไว้ที่ 20 เป็นค่าเริ่มต้น และมีนโยบายส่งข้อความที่ล้มเหลวไปยัง dead letter exchange เพื่อให้คุณตรวจสอบได้ เลือก JetStream หากเหตุการณ์เดียวกันจำเป็นต้องถูก replay โดย consumer อื่นในภายหลัง เนื่องจาก stream จะเก็บข้อความไว้แม้จะได้รับ acknowledgement แล้ว ในขณะที่คิวจะไม่เก็บไว้ สำหรับ JetStream คุณต้องตั้งค่า --max-deliver และสร้างเส้นทางสำหรับ dead letter ด้วยตนเองผ่าน $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.> advisory

ฉันจะทราบได้อย่างไรว่า consumer ของฉันประมวลผลช้าไปแค่ไหน?

แต่ละ broker มีคำสั่งเฉพาะ สำหรับ RabbitMQ คำสั่ง rabbitmqctl list_queues name messages messages_ready messages_unacknowledged จะแยกงานที่รอ consumer ออกจากงานที่ส่งไปแล้วแต่ยังไม่ได้รับ ack สำหรับ JetStream คำสั่ง nats consumer report <stream> จะแสดงข้อความที่ยังไม่ได้ประมวลผลและสถานะการรอ acknowledgement ของแต่ละ consumer สำหรับ Kafka คำสั่ง kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group <group> จะแสดงคอลัมน์ LAG ของแต่ละ partition ส่วน Core NATS ไม่มี backlog ให้ตรวจสอบเพราะไม่ได้จัดเก็บข้อมูลใดๆ ให้คุณเฝ้าสังเกตตัวนับ slow_consumers บน http://localhost:8222/varz แทน ซึ่งจะนับจำนวนการเชื่อมต่อที่เซิร์ฟเวอร์ตัดทิ้งเนื่องจากประมวลผลไม่ทัน ซึ่งหมายถึงการสูญหายของข้อความ

#nats#rabbitmq#kafka#message-queue#architecture