บทความ › การเขียนโปรแกรม

Background job และ queue: ย้ายงานช้าออกจาก request พร้อม retry ที่ไม่ส่งซ้ำ

ส่งอีเมล ย่อรูป เรียก AI ไม่ควรทำใน request ทำ job queue ด้วย Postgres SKIP LOCKED พร้อม retry แบบ backoff, visibility timeout, dead-letter และทำให้ job รันซ้ำได้ปลอดภัย

กลาง อ่าน 15 นาที 22 ตุลาคม 2569

อ่านจบแล้วจะรู้
  • งานที่ช้าหรือพึ่งบริการภายนอก ให้ 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
ส่งอีเมลยืนยันผ่าน SMTP1,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)
workerprocess ที่ดึง job มาทำ รันกี่ตัวก็ได้
at-least-oncequeue รับประกันว่า 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

readyรอคิวrunningจองไว้ 5 นาที · lease หมด = หยิบใหม่doneสำเร็จล้ม: รอ backoffกลับไป readydeadครบ max_attempts · รอคนดู
job วนลองใหม่ด้วย backoff จนสำเร็จ หรือครบจำนวนครั้งแล้วไปพักที่ dead-letter

ตาราง 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) วินาที

ล้มครั้งที่รอรอสะสม
130 s30 s
260 s1.5 นาที
3120 s3.5 นาที
4240 s7.5 นาที
5480 s15.5 นาที
6960 s31.5 นาที
71,920 s63.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 LOCKEDRedis-based (BullMQ, Sidekiq)
ต้องลงเพิ่มไม่ ใช้ฐานข้อมูลเดิมRedis อีกตัว
ใส่ job พร้อมข้อมูลธุรกิจtransaction เดียวคนละระบบ ต้องระวังกรณี commit แล้วส่ง job ไม่ทัน
retry ในตัวเขียนเอง (ข้างบน) หรือ pg-boss / Solid QueueBullMQ: attempts + backoff 2^(attempts-1) × delay, Sidekiq: 25 ครั้งราว 20 วัน
dead-letterstatus = '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 ถูกทำซ้ำซ้อนกันเงียบ ๆ

แหล่งอ้างอิง

คำถามที่พบบ่อย

ทำไมไม่ทำให้ 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 จะทำงานผิด

คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ

อ่านต่อ

#โค้ดเริ่มต้น

เก็บวันเวลาให้ถูก: เก็บเป็น UTC เก็บ timezone ผู้ใช้ แล้วค่อยแปลงตอนแสดง

เวลาเพี้ยน 7 ชั่วโมง วันเกิดเลื่อน นัดหมายเคลื่อนตอน DST แก้ด้วยกฎสามข้อ ใช้ timestamptz ใน Postgres, zoneinfo ใน Python, Temporal ใน JS และแปลงเป็น พ.ศ. ตอนแสดงเท่านั้น

อ่าน 13 นาที
#โค้ดกลาง

Idempotency Key กดจ่ายซ้ำไม่ตัดเงินสองครั้ง และ retry แบบ backoff + jitter

ลูกค้ากดจ่ายสองครั้งหรือ client ส่งซ้ำตอนเน็ตหลุด ต้องไม่ตัดเงินซ้ำ ใช้ idempotency key เก็บผลลัพธ์ unique constraint และ retry แบบ exponential backoff + jitter

อ่าน 15 นาที
#โค้ดกลาง

เงินห้ามใช้ float ทำไม 19.99 × 100 ถึงไม่ได้ 1999 และวิธีเก็บเงินให้เป๊ะทุกขั้น

float เก็บ 0.1 ได้ไม่เป๊ะ เงินจึงเพี้ยนทีละสตางค์และสะสม เล่าวิธีให้ตัวเลขเงินเป๊ะตั้งแต่ต้นทางจนถึงฐานข้อมูล จากระบบบันทึกผลเทรดที่ใช้จริง พร้อมโค้ด Go SQL และ Python

อ่าน 7 นาที