Hướng dẫn sử dụng APScheduler trong Python: Lập lịch tác vụ định kỳ chuẩn chỉnh cho Backend

Python tutorial - IT technology blog
Python tutorial - IT technology blog

1. Vấn đề thực tế khi lập lịch tác vụ trên Backend

Làm backend một thời gian, chắc chắn bạn sẽ gặp những tác vụ cần chạy ngầm theo chu kỳ: gửi email digest lúc 8h sáng, dọn dẹp hàng trăm nghìn token JWT hết hạn mỗi đêm, hay đồng bộ tỷ giá 15 phút một lần. Ban đầu, nhiều anh em thường chọn cách nhanh nhất: nhét logic vào vòng lặp vô tận cùng time.sleep.

import time
import threading

def cleanup_expired_tokens():
    while True:
        print("Đang quét và xóa token rác...")
        # logic xóa database ở đây
        time.sleep(3600)  # Ngủ 1 tiếng

# Chạy luồng nền cùng ứng dụng web
threading.Thread(target=cleanup_expired_tokens, daemon=True).start()

Cách này chạy local thì ổn, nhưng lên staging hay production là vỡ trận ngay. Nếu logic ném ra unhandled exception, background thread chết âm thầm mà app chính không hề hay biết. Mỗi lần restart app hoặc deploy bản mới, bộ đếm giờ lại reset về 0. Chưa kể, bạn không thể set giờ theo timezone hay đặt lịch kiểu ‘chỉ chạy 9h sáng thứ Hai’.

Nhiều team chọn giải pháp khác: dùng crontab của Linux gọi script Python độc lập. Ổn định hơn, nhưng crontab lại hoàn toàn tách rời khỏi app backend. Mỗi lần script chạy, hệ điều hành phải bootstrap lại runtime, nạp cấu hình và mở kết nối database từ đầu. Bạn cũng không thể tận dụng connection pool sẵn có, không đọc được in-memory cache hay tái sử dụng service layer của FastAPI/Flask.

2. Bốn bài toán cốt lõi của một hệ thống lập lịch

Để scheduler chạy ổn định khi tải tăng, hệ thống cần giải quyết dứt điểm 4 bài toán kỹ thuật:

  • Lưu trạng thái bền vững (Job Persistence): Khi server crash hoặc reboot đột ngột, scheduler cần biết job nào chưa kịp chạy hoặc bị trễ lịch (misfire). Dữ liệu này phải nằm trong database persistent thay vì bốc hơi theo RAM.
  • Chống chồng chéo tác vụ (Concurrency Control): Giả sử tác vụ xuất báo cáo nặng mất 20 phút, nhưng chu kỳ kích hoạt lại là 15 phút. Nếu không kiểm soát instance, job mới sẽ đè lên job cũ, ngốn sạch CPU và dễ gây race condition dữ liệu.
  • Quản lý vòng đời tác vụ linh hoạt: Nhiều tính năng yêu cầu người dùng tự hẹn giờ (ví dụ: gửi mail nhắc lịch lúc 14h ngày mai). Bạn phải thêm, sửa, dừng hoặc hủy job qua REST API ngay lúc runtime đang chạy mà không cần reload server.
  • Tương thích Async I/O: Backend hiện đại viết bằng FastAPI hay Starlette chạy trên Event Loop non-blocking. Nếu chèn một hàm đồng bộ nặng vào luồng chính, toàn bộ API của bạn sẽ bị nghẽn.

3. So sánh các hướng giải quyết phổ biến

Trước khi chốt công cụ, hãy cùng so sánh 3 hướng đi quen thuộc trong hệ sinh thái Python:

  • Celery Beat + Redis/RabbitMQ: Chuẩn mực cho hệ thống microservices phân tán. Xử lý hàng chục nghìn task/phút rất mượt, nhưng kiến trúc khá cồng kềnh. Bạn phải dựng thêm broker, cấu hình worker riêng và tốn thêm chi phí vận hành hạ tầng.
  • Linux Crontab / Systemd Timers: Nhẹ và tận dụng sẵn OS, nhưng khó quản lý tập trung khi scale nhiều node. Bạn không thể điều khiển động qua code API và việc đồng bộ timezone giữa các container Docker thường khá phiền toái.
  • APScheduler (Advanced Python Scheduler): Thư viện standalone chạy trực tiếp trong tiến trình Python. Hỗ trợ đủ cron, interval, date trigger, kết nối thẳng vào database (PostgreSQL, MySQL, SQLite) và tương thích tốt với cả AsyncIO lẫn ThreadPool.

4. Thiết lập APScheduler thực tế với JobStore và Executor

Nếu hệ thống của bạn ở quy mô vừa và nhỏ (dưới 500 job định kỳ), nhúng APScheduler kèm SQLAlchemyJobStore là phương án gọn gàng nhất. Không tốn tiền nuôi broker riêng, code gói gọn ngay trong backend service.

Bước 1: Cài đặt thư viện

Cài APScheduler kèm driver database (ở đây dùng SQLAlchemy):

pip install apscheduler sqlalchemy
# Hoặc psycopg2-binary nếu dùng PostgreSQL
pip install psycopg2-binary

Bước 2: Cấu hình Scheduler với persistent store

Cấu hình mặc định của APScheduler chỉ lưu job trong RAM (MemoryJobStore). Hãy chuyển sang SQLAlchemyJobStore để danh sách task không bị mất khi deploy code mới:

from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.executors.pool import ThreadPoolExecutor
from datetime import datetime
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("TaskScheduler")

# 1. Khai báo kho lưu trữ persistent và executor
jobstores = {
    'default': SQLAlchemyJobStore(url='sqlite:///backend_jobs.sqlite')
}
executors = {
    'default': ThreadPoolExecutor(max_workers=5)
}
job_defaults = {
    'coalesce': True,            # Gộp nhiều lần chạy trễ thành 1 lần duy nhất
    'max_instances': 1,           # Không cho phép 2 bản sao cùng 1 job chạy đè lên nhau
    'misfire_grace_time': 300     # Chấp nhận chạy bù nếu trễ dưới 5 phút (300s)
}

scheduler = BackgroundScheduler(
    jobstores=jobstores,
    executors=executors,
    job_defaults=job_defaults
)

Bước 3: Định nghĩa tác vụ và kích hoạt trigger

APScheduler hỗ trợ 3 kiểu trigger chính: date (chạy 1 lần đúng thời điểm), interval (chạy lặp lại theo chu kỳ), và cron (chạy theo quy tắc ngày giờ như Linux cron):

def send_weekly_report(workspace_id: str):
    logger.info(f"[{datetime.now()}] Bắt đầu gửi báo cáo cho workspace: {workspace_id}")
    # Logic xử lý nghiệp vụ...

def cleanup_audit_logs():
    logger.info(f"[{datetime.now()}] Xóa log cũ thành công.")

# Khởi động scheduler
scheduler.start()

# Thêm job theo định dạng Cron: 08:30 sáng từ thứ Hai đến thứ Sáu
scheduler.add_job(
    func=send_weekly_report,
    trigger='cron',
    day_of_week='mon-fri',
    hour=8,
    minute=30,
    args=['workspace_vip_01'],
    id='job_weekly_report',
    replace_existing=True
)

# Thêm job theo Interval: Chạy định kỳ mỗi 30 phút một lần
scheduler.add_job(
    func=cleanup_audit_logs,
    trigger='interval',
    minutes=30,
    id='job_cleanup_logs',
    replace_existing=True
)

Bước 4: Tích hợp vào vòng đời ứng dụng FastAPI

Với các framework hiện đại, hãy tận dụng lifespan để khởi tạo và tắt scheduler gọn gàng:

from contextlib import asynccontextmanager
from fastapi import FastAPI

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Startup: Chạy scheduler
    scheduler.start()
    logger.info("APScheduler đã sẵn sàng hoạt động.")
    yield
    # Shutdown: Chờ các job đang dở dang hoàn tất rồi mới tắt server
    scheduler.shutdown(wait=True)
    logger.info("APScheduler đã dừng an toàn.")

app = FastAPI(lifespan=lifespan)

@app.get("/jobs")
def list_all_jobs():
    jobs = scheduler.get_jobs()
    return [{"id": j.id, "next_run": str(j.next_run_time)} for j in jobs]

Khi viết các job xử lý dữ liệu định kỳ như lọc email hay quét log, việc dùng regex là khó tránh khỏi. Để test nhanh pattern trước khi nhét vào code scheduler, bạn có thể thử công cụ regex tester tại toolcraft.app/vi/tools/developer/regex-tester — chạy trực tiếp trên browser, đỡ mất công print debug trong Python.

Bước 5: Lắng nghe sự kiện để giám sát lỗi

Để không bỏ lọt những tác vụ chết yểu do lỗi runtime, hãy lắng nghe trực tiếp EVENT_JOB_ERROR:

from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED

def job_listener(event):
    if event.exception:
        logger.error(f"Tác vụ {event.job_id} thất bại: {event.exception}")
    else:
        logger.info(f"Tác vụ {event.job_id} đã hoàn tất suôn sẻ.")

scheduler.add_listener(job_listener, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR)

Nhờ SQLAlchemyJobStore, kiểm soát instance chặt chẽ và bắt exception rõ ràng, bạn đã có một background scheduler vững chãi mà không cần đau đầu dựng thêm cụm worker phức tạp.

Share: