วิธีรันงาน Laravel Scheduler ที่พลาดไปตอนเซิร์ฟเวอร์ล่ม

Laravel Scheduler ไม่ย้อนรันงานที่พลาดไปตอนเซิร์ฟเวอร์ล่ม แพ็กเกจ Schedule Catchup จดเวลาที่ scheduler ทำงานล่าสุดกับงานที่รันแล้วลงฐานข้อมูล พอเครื่องกลับมาก็รันงานที่ติด replayOnCatchup() ย้อนให้ทีละรอบ โดยส่งวันที่ของรอบที่พลาดให้คำสั่งด้วย

เผยแพร่
เวลาอ่าน
5 นาที
สรุปจาก
Yannick Lyn Fatt · laravel-news.com
ในโพสต์นี้ 6 หัวข้อ
  1. งานที่พลาดไประหว่างเซิร์ฟเวอร์ล่มจะหายไปเงียบ ๆ
  2. แพ็กเกจจดเวลาไว้ แล้วไล่รันรอบที่ขาด
  3. ติดตั้งและเลือกงานที่จะให้รันย้อน
  4. คำสั่งต้องรู้ว่ากำลังรันแทนรอบไหน
  5. รันซ้ำแล้วต้องไม่เกิดผลซ้ำ
  6. แบบไหนพอโดยไม่ต้องลงแพ็กเกจ

งานที่พลาดไประหว่างเซิร์ฟเวอร์ล่มจะหายไปเงียบ ๆ

schedule:run เช็คแค่ว่านาทีปัจจุบันมีงานไหนถึงเวลา ไม่ได้ดูย้อนหลังว่ามีรอบไหนที่ไม่ได้รัน ถ้า VPS รีบูตตอน 23:58 แล้วกลับมาตอน 00:20 งาน dailyAt('00:00') ของคืนนั้นจะไม่เกิดขึ้นเลย และไม่มี error ให้เห็น

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

  • รายงานยอดขายประจำวันที่ฝ่ายบัญชีใช้ปิดยอด
  • ตัดรอบสมาชิกหรือคิดค่าบริการรายวัน
  • สรุปข้อมูลรายวันลงตารางสถิติ ถ้าขาดวันไหน กราฟทั้งเดือนก็ผิด

ที่ผ่านมาเรามักแก้ด้วยการให้คำสั่งรับวันที่ (--date=2026-11-10) ไว้ แล้วรันเองย้อนหลังเมื่อมีคนสังเกตว่าข้อมูลหาย ใช้ได้นะ แต่ต้องมีคนรู้ก่อนว่าพลาดไปวันไหน

ระบบรายงานจาก ERP ที่เราทำส่งรายงาน Excel ให้แต่ละฝ่ายราว 40 ชุดตามเวลา และรันบนเซิร์ฟเวอร์ในสำนักงานลูกค้า ซึ่งไฟดับและเน็ตหลุดเป็นครั้งคราว พอเครื่องกลับมา รายงานของช่วงนั้นก็ไม่ออก เราจึงต้องทำให้ระบบตามงานที่พลาดเอง

แพ็กเกจจดเวลาไว้ แล้วไล่รันรอบที่ขาด

Schedule Catchup ของ Arowosaye Remilekun ทำงานเป็นสามส่วน

  1. heartbeat แพ็กเกจตั้งคำสั่ง schedule-catchup:heartbeat ให้รันทุก 5 นาที เพื่อเขียนเวลาปัจจุบันลงตาราง เวลานี้คือครั้งสุดท้ายที่รู้แน่ว่า scheduler ยังทำงานอยู่
  2. บันทึกรอบที่รันแล้ว ทุกครั้งที่งานที่ติด tag รันเสร็จตามปกติ แพ็กเกจจดไว้ว่ารอบเวลานั้นรันแล้ว จึงแยกได้ว่ารอบไหนพลาดจริง และไม่รันซ้ำตอนรีสตาร์ต
  3. ไล่รันรอบที่ขาด schedule-catchup:run (ตั้งให้ทุก 5 นาทีเหมือนกัน) อ่าน cron expression ของแต่ละงาน ไล่ดูทุกรอบตั้งแต่ heartbeat ล่าสุดถึงตอนนี้ ตัดรอบที่รันแล้วออก แล้วรันที่เหลือจากเก่าไปใหม่

ทั้งสองคำสั่งตั้ง withoutOverlapping() กับ onOneServer() ไว้ให้แล้ว ช่วงที่ scheduler หายไปสั้นกว่า 2 นาทีแพ็กเกจจะไม่นับ และตอนติดตั้งครั้งแรกจะจดเวลาไว้เป็นจุดเริ่ม ไม่ย้อนไปเดาประวัติก่อนหน้า

ระบบรายงานของเราใช้หลักเดียวกันแต่ไม่มี heartbeat คำสั่งที่รันทุก 5 นาทีจะไล่ cron ของแต่ละรายงานย้อนกลับไปทีละรอบด้วย CronExpression::getPreviousRunDate() (มากับ Laravel อยู่แล้ว) ย้อนได้ไม่เกิน 3 วัน แล้วดูว่ารอบนั้นมีการรันเริ่มขึ้นหรือยัง จุดที่ต้องคิดเพิ่มมีสองจุด

  • จดรอบที่สั่งรันย้อนก่อนส่ง job ลงตารางที่มี unique index บนคู่รายงานกับเวลาของรอบ เพราะรอบที่รันย้อนเริ่มตอนนี้ ไม่ได้เริ่มในช่วงเวลาของรอบเดิม ถ้าไม่จดไว้ รอบถัดไปจะเห็นว่ายังขาดแล้วสั่งซ้ำ
  • ไม่ย้อนไปก่อนวันที่ตั้งหรือแก้ตารางเวลา รายงานที่เพิ่งเพิ่มหรือเพิ่งเปลี่ยนเวลาจะได้ไม่มีรอบเก่าค้างให้รันทันที

ส่วนการเปิดใช้ครั้งแรก เราสั่งจดจุดเริ่มด้วยมือครั้งเดียว ไม่ใส่ไว้ในขั้นตอน deploy เพราะถ้าเครื่องเพิ่งล่มก่อน deploy พอดี การจดจุดเริ่มใหม่ทุกครั้งจะกลบรอบที่พลาดไปทิ้ง

ติดตั้งและเลือกงานที่จะให้รันย้อน

ต้องใช้ PHP 8.2 ขึ้นไป และ Laravel 11, 12 หรือ 13

composer require cyberwizard/schedule-catchup
php artisan migrate

แพ็กเกจจะสร้างตารางของตัวเองสี่ตาราง ขึ้นต้นด้วย cyber_ จากนั้นติด replayOnCatchup() ให้เฉพาะงานที่ต้องครบทุกรอบ

use Illuminate\Support\Facades\Schedule;

Schedule::command('reports:daily')
    ->dailyAt('00:00')
    ->withoutOverlapping()
    ->onOneServer()
    ->replayOnCatchup();

ไม่ต้องติดให้งานที่รันถี่อย่างทุก 5 นาที เพราะรอบถัดไปทำแทนได้อยู่แล้ว ติดไปก็มีแต่รอบที่ต้องไล่รันเพิ่ม

ก่อนเปิดใช้จริง ลองดูว่าแพ็กเกจจะรันอะไรบ้าง โดยยังไม่รันจริง

php artisan schedule-catchup:run --dry-run --only=reports:daily

คำสั่งต้องรู้ว่ากำลังรันแทนรอบไหน

ถ้าเครื่องล่มคืนวันศุกร์แล้วกลับมาเช้าวันเสาร์ รายงานที่รันย้อนต้องเป็นของวันศุกร์ ไม่ใช่วันเสาร์ แพ็กเกจไม่ได้เปลี่ยนค่า now() ให้ คำสั่งที่อิงวันที่จึงต้องเปลี่ยนไปใช้ scheduledFor() จาก trait ResolvesScheduledTime

use Cyberwizard\ScheduleCatchup\Traits\ResolvesScheduledTime;

class DailyReport extends Command
{
    use ResolvesScheduledTime;

    protected $signature = 'reports:daily';

    public function handle(): int
    {
        // รันตามปกติได้เวลาปัจจุบัน รันย้อนได้เวลาของรอบที่พลาด
        $reportDate = $this->scheduledFor()->subDay()->toDateString();

        // สร้างหรืออัปเดตรายงานของ $reportDate

        return self::SUCCESS;
    }
}

isCatchupReplay() บอกได้ว่ารอบนี้เป็นการรันย้อนหรือเปล่า เผื่ออยากข้ามบางขั้น เช่นไม่ส่ง LINE แจ้งผู้บริหารซ้ำสำหรับรายงานเมื่อวานซืน

วันที่ของรอบต้องไปถึงทุกที่ที่อิงวันที่ ไม่ใช่แค่ใน query ระบบรายงานของเราส่งวันที่ของรอบที่พลาดไปกับ job ตั้งแต่แรก แต่ชื่อไฟล์ยังใช้วันที่ตอนรัน พอรันย้อนสามวันพร้อมกันก็ได้ไฟล์ชื่อเดียวกันสามไฟล์ที่เขียนทับกันเอง เหลือแค่ไฟล์สุดท้าย หลังแก้ให้ชื่อไฟล์ใช้เวลาของรอบด้วย ก็ได้ครบทุกวัน อีกเรื่องคือรายงานบางชุดดึงข้อมูลแบบ "ณ ตอนนี้" เช่นสต็อกคงเหลือ รันย้อนก็ได้ตัวเลขของวันนี้ ไม่ใช่ของวันที่พลาด ถ้าต้องใช้ตัวเลขย้อนหลังจริง ต้องเก็บ snapshot ไว้ตั้งแต่แรก

รันซ้ำแล้วต้องไม่เกิดผลซ้ำ

แพ็กเกจลองใหม่ให้เมื่อรอบไหนล้มเหลว สูงสุด 3 ครั้ง (max_attempts) แล้วเลิก จะได้ไม่ค้างลองงานที่พังถาวรไปตลอด ถ้างานหนึ่งพลาดหลายรอบ แพ็กเกจรันให้รอบละไม่เกิน 50 ครั้ง (max_occurrences) ที่เหลือค่อยตามในรอบถัดไป

ระบบรายงานของเราเลือกอีกทาง คือนับรอบที่เริ่มรันแล้วว่าครบ แม้จะล้มเหลว เพราะถ้ารายงานพังจากฝั่ง ERP เช่น user ที่ใช้ดึงข้อมูลโดนล็อก การลองใหม่ทุก 5 นาทีก็มีแต่ยิง ERP ซ้ำ รอบที่ล้มเหลวจึงไปรอให้คนกดรันใหม่ในหน้าจอ หรือสั่งรันใหม่ทั้งช่วงวันที่ทีเดียวหลังแก้ต้นเหตุแล้ว แบบไหนเหมาะกว่าขึ้นกับว่างานพังเพราะอะไรบ่อย

ข้อจำกัดที่สำคัญที่สุดคือ ถ้าคำสั่งส่งอีเมลไปแล้วแต่พังก่อนแพ็กเกจจะจดว่าสำเร็จ รอบลองใหม่ก็จะส่งอีเมลซ้ำ คำสั่งที่ติด replayOnCatchup() จึงต้อง idempotent คือรันซ้ำกี่ครั้งก็ได้ผลเหมือนครั้งเดียว ทางที่ง่ายที่สุดสำหรับรายงานคือบันทึกด้วย updateOrCreate() โดยใช้วันที่ของรายงานเป็น key รันซ้ำก็แค่เขียนทับแถวเดิม

ข้อควรระวังอื่นที่ต้นทางพูดถึง

  • scheduler ต้องกลับมาก่อน แพ็กเกจรันย้อนได้ก็ต่อเมื่อ cron กลับมาทำงานแล้ว ถ้า cron หายไปหลังย้ายเครื่อง ก็ไม่มีอะไรรันเหมือนเดิม จึงยังต้องมีตัวเฝ้า scheduler อยู่
  • หลายเซิร์ฟเวอร์ต้องใช้ cache กลาง อย่าง Redis หรือ database เพื่อให้ lock ของแพ็กเกจกับ onOneServer() เห็นกันทุกเครื่อง
  • event ของ Laravel ไม่ยิงตอนรันย้อน ScheduledTaskFinished และตัวอื่นในชุดเดียวกันจะไม่ยิง ถ้ามีตัวฟัง event พวกนี้อยู่ ให้ฟัง event ของแพ็กเกจแทน เช่น ScheduleOccurrenceFailed
  • เปลี่ยน cron หรือ timezone ของงาน แพ็กเกจจะนับเป็นงานใหม่ เริ่มนับรอบจาก heartbeat ล่าสุด

แพ็กเกจยังใหม่มาก ออกรุ่นแรกเดือนกันยายน 2026 และออก 1.2.0 ถึง 1.2.3 ภายในวันเดียว ถ้าจะใช้กับงานที่เกี่ยวกับเงิน ลองรันบนเครื่องทดสอบด้วยการปิด cron ทิ้งไว้สักชั่วโมงแล้วเปิดใหม่ ดูว่ารันย้อนตรงรอบจริง และใช้รุ่น 1.2.1 ขึ้นไปถ้ายังอยู่บน Laravel 12 เพราะ 1.2.0 พังบน Laravel 12 ลงไป

แบบไหนพอโดยไม่ต้องลงแพ็กเกจ

ถ้ามีงานแบบนี้แค่หนึ่งสองงาน การทำเองอาจพอ ให้คำสั่งรับ --date ได้ บันทึกผลแบบ idempotent และให้งานสำคัญ ping บริการเฝ้าระวังทุกครั้งที่รันเสร็จ พอรอบไหนไม่มา ping เราก็รู้ตัวแล้วสั่งรันย้อนเองได้ ส่วนถ้ามีหลายงานที่ต้องครบทุกรอบ หรือไม่อยากพึ่งคนสังเกต แพ็กเกจนี้ช่วยให้ระบบตามเองได้

ใน Laravel 13.42 event ScheduledTaskSkipped มีเหตุผลที่ข้ามงานติดมาด้วย ช่วยตอบได้ว่างานที่ไม่ออกโดนข้ามเพราะอะไร ส่วนงานที่ไม่ได้รันเพราะเครื่องล่มยังต้องพึ่งวิธีในโพสต์นี้

รายละเอียดตัวเลือกทั้งหมด event และ trait RecordsScheduleRun สำหรับงานที่ต้องรันครั้งเดียวต่อรอบ อยู่ในบทความต้นฉบับและ README ของแพ็กเกจครับ

อ่านต่อ

ล่าสุด

ดูทั้งหมด →