KoderKoder.ai
ราคาองค์กรการศึกษาสำหรับนักลงทุน
เข้าสู่ระบบเริ่มต้นใช้งาน

ผลิตภัณฑ์

ราคาองค์กรสำหรับนักลงทุน

ทรัพยากร

ติดต่อเราสนับสนุนการศึกษาบล็อก

กฎหมาย

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

โซเชียล

LinkedInTwitter
Koder.ai
ภาษา

© 2026 Koder.ai สงวนลิขสิทธิ์

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

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

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

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

ปัญหา: งานตามกำหนดเวลาโดยไม่เพิ่มโครงสร้างพื้นฐาน\n\nแอปส่วนใหญ่ต้องให้มีงานที่เกิดขึ้นภายหลังหรือเป็นตามตารางเวลา: ส่งอีเมลติดตาม, ตรวจสอบบิลรายคืน, ลบระเบียนเก่า, สร้างรายงานใหม่, หรือรีเฟรชแคช\n\nตอนแรกมักอยากใส่ระบบคิวเต็มรูปแบบเพราะรู้สึกว่าเป็นทางที่ “ถูกต้อง” สำหรับงานแบ็กกราวด์ แต่คิวเพิ่มชิ้นส่วนที่ต้องดูแล: บริการอีกตัวให้รัน, ต้องมอนิเตอร์, ดีพลอย, และดีบัก สำหรับทีมเล็ก (หรือผู้ก่อตั้งคนเดียว) ภาระที่เพิ่มมานั้นอาจทำให้ความเร็วในการพัฒนาช้าลง\n\nคำถามจริง ๆ คือ: จะรันงานตามกำหนดอย่างน่าเชื่อถือโดยไม่ต้องตั้งโครงสร้างพื้นฐานเพิ่มได้อย่างไร?\n\nความพยายามเริ่มต้นที่พบบ่อยคือเรียบง่าย: เพิ่มรายการ cron ที่เรียก endpoint และ endpoint นั้นทำงาน มันใช้ได้จนกว่าจะใช้ไม่ได้ เมื่อมีมากกว่าหนึ่งเซิร์ฟเวอร์, ดีพลอยช่วงผิดเวลา, หรือหากงานใช้เวลานานกว่าที่คาดไว้ คุณจะเริ่มเห็นความผิดพลาดที่สับสน\n\nงานตามกำหนดมักพังในแบบที่คาดเดาได้ไม่กี่แบบ:\n\n- รันซ้ำ: สองเซิร์ฟเวอร์รันงานเดียวกัน ทำให้ใบแจ้งหนี้ถูกสร้างซ้ำหรืออีเมลถูกส่งสองครั้ง\n- งานหาย: การเรียก cron ล้มเหลวระหว่างดีพลอยและไม่มีใครสังเกตจนผู้ใช้ร้องเรียน\n- ล้มเหลวเงียบ: งาน error ครั้งหนึ่งแล้วไม่รันอีกเพราะไม่มีแผน retry\n- งานทำไม่ครบ: งาน crash กลางทางแล้วทิ้งข้อมูลในสถานะแปลกๆ\n- ไม่มีบันทึก: ไม่สามารถตอบได้ว่า “ครั้งสุดท้ายที่รันเมื่อไหร่?” หรือ “เมื่อคืนเกิดอะไรขึ้น?”\n\nรูปแบบ cron + ฐานข้อมูลเป็นทางเลือกกลาง คุณยังใช้ cron เพื่อ “ปลุก” ตามตาราง แต่คุณเก็บเจตนางานและสถานะงานในฐานข้อมูลเพื่อให้ระบบสามารถประสานงาน, ลองใหม่, และบันทึกสิ่งที่เกิดขึ้น\n\nเหมาะเมื่อคุณมีฐานข้อมูลตัวเดียว (มักเป็น PostgreSQL), ชนิดงานจำนวนไม่มาก, และต้องการพฤติกรรมที่คาดเดาได้พร้อมงานปฏิบัติการน้อยที่สุด มันยังเหมาะกับแอปที่สร้างเร็วบนสแต็กสมัยใหม่ (เช่น React + Go + PostgreSQL)\n\nมันไม่เหมาะเมื่อคุณต้องการ throughput สูงมาก, งานระยะยาวที่ต้องสตรีมความคืบหน้า, การเรียงลำดับเข้มงวดข้ามชนิดงานจำนวนมาก, หรือการแตกพัด (fan-out) หนัก ๆ (หลายพันซับทาสก์ต่อนาที) ในกรณีเหล่านั้น ระบบคิวจริง ๆ และ worker เฉพาะทางมักคุ้มค่า\n\n## แนวคิดหลักแบบเข้าใจง่าย\n\nรูปแบบ cron + ฐานข้อมูลรันงานแบ็กกราวด์ตามตารางโดยไม่ต้องใช้ระบบคิวเต็มรูปแบบ คุณยังใช้ cron (หรือ scheduler อื่น) แต่ cron ไม่ตัดสินใจว่าจะรันอะไร มันแค่ปลุก worker บ่อย ๆ (รอบละนาทีเป็นเรื่องปกติ) ฐานข้อมูลเป็นตัวตัดสินว่างานไหนถึงเวลาและมั่นใจว่า worker เพียงตัวเดียวจะรับงานแต่ละตัว\n\nคิดว่ามันเหมือนบอร์ดเช็คลิสต์ที่หลายคนใช้ร่วมกัน Cron คือคนที่เดินเข้าห้องทุกนาทีและถามว่า “มีใครต้องทำอะไรตอนนี้ไหม?” ฐานข้อมูลคือบอร์ดที่บอกว่างานไหนถึงเวลา งานไหนถูกจับ และงานไหนเสร็จแล้ว\n\nส่วนประกอบไม่ซับซ้อน:\n\n- ทริกเกอร์ scheduler เดียวรันบ่อย\n- ตาราง jobs เก็บ “อะไร” และ “เมื่อไหร่” (เวลาที่ถึงกำหนด) พร้อมสถานะและจำนวนครั้งที่พยายาม\n- หนึ่งหรือมากกว่า worker ดึงตาราง โกงงาน และทำงาน\n- การโกรงานต้องใช้ล็อกในฐานข้อมูลเพื่อไม่ให้สอง worker จับแถวเดียวกัน\n- ฐานข้อมูลยังคงเป็นแหล่งความจริงสำหรับสิ่งที่รัน สิ่งที่ล้มเหลว และสิ่งที่ควรลองใหม่\n\nตัวอย่าง: คุณอยากส่งการเตือนใบแจ้งหนี้ทุกเช้า, รีเฟรชแคชทุก 10 นาที, และล้าง sessions เก่าคืนละหนึ่งครั้ง แทนที่จะมีคำสั่ง cron แยกสามชุด (แต่ละอันมีโหมดซ้อนทับและข้อผิดพลาดต่างกัน) ให้เก็บรายการงานไว้ที่เดียว Cron เริ่ม process worker เดียวกัน Worker ถาม Postgres ว่า “อะไรถึงเวอตอนนี้?” และ Postgres ตอบโดยให้ worker จับงานได้อย่างปลอดภัยทีละงาน\n\nมันปรับขนาดแบบค่อยเป็นค่อยไป คุณเริ่มด้วย worker ตัวเดียวบนเซิร์ฟเวอร์หนึ่ง ภายหลังคุณอาจรันห้า worker ข้ามหลายเซิร์ฟเวอร์ ข้อตกลงยังคงเหมือนเดิม: ตารางคือสัญญา\n\nการเปลี่ยนแนวคิดคือ: cron เป็นเพียงการปลุก ฐานข้อมูลเป็นตำรวจกำกับการจราจรที่ตัดสินว่าสิ่งใดอนุญาตให้รัน บันทึกสิ่งที่เกิดขึ้น และให้ประวัติชัดเจนเมื่อมีปัญหา\n\n## การออกแบบตาราง jobs (สคีมาปฏิบัติ)\n\nรูปแบบนี้ทำงานได้ดีที่สุดเมื่อฐานข้อมูลของคุณกลายเป็นแหล่งความจริงว่าอะไรควรรัน เมื่อไหร่ควรรัน และเกิดอะไรขึ้นครั้งสุดท้าย สคีมานั้นไม่ซับซ้อน แต่รายละเอียดเล็กน้อย (ฟิลด์ล็อกและดัชนีที่ถูกต้อง) จะช่วยได้มากเมื่อโหลดเพิ่มขึ้น\n\n### ตารางเดียวหรือสองตาราง?\n\nสองแนวทางที่พบบ่อย:\n\n- ตารางรวมหนึ่งตาราง เมื่อคุณสนใจเพียงสถานะล่าสุดของแต่ละงาน (เรียบง่าย ไม่ต้อง join เยอะ)\n- สองตาราง ถ้าคุณต้องการแยกระหว่าง “งานนี้คืออะไร” กับ “แต่ละครั้งที่มันถูกรัน” (มีประวัติชัดเจน ง่ายต่อการดีบัก)\n\nถ้าคุณคาดว่าจะดีบักความล้มเหลวบ่อย ให้เก็บประวัติไว้ ถ้าต้องการเซ็ตอัพเล็กที่สุด ให้เริ่มด้วยตารางเดียวแล้วเพิ่มประวัติทีหลัง\n\n### สคีมาปฏิบัติ (เวอร์ชันสองตาราง)\n\nนี่คือลักษณะสำหรับ PostgreSQL ถ้าคุณสร้างใน Go กับ PostgreSQL คอลัมน์เหล่านี้จับคู่กับ struct ได้เรียบร้อย\n\nsql\n-- What should exist (the definition)\ncreate table job_definitions (\n id bigserial primary key,\n job_type text not null,\n payload jsonb not null default '{}'::jsonb,\n schedule text, -- optional: cron-like text if you store it\n max_attempts int not null default 5,\n created_at timestamptz not null default now(),\n updated_at timestamptz not null default now()\n);\n\n-- What should run (each run / attempt group)\ncreate table job_runs (\n id bigserial primary key,\n definition_id bigint references job_definitions(id),\n job_type text not null,\n payload jsonb not null default '{}'::jsonb,\n run_at timestamptz not null,\n status text not null, -- queued | running | succeeded | failed | dead\n attempts int not null default 0,\n max_attempts int not null default 5,\n\n locked_by text,\n locked_until timestamptz,\n\n last_error text,\n created_at timestamptz not null default now(),\n updated_at timestamptz not null default now()\n);\n\n\nรายละเอียดเล็ก ๆ ที่ช่วยได้ในภายหลัง:\n\n- เก็บ job_type เป็นสตริงสั้นที่คุณสามารถ route ได้ (เช่น send_invoice_emails)\n- เก็บ payload เป็น jsonb เพื่อให้พัฒนาได้โดยไม่ต้อง migration บ่อย\n- run_at คือ “เวลาถัดไปที่ถึงกำหนด” Cron (หรือสคริปต์ scheduler) กำหนดค่า และ worker เป็นผู้บริโภค\n- locked_by และ locked_until ให้ worker จับงานโดยไม่ชนกัน\n- last_error ควรเป็นข้อความสั้นที่อ่านได้สำหรับมนุษย์ เก็บ stack trace แยกที่อื่นถ้าจำเป็น\n\n### ดัชนีที่ควรมี\n\nหากไม่มีดัชนี worker จะสแกนเยอะเกินไป เริ่มด้วย:\n\n- ดัชนีเพื่อค้นหางานที่ถึงเวรได้เร็ว: (status, run_at)\n- ดัชนีช่วยตรวจจับล็อกหมดเวลา: (locked_until)\n- ทางเลือก: partial index สำหรับงานที่ active เท่านั้น (เช่น status ใน queued และ failed)\n\nดัชนีเหล่านี้ทำให้การค้นหา “งานถัดไปที่รันได้” รวดเร็วแม้ตารางจะโตขึ้น\n\n## การล็อกและการโกรงานอย่างปลอดภัย\n\nเป้าหมายง่าย ๆ คือ: worker หลายตัวอาจรัน แต่มีเพียงตัวเดียวเท่านั้นที่ควรจับงานเฉพาะ หากสอง worker ประมวลผลแถวเดียวกันคุณจะได้อีเมลซ้ำ, การคิดเงินซ้ำ, หรือข้อมูลสกปรก\n\nวิธีที่ปลอดภัยคือถือว่าการโกรงานเป็นเหมือน “สัญญาเช่า (lease)” Worker ทำเครื่องหมายว่าได้ล็อกงานไว้ช่วงสั้น ๆ หาก worker crash สัญญาจะหมดอายุและ worker ตัวอื่นสามารถรับงานได้ นั่นคือเหตุผลที่มี locked_until\n\n### ใช้ lease เพื่อให้การ crash ไม่บล็อกงานตลอดไป\n\nหากไม่มี lease worker อาจล็อกงานแล้วไม่ปลดล็อกเลย (process ถูกฆ่า, server รีบูท, ดีพลอยพังก์) ด้วย locked_until งานจะกลับมาใช้งานได้เมื่อเวลาผ่านไป\n\nกฎทั่วไป: งานสามารถถูกจับได้เมื่อ locked_until เป็น NULL หรือ locked_until <= now()\n\n### จับงานด้วยการอัพเดตแบบอะตอมเดียว\n\nรายละเอียดสำคัญคือจับงานในคำสั่งเดียว (หรือหนึ่งธุรกรรม) คุณต้องการให้ฐานข้อมูลเป็นผู้ตัดสิน\n\nนี่คือลายพบบ่อยบน PostgreSQL: เลือกงานที่ถึงเวลา ล็อกมัน และคืนมาให้ worker (ตัวอย่างนี้ใช้ตาราง jobs เดียว แนวคิดเดียวกันใช้กับ job_runs)\n\nsql\nWITH next_job AS (\n SELECT id\n FROM jobs\n WHERE status = 'queued'\n AND run_at <= now()\n AND (locked_until IS NULL OR locked_until <= now())\n ORDER BY run_at ASC\n LIMIT 1\n FOR UPDATE SKIP LOCKED\n)\nUPDATE jobs j\nSET status = 'running',\n locked_until = now() + interval '2 minutes',\n locked_by = $1,\n attempts = attempts + 1,\n updated_at = now()\nFROM next_job\nWHERE j.id = next_job.id\nRETURNING j.*;\n\n\nเหตุผลที่มันทำงาน:\n\n- FOR UPDATE SKIP LOCKED ให้ worker หลายตัวแข่งกันโดยไม่บล็อกกัน\n- lease ถูกเซ็ตเมื่อจับงาน ดังนั้น worker ตัวอื่นจะไม่สนใจจนกว่าจะหมดอายุ\n- RETURNING ส่งแถวให้ worker ที่ชนะการแข่ง\n\n### ระยะเวลา lease ควรเป็นเท่าไหร่ และจะต่ออายุอย่างไร\n\nตั้ง lease ให้ยาวกว่าการรันปกติ แต่สั้นพอที่การ crash จะกลับมาฟื้นตัวได้เร็ว หากงานส่วนใหญ่เสร็จใน 10 วินาที ให้ lease 2 นาทีก็พอสำหรับหลายกรณี\n\nสำหรับงานยาว ให้ต่ออายุ lease ระหว่างทำงาน (heartbeat) วิธีง่าย ๆ คือทุก 30 วินาที ขยาย locked_until หากคุณยังเป็นเจ้าของงานอยู่\n\n- ความยาว lease: 5x ถึง 20x เวลาทั่วไปของงาน\n- ระยะ heartbeat: 1/4 ถึง 1/2 ของ lease\n- คำสั่งต่ออายุควรมี WHERE id = $job_id AND locked_by = $worker_id\n\nเงื่อนไขสุดท้ายสำคัญ มันป้องกันไม่ให้ worker ขยาย lease บนงานที่มันไม่ได้เป็นเจ้าของแล้ว\n\n## การลองใหม่และ backoff ให้คาดเดาได้\n\nการลองใหม่คือจุดที่รูปแบบนี้จะทำให้คุณสงบหรือกลายเป็นความวุ่นวาย เป้าหมายคือเรียบง่าย: เมื่องานล้มเหลว ให้ลองใหม่ในภายหลังในวิธีที่คุณอธิบาย, วัดผล, และหยุดได้\n\nเริ่มจากทำให้สถานะงานชัดและจำกัด: queued, running, succeeded, failed, dead ในงานจริงทีมมักใช้ failed เพื่อหมายถึง “ล้มเหลวแต่จะลองใหม่” และ dead หมายถึง “ล้มเหลวและยอมแพ้” ความต่างนี้ป้องกันลูปไม่รู้จบ\n\nการนับ attempts เป็นการป้องกันชั้นที่สอง เก็บ attempts (จำนวนครั้งที่ลอง) และ max_attempts (จำนวนครั้งที่ยอมให้ลอง) เมื่อ worker จับข้อผิดพลาด ควร:\n\n- เพิ่ม attempts\n- ตั้งสถานะเป็น failed หาก attempts < max_attempts มิฉะนั้น dead\n- คำนวณ run_at สำหรับครั้งถัดไป (เฉพาะกรณี failed)\n\nbackoff คือกฎที่กำหนด run_at ถัดไป เลือกแบบหนึ่งแล้วจงสม่ำเสมอและบันทึก:\n\n- หน่วงคงที่: รอเสมอ 1 นาที\n- กำลังสอง (exponential): 1m, 2m, 4m, 8m\n- exponential พร้อมเพดาน: exponential แต่ไม่เกิน เช่น 30m\n- ใส่ jitter: ทำให้เวลาเล็กน้อยเป็นแบบสุ่มเพื่อไม่ให้งานลองใหม่พร้อมกันทั้งหมด\n\nJitter สำคัญเมื่อ dependency ล่มแล้วกลับมา หากไม่มีมัน งานหลายร้อยงานจะลองพร้อมกันและล้มอีก\n\nเก็บรายละเอียดข้อผิดพลาดพอให้มองเห็นและดีบักได้ ไม่จำเป็นต้องมีระบบล็อกเต็มรูปแบบ แต่ต้องมีพื้นฐาน:\n\n- last_error (ข้อความสั้น แสดงในจอแอดมินได้)\n- error_code หรือ error_type (ช่วยจัดกลุ่ม)\n- failed_at และ next_run_at\n- last_stack แบบออฟชันัล (ถ้าควบคุมขนาดได้)\n\nกฎที่ใช้ได้ดี: ทำ dead หลัง 10 attempts แล้ว backoff แบบ exponential พร้อม jitter วิธีนี้ทำให้ความล้มเหลวชั่วคราวถูกลองใหม่ แต่หยุดงานที่พังจริง ๆ จากการใช้ CPU ตลอดไป\n\n## Idempotency: ป้องกันผลซ้ำแม้งานจะรันซ้ำ\n\nIdempotency หมายถึงงานของคุณสามารถรันซ้ำแล้วยังให้ผลสุดท้ายเหมือนเดิม ในรูปแบบนี้มันสำคัญเพราะแถวเดียวอาจถูกหยิบขึ้นมาซ้ำหลัง crash, timeout, หรือ retry หากงานของคุณคือ “ส่งอีเมลใบแจ้งหนี้” การรันซ้ำไม่ใช่เรื่องเล็กเสมอไป\n\nวิธีปฏิบัติคือแยกงานเป็น (1) ทำงาน และ (2) ประยุกต์ผลลัพธ์ คุณต้องการให้ผลลัพธ์เกิดขึ้นครั้งเดียว แม้ว่างานครั้งแรกจะพยายามหลายครั้ง\n\n### ใช้ idempotency key ที่ผูกกับเหตุการณ์ธุรกิจ\n\nidempotency key ควรมาจากสิ่งที่งานแทนค่า ไม่ใช่จากความพยายามของ worker คีย์ที่ดีคือคีย์ที่คงที่และอธิบายง่าย เช่น invoice_id, user_id + day, หรือ report_name + report_date หากสองความพยายามของงานอ้างถึงเหตุการณ์โลกจริงเดียวกัน ควรใช้คีย์เดียวกัน\n\nตัวอย่าง: “สร้างรายงานขายรายวันสำหรับ 2026-01-14” ใช้ sales_report:2026-01-14 “เรียกเก็บเงิน invoice 812” ใช้ invoice_charge:812\n\n### บังคับ “ครั้งเดียวเท่านั้น” ด้วยข้อจำกัดฐานข้อมูล\n\nการป้องกันที่ง่ายที่สุดคือให้ PostgreSQL ปฏิเสธรายการซ้ำ เก็บ idempotency key ที่สามารถทำดัชนีได้ แล้วเพิ่ม unique constraint\n\nsql\n-- Example: ensure one logical job/effect per business key\nALTER TABLE jobs\nADD COLUMN idempotency_key text;\n\nCREATE UNIQUE INDEX jobs_idempotency_key_uniq\nON jobs (idempotency_key)\nWHERE idempotency_key IS NOT NULL;\n\n\nมันป้องกันไม่ให้มีสองแถวที่มีคีย์เดียวกันอยู่พร้อมกัน หากออกแบบให้มีหลายแถว (เพื่อเก็บประวัติ) ให้ใส่ uniqueness บนตาราง "effects" แทน เช่น sent_emails(idempotency_key) หรือ payments(idempotency_key)\n\nผลข้างเคียงที่ควรปกป้อง:\n\n- อีเมล: สร้างแถว sent_emails ที่มีคีย์ unique ก่อนส่ง หรือลง provider message id เมื่อส่งแล้ว\n- Webhooks: บันทึก delivered_webhooks(event_id) และข้ามถ้ามีแล้ว\n- การชำระเงิน: ใช้ idempotency ของ provider พร้อม unique key ในฐานข้อมูลของคุณเอง\n- การเขียนไฟล์: เขียนเป็นชื่อชั่วคราวแล้วเปลี่ยนชื่อ หรือบันทึก file_generated โดยคีย์ (type, date)\n\nถ้าคุณสร้างบนสแตกที่ใช้ Postgres (เช่น backend Go + PostgreSQL) การตรวจสอบความเป็นเอกลักษณ์พวกนี้เร็วและทำใกล้ข้อมูลได้ ความคิดหลักคือ: retry เป็นเรื่องปกติ แต่การซ้ำซ้อนเป็นเรื่องที่ต้องเลือก\n\n## ขั้นตอนทีละขั้น: สร้าง worker และ scheduler ขั้นพื้นฐาน\n\nเลือก runtime เดี่ยวที่เชื่อถือได้และยึดตามมัน จุดมุ่งหมายของรูปแบบ cron + ฐานข้อมูลคือลดชิ้นส่วนที่ต้องดูแล ดังนั้น process เล็ก ๆ ใน Go, Node, หรือ Python ที่คุยกับ PostgreSQL ก็เพียงพอ\n\n### สร้างใน 5 ขั้นเล็ก ๆ\n\n1) สร้างตารางและดัชนี. เพิ่มตาราง jobs (และตาราง lookup เพิ่มเติมถ้าต้องการ) จากนั้นทำดัชนี run_at และดัชนีที่ช่วย worker ค้นหางานที่พร้อมได้เร็ว (เช่น (status, run_at)).\n\n2) เขียนฟังก์ชัน enqueue เล็ก ๆ. แอปของคุณควร insert แถวโดยมี run_at เป็น now() หรือเวลาในอนาคต เก็บ payload ให้เล็กและคาดเดาได้ (ID และ job type ไม่ใช่ blob ใหญ่)\n\nsql\nINSERT INTO jobs (type, payload, status, run_at, attempts, max_attempts)\nVALUES ($1, $2::jsonb, 'queued', $3, 0, 10);\n\n\n3) ทำวงจรการ claim. รันมันใน transaction เลือกงานที่ถึงเวลา ล็อกพวกมันเพื่อให้ worker คนอื่นข้าม และตั้งเป็น running ใน transaction เดียวกัน\n\nsql\nWITH picked AS (\n SELECT id\n FROM jobs\n WHERE status = 'queued' AND run_at <= now()\n ORDER BY run_at\n FOR UPDATE SKIP LOCKED\n LIMIT 10\n)\nUPDATE jobs\nSET status = 'running', started_at = now()\nWHERE id IN (SELECT id FROM picked)\nRETURNING *;\n\n\n4) ประมวลผลและสรุปผล. สำหรับแต่ละงานที่จับได้ ทำงาน แล้วอัพเดตเป็น done พร้อม finished_at หากล้มเหลว ให้บันทึกข้อความ error และเลื่อนไปเป็น queued พร้อม run_at ใหม่ (backoff) ทำให้การอัพเดตสรุปเล็กและทำเสมอ แม้กระทั่งเมื่อ process กำลังปิดตัว\n\n5) เพิ่มกฎ retry ที่อธิบายได้. ใช้สูตรง่าย ๆ เช่น run_at = now() + (attempts^2) * interval '10 seconds' และหยุดหลัง max_attempts โดยตั้ง status = 'dead'\n\n### เพิ่มการมองเห็นพื้นฐาน\n\nไม่จำเป็นต้องมี dashboard เต็มรูปแบบตั้งแต่วันแรก แต่ต้องพอให้เห็นปัญหา\n\n- โลกหนึ่งบรรทัดต่อ job: claimed, succeeded, failed, retried, dead\n- สร้าง query/view ง่าย ๆ สำหรับ “dead jobs” และ “running jobs เก่านาน”\n- แจ้งเตือนเมื่อจำนวนเพิ่ม (เช่น มากกว่า N dead jobs ในชั่วโมงที่ผ่านมา)\n\nถ้าคุณอยู่บนสแต็ก Go + PostgreSQL สิ่งนี้จับคู่ได้ดีกับ binary worker เดียวบวก cron\n\n## ตัวอย่างสมจริงที่คัดลอกได้\n\nจินตนาการแอป SaaS ขนาดเล็กที่มีงานตามกำหนดสองอย่าง:\n\n- การล้างข้อมูลรายคืนที่ลบ sessions หมดอายุและไฟล์ชั่วคราวเก่า\n- อีเมล “รายงานกิจกรรมของคุณ” รายสัปดาห์ส่งให้ผู้ใช้ทุกเช้าวันจันทร์\n\nทำให้ง่าย: ตาราง PostgreSQL เดียวเก็บ jobs และ worker ตัวเดียวรันทุกนาที (trigger โดย cron) Worker จับงานที่ถึงเวลา, รันมัน, และบันทึกความสำเร็จหรือความล้มเหลว\n\n### อะไรถูก enqueue และเมื่อไหร่\n\nคุณสามารถ enqueue งานจากหลายที่:\n\n- ทุกวันเวลา 02:00: enqueue งาน cleanup_nightly หนึ่งงานสำหรับ “วันนี้”\n- เมื่อสมัคร: enqueue งาน send_weekly_report สำหรับผู้ใช้ในจันทร์ถัดไป\n- หลังเหตุการณ์ (เช่น “ผู้ใช้คลิก Export report”): enqueue งาน send_weekly_report ที่รันทันทีสำหรับช่วงวันที่เฉพาะ\n\npayload คือสิ่งจำเป็นขั้นต่ำที่ worker ต้องการ เก็บให้เล็กเพื่อ retry ง่าย\n\njson\n{\n "type": "send_weekly_report",\n "payload": {\n "user_id": 12345,\n "date_range": {\n "from": "2026-01-01",\n "to": "2026-01-07"\n }\n }\n}\n\n\n### idempotency ป้องกันการส่งซ้ำอย่างไร\n\nworker อาจ crash ในช่วงแย่ที่สุด: หลังส่งอีเมลแล้วแต่ก่อนจะมาร์กงานเป็น “done” เมื่อมันรีสตาร์ท มันอาจหยิบงานเดิมขึ้นมาอีกครั้ง\n\nเพื่อหยุดการส่งซ้ำ ให้การทำงานมีคีย์ dedupe ตามธรรมชาติและเก็บไว้ในที่ที่ฐานข้อมูลบังคับได้ สำหรับรายงานประจำสัปดาห์ คีย์ที่ดีคือ (user_id, week_start_date) ก่อนส่ง worker บันทึกว่า “ฉันกำลังจะส่งรายงาน X” ถ้ารายการนั้นมีอยู่แล้ว ให้ข้ามการส่ง\n\nสิ่งนี้อาจเป็นตาราง sent_reports ที่มี unique constraint บน (user_id, week_start_date) หรือ idempotency_key แบบ unique บน job เอง\n\n### ลักษณะความล้มเหลว (และการกู้คืน)\n\nสมมติ provider ส่งอีเมล timeout งานล้มเหลว worker จะ:\n\n- เพิ่ม attempts\n- บันทึกข้อความ error เพื่อดีบัก\n- กำหนดเวลาลองใหม่ด้วย backoff (เช่น: +1 นาที, +5 นาที, +30 นาที, +2 ชั่วโมง)\n\nถ้ามันยังล้มเหลวเกินขีดจำกัด (เช่น 10 attempts) ให้มาร์กเป็น “dead” แล้วหยุด retry งานนั้นก็จะสำเร็จครั้งหนึ่งหรือมันจะลองใหม่ตามตารางที่ชัดเจน และ idempotency ทำให้การ retry ปลอดภัย\n\n## ข้อผิดพลาดและกับดักที่พบบ่อย\n\nรูปแบบ cron + ฐานข้อมูลเรียบง่าย แต่ความผิดพลาดเล็ก ๆ น้อย ๆ อาจทำให้เกิดการซ้ำ, งานติด, หรือโหลดที่ไม่คาดคิด ปัญหาส่วนใหญ่ปรากฏหลัง crash, ดีพลอย, หรือสไปค์การใช้งานครั้งแรก\n\n### ข้อผิดพลาดที่ทำให้เกิดการซ้ำหรือการติดงาน\n\nเหตุการณ์จริงมักมาจากกับดักไม่กี่ข้อ:\n\n- รันงานเดียวกันจากหลายรายการ cron โดยไม่มี lease ถ้าสองเซิร์ฟเวอร์ติ๊กในนาทีเดียวกัน ทั้งคู่อาจพยายามจับงานเดียวกันหากขั้นตอนการจับไม่ใช่แบบอะตอมและไม่ตั้งล็อก/lease ใน transaction เดียว\n- ข้าม locked_until ถ้า worker crash หลังจับงาน แถวอาจอยู่ในสถานะ “กำลังประมวลผล” ตลอดไป timestamp ของ lease ช่วยให้ worker อื่นรับงานได้ในภายหลัง\n- ลองใหม่ทันทีเมื่อเกิดความล้มเหลว เมื่อ API ล่ม instant retry สร้างสไปค์ เผาผลาญ rate limit และยังคงล้มในลูปหนาแน่น ให้กำหนดเวลาลองใหม่ไปข้างหน้าเสมอ\n- ถือว่า “at least once” เป็น “exactly once” งานอาจวิ่งสองครั้ง (timeout, worker restart, network) หากการรันสองครั้งเป็นอันตราย ให้ทำให้ผลข้างเคียงสามารถทำซ้ำได้อย่างปลอดภัย\n- เก็บ payload ใหญ่ในแถวงาน บลอบ JSON ใหญ่พองโตตาราง ช้าในการดัชนี และทำให้การล็อกหนักขึ้น จงเก็บ reference (เช่น user_id, invoice_id, หรือ file key) แล้วดึงข้อมูลที่เหลือตอนรัน\n\nตัวอย่าง: ส่งอีเมลใบแจ้งหนี้รายสัปดาห์ ถ้า worker timeout หลังส่งแต่ก่อนมาร์กเป็น done งานเดียวอาจถูก retried และส่งซ้ำ นั่นเป็นเรื่องปกติในรูปแบบนี้ถ้าไม่มีหลักประกัน (เช่น บันทึก event "อีเมลส่งแล้ว" โดยใช้ invoice id เป็น key)\n\n### กับดักที่ไม่ชัดเจน\n\nหลีกเลี่ยงการผสมการ schedule และการ execute ใน transaction ยาว หากคุณถือ transaction ขณะเรียกเครือข่าย จะทำให้ล็อกค้างนานกว่าที่จำเป็นและบล็อก worker อื่น\n\nระวังความต่างของนาฬิการะหว่างเครื่อง ใช้เวลาในฐานข้อมูล (NOW()ใน PostgreSQL) เป็นแหล่งความจริงสำหรับrun_atและlocked_untilไม่ใช่นาฬิกาแอปเซิร์ฟเวอร์\n\nตั้งค่าระยะเวลารันสูงสุดชัดเจน หากงานอาจใช้ 30 นาที ให้ lease ยาวกว่านั้น และต่ออายุเมื่อจำเป็น มิฉะนั้น worker ตัวอื่นอาจหยิบมันในระหว่างที่ยังรันอยู่\n\nรักษาตาราง jobs ให้ดี ถ้ารายการเสร็จทับถมไม่ถูกลบ คิวรีจะช้าลงและการแย่งล็อกเพิ่มขึ้น เลือกกฎเก็บรักษา (archive หรือลบแถวเก่า) ก่อนที่ตารางจะใหญ่เกินไป\n\n## เช็คลิสต์ด่วนและขั้นตอนถัดไป\n\n### เช็คลิสต์ด่วน\n\nก่อนส่งรูปแบบนี้ ให้ตรวจสอบพื้นฐาน หากขาดอะไรเล็ก ๆ ที่นี่ มักจะกลายเป็นงานติด, การซ้ำที่ไม่คาดคิด, หรือ worker กระหน่ำฐานข้อมูล\n\n- ตาราง jobs ของคุณมีสิ่งสำคัญ:run_at, status, attempts, locked_until, และ max_attempts(บวกlast_errorหรือคล้ายเพื่อดูสิ่งที่เกิดขึ้น)\n- แต่ละงานสามารถรันซ้ำได้อย่างปลอดภัย หากไม่แน่ใจ ให้เพิ่ม idempotency key หรือกฎ unique รอบผลข้างเคียง (เช่น หนึ่ง invoice ต่อinvoice_id)\n- มีที่ชัดเจนในการสังเกตความล้มเหลวและตัดสินใจทำอะไร: ดู failed jobs, รันงานอีกครั้ง, หรือมาร์กเป็น dead เมื่อควรหยุด retry\n- เวลา lease (lock) มีเหตุผลพอสำหรับงาน มันควรยาวพอสำหรับการรันปกติ แต่สั้นพอที่ worker crash จะไม่บล็อกความคืบหน้าหลายชั่วโมง\n- backoff ของ retry คาดเดาได้ มันควรชะลอการลองใหม่ซ้ำ ๆ และหยุดหลัง max_attempts\n\nถ้าข้อเหล่านี้เป็นจริง รูปแบบ cron + ฐานข้อมูลมักเสถียรพอสำหรับงานจริง\n\n### ขั้นตอนถัดไป\n\nเมื่อเช็คลิสต์ดูโอเค ให้โฟกัสที่การปฏิบัติงานประจำวัน\n\n- เพิ่มสอง action แอดมินเล็ก ๆ: “retry now” (ตั้ง run_at = now()และเคลียร์ล็อก) และ “cancel” (ย้ายไปสถานะ terminal) ช่วยประหยัดเวลาในเหตุการณ์ฉุกเฉิน\n- ให้ worker log หนึ่งบรรทัดต่อ job: job type, job id, เลข attempt, และผล เพิ่มการแจ้งเตือนเมื่อ count ความล้มเหลวเพิ่มขึ้น\n- ทดสอบโหลดด้วยสไปค์สมจริง: งานจำนวนมากถูกกำหนดไว้สำหรับนาทีเดียว หากการ claim งานช้า ให้เพิ่มดัชนีที่ถูกต้อง (มักเป็น(status, run_at)`)\n\nถ้าคุณต้องการสร้างเซ็ตอัพแบบนี้เร็ว ๆ Koder.ai (koder.ai) สามารถช่วยจากสคีมาไปจนถึงแอป Go + PostgreSQL ที่ deploy ได้ โดยลดงานเชื่อมต่อนิดหน่อย ให้คุณโฟกัสที่ล็อก, การ retry, และกฎ idempotency\n\nถ้าคุณเติบโตเกินขอบเขตนี้ภายหลัง คุณยังได้เรียนรู้วงจรชีวิตของงานอย่างชัดเจน และแนวคิดเดียวกันนี้ก็แปลงไปใช้กับระบบคิวเต็มรูปแบบได้ดี

สารบัญ
ปัญหา: งานตามกำหนดเวลาโดยไม่เพิ่มโครงสร้างพื้นฐาน\n\nแอปส่วนใหญ่ต้องให้มีงานที่เกิดขึ้นภายหลังหรือเป็นตามตารางเวลา: ส่งอีเมลติดตาม, ตรวจสอบบิลรายคืน, ลบระเบียนเก่า, สร้างรายงานใหม่, หรือรีเฟรชแคช\n\nตอนแรกมักอยากใส่ระบบคิวเต็มรูปแบบเพราะรู้สึกว่าเป็นทางที่ “ถูกต้อง” สำหรับงานแบ็กกราวด์ แต่คิวเพิ่มชิ้นส่วนที่ต้องดูแล: บริการอีกตัวให้รัน, ต้องมอนิเตอร์, ดีพลอย, และดีบัก สำหรับทีมเล็ก (หรือผู้ก่อตั้งคนเดียว) ภาระที่เพิ่มมานั้นอาจทำให้ความเร็วในการพัฒนาช้าลง\n\nคำถามจริง ๆ คือ: จะรันงานตามกำหนดอย่างน่าเชื่อถือโดยไม่ต้องตั้งโครงสร้างพื้นฐานเพิ่มได้อย่างไร?\n\nความพยายามเริ่มต้นที่พบบ่อยคือเรียบง่าย: เพิ่มรายการ cron ที่เรียก endpoint และ endpoint นั้นทำงาน มันใช้ได้จนกว่าจะใช้ไม่ได้ เมื่อมีมากกว่าหนึ่งเซิร์ฟเวอร์, ดีพลอยช่วงผิดเวลา, หรือหากงานใช้เวลานานกว่าที่คาดไว้ คุณจะเริ่มเห็นความผิดพลาดที่สับสน\n\nงานตามกำหนดมักพังในแบบที่คาดเดาได้ไม่กี่แบบ:\n\n- รันซ้ำ: สองเซิร์ฟเวอร์รันงานเดียวกัน ทำให้ใบแจ้งหนี้ถูกสร้างซ้ำหรืออีเมลถูกส่งสองครั้ง\n- งานหาย: การเรียก cron ล้มเหลวระหว่างดีพลอยและไม่มีใครสังเกตจนผู้ใช้ร้องเรียน\n- ล้มเหลวเงียบ: งาน error ครั้งหนึ่งแล้วไม่รันอีกเพราะไม่มีแผน retry\n- งานทำไม่ครบ: งาน crash กลางทางแล้วทิ้งข้อมูลในสถานะแปลกๆ\n- ไม่มีบันทึก: ไม่สามารถตอบได้ว่า “ครั้งสุดท้ายที่รันเมื่อไหร่?” หรือ “เมื่อคืนเกิดอะไรขึ้น?”\n\nรูปแบบ cron + ฐานข้อมูลเป็นทางเลือกกลาง คุณยังใช้ cron เพื่อ “ปลุก” ตามตาราง แต่คุณเก็บเจตนางานและสถานะงานในฐานข้อมูลเพื่อให้ระบบสามารถประสานงาน, ลองใหม่, และบันทึกสิ่งที่เกิดขึ้น\n\nเหมาะเมื่อคุณมีฐานข้อมูลตัวเดียว (มักเป็น PostgreSQL), ชนิดงานจำนวนไม่มาก, และต้องการพฤติกรรมที่คาดเดาได้พร้อมงานปฏิบัติการน้อยที่สุด มันยังเหมาะกับแอปที่สร้างเร็วบนสแต็กสมัยใหม่ (เช่น React + Go + PostgreSQL)\n\nมันไม่เหมาะเมื่อคุณต้องการ throughput สูงมาก, งานระยะยาวที่ต้องสตรีมความคืบหน้า, การเรียงลำดับเข้มงวดข้ามชนิดงานจำนวนมาก, หรือการแตกพัด (fan-out) หนัก ๆ (หลายพันซับทาสก์ต่อนาที) ในกรณีเหล่านั้น ระบบคิวจริง ๆ และ worker เฉพาะทางมักคุ้มค่า\n\n## แนวคิดหลักแบบเข้าใจง่าย\n\nรูปแบบ cron + ฐานข้อมูลรันงานแบ็กกราวด์ตามตารางโดยไม่ต้องใช้ระบบคิวเต็มรูปแบบ คุณยังใช้ cron (หรือ scheduler อื่น) แต่ cron ไม่ตัดสินใจว่าจะรันอะไร มันแค่ปลุก worker บ่อย ๆ (รอบละนาทีเป็นเรื่องปกติ) ฐานข้อมูลเป็นตัวตัดสินว่างานไหนถึงเวลาและมั่นใจว่า worker เพียงตัวเดียวจะรับงานแต่ละตัว\n\nคิดว่ามันเหมือนบอร์ดเช็คลิสต์ที่หลายคนใช้ร่วมกัน Cron คือคนที่เดินเข้าห้องทุกนาทีและถามว่า “มีใครต้องทำอะไรตอนนี้ไหม?” ฐานข้อมูลคือบอร์ดที่บอกว่างานไหนถึงเวลา งานไหนถูกจับ และงานไหนเสร็จแล้ว\n\nส่วนประกอบไม่ซับซ้อน:\n\n- ทริกเกอร์ scheduler เดียวรันบ่อย\n- ตาราง jobs เก็บ “อะไร” และ “เมื่อไหร่” (เวลาที่ถึงกำหนด) พร้อมสถานะและจำนวนครั้งที่พยายาม\n- หนึ่งหรือมากกว่า worker ดึงตาราง โกงงาน และทำงาน\n- การโกรงานต้องใช้ล็อกในฐานข้อมูลเพื่อไม่ให้สอง worker จับแถวเดียวกัน\n- ฐานข้อมูลยังคงเป็นแหล่งความจริงสำหรับสิ่งที่รัน สิ่งที่ล้มเหลว และสิ่งที่ควรลองใหม่\n\nตัวอย่าง: คุณอยากส่งการเตือนใบแจ้งหนี้ทุกเช้า, รีเฟรชแคชทุก 10 นาที, และล้าง sessions เก่าคืนละหนึ่งครั้ง แทนที่จะมีคำสั่ง cron แยกสามชุด (แต่ละอันมีโหมดซ้อนทับและข้อผิดพลาดต่างกัน) ให้เก็บรายการงานไว้ที่เดียว Cron เริ่ม process worker เดียวกัน Worker ถาม Postgres ว่า “อะไรถึงเวอตอนนี้?” และ Postgres ตอบโดยให้ worker จับงานได้อย่างปลอดภัยทีละงาน\n\nมันปรับขนาดแบบค่อยเป็นค่อยไป คุณเริ่มด้วย worker ตัวเดียวบนเซิร์ฟเวอร์หนึ่ง ภายหลังคุณอาจรันห้า worker ข้ามหลายเซิร์ฟเวอร์ ข้อตกลงยังคงเหมือนเดิม: ตารางคือสัญญา\n\nการเปลี่ยนแนวคิดคือ: cron เป็นเพียงการปลุก ฐานข้อมูลเป็นตำรวจกำกับการจราจรที่ตัดสินว่าสิ่งใดอนุญาตให้รัน บันทึกสิ่งที่เกิดขึ้น และให้ประวัติชัดเจนเมื่อมีปัญหา\n\n## การออกแบบตาราง jobs (สคีมาปฏิบัติ)\n\nรูปแบบนี้ทำงานได้ดีที่สุดเมื่อฐานข้อมูลของคุณกลายเป็นแหล่งความจริงว่าอะไรควรรัน เมื่อไหร่ควรรัน และเกิดอะไรขึ้นครั้งสุดท้าย สคีมานั้นไม่ซับซ้อน แต่รายละเอียดเล็กน้อย (ฟิลด์ล็อกและดัชนีที่ถูกต้อง) จะช่วยได้มากเมื่อโหลดเพิ่มขึ้น\n\n### ตารางเดียวหรือสองตาราง?\n\nสองแนวทางที่พบบ่อย:\n\n- ตารางรวมหนึ่งตาราง เมื่อคุณสนใจเพียงสถานะล่าสุดของแต่ละงาน (เรียบง่าย ไม่ต้อง join เยอะ)\n- สองตาราง ถ้าคุณต้องการแยกระหว่าง “งานนี้คืออะไร” กับ “แต่ละครั้งที่มันถูกรัน” (มีประวัติชัดเจน ง่ายต่อการดีบัก)\n\nถ้าคุณคาดว่าจะดีบักความล้มเหลวบ่อย ให้เก็บประวัติไว้ ถ้าต้องการเซ็ตอัพเล็กที่สุด ให้เริ่มด้วยตารางเดียวแล้วเพิ่มประวัติทีหลัง\n\n### สคีมาปฏิบัติ (เวอร์ชันสองตาราง)\n\nนี่คือลักษณะสำหรับ PostgreSQL ถ้าคุณสร้างใน Go กับ PostgreSQL คอลัมน์เหล่านี้จับคู่กับ struct ได้เรียบร้อย\n\n```sql\n-- What should exist (the definition)\ncreate table job_definitions (\n id bigserial primary key,\n job_type text not null,\n payload jsonb not null default '{}'::jsonb,\n schedule text, -- optional: cron-like text if you store it\n max_attempts int not null default 5,\n created_at timestamptz not null default now(),\n updated_at timestamptz not null default now()\n);\n\n-- What should run (each run / attempt group)\ncreate table job_runs (\n id bigserial primary key,\n definition_id bigint references job_definitions(id),\n job_type text not null,\n payload jsonb not null default '{}'::jsonb,\n run_at timestamptz not null,\n status text not null, -- queued | running | succeeded | failed | dead\n attempts int not null default 0,\n max_attempts int not null default 5,\n\n locked_by text,\n locked_until timestamptz,\n\n last_error text,\n created_at timestamptz not null default now(),\n updated_at timestamptz not null default now()\n);\n```\n\nรายละเอียดเล็ก ๆ ที่ช่วยได้ในภายหลัง:\n\n- เก็บ **job_type** เป็นสตริงสั้นที่คุณสามารถ route ได้ (เช่น `send_invoice_emails`)\n- เก็บ **payload** เป็น `jsonb` เพื่อให้พัฒนาได้โดยไม่ต้อง migration บ่อย\n- **run_at** คือ “เวลาถัดไปที่ถึงกำหนด” Cron (หรือสคริปต์ scheduler) กำหนดค่า และ worker เป็นผู้บริโภค\n- **locked_by** และ **locked_until** ให้ worker จับงานโดยไม่ชนกัน\n- **last_error** ควรเป็นข้อความสั้นที่อ่านได้สำหรับมนุษย์ เก็บ stack trace แยกที่อื่นถ้าจำเป็น\n\n### ดัชนีที่ควรมี\n\nหากไม่มีดัชนี worker จะสแกนเยอะเกินไป เริ่มด้วย:\n\n- ดัชนีเพื่อค้นหางานที่ถึงเวรได้เร็ว: `(status, run_at)`\n- ดัชนีช่วยตรวจจับล็อกหมดเวลา: `(locked_until)`\n- ทางเลือก: partial index สำหรับงานที่ active เท่านั้น (เช่น status ใน `queued` และ `failed`)\n\nดัชนีเหล่านี้ทำให้การค้นหา “งานถัดไปที่รันได้” รวดเร็วแม้ตารางจะโตขึ้น\n\n## การล็อกและการโกรงานอย่างปลอดภัย\n\nเป้าหมายง่าย ๆ คือ: worker หลายตัวอาจรัน แต่มีเพียงตัวเดียวเท่านั้นที่ควรจับงานเฉพาะ หากสอง worker ประมวลผลแถวเดียวกันคุณจะได้อีเมลซ้ำ, การคิดเงินซ้ำ, หรือข้อมูลสกปรก\n\nวิธีที่ปลอดภัยคือถือว่าการโกรงานเป็นเหมือน “สัญญาเช่า (lease)” Worker ทำเครื่องหมายว่าได้ล็อกงานไว้ช่วงสั้น ๆ หาก worker crash สัญญาจะหมดอายุและ worker ตัวอื่นสามารถรับงานได้ นั่นคือเหตุผลที่มี `locked_until`\n\n### ใช้ lease เพื่อให้การ crash ไม่บล็อกงานตลอดไป\n\nหากไม่มี lease worker อาจล็อกงานแล้วไม่ปลดล็อกเลย (process ถูกฆ่า, server รีบูท, ดีพลอยพังก์) ด้วย `locked_until` งานจะกลับมาใช้งานได้เมื่อเวลาผ่านไป\n\nกฎทั่วไป: งานสามารถถูกจับได้เมื่อ `locked_until` เป็น `NULL` หรือ `locked_until <= now()`\n\n### จับงานด้วยการอัพเดตแบบอะตอมเดียว\n\nรายละเอียดสำคัญคือจับงานในคำสั่งเดียว (หรือหนึ่งธุรกรรม) คุณต้องการให้ฐานข้อมูลเป็นผู้ตัดสิน\n\nนี่คือลายพบบ่อยบน PostgreSQL: เลือกงานที่ถึงเวลา ล็อกมัน และคืนมาให้ worker (ตัวอย่างนี้ใช้ตาราง `jobs` เดียว แนวคิดเดียวกันใช้กับ `job_runs`)\n\n```sql\nWITH next_job AS (\n SELECT id\n FROM jobs\n WHERE status = 'queued'\n AND run_at <= now()\n AND (locked_until IS NULL OR locked_until <= now())\n ORDER BY run_at ASC\n LIMIT 1\n FOR UPDATE SKIP LOCKED\n)\nUPDATE jobs j\nSET status = 'running',\n locked_until = now() + interval '2 minutes',\n locked_by = $1,\n attempts = attempts + 1,\n updated_at = now()\nFROM next_job\nWHERE j.id = next_job.id\nRETURNING j.*;\n```\n\nเหตุผลที่มันทำงาน:\n\n- `FOR UPDATE SKIP LOCKED` ให้ worker หลายตัวแข่งกันโดยไม่บล็อกกัน\n- lease ถูกเซ็ตเมื่อจับงาน ดังนั้น worker ตัวอื่นจะไม่สนใจจนกว่าจะหมดอายุ\n- `RETURNING` ส่งแถวให้ worker ที่ชนะการแข่ง\n\n### ระยะเวลา lease ควรเป็นเท่าไหร่ และจะต่ออายุอย่างไร\n\nตั้ง lease ให้ยาวกว่าการรันปกติ แต่สั้นพอที่การ crash จะกลับมาฟื้นตัวได้เร็ว หากงานส่วนใหญ่เสร็จใน 10 วินาที ให้ lease 2 นาทีก็พอสำหรับหลายกรณี\n\nสำหรับงานยาว ให้ต่ออายุ lease ระหว่างทำงาน (heartbeat) วิธีง่าย ๆ คือทุก 30 วินาที ขยาย `locked_until` หากคุณยังเป็นเจ้าของงานอยู่\n\n- ความยาว lease: 5x ถึง 20x เวลาทั่วไปของงาน\n- ระยะ heartbeat: 1/4 ถึง 1/2 ของ lease\n- คำสั่งต่ออายุควรมี `WHERE id = $job_id AND locked_by = $worker_id`\n\nเงื่อนไขสุดท้ายสำคัญ มันป้องกันไม่ให้ worker ขยาย lease บนงานที่มันไม่ได้เป็นเจ้าของแล้ว\n\n## การลองใหม่และ backoff ให้คาดเดาได้\n\nการลองใหม่คือจุดที่รูปแบบนี้จะทำให้คุณสงบหรือกลายเป็นความวุ่นวาย เป้าหมายคือเรียบง่าย: เมื่องานล้มเหลว ให้ลองใหม่ในภายหลังในวิธีที่คุณอธิบาย, วัดผล, และหยุดได้\n\nเริ่มจากทำให้สถานะงานชัดและจำกัด: `queued`, `running`, `succeeded`, `failed`, `dead` ในงานจริงทีมมักใช้ `failed` เพื่อหมายถึง “ล้มเหลวแต่จะลองใหม่” และ `dead` หมายถึง “ล้มเหลวและยอมแพ้” ความต่างนี้ป้องกันลูปไม่รู้จบ\n\nการนับ attempts เป็นการป้องกันชั้นที่สอง เก็บ `attempts` (จำนวนครั้งที่ลอง) และ `max_attempts` (จำนวนครั้งที่ยอมให้ลอง) เมื่อ worker จับข้อผิดพลาด ควร:\n\n- เพิ่ม `attempts`\n- ตั้งสถานะเป็น `failed` หาก `attempts < max_attempts` มิฉะนั้น `dead`\n- คำนวณ `run_at` สำหรับครั้งถัดไป (เฉพาะกรณี `failed`)\n\nbackoff คือกฎที่กำหนด `run_at` ถัดไป เลือกแบบหนึ่งแล้วจงสม่ำเสมอและบันทึก:\n\n- หน่วงคงที่: รอเสมอ 1 นาที\n- กำลังสอง (exponential): 1m, 2m, 4m, 8m\n- exponential พร้อมเพดาน: exponential แต่ไม่เกิน เช่น 30m\n- ใส่ jitter: ทำให้เวลาเล็กน้อยเป็นแบบสุ่มเพื่อไม่ให้งานลองใหม่พร้อมกันทั้งหมด\n\nJitter สำคัญเมื่อ dependency ล่มแล้วกลับมา หากไม่มีมัน งานหลายร้อยงานจะลองพร้อมกันและล้มอีก\n\nเก็บรายละเอียดข้อผิดพลาดพอให้มองเห็นและดีบักได้ ไม่จำเป็นต้องมีระบบล็อกเต็มรูปแบบ แต่ต้องมีพื้นฐาน:\n\n- `last_error` (ข้อความสั้น แสดงในจอแอดมินได้)\n- `error_code` หรือ `error_type` (ช่วยจัดกลุ่ม)\n- `failed_at` และ `next_run_at`\n- `last_stack` แบบออฟชันัล (ถ้าควบคุมขนาดได้)\n\nกฎที่ใช้ได้ดี: ทำ `dead` หลัง 10 attempts แล้ว backoff แบบ exponential พร้อม jitter วิธีนี้ทำให้ความล้มเหลวชั่วคราวถูกลองใหม่ แต่หยุดงานที่พังจริง ๆ จากการใช้ CPU ตลอดไป\n\n## Idempotency: ป้องกันผลซ้ำแม้งานจะรันซ้ำ\n\nIdempotency หมายถึงงานของคุณสามารถรันซ้ำแล้วยังให้ผลสุดท้ายเหมือนเดิม ในรูปแบบนี้มันสำคัญเพราะแถวเดียวอาจถูกหยิบขึ้นมาซ้ำหลัง crash, timeout, หรือ retry หากงานของคุณคือ “ส่งอีเมลใบแจ้งหนี้” การรันซ้ำไม่ใช่เรื่องเล็กเสมอไป\n\nวิธีปฏิบัติคือแยกงานเป็น (1) ทำงาน และ (2) ประยุกต์ผลลัพธ์ คุณต้องการให้ผลลัพธ์เกิดขึ้นครั้งเดียว แม้ว่างานครั้งแรกจะพยายามหลายครั้ง\n\n### ใช้ idempotency key ที่ผูกกับเหตุการณ์ธุรกิจ\n\nidempotency key ควรมาจากสิ่งที่งานแทนค่า ไม่ใช่จากความพยายามของ worker คีย์ที่ดีคือคีย์ที่คงที่และอธิบายง่าย เช่น `invoice_id`, `user_id + day`, หรือ `report_name + report_date` หากสองความพยายามของงานอ้างถึงเหตุการณ์โลกจริงเดียวกัน ควรใช้คีย์เดียวกัน\n\nตัวอย่าง: “สร้างรายงานขายรายวันสำหรับ 2026-01-14” ใช้ `sales_report:2026-01-14` “เรียกเก็บเงิน invoice 812” ใช้ `invoice_charge:812`\n\n### บังคับ “ครั้งเดียวเท่านั้น” ด้วยข้อจำกัดฐานข้อมูล\n\nการป้องกันที่ง่ายที่สุดคือให้ PostgreSQL ปฏิเสธรายการซ้ำ เก็บ idempotency key ที่สามารถทำดัชนีได้ แล้วเพิ่ม unique constraint\n\n```sql\n-- Example: ensure one logical job/effect per business key\nALTER TABLE jobs\nADD COLUMN idempotency_key text;\n\nCREATE UNIQUE INDEX jobs_idempotency_key_uniq\nON jobs (idempotency_key)\nWHERE idempotency_key IS NOT NULL;\n```\n\nมันป้องกันไม่ให้มีสองแถวที่มีคีย์เดียวกันอยู่พร้อมกัน หากออกแบบให้มีหลายแถว (เพื่อเก็บประวัติ) ให้ใส่ uniqueness บนตาราง "effects" แทน เช่น `sent_emails(idempotency_key)` หรือ `payments(idempotency_key)`\n\nผลข้างเคียงที่ควรปกป้อง:\n\n- อีเมล: สร้างแถว `sent_emails` ที่มีคีย์ unique ก่อนส่ง หรือลง provider message id เมื่อส่งแล้ว\n- Webhooks: บันทึก `delivered_webhooks(event_id)` และข้ามถ้ามีแล้ว\n- การชำระเงิน: ใช้ idempotency ของ provider พร้อม unique key ในฐานข้อมูลของคุณเอง\n- การเขียนไฟล์: เขียนเป็นชื่อชั่วคราวแล้วเปลี่ยนชื่อ หรือบันทึก `file_generated` โดยคีย์ `(type, date)`\n\nถ้าคุณสร้างบนสแตกที่ใช้ Postgres (เช่น backend Go + PostgreSQL) การตรวจสอบความเป็นเอกลักษณ์พวกนี้เร็วและทำใกล้ข้อมูลได้ ความคิดหลักคือ: retry เป็นเรื่องปกติ แต่การซ้ำซ้อนเป็นเรื่องที่ต้องเลือก\n\n## ขั้นตอนทีละขั้น: สร้าง worker และ scheduler ขั้นพื้นฐาน\n\nเลือก runtime เดี่ยวที่เชื่อถือได้และยึดตามมัน จุดมุ่งหมายของรูปแบบ cron + ฐานข้อมูลคือลดชิ้นส่วนที่ต้องดูแล ดังนั้น process เล็ก ๆ ใน Go, Node, หรือ Python ที่คุยกับ PostgreSQL ก็เพียงพอ\n\n### สร้างใน 5 ขั้นเล็ก ๆ\n\n1) **สร้างตารางและดัชนี.** เพิ่มตาราง `jobs` (และตาราง lookup เพิ่มเติมถ้าต้องการ) จากนั้นทำดัชนี `run_at` และดัชนีที่ช่วย worker ค้นหางานที่พร้อมได้เร็ว (เช่น `(status, run_at)`).\n\n2) **เขียนฟังก์ชัน enqueue เล็ก ๆ.** แอปของคุณควร insert แถวโดยมี `run_at` เป็น `now()` หรือเวลาในอนาคต เก็บ payload ให้เล็กและคาดเดาได้ (ID และ job type ไม่ใช่ blob ใหญ่)\n\n```sql\nINSERT INTO jobs (type, payload, status, run_at, attempts, max_attempts)\nVALUES ($1, $2::jsonb, 'queued', $3, 0, 10);\n```\n\n3) **ทำวงจรการ claim.** รันมันใน transaction เลือกงานที่ถึงเวลา ล็อกพวกมันเพื่อให้ worker คนอื่นข้าม และตั้งเป็น `running` ใน transaction เดียวกัน\n\n```sql\nWITH picked AS (\n SELECT id\n FROM jobs\n WHERE status = 'queued' AND run_at <= now()\n ORDER BY run_at\n FOR UPDATE SKIP LOCKED\n LIMIT 10\n)\nUPDATE jobs\nSET status = 'running', started_at = now()\nWHERE id IN (SELECT id FROM picked)\nRETURNING *;\n```\n\n4) **ประมวลผลและสรุปผล.** สำหรับแต่ละงานที่จับได้ ทำงาน แล้วอัพเดตเป็น `done` พร้อม `finished_at` หากล้มเหลว ให้บันทึกข้อความ error และเลื่อนไปเป็น `queued` พร้อม `run_at` ใหม่ (backoff) ทำให้การอัพเดตสรุปเล็กและทำเสมอ แม้กระทั่งเมื่อ process กำลังปิดตัว\n\n5) **เพิ่มกฎ retry ที่อธิบายได้.** ใช้สูตรง่าย ๆ เช่น `run_at = now() + (attempts^2) * interval '10 seconds'` และหยุดหลัง `max_attempts` โดยตั้ง `status = 'dead'`\n\n### เพิ่มการมองเห็นพื้นฐาน\n\nไม่จำเป็นต้องมี dashboard เต็มรูปแบบตั้งแต่วันแรก แต่ต้องพอให้เห็นปัญหา\n\n- โลกหนึ่งบรรทัดต่อ job: claimed, succeeded, failed, retried, dead\n- สร้าง query/view ง่าย ๆ สำหรับ “dead jobs” และ “running jobs เก่านาน”\n- แจ้งเตือนเมื่อจำนวนเพิ่ม (เช่น มากกว่า N dead jobs ในชั่วโมงที่ผ่านมา)\n\nถ้าคุณอยู่บนสแต็ก Go + PostgreSQL สิ่งนี้จับคู่ได้ดีกับ binary worker เดียวบวก cron\n\n## ตัวอย่างสมจริงที่คัดลอกได้\n\nจินตนาการแอป SaaS ขนาดเล็กที่มีงานตามกำหนดสองอย่าง:\n\n- การล้างข้อมูลรายคืนที่ลบ sessions หมดอายุและไฟล์ชั่วคราวเก่า\n- อีเมล “รายงานกิจกรรมของคุณ” รายสัปดาห์ส่งให้ผู้ใช้ทุกเช้าวันจันทร์\n\nทำให้ง่าย: ตาราง PostgreSQL เดียวเก็บ jobs และ worker ตัวเดียวรันทุกนาที (trigger โดย cron) Worker จับงานที่ถึงเวลา, รันมัน, และบันทึกความสำเร็จหรือความล้มเหลว\n\n### อะไรถูก enqueue และเมื่อไหร่\n\nคุณสามารถ enqueue งานจากหลายที่:\n\n- ทุกวันเวลา 02:00: enqueue งาน `cleanup_nightly` หนึ่งงานสำหรับ “วันนี้”\n- เมื่อสมัคร: enqueue งาน `send_weekly_report` สำหรับผู้ใช้ในจันทร์ถัดไป\n- หลังเหตุการณ์ (เช่น “ผู้ใช้คลิก Export report”): enqueue งาน `send_weekly_report` ที่รันทันทีสำหรับช่วงวันที่เฉพาะ\n\npayload คือสิ่งจำเป็นขั้นต่ำที่ worker ต้องการ เก็บให้เล็กเพื่อ retry ง่าย\n\n```json\n{\n "type": "send_weekly_report",\n "payload": {\n "user_id": 12345,\n "date_range": {\n "from": "2026-01-01",\n "to": "2026-01-07"\n }\n }\n}\n```\n\n### idempotency ป้องกันการส่งซ้ำอย่างไร\n\nworker อาจ crash ในช่วงแย่ที่สุด: หลังส่งอีเมลแล้วแต่ก่อนจะมาร์กงานเป็น “done” เมื่อมันรีสตาร์ท มันอาจหยิบงานเดิมขึ้นมาอีกครั้ง\n\nเพื่อหยุดการส่งซ้ำ ให้การทำงานมีคีย์ dedupe ตามธรรมชาติและเก็บไว้ในที่ที่ฐานข้อมูลบังคับได้ สำหรับรายงานประจำสัปดาห์ คีย์ที่ดีคือ `(user_id, week_start_date)` ก่อนส่ง worker บันทึกว่า “ฉันกำลังจะส่งรายงาน X” ถ้ารายการนั้นมีอยู่แล้ว ให้ข้ามการส่ง\n\nสิ่งนี้อาจเป็นตาราง `sent_reports` ที่มี unique constraint บน `(user_id, week_start_date)` หรือ `idempotency_key` แบบ unique บน job เอง\n\n### ลักษณะความล้มเหลว (และการกู้คืน)\n\nสมมติ provider ส่งอีเมล timeout งานล้มเหลว worker จะ:\n\n- เพิ่ม `attempts`\n- บันทึกข้อความ error เพื่อดีบัก\n- กำหนดเวลาลองใหม่ด้วย backoff (เช่น: +1 นาที, +5 นาที, +30 นาที, +2 ชั่วโมง)\n\nถ้ามันยังล้มเหลวเกินขีดจำกัด (เช่น 10 attempts) ให้มาร์กเป็น “dead” แล้วหยุด retry งานนั้นก็จะสำเร็จครั้งหนึ่งหรือมันจะลองใหม่ตามตารางที่ชัดเจน และ idempotency ทำให้การ retry ปลอดภัย\n\n## ข้อผิดพลาดและกับดักที่พบบ่อย\n\nรูปแบบ cron + ฐานข้อมูลเรียบง่าย แต่ความผิดพลาดเล็ก ๆ น้อย ๆ อาจทำให้เกิดการซ้ำ, งานติด, หรือโหลดที่ไม่คาดคิด ปัญหาส่วนใหญ่ปรากฏหลัง crash, ดีพลอย, หรือสไปค์การใช้งานครั้งแรก\n\n### ข้อผิดพลาดที่ทำให้เกิดการซ้ำหรือการติดงาน\n\nเหตุการณ์จริงมักมาจากกับดักไม่กี่ข้อ:\n\n- รันงานเดียวกันจากหลายรายการ cron โดยไม่มี lease ถ้าสองเซิร์ฟเวอร์ติ๊กในนาทีเดียวกัน ทั้งคู่อาจพยายามจับงานเดียวกันหากขั้นตอนการจับไม่ใช่แบบอะตอมและไม่ตั้งล็อก/lease ใน transaction เดียว\n- ข้าม `locked_until` ถ้า worker crash หลังจับงาน แถวอาจอยู่ในสถานะ “กำลังประมวลผล” ตลอดไป timestamp ของ lease ช่วยให้ worker อื่นรับงานได้ในภายหลัง\n- ลองใหม่ทันทีเมื่อเกิดความล้มเหลว เมื่อ API ล่ม instant retry สร้างสไปค์ เผาผลาญ rate limit และยังคงล้มในลูปหนาแน่น ให้กำหนดเวลาลองใหม่ไปข้างหน้าเสมอ\n- ถือว่า “at least once” เป็น “exactly once” งานอาจวิ่งสองครั้ง (timeout, worker restart, network) หากการรันสองครั้งเป็นอันตราย ให้ทำให้ผลข้างเคียงสามารถทำซ้ำได้อย่างปลอดภัย\n- เก็บ payload ใหญ่ในแถวงาน บลอบ JSON ใหญ่พองโตตาราง ช้าในการดัชนี และทำให้การล็อกหนักขึ้น จงเก็บ reference (เช่น `user_id`, `invoice_id`, หรือ file key`) แล้วดึงข้อมูลที่เหลือตอนรัน\n\nตัวอย่าง: ส่งอีเมลใบแจ้งหนี้รายสัปดาห์ ถ้า worker timeout หลังส่งแต่ก่อนมาร์กเป็น done งานเดียวอาจถูก retried และส่งซ้ำ นั่นเป็นเรื่องปกติในรูปแบบนี้ถ้าไม่มีหลักประกัน (เช่น บันทึก event "อีเมลส่งแล้ว" โดยใช้ invoice id เป็น key)\n\n### กับดักที่ไม่ชัดเจน\n\nหลีกเลี่ยงการผสมการ schedule และการ execute ใน transaction ยาว หากคุณถือ transaction ขณะเรียกเครือข่าย จะทำให้ล็อกค้างนานกว่าที่จำเป็นและบล็อก worker อื่น\n\nระวังความต่างของนาฬิการะหว่างเครื่อง ใช้เวลาในฐานข้อมูล (`NOW()` ใน PostgreSQL) เป็นแหล่งความจริงสำหรับ `run_at` และ `locked_until` ไม่ใช่นาฬิกาแอปเซิร์ฟเวอร์\n\nตั้งค่าระยะเวลารันสูงสุดชัดเจน หากงานอาจใช้ 30 นาที ให้ lease ยาวกว่านั้น และต่ออายุเมื่อจำเป็น มิฉะนั้น worker ตัวอื่นอาจหยิบมันในระหว่างที่ยังรันอยู่\n\nรักษาตาราง jobs ให้ดี ถ้ารายการเสร็จทับถมไม่ถูกลบ คิวรีจะช้าลงและการแย่งล็อกเพิ่มขึ้น เลือกกฎเก็บรักษา (archive หรือลบแถวเก่า) ก่อนที่ตารางจะใหญ่เกินไป\n\n## เช็คลิสต์ด่วนและขั้นตอนถัดไป\n\n### เช็คลิสต์ด่วน\n\nก่อนส่งรูปแบบนี้ ให้ตรวจสอบพื้นฐาน หากขาดอะไรเล็ก ๆ ที่นี่ มักจะกลายเป็นงานติด, การซ้ำที่ไม่คาดคิด, หรือ worker กระหน่ำฐานข้อมูล\n\n- ตาราง jobs ของคุณมีสิ่งสำคัญ: `run_at`, `status`, `attempts`, `locked_until`, และ `max_attempts` (บวก `last_error` หรือคล้ายเพื่อดูสิ่งที่เกิดขึ้น)\n- แต่ละงานสามารถรันซ้ำได้อย่างปลอดภัย หากไม่แน่ใจ ให้เพิ่ม idempotency key หรือกฎ unique รอบผลข้างเคียง (เช่น หนึ่ง invoice ต่อ `invoice_id`)\n- มีที่ชัดเจนในการสังเกตความล้มเหลวและตัดสินใจทำอะไร: ดู failed jobs, รันงานอีกครั้ง, หรือมาร์กเป็น dead เมื่อควรหยุด retry\n- เวลา lease (lock) มีเหตุผลพอสำหรับงาน มันควรยาวพอสำหรับการรันปกติ แต่สั้นพอที่ worker crash จะไม่บล็อกความคืบหน้าหลายชั่วโมง\n- backoff ของ retry คาดเดาได้ มันควรชะลอการลองใหม่ซ้ำ ๆ และหยุดหลัง `max_attempts`\n\nถ้าข้อเหล่านี้เป็นจริง รูปแบบ cron + ฐานข้อมูลมักเสถียรพอสำหรับงานจริง\n\n### ขั้นตอนถัดไป\n\nเมื่อเช็คลิสต์ดูโอเค ให้โฟกัสที่การปฏิบัติงานประจำวัน\n\n- เพิ่มสอง action แอดมินเล็ก ๆ: “retry now” (ตั้ง `run_at = now()` และเคลียร์ล็อก) และ “cancel” (ย้ายไปสถานะ terminal) ช่วยประหยัดเวลาในเหตุการณ์ฉุกเฉิน\n- ให้ worker log หนึ่งบรรทัดต่อ job: job type, job id, เลข attempt, และผล เพิ่มการแจ้งเตือนเมื่อ count ความล้มเหลวเพิ่มขึ้น\n- ทดสอบโหลดด้วยสไปค์สมจริง: งานจำนวนมากถูกกำหนดไว้สำหรับนาทีเดียว หากการ claim งานช้า ให้เพิ่มดัชนีที่ถูกต้อง (มักเป็น `(status, run_at)`)\n\nถ้าคุณต้องการสร้างเซ็ตอัพแบบนี้เร็ว ๆ Koder.ai (koder.ai) สามารถช่วยจากสคีมาไปจนถึงแอป Go + PostgreSQL ที่ deploy ได้ โดยลดงานเชื่อมต่อนิดหน่อย ให้คุณโฟกัสที่ล็อก, การ retry, และกฎ idempotency\n\nถ้าคุณเติบโตเกินขอบเขตนี้ภายหลัง คุณยังได้เรียนรู้วงจรชีวิตของงานอย่างชัดเจน และแนวคิดเดียวกันนี้ก็แปลงไปใช้กับระบบคิวเต็มรูปแบบได้ดี
แชร์
Koder.ai
Build your own app with Koder today!

The best way to understand the power of Koder is to see it for yourself.

Start FreeBook a Demo