ข้ามไปยังเนื้อหา
Toova
เครื่องมือทั้งหมด

5 รูปแบบ Cron Expression ที่ใช้จริงในระบบ Production

Toova

Cron expression ดูเรียบง่าย — ห้าฟิลด์ ตัวดำเนินการไม่กี่ตัว — แต่ทีม production ทำผิดเหมือนกันซ้ำๆ: job ที่ข้ามแบบเงียบๆ ในบางเดือน, การรันที่ซ้อนกันจนข้อมูลที่ใช้ร่วมเสียหาย, และ job หลายร้อยตัวที่ตื่นพร้อมกันในวินาทีเดียวจนทำให้ฐานข้อมูลล้น ปัญหาแทบไม่เคยอยู่ที่ expression แต่อยู่ที่การไม่ได้คิดถึง edge case

คู่มือนี้ครอบคลุม 5 รูปแบบ cron ที่ทีมวิศวกรรมใช้จริงในระบบ production พร้อมเหตุผลและกับดักที่ต้องเลี่ยง ใช้ Toova Cron Parser ตรวจสอบ expression ใดๆ และดูเวลาทำงานสิบครั้งถัดไปก่อน deploy

กายวิภาคของ Cron Expression

Cron expression มาตรฐานมีห้าฟิลด์คั่นด้วยช่องว่าง:

*/15  *  *  *  *
  |   |  |  |  |
  |   |  |  |  +---- วันในสัปดาห์ (0-6, 0=อาทิตย์)
  |   |  |  +------- เดือน (1-12)
  |   |  +---------- วันในเดือน (1-31)
  |   +------------- ชั่วโมง (0-23)
  +----------------- นาที: ทุกๆ 15 (0, 15, 30, 45)

แต่ละฟิลด์รับ: ค่าเฉพาะ (5), wildcard (*), ช่วง (1-5), รายการ (1,3,5) หรือค่า step (*/15) บางการนำไปใช้เพิ่มฟิลด์ที่หกสำหรับวินาที ส่วนตัวอื่น (เช่น GitHub Actions) คาดหวังห้าฟิลด์เป๊ะๆ ตรวจสอบเสมอว่า scheduler ใช้สำเนียงไหนก่อนเขียน expression ที่ซับซ้อน

รูปแบบ 1: ทุก 15 นาที — */15 * * * *

*/15  *  *  *  *
  |   |  |  |  |
  |   |  |  |  +---- วันในสัปดาห์ (0-6, 0=อาทิตย์)
  |   |  |  +------- เดือน (1-12)
  |   |  +---------- วันในเดือน (1-31)
  |   +------------- ชั่วโมง (0-23)
  +----------------- นาที: ทุกๆ 15 (0, 15, 30, 45)

Expression ยอดนิยมสำหรับ queue processor, การตรวจสอบ heartbeat และงาน polling ที่ต้องรันบ่อยแต่ไม่ต่อเนื่อง ทริกเกอร์ที่ :00, :15, :30 และ :45 ของทุกชั่วโมงทุกวัน

ใน crontab:

# crontab entry — รัน batch processor ทุก 15 นาที
*/15 * * * * /opt/app/bin/process-queue.sh >> /var/log/queue.log 2>&1

ใน Node.js:

// Node.js กับ node-cron
import cron from 'node-cron';

cron.schedule('*/15 * * * *', async () => {
  const batch = await db.query(
    'SELECT * FROM jobs WHERE status = $1 LIMIT 100',
    ['pending']
  );
  await processBatch(batch);
});

ปัญหา thundering herd เมื่อรัน worker หลายร้อยตัวด้วย */15 เดียวกัน ทุกตัวยิงพร้อมกันที่ :00, :15, :30 และ :45 ถ้าแต่ละ worker ตี shared database คุณจะได้สี่จุดพีคต่อชั่วโมงแทนโหลดที่ราบเรียบ วิธีแก้คือเพิ่ม jitter แบบสุ่มก่อนการรันแต่ละครั้ง:

// เพิ่ม jitter เพื่อหลีกเลี่ยง thundering herd ในระบบกระจาย
cron.schedule('*/15 * * * *', async () => {
  const jitterMs = Math.floor(Math.random() * 30_000); // ถึง 30 วินาที
  await sleep(jitterMs);
  await processBatch();
});

Jitter ถึง 30 วินาที กระจายจุดพีคไปทั่วหน้าต่าง 30 วินาที ขณะที่ job ยังอยู่ในรอบ 15 นาทีที่คาดไว้ สำหรับการกระจายที่ซับซ้อนกว่านี้ ดูรูปแบบ 5 (ไวยากรณ์ Jenkins H)

ใช้ Timestamp Converter แปลงเวลาทำงานครั้งถัดไปจาก Unix epoch เป็นเวลาที่อ่านได้ขณะดีบักจังหวะการทำงาน

รูปแบบ 2: เวลาทำงานวันธรรมดา — 0 9 * * 1-5

0   9   *   *   1-5
|   |   |   |   |
|   |   |   |   +-- วันในสัปดาห์: จันทร์-ศุกร์ (1-5)
|   |   |   +------ เดือน: ทุกเดือน
|   |   +---------- วันในเดือน: ทุกวัน
|   +-------------- ชั่วโมง: 9
+------------------ นาที: 0 (ตรงชั่วโมงพอดี)

Expression นี้ทริกเกอร์หนึ่งครั้งที่ 9:00 น. เป๊ะ จันทร์ถึงศุกร์ เป็นรูปแบบมาตรฐานสำหรับ: ส่งอีเมล daily digest, รันรายงานเวลาทำการ, ทริกเกอร์ workflow ที่ต้องตรวจสอบโดยมนุษย์ในเวลาทำงาน และ deploy ไป staging ตอนต้นวันของทีมวิศวกร

ใน Kubernetes CronJob:

# Kubernetes CronJob — อีเมล digest วันธรรมดา 9 โมงเช้า UTC
apiVersion: batch/v1
kind: CronJob
metadata:
  name: digest-email
spec:
  schedule: "0 9 * * 1-5"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: mailer
            image: myapp/mailer:latest
            command: ["/app/send-digest"]
          restartPolicy: OnFailure

กับดัก timezone ตารางเวลา Kubernetes CronJob รันตาม timezone ของ cluster ซึ่งค่าเริ่มต้นคือ UTC "9 โมง UTC" คือ 5 โมงเช้า Eastern ในฤดูหนาว และ 4 โมงเช้า Pacific ผู้ใช้จะได้รับ "morning digest" กลางดึก ใช้ cron library ที่รับสตริง IANA timezone:

// จัดการ timezone — "9 โมงเช้า UTC" มักไม่ใช่สิ่งที่ผู้ใช้คาดหวัง
// ใช้ library ที่เข้าใจ IANA timezone
import { CronJob } from 'cron';

const job = new CronJob(
  '0 9 * * 1-5',
  () => sendDailyDigest(),
  null,
  true,
  'America/New_York' // รันเวลา 9 โมงเช้า Eastern ไม่ใช่ 9 โมง UTC
);

Timezone Converter ช่วยหาค่าเทียบเท่า UTC สำหรับเวลาท้องถิ่นใน IANA timezone ใดๆ

การนับวันในสัปดาห์ นิยาม cron มาตรฐานใช้ 0 หรือ 7 สำหรับวันอาทิตย์ บางการนำไปใช้ (Quartz, cron4j) ใช้ 1 สำหรับวันจันทร์แทน 0 ตรวจสอบเอกสารของ scheduler และทดสอบด้วย Cron Parser ก่อน deploy

รูปแบบ 3: Batch ตอนกลางคืน — 0 22 * * *

0   22   *   *   *
|    |   |   |   |
|    |   |   |   +-- วันในสัปดาห์: ใดๆ
|    |   |   +------ เดือน: ใดๆ
|    |   +---------- วันในเดือน: ใดๆ
|    +-------------- ชั่วโมง: 22 (4 ทุ่ม)
+------------------- นาที: 0

รันวันละครั้งเวลา 4 ทุ่ม Nightly batch เป็นรูปแบบที่พบบ่อยที่สุดในงาน data engineering: รวมเหตุการณ์ของวัน, สร้าง search index ใหม่, สร้างรายงาน, เก็บถาวร log, ส่งการแจ้งเตือนสิ้นวัน

ทำไม 4 ทุ่มไม่ใช่เที่ยงคืน? เหตุผลสองข้อ ข้อแรก 4 ทุ่มให้บัฟเฟอร์ก่อนวันใหม่ ลดโอกาสจับเหตุการณ์ที่มาถึงสาย ข้อสอง การรันงานหนักตอนเที่ยงคืนหมายถึงมันแข่งกับ month-end billing (รูปแบบ 4) ในวันแรกของทุกเดือน

สามวิธีที่พบบ่อยที่สุดในการเขียน daily job:

# สามวิธีเขียน "ทุกคืน 4 ทุ่ม":
0 22 * * *    # cron มาตรฐาน
@daily        # เที่ยงคืน — ไม่เท่ากับ 4 ทุ่ม
0 22 * * *    # เลือกเวลาแบบชัดเจนเสมอแทน @macro สำหรับเวลาที่ไม่ใช่เที่ยงคืน

การป้องกันการซ้อน Nightly job ที่ปกติใช้เวลา 45 นาที อาจใช้เวลา 2 ชั่วโมงเป็นครั้งคราว — และหากยังรันอยู่เมื่อทริกเกอร์คืนถัดไปเริ่ม ทั้งสอง job จะแก้ไขข้อมูลเดียวกัน ใช้ distributed lock:

// ตรวจสอบ idempotency — ป้องกัน double-processing หาก job ซ้อนกัน
async function runNightlyBatch() {
  const lock = await redis.set(
    'nightly-batch-lock',
    process.pid,
    'EX', 3600,   // TTL: 1 ชั่วโมง
    'NX'          // set เฉพาะถ้ายังไม่มี
  );

  if (!lock) {
    console.log('Batch กำลังรันอยู่ ข้ามการรัน');
    return;
  }

  try {
    await processNightlyData();
  } finally {
    await redis.del('nightly-batch-lock');
  }
}

แฟล็ก Redis NX รับประกันว่ามีเพียง process เดียวที่ถือ lock ได้ในแต่ละครั้ง TTL ป้องกันไม่ให้ job ที่ล่มถือ lock ตลอดไป

รูปแบบ 4: เก็บเงินรายเดือน — 0 0 1 * *

0   0   1   *   *
|   |   |   |   |
|   |   |   |   +-- วันในสัปดาห์: ใดๆ
|   |   |   +------ เดือน: ใดๆ
|   |   +---------- วันในเดือน: 1 (วันแรก)
|   +-------------- ชั่วโมง: 0 (เที่ยงคืน)
+------------------ นาที: 0

ทริกเกอร์เที่ยงคืนวันแรกของทุกเดือน รูปแบบคลาสสิกสำหรับ: สร้างใบแจ้งหนี้รายเดือน, รีเซ็ตโควต้าการใช้งาน, รัน month-end reconciliation และเก็บถาวรข้อมูลเดือนก่อน

กับดัก cron สำหรับงานรายเดือน:

# วันแรกของเดือนเวลาเที่ยงคืน UTC
0 0 1 * *

# ---- กับดักที่ต้องเลี่ยง ----

# อันนี้ไม่ได้รัน "ทุกเดือน" อย่างน่าเชื่อถือ:
0 0 29-31 * *   # เดือนที่วันน้อยกว่าจะข้ามแบบเงียบๆ

# Quarterly billing (มกรา, เมษา, กรกฎา, ตุลา):
0 0 1 1,4,7,10 *

# วันสุดท้ายของเดือนเป็นเรื่องยุ่งยาก — cron มาตรฐานไม่มี "วันสุดท้าย"
# ใช้ @reboot + ตรรกะแอป หรือ cron ที่รันทุกวันแล้ว
# เช็คในโค้ดว่าวันนี้เป็นวันสุดท้ายของเดือนหรือไม่

การเก็บเงินวันสุดท้ายของเดือน หากต้องเก็บเงินวันสุดท้ายของแต่ละเดือน (แทนวันแรก) ไม่มี cron expression ที่สะอาดสำหรับเรื่องนี้ วิธีแก้:

// ตรวจสอบ "วันสุดท้ายของเดือน" ในระดับแอป
cron.schedule('0 23 28-31 * *', async () => {
  const today = new Date();
  const tomorrow = new Date(today);
  tomorrow.setDate(today.getDate() + 1);

  // หากพรุ่งนี้เป็นวันที่ 1 วันนี้คือวันสุดท้ายของเดือน
  if (tomorrow.getDate() === 1) {
    await runEndOfMonthBilling();
  }
});

อันนี้ทริกเกอร์ในวันที่ 28-31 ของทุกเดือน แล้วเช็คในโค้ดว่าวันนี้เป็นวันสุดท้ายจริงหรือไม่ มันรันมากเกินไปสูงสุดสี่ครั้ง (ในเดือนที่มีน้อยกว่า 31 วัน) แต่การเช็คนั้นถูกและตรรกะชัดเจน

สำหรับการเก็บเงินต้นไตรมาส ใช้ตัวดำเนินการ list ในฟิลด์เดือน: 0 0 1 1,4,7,10 * (มกราคม, เมษายน, กรกฎาคม, ตุลาคม)

รูปแบบ 5: ตารางแบบ Hash (ไวยากรณ์ Jenkins H) — H/30 * * * *

H/30 * * * *
|
+--- H หมายถึง Jenkins เลือกนาทีสุ่มต่องาน
     H/30 = ทุก 30 นาที เริ่มที่ค่า H
     งานต่างกันได้ค่า H ต่างกัน กระจายภาระโหลด

Token H คือส่วนขยายเฉพาะ Jenkins ของไวยากรณ์ cron มันแทนที่ H ด้วยเลข pseudo-random ที่คงที่ซึ่งได้จากชื่อ job H/30 ในฟิลด์นาทีหมายถึง "ทุก 30 นาที เริ่มที่นาทีที่กำหนดโดย hash ของชื่อ job"

ใน Jenkinsfile:

// Jenkinsfile — pipeline ทริกเกอร์ทุก 30 นาที แบบกระจาย
pipeline {
  triggers {
    cron('H/30 * * * *')
  }
  stages {
    stage('Deploy') {
      steps {
        sh './deploy.sh'
      }
    }
  }
}

ทำไมเรื่องนี้สำคัญ การติดตั้ง Jenkins มักมี pipeline หลายร้อยตัว หากทุก pipeline ใช้ */30 * * * * ทุกตัวจะทริกเกอร์ที่ :00 และ :30 สร้างจุดพีคโหลดมหาศาลใน agent pool และระบบปลายน้ำ ด้วย H/30 แต่ละ job ได้ offset ต่างกัน — ตัวหนึ่งที่ :03/:33, อีกตัวที่ :17/:47 — กระจายโหลดอย่างสม่ำเสมอตลอดชั่วโมง

GitHub Actions ไม่รองรับ H สำหรับการกระจาย workflow Actions หลายตัว เลือก offset นาทีคงที่ต่างกันด้วยตัวเอง:

// GitHub Actions — ไม่รองรับ H ใช้ schedule กับ offset แบบกำหนดเอง
// เพื่อกระจายงานสองตัว: เลือกนาทีคงที่ที่ต่างกัน
name: Job A
on:
  schedule:
    - cron: '5 * * * *'   # รันที่นาทีที่ :05 ของทุกชั่วโมง

---
name: Job B
on:
  schedule:
    - cron: '35 * * * *'  # รันที่นาทีที่ :35 — 30 นาทีหลัง Job A

อันนี้สง่างามน้อยกว่า Jenkins H แต่ให้ผลแบบเดียวกันสำหรับ workflow จำนวนน้อย สำหรับ workflow จำนวนมาก พิจารณา orchestrator ภายนอกที่รองรับ distributed scheduling โดยตรง

ข้อกังวลข้ามขอบเขต

Idempotency เป็นเรื่องที่ต่อรองไม่ได้

ทุก cron job ควรปลอดภัยที่จะรันสองครั้ง โครงสร้างพื้นฐานล้มเหลว, scheduler รีสตาร์ท และ distributed lock บางครั้งก็หมดเวลา ออกแบบ job ให้การรันครั้งที่สอง — ด้วย input เดียวกัน เวลาเดียวกัน — ให้ผลเหมือนกัน ใช้ upsert แทน insert, รูปแบบ check-before-act และ idempotency key สำหรับการเรียก API ภายนอก

การแจ้งเตือน Dead Letter

Cron job ที่ล้มเหลวแบบเงียบๆ แย่กว่าตัวที่ไม่รันเลย ส่งการแจ้งเตือนไปยัง dead letter queue หรือระบบ monitoring เมื่อ job ล้มเหลวหรือไม่เสร็จในหน้าต่างเวลาที่คาดไว้ เครื่องมืออย่าง Sentry Crons, Healthchecks.io และ Cronitor เพิ่มการเช็ค heartbeat: job ping ที่ URL เมื่อเริ่มและเมื่อเสร็จ หาก finish ping ไม่มาถึงในกำหนดเวลา การแจ้งเตือนจะทริกเกอร์

Log ตารางเวลา

ส่ง log line ที่มีโครงสร้างตอนเริ่มและจบของทุก cron run รวมถึง expression, timestamp triggered-at (ไม่ใช่เวลาปัจจุบันซึ่งอาจต่างเล็กน้อยจาก jitter) และระยะเวลา เรื่องนี้ทำให้สหสัมพันธ์เหตุการณ์ปลายน้ำ — "ฐานข้อมูลช้าตอน 4 ทุ่ม" — กับการรัน batch job ที่เฉพาะเจาะจงทำได้ตรงไปตรงมา

อ้างอิงด่วน

Parse และตรวจสอบ expression ใดๆ ด้วย Cron Parser แปลงเวลาที่ตั้งไว้ระหว่าง timezone ด้วย Timezone Converter หาก job ของคุณ log Unix timestamp, Timestamp Converter เปลี่ยนเป็นวันที่อ่านได้ทันที

สำหรับเอกสารอ้างอิงครบถ้วนของไวยากรณ์ cron และตัวอย่างทั่วไป, crontab.guru คืออ้างอิงด่วนที่เป็นมาตรฐาน สำหรับไวยากรณ์ Jenkins H และข้อกำหนดเต็ม ดู เอกสาร Jenkins Pipeline cron syntax