3 นาที

รูปแบบ Cron + ฐานข้อมูล: งานแบ็กกราวด์ตามกำหนดโดยไม่ต้องใช้คิว

เรียนรู้รูปแบบ Cron + ฐานข้อมูล เพื่อรันงานตามกำหนดพร้อม retry, การล็อก และ idempotency — โดยไม่ต้องตั้งระบบคิวเต็มรูปแบบ

รูปแบบ Cron + ฐานข้อมูล: งานแบ็กกราวด์ตามกำหนดโดยไม่ต้องใช้คิว

ปัญหา: งานตามกำหนดเวลาโดยไม่เพิ่มโครงสร้างพื้นฐาน

แอปส่วนใหญ่ต้องให้มีงานที่เกิดขึ้นภายหลังหรือเป็นตามตารางเวลา: ส่งอีเมลติดตาม, ตรวจสอบบิลรายคืน, ลบระเบียนเก่า, สร้างรายงานใหม่, หรือรีเฟรชแคช

ตอนแรกมักอยากใส่ระบบคิวเต็มรูปแบบเพราะรู้สึกว่าเป็นทางที่ “ถูกต้อง” สำหรับงานแบ็กกราวด์ แต่คิวเพิ่มชิ้นส่วนที่ต้องดูแล: บริการอีกตัวให้รัน, ต้องมอนิเตอร์, ดีพลอย, และดีบัก สำหรับทีมเล็ก (หรือผู้ก่อตั้งคนเดียว) ภาระที่เพิ่มมานั้นอาจทำให้ความเร็วในการพัฒนาช้าลง

คำถามจริง ๆ คือ: จะรันงานตามกำหนดอย่างน่าเชื่อถือโดยไม่ต้องตั้งโครงสร้างพื้นฐานเพิ่มได้อย่างไร?

ความพยายามเริ่มต้นที่พบบ่อยคือเรียบง่าย: เพิ่มรายการ cron ที่เรียก endpoint และ endpoint นั้นทำงาน มันใช้ได้จนกว่าจะใช้ไม่ได้ เมื่อมีมากกว่าหนึ่งเซิร์ฟเวอร์, ดีพลอยช่วงผิดเวลา, หรือหากงานใช้เวลานานกว่าที่คาดไว้ คุณจะเริ่มเห็นความผิดพลาดที่สับสน

งานตามกำหนดมักพังในแบบที่คาดเดาได้ไม่กี่แบบ:

  • รันซ้ำ: สองเซิร์ฟเวอร์รันงานเดียวกัน ทำให้ใบแจ้งหนี้ถูกสร้างซ้ำหรืออีเมลถูกส่งสองครั้ง
  • งานหาย: การเรียก cron ล้มเหลวระหว่างดีพลอยและไม่มีใครสังเกตจนผู้ใช้ร้องเรียน
  • ล้มเหลวเงียบ: งาน error ครั้งหนึ่งแล้วไม่รันอีกเพราะไม่มีแผน retry
  • งานทำไม่ครบ: งาน crash กลางทางแล้วทิ้งข้อมูลในสถานะแปลกๆ
  • ไม่มีบันทึก: ไม่สามารถตอบได้ว่า “ครั้งสุดท้ายที่รันเมื่อไหร่?” หรือ “เมื่อคืนเกิดอะไรขึ้น?”

รูปแบบ cron + ฐานข้อมูลเป็นทางเลือกกลาง คุณยังใช้ cron เพื่อ “ปลุก” ตามตาราง แต่คุณเก็บเจตนางานและสถานะงานในฐานข้อมูลเพื่อให้ระบบสามารถประสานงาน, ลองใหม่, และบันทึกสิ่งที่เกิดขึ้น

เหมาะเมื่อคุณมีฐานข้อมูลตัวเดียว (มักเป็น PostgreSQL), ชนิดงานจำนวนไม่มาก, และต้องการพฤติกรรมที่คาดเดาได้พร้อมงานปฏิบัติการน้อยที่สุด มันยังเหมาะกับแอปที่สร้างเร็วบนสแต็กสมัยใหม่ (เช่น React + Go + PostgreSQL)

มันไม่เหมาะเมื่อคุณต้องการ throughput สูงมาก, งานระยะยาวที่ต้องสตรีมความคืบหน้า, การเรียงลำดับเข้มงวดข้ามชนิดงานจำนวนมาก, หรือการแตกพัด (fan-out) หนัก ๆ (หลายพันซับทาสก์ต่อนาที) ในกรณีเหล่านั้น ระบบคิวจริง ๆ และ worker เฉพาะทางมักคุ้มค่า

แนวคิดหลักแบบเข้าใจง่าย

รูปแบบ cron + ฐานข้อมูลรันงานแบ็กกราวด์ตามตารางโดยไม่ต้องใช้ระบบคิวเต็มรูปแบบ คุณยังใช้ cron (หรือ scheduler อื่น) แต่ cron ไม่ตัดสินใจว่าจะรันอะไร มันแค่ปลุก worker บ่อย ๆ (รอบละนาทีเป็นเรื่องปกติ) ฐานข้อมูลเป็นตัวตัดสินว่างานไหนถึงเวลาและมั่นใจว่า worker เพียงตัวเดียวจะรับงานแต่ละตัว

คิดว่ามันเหมือนบอร์ดเช็คลิสต์ที่หลายคนใช้ร่วมกัน Cron คือคนที่เดินเข้าห้องทุกนาทีและถามว่า “มีใครต้องทำอะไรตอนนี้ไหม?” ฐานข้อมูลคือบอร์ดที่บอกว่างานไหนถึงเวลา งานไหนถูกจับ และงานไหนเสร็จแล้ว

ส่วนประกอบไม่ซับซ้อน:

  • ทริกเกอร์ scheduler เดียวรันบ่อย
  • ตาราง jobs เก็บ “อะไร” และ “เมื่อไหร่” (เวลาที่ถึงกำหนด) พร้อมสถานะและจำนวนครั้งที่พยายาม
  • หนึ่งหรือมากกว่า worker ดึงตาราง โกงงาน และทำงาน
  • การโกรงานต้องใช้ล็อกในฐานข้อมูลเพื่อไม่ให้สอง worker จับแถวเดียวกัน
  • ฐานข้อมูลยังคงเป็นแหล่งความจริงสำหรับสิ่งที่รัน สิ่งที่ล้มเหลว และสิ่งที่ควรลองใหม่

ตัวอย่าง: คุณอยากส่งการเตือนใบแจ้งหนี้ทุกเช้า, รีเฟรชแคชทุก 10 นาที, และล้าง sessions เก่าคืนละหนึ่งครั้ง แทนที่จะมีคำสั่ง cron แยกสามชุด (แต่ละอันมีโหมดซ้อนทับและข้อผิดพลาดต่างกัน) ให้เก็บรายการงานไว้ที่เดียว Cron เริ่ม process worker เดียวกัน Worker ถาม Postgres ว่า “อะไรถึงเวอตอนนี้?” และ Postgres ตอบโดยให้ worker จับงานได้อย่างปลอดภัยทีละงาน

มันปรับขนาดแบบค่อยเป็นค่อยไป คุณเริ่มด้วย worker ตัวเดียวบนเซิร์ฟเวอร์หนึ่ง ภายหลังคุณอาจรันห้า worker ข้ามหลายเซิร์ฟเวอร์ ข้อตกลงยังคงเหมือนเดิม: ตารางคือสัญญา

การเปลี่ยนแนวคิดคือ: cron เป็นเพียงการปลุก ฐานข้อมูลเป็นตำรวจกำกับการจราจรที่ตัดสินว่าสิ่งใดอนุญาตให้รัน บันทึกสิ่งที่เกิดขึ้น และให้ประวัติชัดเจนเมื่อมีปัญหา

การออกแบบตาราง jobs (สคีมาปฏิบัติ)

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

ตารางเดียวหรือสองตาราง?

สองแนวทางที่พบบ่อย:

  • ตารางรวมหนึ่งตาราง เมื่อคุณสนใจเพียงสถานะล่าสุดของแต่ละงาน (เรียบง่าย ไม่ต้อง join เยอะ)
  • สองตาราง ถ้าคุณต้องการแยกระหว่าง “งานนี้คืออะไร” กับ “แต่ละครั้งที่มันถูกรัน” (มีประวัติชัดเจน ง่ายต่อการดีบัก)

ถ้าคุณคาดว่าจะดีบักความล้มเหลวบ่อย ให้เก็บประวัติไว้ ถ้าต้องการเซ็ตอัพเล็กที่สุด ให้เริ่มด้วยตารางเดียวแล้วเพิ่มประวัติทีหลัง

สคีมาปฏิบัติ (เวอร์ชันสองตาราง)

นี่คือลักษณะสำหรับ PostgreSQL ถ้าคุณสร้างใน Go กับ PostgreSQL คอลัมน์เหล่านี้จับคู่กับ struct ได้เรียบร้อย

-- What should exist (the definition)
create table job_definitions (
  id            bigserial primary key,
  job_type      text not null,
  payload       jsonb not null default '{}'::jsonb,
  schedule      text,                      -- optional: cron-like text if you store it
  max_attempts  int not null default 5,
  created_at    timestamptz not null default now(),
  updated_at    timestamptz not null default now()
);

-- What should run (each run / attempt group)
create table job_runs (
  id            bigserial primary key,
  definition_id bigint references job_definitions(id),
  job_type      text not null,
  payload       jsonb not null default '{}'::jsonb,
  run_at        timestamptz not null,
  status        text not null,             -- queued | running | succeeded | failed | dead
  attempts      int not null default 0,
  max_attempts  int not null default 5,

  locked_by     text,
  locked_until  timestamptz,

  last_error    text,
  created_at    timestamptz not null default now(),
  updated_at    timestamptz not null default now()
);

รายละเอียดเล็ก ๆ ที่ช่วยได้ในภายหลัง:

  • เก็บ job_type เป็นสตริงสั้นที่คุณสามารถ route ได้ (เช่น send_invoice_emails)
  • เก็บ payload เป็น jsonb เพื่อให้พัฒนาได้โดยไม่ต้อง migration บ่อย
  • run_at คือ “เวลาถัดไปที่ถึงกำหนด” Cron (หรือสคริปต์ scheduler) กำหนดค่า และ worker เป็นผู้บริโภค
  • locked_by และ locked_until ให้ worker จับงานโดยไม่ชนกัน
  • last_error ควรเป็นข้อความสั้นที่อ่านได้สำหรับมนุษย์ เก็บ stack trace แยกที่อื่นถ้าจำเป็น

ดัชนีที่ควรมี

หากไม่มีดัชนี worker จะสแกนเยอะเกินไป เริ่มด้วย:

  • ดัชนีเพื่อค้นหางานที่ถึงเวรได้เร็ว: (status, run_at)
  • ดัชนีช่วยตรวจจับล็อกหมดเวลา: (locked_until)
  • ทางเลือก: partial index สำหรับงานที่ active เท่านั้น (เช่น status ใน queued และ failed)

ดัชนีเหล่านี้ทำให้การค้นหา “งานถัดไปที่รันได้” รวดเร็วแม้ตารางจะโตขึ้น

การล็อกและการโกรงานอย่างปลอดภัย

เป้าหมายง่าย ๆ คือ: worker หลายตัวอาจรัน แต่มีเพียงตัวเดียวเท่านั้นที่ควรจับงานเฉพาะ หากสอง worker ประมวลผลแถวเดียวกันคุณจะได้อีเมลซ้ำ, การคิดเงินซ้ำ, หรือข้อมูลสกปรก

วิธีที่ปลอดภัยคือถือว่าการโกรงานเป็นเหมือน “สัญญาเช่า (lease)” Worker ทำเครื่องหมายว่าได้ล็อกงานไว้ช่วงสั้น ๆ หาก worker crash สัญญาจะหมดอายุและ worker ตัวอื่นสามารถรับงานได้ นั่นคือเหตุผลที่มี locked_until

ใช้ lease เพื่อให้การ crash ไม่บล็อกงานตลอดไป

หากไม่มี lease worker อาจล็อกงานแล้วไม่ปลดล็อกเลย (process ถูกฆ่า, server รีบูท, ดีพลอยพังก์) ด้วย locked_until งานจะกลับมาใช้งานได้เมื่อเวลาผ่านไป

กฎทั่วไป: งานสามารถถูกจับได้เมื่อ locked_until เป็น NULL หรือ locked_until <= now()

จับงานด้วยการอัพเดตแบบอะตอมเดียว

รายละเอียดสำคัญคือจับงานในคำสั่งเดียว (หรือหนึ่งธุรกรรม) คุณต้องการให้ฐานข้อมูลเป็นผู้ตัดสิน

นี่คือลายพบบ่อยบน PostgreSQL: เลือกงานที่ถึงเวลา ล็อกมัน และคืนมาให้ worker (ตัวอย่างนี้ใช้ตาราง jobs เดียว แนวคิดเดียวกันใช้กับ job_runs)

WITH next_job AS (
  SELECT id
  FROM jobs
  WHERE status = 'queued'
    AND run_at <= now()
    AND (locked_until IS NULL OR locked_until <= now())
  ORDER BY run_at ASC
  LIMIT 1
  FOR UPDATE SKIP LOCKED
)
UPDATE jobs j
SET status = 'running',
    locked_until = now() + interval '2 minutes',
    locked_by = $1,
    attempts = attempts + 1,
    updated_at = now()
FROM next_job
WHERE j.id = next_job.id
RETURNING j.*;

เหตุผลที่มันทำงาน:

  • FOR UPDATE SKIP LOCKED ให้ worker หลายตัวแข่งกันโดยไม่บล็อกกัน
  • lease ถูกเซ็ตเมื่อจับงาน ดังนั้น worker ตัวอื่นจะไม่สนใจจนกว่าจะหมดอายุ
  • RETURNING ส่งแถวให้ worker ที่ชนะการแข่ง

ระยะเวลา lease ควรเป็นเท่าไหร่ และจะต่ออายุอย่างไร

ตั้ง lease ให้ยาวกว่าการรันปกติ แต่สั้นพอที่การ crash จะกลับมาฟื้นตัวได้เร็ว หากงานส่วนใหญ่เสร็จใน 10 วินาที ให้ lease 2 นาทีก็พอสำหรับหลายกรณี

สำหรับงานยาว ให้ต่ออายุ lease ระหว่างทำงาน (heartbeat) วิธีง่าย ๆ คือทุก 30 วินาที ขยาย locked_until หากคุณยังเป็นเจ้าของงานอยู่

  • ความยาว lease: 5x ถึง 20x เวลาทั่วไปของงาน
  • ระยะ heartbeat: 1/4 ถึง 1/2 ของ lease
  • คำสั่งต่ออายุควรมี WHERE id = $job_id AND locked_by = $worker_id

เงื่อนไขสุดท้ายสำคัญ มันป้องกันไม่ให้ worker ขยาย lease บนงานที่มันไม่ได้เป็นเจ้าของแล้ว

การลองใหม่และ backoff ให้คาดเดาได้

แอปเริ่มต้น Go และ Postgres
เริ่มจาก frontend React และ backend Go + PostgreSQL ที่สร้างจากสเปกของคุณ

การลองใหม่คือจุดที่รูปแบบนี้จะทำให้คุณสงบหรือกลายเป็นความวุ่นวาย เป้าหมายคือเรียบง่าย: เมื่องานล้มเหลว ให้ลองใหม่ในภายหลังในวิธีที่คุณอธิบาย, วัดผล, และหยุดได้

เริ่มจากทำให้สถานะงานชัดและจำกัด: queued, running, succeeded, failed, dead ในงานจริงทีมมักใช้ failed เพื่อหมายถึง “ล้มเหลวแต่จะลองใหม่” และ dead หมายถึง “ล้มเหลวและยอมแพ้” ความต่างนี้ป้องกันลูปไม่รู้จบ

การนับ attempts เป็นการป้องกันชั้นที่สอง เก็บ attempts (จำนวนครั้งที่ลอง) และ max_attempts (จำนวนครั้งที่ยอมให้ลอง) เมื่อ worker จับข้อผิดพลาด ควร:

  • เพิ่ม attempts
  • ตั้งสถานะเป็น failed หาก attempts < max_attempts มิฉะนั้น dead
  • คำนวณ run_at สำหรับครั้งถัดไป (เฉพาะกรณี failed)

backoff คือกฎที่กำหนด run_at ถัดไป เลือกแบบหนึ่งแล้วจงสม่ำเสมอและบันทึก:

  • หน่วงคงที่: รอเสมอ 1 นาที
  • กำลังสอง (exponential): 1m, 2m, 4m, 8m
  • exponential พร้อมเพดาน: exponential แต่ไม่เกิน เช่น 30m
  • ใส่ jitter: ทำให้เวลาเล็กน้อยเป็นแบบสุ่มเพื่อไม่ให้งานลองใหม่พร้อมกันทั้งหมด

Jitter สำคัญเมื่อ dependency ล่มแล้วกลับมา หากไม่มีมัน งานหลายร้อยงานจะลองพร้อมกันและล้มอีก

เก็บรายละเอียดข้อผิดพลาดพอให้มองเห็นและดีบักได้ ไม่จำเป็นต้องมีระบบล็อกเต็มรูปแบบ แต่ต้องมีพื้นฐาน:

  • last_error (ข้อความสั้น แสดงในจอแอดมินได้)
  • error_code หรือ error_type (ช่วยจัดกลุ่ม)
  • failed_at และ next_run_at
  • last_stack แบบออฟชันัล (ถ้าควบคุมขนาดได้)

กฎที่ใช้ได้ดี: ทำ dead หลัง 10 attempts แล้ว backoff แบบ exponential พร้อม jitter วิธีนี้ทำให้ความล้มเหลวชั่วคราวถูกลองใหม่ แต่หยุดงานที่พังจริง ๆ จากการใช้ CPU ตลอดไป

Idempotency: ป้องกันผลซ้ำแม้งานจะรันซ้ำ

Idempotency หมายถึงงานของคุณสามารถรันซ้ำแล้วยังให้ผลสุดท้ายเหมือนเดิม ในรูปแบบนี้มันสำคัญเพราะแถวเดียวอาจถูกหยิบขึ้นมาซ้ำหลัง crash, timeout, หรือ retry หากงานของคุณคือ “ส่งอีเมลใบแจ้งหนี้” การรันซ้ำไม่ใช่เรื่องเล็กเสมอไป

วิธีปฏิบัติคือแยกงานเป็น (1) ทำงาน และ (2) ประยุกต์ผลลัพธ์ คุณต้องการให้ผลลัพธ์เกิดขึ้นครั้งเดียว แม้ว่างานครั้งแรกจะพยายามหลายครั้ง

ใช้ idempotency key ที่ผูกกับเหตุการณ์ธุรกิจ

idempotency key ควรมาจากสิ่งที่งานแทนค่า ไม่ใช่จากความพยายามของ worker คีย์ที่ดีคือคีย์ที่คงที่และอธิบายง่าย เช่น invoice_id, user_id + day, หรือ report_name + report_date หากสองความพยายามของงานอ้างถึงเหตุการณ์โลกจริงเดียวกัน ควรใช้คีย์เดียวกัน

ตัวอย่าง: “สร้างรายงานขายรายวันสำหรับ 2026-01-14” ใช้ sales_report:2026-01-14 “เรียกเก็บเงิน invoice 812” ใช้ invoice_charge:812

บังคับ “ครั้งเดียวเท่านั้น” ด้วยข้อจำกัดฐานข้อมูล

การป้องกันที่ง่ายที่สุดคือให้ PostgreSQL ปฏิเสธรายการซ้ำ เก็บ idempotency key ที่สามารถทำดัชนีได้ แล้วเพิ่ม unique constraint

-- Example: ensure one logical job/effect per business key
ALTER TABLE jobs
ADD COLUMN idempotency_key text;

CREATE UNIQUE INDEX jobs_idempotency_key_uniq
ON jobs (idempotency_key)
WHERE idempotency_key IS NOT NULL;

มันป้องกันไม่ให้มีสองแถวที่มีคีย์เดียวกันอยู่พร้อมกัน หากออกแบบให้มีหลายแถว (เพื่อเก็บประวัติ) ให้ใส่ uniqueness บนตาราง "effects" แทน เช่น sent_emails(idempotency_key) หรือ payments(idempotency_key)

ผลข้างเคียงที่ควรปกป้อง:

  • อีเมล: สร้างแถว sent_emails ที่มีคีย์ unique ก่อนส่ง หรือลง provider message id เมื่อส่งแล้ว
  • Webhooks: บันทึก delivered_webhooks(event_id) และข้ามถ้ามีแล้ว
  • การชำระเงิน: ใช้ idempotency ของ provider พร้อม unique key ในฐานข้อมูลของคุณเอง
  • การเขียนไฟล์: เขียนเป็นชื่อชั่วคราวแล้วเปลี่ยนชื่อ หรือบันทึก file_generated โดยคีย์ (type, date)

ถ้าคุณสร้างบนสแตกที่ใช้ Postgres (เช่น backend Go + PostgreSQL) การตรวจสอบความเป็นเอกลักษณ์พวกนี้เร็วและทำใกล้ข้อมูลได้ ความคิดหลักคือ: retry เป็นเรื่องปกติ แต่การซ้ำซ้อนเป็นเรื่องที่ต้องเลือก

ขั้นตอนทีละขั้น: สร้าง worker และ scheduler ขั้นพื้นฐาน

เป็นเจ้าของโค้ด scheduler ของคุณ
ส่งออกซอร์สโค้ดได้ทุกเมื่่อและเก็บระบบงานแบ็กกราวด์ของคุณให้พกพาได้

เลือก runtime เดี่ยวที่เชื่อถือได้และยึดตามมัน จุดมุ่งหมายของรูปแบบ cron + ฐานข้อมูลคือลดชิ้นส่วนที่ต้องดูแล ดังนั้น process เล็ก ๆ ใน Go, Node, หรือ Python ที่คุยกับ PostgreSQL ก็เพียงพอ

สร้างใน 5 ขั้นเล็ก ๆ

  1. สร้างตารางและดัชนี. เพิ่มตาราง jobs (และตาราง lookup เพิ่มเติมถ้าต้องการ) จากนั้นทำดัชนี run_at และดัชนีที่ช่วย worker ค้นหางานที่พร้อมได้เร็ว (เช่น (status, run_at)).

  2. เขียนฟังก์ชัน enqueue เล็ก ๆ. แอปของคุณควร insert แถวโดยมี run_at เป็น now() หรือเวลาในอนาคต เก็บ payload ให้เล็กและคาดเดาได้ (ID และ job type ไม่ใช่ blob ใหญ่)

INSERT INTO jobs (type, payload, status, run_at, attempts, max_attempts)
VALUES ($1, $2::jsonb, 'queued', $3, 0, 10);
  1. ทำวงจรการ claim. รันมันใน transaction เลือกงานที่ถึงเวลา ล็อกพวกมันเพื่อให้ worker คนอื่นข้าม และตั้งเป็น running ใน transaction เดียวกัน
WITH picked AS (
  SELECT id
  FROM jobs
  WHERE status = 'queued' AND run_at <= now()
  ORDER BY run_at
  FOR UPDATE SKIP LOCKED
  LIMIT 10
)
UPDATE jobs
SET status = 'running', started_at = now()
WHERE id IN (SELECT id FROM picked)
RETURNING *;
  1. ประมวลผลและสรุปผล. สำหรับแต่ละงานที่จับได้ ทำงาน แล้วอัพเดตเป็น done พร้อม finished_at หากล้มเหลว ให้บันทึกข้อความ error และเลื่อนไปเป็น queued พร้อม run_at ใหม่ (backoff) ทำให้การอัพเดตสรุปเล็กและทำเสมอ แม้กระทั่งเมื่อ process กำลังปิดตัว

  2. เพิ่มกฎ retry ที่อธิบายได้. ใช้สูตรง่าย ๆ เช่น run_at = now() + (attempts^2) * interval '10 seconds' และหยุดหลัง max_attempts โดยตั้ง status = 'dead'

เพิ่มการมองเห็นพื้นฐาน

ไม่จำเป็นต้องมี dashboard เต็มรูปแบบตั้งแต่วันแรก แต่ต้องพอให้เห็นปัญหา

  • โลกหนึ่งบรรทัดต่อ job: claimed, succeeded, failed, retried, dead
  • สร้าง query/view ง่าย ๆ สำหรับ “dead jobs” และ “running jobs เก่านาน”
  • แจ้งเตือนเมื่อจำนวนเพิ่ม (เช่น มากกว่า N dead jobs ในชั่วโมงที่ผ่านมา)

ถ้าคุณอยู่บนสแต็ก Go + PostgreSQL สิ่งนี้จับคู่ได้ดีกับ binary worker เดียวบวก cron

ตัวอย่างสมจริงที่คัดลอกได้

จินตนาการแอป SaaS ขนาดเล็กที่มีงานตามกำหนดสองอย่าง:

  • การล้างข้อมูลรายคืนที่ลบ sessions หมดอายุและไฟล์ชั่วคราวเก่า
  • อีเมล “รายงานกิจกรรมของคุณ” รายสัปดาห์ส่งให้ผู้ใช้ทุกเช้าวันจันทร์

ทำให้ง่าย: ตาราง PostgreSQL เดียวเก็บ jobs และ worker ตัวเดียวรันทุกนาที (trigger โดย cron) Worker จับงานที่ถึงเวลา, รันมัน, และบันทึกความสำเร็จหรือความล้มเหลว

อะไรถูก enqueue และเมื่อไหร่

คุณสามารถ enqueue งานจากหลายที่:

  • ทุกวันเวลา 02:00: enqueue งาน cleanup_nightly หนึ่งงานสำหรับ “วันนี้”
  • เมื่อสมัคร: enqueue งาน send_weekly_report สำหรับผู้ใช้ในจันทร์ถัดไป
  • หลังเหตุการณ์ (เช่น “ผู้ใช้คลิก Export report”): enqueue งาน send_weekly_report ที่รันทันทีสำหรับช่วงวันที่เฉพาะ

payload คือสิ่งจำเป็นขั้นต่ำที่ worker ต้องการ เก็บให้เล็กเพื่อ retry ง่าย

{
  "type": "send_weekly_report",
  "payload": {
    "user_id": 12345,
    "date_range": {
      "from": "2026-01-01",
      "to": "2026-01-07"
    }
  }
}

idempotency ป้องกันการส่งซ้ำอย่างไร

worker อาจ crash ในช่วงแย่ที่สุด: หลังส่งอีเมลแล้วแต่ก่อนจะมาร์กงานเป็น “done” เมื่อมันรีสตาร์ท มันอาจหยิบงานเดิมขึ้นมาอีกครั้ง

เพื่อหยุดการส่งซ้ำ ให้การทำงานมีคีย์ dedupe ตามธรรมชาติและเก็บไว้ในที่ที่ฐานข้อมูลบังคับได้ สำหรับรายงานประจำสัปดาห์ คีย์ที่ดีคือ (user_id, week_start_date) ก่อนส่ง worker บันทึกว่า “ฉันกำลังจะส่งรายงาน X” ถ้ารายการนั้นมีอยู่แล้ว ให้ข้ามการส่ง

สิ่งนี้อาจเป็นตาราง sent_reports ที่มี unique constraint บน (user_id, week_start_date) หรือ idempotency_key แบบ unique บน job เอง

ลักษณะความล้มเหลว (และการกู้คืน)

สมมติ provider ส่งอีเมล timeout งานล้มเหลว worker จะ:

  • เพิ่ม attempts
  • บันทึกข้อความ error เพื่อดีบัก
  • กำหนดเวลาลองใหม่ด้วย backoff (เช่น: +1 นาที, +5 นาที, +30 นาที, +2 ชั่วโมง)

ถ้ามันยังล้มเหลวเกินขีดจำกัด (เช่น 10 attempts) ให้มาร์กเป็น “dead” แล้วหยุด retry งานนั้นก็จะสำเร็จครั้งหนึ่งหรือมันจะลองใหม่ตามตารางที่ชัดเจน และ idempotency ทำให้การ retry ปลอดภัย

ข้อผิดพลาดและกับดักที่พบบ่อย

ปรับใช้ worker ของคุณอย่างมั่นใจ
ปรับใช้งาน พร้อมสแน็ปชอตและ rollback เมื่อการเปลี่ยนแปลง job เกิดปัญหา

รูปแบบ cron + ฐานข้อมูลเรียบง่าย แต่ความผิดพลาดเล็ก ๆ น้อย ๆ อาจทำให้เกิดการซ้ำ, งานติด, หรือโหลดที่ไม่คาดคิด ปัญหาส่วนใหญ่ปรากฏหลัง crash, ดีพลอย, หรือสไปค์การใช้งานครั้งแรก

ข้อผิดพลาดที่ทำให้เกิดการซ้ำหรือการติดงาน

เหตุการณ์จริงมักมาจากกับดักไม่กี่ข้อ:

  • รันงานเดียวกันจากหลายรายการ cron โดยไม่มี lease ถ้าสองเซิร์ฟเวอร์ติ๊กในนาทีเดียวกัน ทั้งคู่อาจพยายามจับงานเดียวกันหากขั้นตอนการจับไม่ใช่แบบอะตอมและไม่ตั้งล็อก/lease ใน transaction เดียว
  • ข้าม locked_until ถ้า worker crash หลังจับงาน แถวอาจอยู่ในสถานะ “กำลังประมวลผล” ตลอดไป timestamp ของ lease ช่วยให้ worker อื่นรับงานได้ในภายหลัง
  • ลองใหม่ทันทีเมื่อเกิดความล้มเหลว เมื่อ API ล่ม instant retry สร้างสไปค์ เผาผลาญ rate limit และยังคงล้มในลูปหนาแน่น ให้กำหนดเวลาลองใหม่ไปข้างหน้าเสมอ
  • ถือว่า “at least once” เป็น “exactly once” งานอาจวิ่งสองครั้ง (timeout, worker restart, network) หากการรันสองครั้งเป็นอันตราย ให้ทำให้ผลข้างเคียงสามารถทำซ้ำได้อย่างปลอดภัย
  • เก็บ payload ใหญ่ในแถวงาน บลอบ JSON ใหญ่พองโตตาราง ช้าในการดัชนี และทำให้การล็อกหนักขึ้น จงเก็บ reference (เช่น user_id, invoice_id, หรือ file key`) แล้วดึงข้อมูลที่เหลือตอนรัน

ตัวอย่าง: ส่งอีเมลใบแจ้งหนี้รายสัปดาห์ ถ้า worker timeout หลังส่งแต่ก่อนมาร์กเป็น done งานเดียวอาจถูก retried และส่งซ้ำ นั่นเป็นเรื่องปกติในรูปแบบนี้ถ้าไม่มีหลักประกัน (เช่น บันทึก event "อีเมลส่งแล้ว" โดยใช้ invoice id เป็น key)

กับดักที่ไม่ชัดเจน

หลีกเลี่ยงการผสมการ schedule และการ execute ใน transaction ยาว หากคุณถือ transaction ขณะเรียกเครือข่าย จะทำให้ล็อกค้างนานกว่าที่จำเป็นและบล็อก worker อื่น

ระวังความต่างของนาฬิการะหว่างเครื่อง ใช้เวลาในฐานข้อมูล (NOW() ใน PostgreSQL) เป็นแหล่งความจริงสำหรับ run_at และ locked_until ไม่ใช่นาฬิกาแอปเซิร์ฟเวอร์

ตั้งค่าระยะเวลารันสูงสุดชัดเจน หากงานอาจใช้ 30 นาที ให้ lease ยาวกว่านั้น และต่ออายุเมื่อจำเป็น มิฉะนั้น worker ตัวอื่นอาจหยิบมันในระหว่างที่ยังรันอยู่

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

เช็คลิสต์ด่วนและขั้นตอนถัดไป

เช็คลิสต์ด่วน

ก่อนส่งรูปแบบนี้ ให้ตรวจสอบพื้นฐาน หากขาดอะไรเล็ก ๆ ที่นี่ มักจะกลายเป็นงานติด, การซ้ำที่ไม่คาดคิด, หรือ worker กระหน่ำฐานข้อมูล

  • ตาราง jobs ของคุณมีสิ่งสำคัญ: run_at, status, attempts, locked_until, และ max_attempts (บวก last_error หรือคล้ายเพื่อดูสิ่งที่เกิดขึ้น)
  • แต่ละงานสามารถรันซ้ำได้อย่างปลอดภัย หากไม่แน่ใจ ให้เพิ่ม idempotency key หรือกฎ unique รอบผลข้างเคียง (เช่น หนึ่ง invoice ต่อ invoice_id)
  • มีที่ชัดเจนในการสังเกตความล้มเหลวและตัดสินใจทำอะไร: ดู failed jobs, รันงานอีกครั้ง, หรือมาร์กเป็น dead เมื่อควรหยุด retry
  • เวลา lease (lock) มีเหตุผลพอสำหรับงาน มันควรยาวพอสำหรับการรันปกติ แต่สั้นพอที่ worker crash จะไม่บล็อกความคืบหน้าหลายชั่วโมง
  • backoff ของ retry คาดเดาได้ มันควรชะลอการลองใหม่ซ้ำ ๆ และหยุดหลัง max_attempts

ถ้าข้อเหล่านี้เป็นจริง รูปแบบ cron + ฐานข้อมูลมักเสถียรพอสำหรับงานจริง

ขั้นตอนถัดไป

เมื่อเช็คลิสต์ดูโอเค ให้โฟกัสที่การปฏิบัติงานประจำวัน

  • เพิ่มสอง action แอดมินเล็ก ๆ: “retry now” (ตั้ง run_at = now() และเคลียร์ล็อก) และ “cancel” (ย้ายไปสถานะ terminal) ช่วยประหยัดเวลาในเหตุการณ์ฉุกเฉิน
  • ให้ worker log หนึ่งบรรทัดต่อ job: job type, job id, เลข attempt, และผล เพิ่มการแจ้งเตือนเมื่อ count ความล้มเหลวเพิ่มขึ้น
  • ทดสอบโหลดด้วยสไปค์สมจริง: งานจำนวนมากถูกกำหนดไว้สำหรับนาทีเดียว หากการ claim งานช้า ให้เพิ่มดัชนีที่ถูกต้อง (มักเป็น (status, run_at))

ถ้าคุณต้องการสร้างเซ็ตอัพแบบนี้เร็ว ๆ Koder.ai (koder.ai) สามารถช่วยจากสคีมาไปจนถึงแอป Go + PostgreSQL ที่ deploy ได้ โดยลดงานเชื่อมต่อนิดหน่อย ให้คุณโฟกัสที่ล็อก, การ retry, และกฎ idempotency

ถ้าคุณเติบโตเกินขอบเขตนี้ภายหลัง คุณยังได้เรียนรู้วงจรชีวิตของงานอย่างชัดเจน และแนวคิดเดียวกันนี้ก็แปลงไปใช้กับระบบคิวเต็มรูปแบบได้ดี

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

ควรใช้ cron ร่วมกับฐานข้อมูลสำหรับงานเบื้องหลังเมื่อใด?

ใช้รูปแบบนี้เมื่อคุณมีฐานข้อมูลอยู่แล้ว มีประเภทงานเพียงไม่มาก และต้องการงานตามกำหนดเวลาโดยไม่ต้องดูแลคิวแยกต่างหาก เหมาะกับงานอย่างการแจ้งเตือนทางอีเมล การล้างข้อมูล รายงาน และการรีเฟรชแคช

ในการตั้งค่านี้ cron ทำอะไร?

Cron ควรทำหน้าที่เพียงเริ่ม worker ตามช่วงเวลาที่กำหนดอย่างสม่ำเสมอ ซึ่งมักเป็นทุก 1 นาที ฐานข้อมูลจะตัดสินว่างานใดถึงกำหนด บันทึกสถานะ และให้ worker เพียงตัวเดียวรับแต่ละงานไปทำ

ตาราง jobs ควรมีฟิลด์อะไรบ้าง?

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

worker หลายตัวหลีกเลี่ยงการทำงานเดียวกันซ้ำได้อย่างไร?

ให้รับงานภายในทรานแซ็กชันฐานข้อมูลเดียวด้วยการล็อกแถว เช่น PostgreSQL FOR UPDATE SKIP LOCKED อัปเดตแถวให้เป็นสถานะ running และกำหนด lease ก่อนที่ worker จะเริ่มทำงานจริง

เหตุใดงานเบื้องหลังจึงต้องมีระยะหมดอายุของ lease?

Lease คือล็อกชั่วคราวที่บันทึกไว้ด้วย locked_until หาก worker ล่ม lease จะหมดอายุ และ worker ตัวอื่นสามารถรับงานไปทำต่อได้ แทนที่จะปล่อยให้งานค้างอยู่ตลอดไป

งานที่ล้มเหลวควรลองใหม่อย่างไร?

ลองใหม่ภายหลังด้วยกฎ backoff ที่ชัดเจน เช่น 1 นาที, 2 นาที, 4 นาที แล้วตามด้วยระยะหน่วงที่มีเพดานและมีการสุ่มเล็กน้อย หลังจากลองครบจำนวนครั้งที่อนุญาต ให้ทำเครื่องหมายงานเป็น dead เพื่อไม่ให้กินเวลาของ worker ต่อไป

Idempotency คืออะไร และเหตุใดจึงสำคัญ?

ทำให้ผลข้างเคียงสามารถทำซ้ำได้อย่างปลอดภัย ตัวอย่างเช่น ใช้คีย์ idempotency ที่คงที่ เช่น ID ใบแจ้งหนี้ หรือ ID ผู้ใช้ร่วมกับวันที่รายงาน แล้วบังคับความไม่ซ้ำกันใน PostgreSQL หรือที่ผู้ให้บริการภายนอก

รูปแบบนี้รับประกันการประมวลผลแบบครั้งเดียวพอดีหรือไม่?

ไม่รับประกัน เพราะ worker อาจล่มหลังส่งอีเมลหรือเรียกใช้ผู้ให้บริการชำระเงิน แต่ก่อนบันทึกความสำเร็จ ออกแบบให้รองรับการประมวลผลแบบอย่างน้อยหนึ่งครั้ง แล้วใช้ idempotency เพื่อป้องกันผลกระทบซ้ำ

worker ควรคงทรานแซ็กชันฐานข้อมูลไว้ระหว่างทำงานหรือไม่?

ทำให้ทรานแซ็กชันฐานข้อมูลสั้น รับงาน, commit, ทำการเรียกเครือข่ายนอกทรานแซ็กชัน แล้วบันทึกความสำเร็จหรือความล้มเหลวด้วยการอัปเดตแยกต่างหาก ทรานแซ็กชันที่ยาวจะคงล็อกไว้และทำให้ worker อื่นช้าลง

จะมอนิเตอร์ cron และงานในฐานข้อมูลได้อย่างไร?

ติดตามงาน dead และงานที่อยู่ในสถานะ running นานเกินไป บันทึก log ของการรับงานและผลลัพธ์แต่ละครั้ง และตั้งการแจ้งเตือนเมื่อความล้มเหลวเพิ่มขึ้น เพิ่มการดำเนินการสำหรับผู้ดูแลระบบแบบง่ายเพื่อให้ลองงานใหม่ทันที ยกเลิกงาน หรือตรวจสอบข้อผิดพลาดล่าสุด

Related posts