Django + Celery 非同步任務模式
在 Django 中使用 Celery 搭配 Redis 或 RabbitMQ 進行背景任務處理的生產級模式。
何時啟用
- 為 Django 應用程式加入背景工作或非同步處理
- 實作定期/排程任務
- 將慢速操作(電子郵件、PDF 產生、API 呼叫)從請求週期中卸載
- 設定 Celery Beat 來實現類 cron 的排程
- 除錯任務失敗、重試或佇列積壓問題
- 為 Celery 任務撰寫測試
專案設定
安裝
pip install 'celery[redis]' django-celery-results django-celery-beat
celery.py — 應用程式入口點
# config/celery.py
import os
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development')
app = Celery('myproject')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks() # 在每個 INSTALLED_APP 中自動發現 tasks.py
@app.task(bind=True, ignore_result=True)
def debug_task(self):
print(f'Request: {self.request!r}')
# config/__init__.py
from .celery import app as celery_app
__all__ = ('celery_app',)
Django 設定
# config/settings/base.py
# Broker(生產環境建議使用 Redis)
CELERY_BROKER_URL = env('CELERY_BROKER_URL', default='redis://localhost:6379/0')
CELERY_RESULT_BACKEND = env('CELERY_RESULT_BACKEND', default='django-db')
# 序列化
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
# 任務行為
CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT = 30 * 60 # 硬限制:30 分鐘
CELERY_TASK_SOFT_TIME_LIMIT = 25 * 60 # 軟限制:觸發 SoftTimeLimitExceeded
CELERY_WORKER_PREFETCH_MULTIPLIER = 1 # 防止 worker 壟斷長時間任務
CELERY_TASK_ACKS_LATE = True # 當 worker 崩潰時重新排入佇列
# 結果持久化
CELERY_RESULT_EXPIRES = 60 * 60 * 24 # 保留結果 24 小時
# Beat 排程器(用於定期任務)
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
# 已安裝的應用程式
INSTALLED_APPS += [
'django_celery_results',
'django_celery_beat',
]
啟動 Worker
# 啟動 worker(開發環境)
celery -A config worker --loglevel=info
# 啟動 Beat 排程器(定期任務)
celery -A config beat --loglevel=info --scheduler django_celery_beat.schedulers:DatabaseScheduler
# 合併 worker + beat(僅限開發環境,絕不適用於生產環境)
celery -A config worker --beat --loglevel=info
# 生產環境:多個 worker 並行處理
celery -A config worker --loglevel=warning --concurrency=4 -Q default,high_priority
任務設計模式
基本任務
# apps/notifications/tasks.py
from celery import shared_task
import logging
logger = logging.getLogger(__name__)
@shared_task(name='notifications.send_welcome_email')
def send_welcome_email(user_id: int) -> None:
"""發送歡迎電子郵件給新註冊的使用者。"""
from apps.users.models import User
from apps.notifications.services import EmailService
try:
user = User.objects.get(pk=user_id)
except User.DoesNotExist:
logger.warning('send_welcome_email: 使用者 %s 未找到', user_id)
return # 冪等性——不要拋出例外,任務已無法完成
EmailService.send_welcome(user)
logger.info('已發送歡迎郵件給使用者 %s', user_id)
可重試任務
@shared_task(
bind=True,
name='integrations.sync_to_crm',
max_retries=5,
default_retry_delay=60, # 首次重試前的秒數
autoretry_for=(ConnectionError, TimeoutError),
retry_backoff=True, # 指數退避
retry_backoff_max=600, # 上限為 10 分鐘
retry_jitter=True, # 隨機化以避免驚群效應
)
def sync_contact_to_crm(self, contact_id: int) -> dict:
"""將聯絡人同步到外部 CRM,並在暫時性失敗時重試。"""
from apps.crm.services import CRMClient
try:
result = CRMClient().sync(contact_id)
return result
except CRMClient.RateLimitError as exc:
# 從回應標頭取得特定重試延遲
raise self.retry(exc=exc, countdown=int(exc.retry_after))
冪等任務模式
設計任務,使其能夠安全地使用相同輸入多次執行:
@shared_task(name='orders.mark_shipped')
def mark_order_shipped(order_id: int, tracking_number: str) -> None:
"""將訂單標記為已出貨——可安全多次執行。"""
from apps.orders.models import Order
updated = Order.objects.filter(
pk=order_id,
status=Order.Status.PROCESSING, # 守衛:僅在尚未出貨時更新
).update(
status=Order.Status.SHIPPED,
tracking_number=tracking_number,
)
if not updated:
logger.info('mark_order_shipped: 訂單 %s 已出貨或未找到', order_id)
具有軟時間限制的任務
from celery.exceptions import SoftTimeLimitExceeded
@shared_task(
bind=True,
name='reports.generate_pdf',
soft_time_limit=120,
time_limit=150,
)
def generate_pdf_report(self, report_id: int) -> str:
"""產生 PDF 報告,並優雅處理逾時。"""
from apps.reports.services import PDFGenerator
try:
path = PDFGenerator.build(report_id)
return path
except SoftTimeLimitExceeded:
# 在強制終止前清理部分檔案
PDFGenerator.cleanup(report_id)
raise
呼叫任務
from datetime import timedelta
from django.utils import timezone
# 投後不理(非同步)
send_welcome_email.delay(user.pk)
# 排程在未來執行
send_reminder.apply_async(args=[user.pk], countdown=3600) # 1 小時後
send_reminder.apply_async(args=[user.pk], eta=timezone.now() + timedelta(days=1))
# 使用佇列路由
sync_contact_to_crm.apply_async(args=[contact.pk], queue='high_priority')
# 同步執行(僅限測試/除錯)
result = generate_pdf_report.apply(args=[report.pk])
Beat 排程(定期任務)
程式碼定義的排程
# config/settings/base.py
from celery.schedules import crontab
CELERY_BEAT_SCHEDULE = {
'cleanup-expired-sessions': {
'task': 'users.cleanup_expired_sessions',
'schedule': crontab(hour=2, minute=0), # 每天凌晨 2 點
},
'sync-inventory': {
'task': 'products.sync_inventory',
'schedule': 60.0, # 每 60 秒
},
'weekly-digest': {
'task': 'notifications.send_weekly_digest',
'schedule': crontab(day_of_week='monday', hour=8, minute=0),
},
}
資料庫定義的排程(透過 django-celery-beat)
# 從 Django 管理後端或程式碼管理定期任務
from django_celery_beat.models import PeriodicTask, CrontabSchedule
import json
schedule, _ = CrontabSchedule.objects.get_or_create(
hour='*/6', minute='0',
timezone='UTC',
)
PeriodicTask.objects.update_or_create(
name='每 6 小時同步庫存',
defaults={
'crontab': schedule,
'task': 'products.sync_inventory',
'args': json.dumps([]),
'enabled': True,
}
)
Canvas:任務串聯與分組
from celery import chain, group, chord
# Chain:依序執行任務,傳遞結果
pipeline = chain(
fetch_data.s(source_id),
transform_data.s(), # 接收 fetch_data 的結果作為第一個參數
load_to_warehouse.s(),
)
pipeline.delay()
# Group:平行執行任務
parallel = group(
send_welcome_email.s(user_id)
for user_id in new_user_ids
)
parallel.delay()
# Chord:平行任務 + 全部完成後的回呼
result = chord(
group(process_chunk.s(chunk) for chunk in data_chunks),
aggregate_results.s(), # 以區塊結果的列表呼叫
)
result.delay()
錯誤處理與死信佇列
# apps/core/tasks.py
from celery.signals import task_failure
@task_failure.connect
def on_task_failure(sender, task_id, exception, args, kwargs, traceback, einfo, **kw):
"""將所有任務失敗記錄到 Sentry / 警報系統。"""
import sentry_sdk
with sentry_sdk.new_scope() as scope:
scope.set_context('celery', {
'task': sender.name,
'task_id': task_id,
'args': args,
'kwargs': kwargs,
})
sentry_sdk.capture_exception(exception)
# 在達到最大重試次數後,將失敗任務路由到死信佇列
@shared_task(
bind=True,
max_retries=3,
name='payments.charge_card',
)
def charge_card(self, order_id: int) -> None:
from apps.payments.models import Order, FailedCharge
try:
_do_charge(order_id)
except Exception as exc:
if self.request.retries >= self.max_retries:
# 持久化到死信表格以供人工審查
FailedCharge.objects.create(
order_id=order_id,
error=str(exc),
task_id=self.request.id,
)
return # 不要拋出——任務已永久失敗
raise self.retry(exc=exc)
測試 Celery 任務
單元測試(無 Broker)
# tests/test_tasks.py
import pytest
from unittest.mock import patch, MagicMock
from apps.notifications.tasks import send_welcome_email
class TestSendWelcomeEmail:
@pytest.mark.django_db
def test_sends_email_to_existing_user(self, user):
with patch('apps.notifications.services.EmailService') as mock_email:
send_welcome_email(user.pk)
mock_email.send_welcome.assert_called_once_with(user)
@pytest.mark.django_db
def test_skips_missing_user_gracefully(self):
"""當使用者在排隊與執行之間被刪除時,不應拋出例外。"""
send_welcome_email(99999) # 不存在的使用者——不得拋出例外
使用 CELERY_TASK_ALWAYS_EAGER 的整合測試
# config/settings/test.py
CELERY_TASK_ALWAYS_EAGER = True # 在測試中同步執行任務
CELERY_TASK_EAGER_PROPAGATES = True # 重新拋出任務中的例外
# tests/test_integration.py
@pytest.mark.django_db
def test_registration_triggers_welcome_email(client):
with patch('apps.notifications.services.EmailService') as mock_email:
response = client.post('/api/users/', {
'email': '[email protected]',
'password': 'strongpass123',
})
assert response.status_code == 201
mock_email.send_welcome.assert_called_once()
測試重試
@pytest.mark.django_db
def test_task_retries_on_connection_error():
with patch('apps.crm.services.CRMClient.sync') as mock_sync:
mock_sync.side_effect = ConnectionError('timeout')
with pytest.raises(ConnectionError):
sync_contact_to_crm.apply(args=[1], throw=True)
assert mock_sync.call_count == 1 # 使用 eager 模式時只有第一次嘗試
監控
# 檢查活躍的 worker 和佇列
celery -A config inspect active
celery -A config inspect stats
celery -A config inspect reserved
# 檢查佇列長度(Redis)
redis-cli llen celery
# Flower:基於網頁的即時監控
pip install flower
celery -A config flower --port=5555
反模式
# 錯誤:傳遞模型實例——它們可能在執行時已過期
send_welcome_email.delay(user) # 永遠不要傳遞 ORM 物件
send_welcome_email.delay(user.pk) # 總是傳遞主鍵
# 錯誤:在生產環境的檢視中同步呼叫任務
result = generate_report.apply() # 阻塞請求執行緒
# 錯誤:沒有守衛的非冪等任務
@shared_task
def charge_and_fulfill(order_id):
order.charge() # 如果任務重試,可能會重複扣款!
order.fulfill()
# 正確:使用狀態守衛達到冪等性
@shared_task
def charge_and_fulfill(order_id):
order = Order.objects.select_for_update().get(pk=order_id)
if order.status != Order.Status.PENDING:
return # 已處理
order.charge()
order.fulfill()
生產環境檢查清單
| 檢查項目 | 設定 |
|---|---|
| Worker 崩潰後自動重啟 | supervisord 或 systemd 單元 |
CELERY_TASK_ACKS_LATE = True | Worker 崩潰時重新排入佇列 |
CELERY_WORKER_PREFETCH_MULTIPLIER = 1 | 公平分配長時間任務 |
| 按優先級分離佇列 | -Q default,high_priority,low_priority |
設定 CELERY_TASK_SOFT_TIME_LIMIT | 在強制終止前優雅逾時 |
| 整合 Sentry | 捕捉所有 task_failure 訊號 |
| 使用 Flower 或其他監控工具 | 可視化佇列深度 |
| Beat 僅在單一節點上執行 | 防止重複執行排程任務 |
相關技能
django-patterns— ORM、服務層和專案結構django-tdd— 測試 Django 模型、檢視和服務python-testing— pytest 配置和測試固件