Animated Architecture Patterns

Priority Queue

ประมวลผล message ด่วนก่อน message ทั่วไป

worker pool priority=high priority=low 1st 2nd Order service urgent emails flash sale: 12/s Catalogue import nightly re-index jobs high-priority queue one queue, first in first out low-priority queue ! ! ! ! ! ! ! ! ! ! ! depth 0 · oldest – depth 1 · oldest 0 s ▲ depth and oldest age rising ▼ depth and oldest age falling depth 500 oldest 50 s oldest 3 min oldest 6 min oldest 10 min 500 jobs ahead of the email starving moving: 2.5 jobs/s or publish to one topic whose subscriptions filter on a priority attribute Reserve: worker 4 takes only low work (or pull high and low in a fixed 3 : 1 ratio) 3 Who may send urgent work? Only the order service may publish to high. If every producer marks its messages urgent, nothing is. denied low only long job worker 1 worker 2 worker 3 worker 4 ! ! ! ! ✓ sent 4 workers × 2.5 jobs/s = 10 jobs/s all 4 on high: 10 jobs/s low: 0 jobs/s high: 3 × 2.5 = 7.5 jobs/s low: 1 × 2.5 = 2.5 jobs/s high: 6 × 2.5 = 15 jobs/s low: 1 × 2.5 = 2.5 jobs/s 2 No pre-emption Worker 1 keeps its long low job; the urgent message waits for worker 2. Split long jobs into short ones. ! Password-reset email 500 jobs ahead ÷ 10 jobs/s the user waits 50 s Password-reset email high queue empty: next free worker (~0.1 s) + 0.4 s of work sent after ≈ 0.5 s Flash sale: 12 urgent orders/s arrive; the pool finishes 10/s. High-first polling: high is never empty, so low gets nothing. Scale each pool on its own queue: high grows to 6 workers, 15 jobs/s. 1 Priority inside one queue? RabbitMQ: yes, in classic and quorum queues. SQS, Service Bus, Pub/Sub, Kafka: no; use a queue, subscription or topic per level. ! ! ! ! ! ! ! password reset

ขั้นที่ 1 · มาก่อนได้ก่อน งาน import ตอนกลางคืนใส่ งาน re-index 500 งาน ไว้ใน queue เดียวที่มี แล้วอีเมล password reset ก็มาต่อท้ายงานพวกนี้ worker สี่ตัวทำได้ตัวละ 2.5 งานต่อวินาที รวมเป็น 10 ทำให้อีเมลต้องรอ 500 ÷ 10 = 50 s ระหว่างที่ผู้ใช้นั่งจ้องหน้าจออยู่ ใน queue เดียว ทุก message มีฐานะเท่ากันหมด ไม่ว่าจะมีไว้ทำอะไร

ขั้นที่ 2 · แยก queue ตาม priority producer ติดป้ายให้แต่ละ message แล้วส่งไปที่ queue high หรือ low (หรือ publish ไปที่ topic เดียว แล้วให้แต่ละ subscription filter ตาม attribute ที่บอก priority) worker เช็ก queue high ก่อนเสมอ อีเมล password reset เลยเจอ queue high ว่างอยู่ ได้ไปที่ worker ตัวถัดไปที่ว่าง และส่งออกไปได้ราว 0.5 s หลังจากเข้ามา ส่วนงาน import ก็ทำต่อไปใน queue low

ขั้นที่ 3 · Starvation แล้วกันกำลังไว้ flash sale ส่ง order confirmation ด่วนเข้ามา 12 รายการต่อวินาที มากกว่า 10 ที่ pool ทำได้ พอ poll queue high ก่อนเสมอ queue high ก็ไม่เคยว่าง และ queue low ไม่ได้อะไรเลย: ไม่นาน message ที่เก่าที่สุดในนั้นก็อายุ 10 นาที แล้ว ทางแก้คือ กันกำลังไว้ ให้ queue low (worker 4 รับแค่งาน low หรือให้ pool ดึง high กับ low ในอัตรา 3 : 1) high เลยได้ 7.5 งานต่อวินาที และ low ได้ 2.5 แล้ว scale แต่ละ pool ตาม queue ของตัวเอง: pool ของ high ขยายเป็น worker หกตัว ได้ 15 งานต่อวินาที

ขั้นที่ 4 · รู้ข้อจำกัด RabbitMQ เรียงตาม priority ใน queue เดียวได้ (ทั้ง classic queue และ quorum queue) ส่วน Amazon SQS, Azure Service Bus, Google Cloud Pub/Sub และ Kafka ต้องแยก queue, subscription หรือ topic ตามแต่ละระดับ ตัว priority เองมีผลแค่ว่า message ไหนจะได้หยิบไปทำเป็นตัวถัดไป: งาน low-priority ยาว ๆ ของ worker 1 ก็ยังครอง worker ตัวนั้นอยู่ เลยควรแบ่งงานยาวให้สั้นลง และต้องกำหนดว่าใครส่งงานด่วนได้บ้าง: ถ้า producer ทุกตัวติดป้ายว่าด่วนหมด ก็ไม่มีอะไรด่วนจริงสักอย่าง

ปัญหา

queue ส่วนใหญ่แจกงานตามลำดับที่มาถึง แบบนี้ยุติธรรมดี แต่ไม่สนเลยว่าแต่ละ message มีไว้ทำอะไร พอ batch ตอนกลางคืนใส่งานประจำหลายร้อยงานลงใน queue เดียวกับงานแบบ interactive ทำให้อีเมล password reset หรือข้อความ "payment failed" ก็ต้องรอหลังงานพวกนั้นทั้งหมด แล้วคนที่นั่งอยู่หน้าจอก็ต้องรอไปด้วย consumer ที่มากขึ้นช่วยลดเวลารอโดยเฉลี่ยได้ แต่ message ด่วนทุกตัวก็ยังนั่งอยู่หลัง backlog ทั้งหมดที่มาถึงก่อนมันอยู่ดี

ทำงานยัง⁠ไง

ให้แต่ละ message มี priority ตั้งแต่ตอนที่สร้างขึ้นมา แล้วให้ consumer หยิบงานที่ priority สูงกว่าไปก่อน มีสองแบบ:

  • queue เดียวที่ broker จัดการ priority ให้ broker เรียง queue ตาม field priority แล้วส่ง message ที่สำคัญที่สุดออกไปก่อน RabbitMQ ทำแบบนี้ โดย classic queue ต้องใส่ argument x-max-priority ตอนประกาศ queue (1 ถึง 255 ส่วนเอกสารแนะนำให้ใช้แค่ไม่กี่ระดับ และตั้งผ่าน policy ไม่ได้) ส่วน quorum queue รองรับ priority เสมอ: ตั้งแต่ RabbitMQ 4.3 เป็นแบบเข้มงวด มีระดับ 0 ถึง 31 ส่วน version 4.0 ถึง 4.2 มีสองระดับคือ normal กับ high โดยส่ง high สองตัวต่อ normal หนึ่งตัว
  • queue หรือ subscription หนึ่งตัวต่อ priority producer ส่งแต่ละ message ไปที่ queue high หรือ queue low หรือ publish ไปที่ topic เดียว แล้วให้ subscription ของแต่ละ priority filter ตาม attribute แบบนี้ใช้ได้กับ broker ที่ไม่มี field priority: Amazon SQS (AWS แนะนำให้แยก queue สำหรับจัด priority ส่วน fair queue ของมันใช้เกลี่ยระหว่าง tenant ไม่ใช่ความด่วน) Azure Service Bus (ตัวอย่างของ Azure pattern ตั้ง property Priority เอง แล้ว route ด้วย SQL filter ไปที่ subscription high กับ low) Google Cloud Pub/Sub (message มี attribute แต่ไม่มี priority และ subscription filter จับคู่กับ attribute ได้) และ Apache Kafka (topic หนึ่งตัวต่อ priority)

จากนั้น consumer ก็เลือกว่าจะหยิบอะไรต่อ:

  • Poll high ก่อน pool เดียวเช็ก queue high และหยิบงาน low ก็ต่อเมื่อ high ว่าง วิธีนี้ทำให้งานด่วนรอสั้นที่สุดและไม่เปลือง capacity เลย แต่งาน low จะอดตายทุกครั้งที่ high ไม่เคยว่าง
  • pool เฉพาะของแต่ละ queue แต่ละ queue มี consumer และขีดจำกัดการ scale ของตัวเอง ตัวอย่างของ Azure ให้ consumer ของ high scale out ได้ถึง 200 instance และของ low ได้ 40 แต่ละ class แยกขาดจากกัน แต่ consumer ของ high ที่ว่างอยู่ก็ไม่ได้ช่วยระบาย backlog ของ low เว้นแต่เราจะยอมให้มันช่วย
  • อัตราส่วนแบบถ่วงน้ำหนัก pool หยิบ เช่น high สามตัวต่อ low หนึ่งตัว งาน low ก็ขยับได้ตลอด แลกกับ latency ของงานด่วนที่เพิ่มขึ้นนิดหน่อย

ใน diagram มี worker สี่ตัว ทำได้ตัวละ 2.5 งานต่อวินาที (รวม 10 งาน ใช้ 0.4 s ต่องาน) ถ้าอยู่หลังงานประจำ 500 งานใน queue เดียว อีเมล reset ต้องรอ 500 ÷ 10 = 50 s แต่ถ้ามี queue high มันรอแค่ worker ตัวถัดไปที่ว่าง

ใช้ตอนไหนดี

  • งาน interactive ใช้ worker ร่วมกับงาน batch: password reset กับ order confirmation อยู่ข้างงาน import, re-index หรือการสร้าง report
  • ลูกค้าหรือ tenant ซื้อระดับบริการต่างกัน และ request ของ premium ต้องมีเป้า latency ที่แคบกว่า
  • alert กับ page ห้ามไปรอหลัง report หรือ digest ประจำ
  • ไม่เหมาะเมื่องานทุกอย่างด่วนเท่ากัน หรือเมื่อสิ่งที่สำคัญมีแค่ throughput เฉลี่ย: priority เพิ่ม queue, routing rule และรูปแบบความล้มเหลว โดยไม่ได้ช่วยเรื่องไหนให้ดีขึ้นเลย

ได้อะไร เสียอะไร

  • Starvation การให้บริการ high ก่อนแบบเข้มงวดอาจรั้งงาน low ไว้ได้นานเท่าที่งานด่วนยังเข้ามาเรื่อย ๆ ให้จำกัดมันไว้: กัน consumer ไว้ให้ low (worker หนึ่งในสี่ตัวใน diagram ทำให้ high ได้ 7.5 งานต่อวินาที และ low ได้ 2.5) ดึงงานในอัตราส่วนตายตัว หรือทำ aging ให้ message โดยเลื่อนอะไรก็ตามที่รอเกินเป้าไปไว้ใน queue ที่สูงกว่า เอกสารของ RabbitMQ บอกว่า classic queue ให้บริการแต่ละระดับ priority เป็นรอบ ๆ ระดับที่ต่ำกว่าเลยไม่มีวันอดตาย ส่วน quorum queue แบบเข้มงวดอาจทำให้ระดับต่ำล่าช้าไปได้เรื่อย ๆ ไม่มีกำหนด
  • ไม่มี pre-emption priority แค่เลือก message ถัดไป งาน low-priority ยาว ๆ ที่เริ่มไปแล้วจะครอง worker ไว้จนกว่าจะเสร็จ เลยควรแบ่งงานยาวเป็นขั้นสั้น ๆ ที่มี checkpoint แล้ว worker จะได้กลับมาที่ queue บ่อย ๆ
  • ลำดับข้าม priority หายไป message ด่วนที่มาทีหลังจะแซง message ประจำที่มาก่อน ถ้า message สองตัวแก้ entity เดียวกัน ให้เก็บไว้ใน class เดียวกัน หรือทำให้ consumer รับมือกับลำดับแบบไหนก็ได้
  • priority ไม่ใช่ capacity ถ้างานด่วนอย่างเดียวก็เกินกว่าที่ consumer ทำเสร็จได้แล้ว queue high ก็จะโตด้วย ให้ scale consumer ของแต่ละ queue ตาม depth ของ queue นั้นเอง หรือตามอายุของ message ที่เก่าที่สุดในนั้น: ดู Autoscaling และ Competing Consumers
  • ชิ้นส่วนที่ต้องดูแลมากขึ้น queue ที่แยกกันก็มาพร้อม dead-letter queue, alarm, permission และค่าใช้จ่ายของการ poll ที่แยกกันด้วย

ข้อควรรู้ตอนลงมือทำ

  • priority มีผลก็ต่อเมื่องานต้องรอ ถ้าใช้ RabbitMQ ให้ใช้ manual acknowledgement กับ prefetch เล็ก ๆ: consumer ที่ prefetch เยอะจะหยิบ message low-priority ไปแล้วตั้งแต่ก่อนที่ตัวด่วนจะมาถึง ถ้าใช้ SQS การทำ long polling กับ queue high ที่ว่างจะดึง loop ไว้ได้นานถึง wait time ก่อนจะไปดู queue low เลยควรใช้ wait สั้น ๆ ใน loop ที่ดู high ก่อน หรือแยก poller กัน
  • Kafka ไม่มี priority ของ message consumer fetch จากทุก partition ของมันพร้อมกัน ถ้าจะให้ topic ที่ priority สูงได้เปรียบ ให้ pause partition ของ topic low ระหว่างที่ high มี backlog แล้ว resume ทีหลัง หรือรัน consumer group แยกกันต่อ topic
  • ตัดสินว่าใครเป็นคนตั้ง priority ให้ได้มาจากข้อเท็จจริงที่ระบบเชื่อถือ (ประเภท message, plan ของลูกค้า) ไม่ใช่จาก flag ที่ producer ไหนก็ตั้งได้ จำกัดว่าใคร publish เข้า queue high ได้ด้วย credential หรือ topic permission ที่แยกกัน และเฝ้าดูปริมาณต่อ producer: ถ้า producer ทุกตัวติดป้ายว่าด่วนหมด ก็ไม่มีอะไรด่วนจริง
  • scale แต่ละ queue ตามสัญญาณของตัวเอง KEDA มี scaler สำหรับ RabbitMQ, Amazon SQS, Azure Service Bus, Google Cloud Pub/Sub และ Kafka ตัว Pub/Sub scaler ใช้อายุของ message ที่ยังไม่ acknowledge ตัวที่เก่าที่สุดเป็นเกณฑ์ scale ได้ ส่วน SQS ก็ publish สัญญาณที่ตรงกันเป็น CloudWatch metric ApproximateAgeOfOldestMessage
  • dead-letter แยกต่อ queue ให้แต่ละ priority มี dead-letter queue ของตัวเอง แบบนี้ poison message ที่ด่วนจะไม่ block เส้นทาง high และ alarm ของมันก็จะไปถึงทีมที่ถูกต้อง
  • monitor แยกต่อ class ติดตาม depth กับอายุของ message ที่เก่าที่สุดของแต่ละ queue เทียบกับเป้าของแต่ละ class เช่นไม่กี่วินาทีสำหรับ high และหนึ่งชั่วโมงสำหรับ low ค่าเฉลี่ยข้าม class จะซ่อน starvation ไว้
  • จัดลำดับงานที่ขอบระบบด้วย queue เป็นแค่ที่หนึ่งที่จัด priority ได้ ที่ API ให้ทิ้ง request ที่สำคัญน้อยที่สุดก่อนเมื่อโหลดเกิน (Rate Limiting ส่วนหนังสือ SRE ของ Google อธิบาย criticality level ที่เดินทางไปกับแต่ละ request) และให้แต่ละ class มี pool ของตัวเอง ตัวหนึ่งจะได้ใช้ทรัพยากรของอีกตัวจนหมดไม่ได้ (Bulkhead)