Background job และ queue: ย้ายงานช้าออกจาก request พร้อม retry ที่ไม่ส่งซ้ำ
ส่งอีเมล ย่อรูป เรียก AI ไม่ควรทำใน request ทำ job queue ด้วย Postgres SKIP LOCKED พร้อม retry แบบ backoff, visibility timeout, dead-letter และทำให้ job รันซ้ำได้ปลอดภัย
- งานที่ช้าหรือพึ่งบริการภายนอก ให้ request แค่บันทึก job แล้วตอบทันที worker ค่อยทำทีหลัง
- queue เกือบทุกตัวส่งแบบ at-least-once job จึงต้องรันซ้ำได้โดยผลไม่เพี้ยน (idempotent)
- retry ด้วย exponential backoff + jitter มีเพดาน และจำนวนครั้งสูงสุด ครบแล้วย้ายไป dead-letter ให้คนดู
- เริ่มง่ายสุด: ตาราง jobs ใน Postgres ที่มีอยู่แล้ว ใช้ FOR UPDATE SKIP LOCKED ให้หลาย worker แย่ง job กันโดยไม่ชน
ผู้ใช้กดสมัครสมาชิกแล้วหน้าเว็บหมุนสองวินาที เพราะ request ต้องรอส่งอีเมลและย่อรูปโปรไฟล์ให้เสร็จก่อน หรือแย่กว่านั้น SMTP ล่มห้านาทีแล้วสมัครไม่ได้ทั้งระบบ ทางแก้คือ background job: request บันทึกว่า “มีงานต้องทำ” แล้วตอบทันที โปรแกรมอีกตัวค่อยทำงานนั้นทีหลัง บทความนี้อธิบายส่วนประกอบที่ทำให้ queue ไว้ใจได้ และทำตาราง jobs บน Postgres ที่ใช้งานได้จริง
SQL ทุกบล็อกทดสอบบน PostgreSQL 18.6 (รันผ่าน PGlite 0.5.11) ค่าเริ่มต้นของ SQS, BullMQ และ Sidekiq ตรวจกับเอกสารทางการ ณ ตุลาคม 2026
ทำไมต้องย้ายงานช้าออกจาก request
สมมติ handler สมัครสมาชิกใช้เวลาแบบนี้
| ขั้น | เวลา |
|---|---|
| บันทึกผู้ใช้ลงฐานข้อมูล | 40 ms |
| ส่งอีเมลยืนยันผ่าน SMTP | 1,200 ms |
| ย่อรูปโปรไฟล์ | 800 ms |
| รวม | 2,040 ms |
ย้ายสองขั้นหลังเป็น job: request เหลือบันทึกผู้ใช้ + insert แถว job สองแถวใน transaction เดียว ราว 40 ms บวกเล็กน้อย เร็วขึ้นราว 50 เท่า (2,040 ÷ 40 ≈ 51) และที่สำคัญกว่าความเร็วคือ SMTP ล่มไม่ทำให้สมัครไม่ได้อีกต่อไป job จะรอและลองใหม่เอง
งานที่ควรเป็น job: ส่งอีเมล/LINE/แจ้งเตือน, ย่อรูปและแปลงวิดีโอ, เรียก AI API ที่ตอบช้าและติด rate limit, สร้างรายงานและ PDF, webhook ไปหาระบบอื่น งานที่ผู้ใช้ต้องเห็นผลทันที (ตรวจรหัสผ่าน, ตัดสต็อก) ยังอยู่ใน request
ส่วนประกอบของ queue ที่ไว้ใจได้
| คำ | ความหมาย |
|---|---|
| producer | โค้ดที่ใส่ job ลง queue (ปกติคือ request handler) |
| worker | process ที่ดึง job มาทำ รันกี่ตัวก็ได้ |
| at-least-once | queue รับประกันว่า job จะถูกทำ “อย่างน้อยหนึ่งครั้ง” อาจมากกว่า |
| visibility timeout / lease | ช่วงเวลาที่ job ถูกจองโดย worker ตัวหนึ่ง ถ้าไม่บอกว่าเสร็จภายในเวลานี้ job จะกลับมาให้ตัวอื่นหยิบ |
| retry + backoff | ล้มแล้วลองใหม่ โดยรอนานขึ้นเรื่อย ๆ |
| dead-letter | ที่พักของ job ที่ลองครบแล้วยังล้ม รอคนมาดู |
at-least-once แปลว่า job ต้องรันซ้ำได้
worker ส่งอีเมลสำเร็จ แล้ว process ตายก่อนบันทึกว่าเสร็จ queue เห็นแค่ว่า lease หมดอายุ จึงให้อีกตัวทำใหม่ อีเมลออกสองฉบับ ไม่มี queue ไหนแก้ปัญหานี้ได้จากฝั่ง queue เอง เอกสาร SQS เขียนตรง ๆ ว่าแม้อยู่ใน visibility timeout ก็ไม่รับประกันว่าข้อความจะไม่ถูกส่งซ้ำ
ทางแก้อยู่ที่ตัว job ทำให้มัน idempotent คือทำซ้ำกี่ครั้งผลก็เหมือนทำครั้งเดียว
- ย่อรูป เขียนผลไปที่ path ที่กำหนดจาก input เช่น
avatars/{user_id}/256.webpทำซ้ำก็แค่เขียนทับไฟล์เดิม idempotent โดยธรรมชาติ - ตัดเงิน/สร้างรายการที่บริการภายนอก ใช้ idempotency key ของผู้ให้บริการ เช่น Stripe รับ header
Idempotency-Keyส่ง key เดิมซ้ำจะได้ผลลัพธ์เดิมกลับมาแทนการสร้างรายการใหม่ (Stripe ลบ key ที่อายุเกิน 24 ชั่วโมงได้) ใช้ id ของ job หรือ order เป็น key - ส่งอีเมล ไม่มี key แบบนั้น ต้องเลือกว่ายอมผิดแบบไหน บันทึก “ส่งแล้ว” ก่อนส่ง = อาจไม่ได้ส่งเลย บันทึกหลังส่ง = อาจส่งซ้ำ ใบเสร็จส่วนใหญ่ยอมส่งซ้ำดีกว่าหาย
queue รับประกันแค่ “อย่างน้อยหนึ่งครั้ง” ส่วน “ผลเกิดครั้งเดียว” เป็นหน้าที่ของโค้ดใน job
ตาราง jobs บน Postgres ด้วย SKIP LOCKED
ถ้ามี Postgres อยู่แล้ว ไม่ต้องลงอะไรเพิ่ม FOR UPDATE SKIP LOCKED ทำให้แต่ละ worker ข้ามแถวที่ตัวอื่นล็อกอยู่แทนการรอ เอกสาร Postgres ระบุว่าเหมาะกับตารางแบบ queue ที่มีผู้บริโภคหลายตัว และเป็นกลไกเดียวกับที่ pg-boss (Node) และ Solid Queue (ค่าเริ่มต้นของ Rails 8) ใช้
CREATE TABLE jobs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
kind text NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'ready', -- ready | running | done | dead
attempts int NOT NULL DEFAULT 0,
max_attempts int NOT NULL DEFAULT 8,
run_at timestamptz NOT NULL DEFAULT now(),
locked_until timestamptz,
last_error text,
dedupe_key text UNIQUE
);
CREATE INDEX jobs_due ON jobs (run_at) WHERE status IN ('ready', 'running');
ใส่ job ใน transaction เดียวกับข้อมูลธุรกิจ นี่คือข้อได้เปรียบใหญ่ของ queue บนฐานข้อมูลเดียวกัน order commit = job มีแน่ order rollback = job หายไปด้วย ไม่มีกรณี “บันทึก order แล้วแต่ส่ง job เข้า Redis ไม่ทัน”
BEGIN;
INSERT INTO orders (id, total) VALUES (1042, 59000);
INSERT INTO jobs (kind, payload, dedupe_key)
VALUES ('send_receipt', '{"order_id": 1042}', 'receipt:1042')
ON CONFLICT (dedupe_key) DO NOTHING;
COMMIT;
dedupe_key กันการ ใส่ job ซ้ำ (เช่นผู้ใช้กดปุ่มสองครั้ง) แต่ไม่กันการ ทำ ซ้ำ ซึ่งยังต้องพึ่ง idempotency ข้างบน
worker หยิบ job จองด้วย locked_until ซึ่งทำหน้าที่เป็น visibility timeout แล้ว commit ทันที ไม่ถือ transaction ค้างระหว่างทำงาน job ที่ lease หมดอายุ (worker ตาย) จะถูกหยิบใหม่
UPDATE jobs
SET status = 'running',
attempts = attempts + 1,
locked_until = now() + interval '5 minutes'
WHERE id = (
SELECT id FROM jobs
WHERE attempts < max_attempts
AND ((status = 'ready' AND run_at <= now())
OR (status = 'running' AND locked_until < now()))
ORDER BY run_at
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING id, kind, payload, attempts;
สำเร็จ และ ล้ม (retry แบบ backoff 30 วินาที × 2 ทุกครั้ง เพดาน 1 ชั่วโมง คูณ jitter 0.5-1.0 ครบ max_attempts แล้วเป็น dead)
-- สำเร็จ
UPDATE jobs SET status = 'done', locked_until = NULL WHERE id = $1;
-- ล้ม: $1 = ข้อความ error, $2 = id
UPDATE jobs
SET last_error = $1,
locked_until = NULL,
status = CASE WHEN attempts >= max_attempts THEN 'dead' ELSE 'ready' END,
run_at = now() + least(interval '1 hour',
interval '30 seconds' * power(2, attempts - 1))
* (0.5 + random() / 2)
WHERE id = $2;
worker ใน Python ด้วย psycopg 3 (เปลี่ยน $1 $2 เป็น %s ตามลำดับ) สั้นแค่นี้
import time, psycopg
conn = psycopg.connect("dbname=app", autocommit=True) # แต่ละคำสั่ง commit เอง
while True:
job = conn.execute(CLAIM_SQL).fetchone()
if job is None:
time.sleep(1)
continue
job_id, kind, payload, attempts = job
try:
HANDLERS[kind](payload) # ฟังก์ชันของแต่ละงาน ต้อง idempotent
conn.execute(DONE_SQL, (job_id,))
except Exception as e:
conn.execute(FAIL_SQL, (repr(e)[:500], job_id))
job ที่ทำให้ worker ตายทุกครั้ง (เช่นรูปเสียจน library crash) จะไม่ผ่าน FAIL เลย ใส่ cron กวาดทุกนาที
UPDATE jobs SET status = 'dead', last_error = coalesce(last_error, 'lease expired')
WHERE status = 'running' AND locked_until < now() AND attempts >= max_attempts;
ตัวอย่างคำนวณ: retry กี่ครั้งถึงพอ
สมมติ SMTP ของผู้ให้บริการล่ม 20 นาที backoff ตามสูตรข้างบน (ก่อน jitter) รอหลังครั้งที่ n = 30 × 2^(n-1) วินาที
| ล้มครั้งที่ | รอ | รอสะสม |
|---|---|---|
| 1 | 30 s | 30 s |
| 2 | 60 s | 1.5 นาที |
| 3 | 120 s | 3.5 นาที |
| 4 | 240 s | 7.5 นาที |
| 5 | 480 s | 15.5 นาที |
| 6 | 960 s | 31.5 นาที |
| 7 | 1,920 s | 63.5 นาที |
max_attempts = 5: ลองครั้งที่ 5 ที่นาทีที่ 7.5 ยังล่มอยู่ job เป็นdeadทั้งหมด ต้องมีคนมากดลองใหม่max_attempts = 8: ครั้งที่ 8 เกิดที่ 3,810 วินาที ≈ 63.5 นาที ถ้า jitter ออกต่ำสุดทุกครั้ง (×0.5) ก็ยัง ≈ 31.75 นาที พ้นช่วงล่ม 20 นาทีแน่นอน
jitter สำคัญเพราะถ้าอีเมล 500 ฉบับล้มพร้อมกันตอน SMTP ล่ม ไม่มี jitter ทั้ง 500 จะกลับมายิงพร้อมกันทุกรอบ AWS Builders’ Library อธิบายว่าการสุ่มกระจายเวลาคือสิ่งที่กันไม่ให้ retry กลายเป็นโหลดถล่มซ้ำ
จำนวน worker: งานสรุปด้วย AI ใช้ 8 วินาทีต่อ job เข้ามา 600 job ต่อชั่วโมง = 600 × 8 = 4,800 วินาทีงานต่อชั่วโมง ÷ 3,600 = 1.33 worker ต้องมีอย่างน้อย 2 ถ้ามี 300 job เข้าพร้อมกันแล้วรัน 4 worker จะใช้ 300 × 8 ÷ 4 = 600 วินาที = 10 นาทีถึงเคลียร์คิว เช็คด้วยว่า rate limit ของ API รับ 4 คำขอพร้อมกันได้
visibility timeout ต้องยาวกว่างาน
lease 5 นาทีในตัวอย่าง ตั้งจาก “งานที่ช้าที่สุดที่ยอมรับได้” ไม่ใช่ค่าเฉลี่ย ถ้างานใช้ 6 นาทีแต่ lease 5 นาที worker ตัวที่สองจะหยิบไปทำซ้ำทั้งที่ตัวแรกยังทำอยู่ งานยาวไม่แน่นอนให้ worker ต่อ lease เป็นระยะ (heartbeat) ซึ่ง SQS แนะนำแบบเดียวกันผ่าน ChangeMessageVisibility แต่ตั้งยาวเกินไปก็ไม่ดี worker ตายแล้ว job จะค้างนานเท่า lease ก่อนมีคนหยิบ
ตัวเลขอ้างอิง: SQS ค่าเริ่มต้น 30 วินาที สูงสุด 12 ชั่วโมงนับจากรับครั้งแรก
dead-letter: ที่ที่คนต้องมาดู
job ที่ลองครบแล้วยังล้ม มักเป็นบั๊กหรือข้อมูลเสีย ไม่ใช่ปัญหาชั่วคราว ลองต่อไปก็ไม่หาย จึงแยกไว้ (status = 'dead' ในตารางเรา, Dead set ใน Sidekiq, DLQ ใน SQS ที่ย้ายเมื่อรับเกิน maxReceiveCount) สิ่งที่ต้องมีคู่กันคือ การแจ้งเตือน dead-letter ที่ไม่มีใครดูคือการทิ้งงานเงียบ ๆ ตั้ง query SELECT count(*) FROM jobs WHERE status = 'dead' เข้าระบบแจ้งเตือนผ่าน Telegram ได้เลย และเก็บ last_error ไว้เสมอ
Postgres หรือ Redis
| Postgres + SKIP LOCKED | Redis-based (BullMQ, Sidekiq) | |
|---|---|---|
| ต้องลงเพิ่ม | ไม่ ใช้ฐานข้อมูลเดิม | Redis อีกตัว |
| ใส่ job พร้อมข้อมูลธุรกิจ | transaction เดียว | คนละระบบ ต้องระวังกรณี commit แล้วส่ง job ไม่ทัน |
| retry ในตัว | เขียนเอง (ข้างบน) หรือ pg-boss / Solid Queue | BullMQ: attempts + backoff 2^(attempts-1) × delay, Sidekiq: 25 ครั้งราว 20 วัน |
| dead-letter | status = 'dead' | Sidekiq Dead set (เก็บ 10,000 job หรือ 6 เดือน) |
| ข้อควรตั้ง | index + ลบ job done เก่าเป็นระยะ | เปิด AOF, maxmemory-policy noeviction |
ทีมเล็กที่มี Postgres อยู่แล้ว เริ่มจากตาราง jobs ก่อน ถ้ามี Redis อยู่แล้วสำหรับcache ทางด่วน และเขียน Node อยู่ BullMQ ก็เป็นทางเลือกที่ดี แต่ต้องตั้ง Redis ให้เป็นที่เก็บข้อมูลจริง ไม่ใช่แค่ cache
ข้อผิดพลาดที่เจอบ่อย
- ถือ transaction เปิดไว้ระหว่างทำงาน ล็อกแถวค้าง 8 วินาทีต่อ job และกิน connection หยิบแล้ว commit ก่อน ค่อยทำงาน
- retry ทันทีไม่มี backoff บริการปลายทางล่มอยู่แล้ว ยิงซ้ำทันทีเท่ากับซ้ำเติม
- retry ไม่มีที่สิ้นสุด job พังถาวรวนกินทรัพยากรตลอดไป
- เชื่อว่า queue ส่งครั้งเดียว แล้วเขียน job ที่ตัดเงินสองรอบได้
- ส่ง job เข้าอีกระบบ (เช่น Redis) ก่อน commit ข้อมูล worker หยิบไปทำแล้วหา order ไม่เจอ เพราะ transaction ยังไม่ commit
- ไม่ลบ job ที่เสร็จแล้ว ตาราง jobs โตไม่หยุด ลบ
doneที่เก่ากว่า 7 วันทุกคืน - lease สั้นกว่างานที่ช้าที่สุด job ถูกทำซ้ำซ้อนกันเงียบ ๆ
แหล่งอ้างอิง
- SELECT — The Locking Clause (SKIP LOCKED), PostgreSQL documentation
- Amazon SQS visibility timeout — AWS documentation
- Using dead-letter queues in Amazon SQS — AWS documentation
- Timeouts, retries, and backoff with jitter — AWS Builders’ Library
- Retrying failing jobs — BullMQ docs และ Going to production — BullMQ docs
- Error Handling — Sidekiq wiki
- Idempotent requests — Stripe API reference
- pg-boss — GitHub และ Solid Queue — GitHub
คำถามที่พบบ่อย
ทำไมไม่ทำให้ queue ส่งแค่ครั้งเดียว (exactly-once)
เพราะ worker อาจทำงานเสร็จแล้วตายก่อนบอก queue ว่าเสร็จ queue ไม่มีทางรู้ว่างานเสร็จหรือยัง ทางเลือกคือส่งซ้ำ (เสี่ยงทำสองครั้ง) หรือไม่ส่งซ้ำ (เสี่ยงหาย) ระบบส่วนใหญ่เลือกส่งซ้ำ แล้วให้ตัว job กันผลซ้ำเอง
ใช้ Postgres เป็น queue ไหวแค่ไหน
100,000 job ต่อวัน เฉลี่ยแค่ราว 1.2 job ต่อวินาที (100,000 ÷ 86,400) เป็นงานเบาสำหรับฐานข้อมูลที่มีอยู่แล้ว ข้อดีใหญ่คือใส่ job ใน transaction เดียวกับข้อมูลธุรกิจได้ ถ้าวันหนึ่ง job เยอะจนกระทบฐานข้อมูลหลัก ค่อยย้ายไป Redis หรือ queue เฉพาะทาง
Redis queue หายได้ไหมถ้า Redis restart
ได้ ถ้าไม่เปิด persistence เอกสาร BullMQ แนะนำให้เปิด AOF และตั้ง maxmemory-policy เป็น noeviction เพราะถ้า Redis ไล่ key ทิ้งตอนแรมเต็ม queue จะทำงานผิด
คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ