2025, Oct 20 22:19
Как обрабатывать задачи по времени в Django: пассивная модель, cron и Celery для биллинга
Почему сигналы Django не подходят для задач по времени и как настроить биллинг: пассивная модель, cron или Celery, идемпотентные команды и догоняющая обработка.
Действия, зависящие от времени, в приложении Django кажутся обманчиво простыми: сохраняешь дату следующего списания — и ожидаешь, что система сделает остальное. На деле опора на цикл запрос–ответ Django или его сигналы ничего не запустит, когда просто проходит время. Если нужно отключить истёкшую подписку или начать регулярное списание ровно в назначенный день, нужен другой подход.
Пример, который показывает проблему
Предположим, вы сохраняете дату ежемесячного выставления счёта сразу при создании подписки. Это может выглядеть так:
from django.utils import timezone
from datetime import timedelta
next_billing_on = timezone.now().date() + timedelta(days=30)
Если попытаться привязать повторяющиеся действия к сигналам, быстро упрётесь в ограничение: сигналы срабатывают только при сохранении, обновлении или удалении экземпляра модели. Само по себе течение времени не приводит к сохранению, поэтому при наступлении следующей даты списания ничего не произойдёт.
Почему сигналы не решают задачи, зависящие от времени
Сигналы Django — это событийный механизм на уровне данных. Они реагируют на операции с базой, а не на ход часов. Можно попытаться запустить асинхронную задачу, которая «уснёт» на неделю, а затем что‑то сделает, но это ненадёжно. Если сервер перезапустится, «спящий» поток исчезнет — вместе с ожидавшим действием. Нужен механизм, переживающий рестарты и ведущий учёт того, что и когда должно произойти.
Практичный подход: делаем состояние пассивным и обрабатываем при чтении или по расписанию
Более безопасно не «запекать» окончание подписки в необратимое изменение состояния. Вместо этого вычисляйте актуальность в момент использования. Модель остаётся простой, а признак активности становится функцией времени.
from django.db import models
from django.utils import timezone
class Membership(models.Model):
    cutoff_at = models.DateTimeField()
    @property
    def is_current(self):
        return self.cutoff_at < timezone.now()
Можно также выбирать аккаунты, у которых на текущий момент есть подходящая подписка, используя время базы данных. Это исключает расхождения на стороне Python и оставляет логику внутри запросов.
from django.contrib.auth import get_user_model
from django.db.models.functions import Now
User = get_user_model()
accounts_with_current = User.objects.filter(membership__lte=Now())
Такой стиль делает приложение пассивным: оно не переключает флаги и не изменяет строки лишь из‑за того, что прошла дата. Условие оценивается по требованию, опираясь на время БД и запрос.
Как запускать регулярные списания вовремя
Для биллинга всё равно нужен процесс, который срабатывает по расписанию. Есть два распространённых варианта. Первый — периодический запуск через cron, который вызывает management-команду Django; команда создаёт списания для всего, что должно быть оплачено сейчас или раньше. Второй — Celery с планировщиком beat и постоянным брокером. Celery умеет выполнять задачи по расписанию и сохранять их, но требует настроек и, хотя он надёжен, его нельзя считать безошибочным. Какой бы путь ни выбрали, идея одна: не полагайтесь на «спящие» задачи в памяти. Нужны устойчивое расписание и идемпотентная задача, способная нагонять пропущенное, чтобы восстановиться, если она какое‑то время не выполнялась.
Шаблон management-команды с логикой догоняющей обработки
Минимальный вариант команды, которую можно запускать ежедневно через cron: выбрать записи с датой выставления счёта сегодня или раньше, сформировать счёт и сдвинуть дату следующего списания согласно плану.
# app/management/commands/process_recurring.py
from django.core.management.base import BaseCommand
from django.utils import timezone
from app.models import Membership
class Command(BaseCommand):
    help = "Process recurring charges and advance billing markers"
    def handle(self, *args, **options):
        today = timezone.now().date()
        due_items = Membership.objects.filter(next_billing_on__lte=today)
        for entry in due_items:
            # 1) сформировать счёт для entry
            # 2) сдвинуть entry.next_billing_on на следующий цикл
            #    для недельной/месячной/годовой периодичности — скорректировать соответствующим образом
            # 3) сохранить запись после сдвига даты
            pass
Главное — выбирать и всё, что «просрочено». Если задача пропустит день, она всё равно обработает накопившееся и передвинет даты вперёд, так что система «самовосстановится».
Где уместен Celery
Celery умеет планировать и выполнять периодические задачи и хранить данные о них в постоянном бекенде. Он может запускать ту же логику, что и management-команда. Это удобно, когда нужен пул воркеров и больше операционного контроля. Но помните: устойчивое хранилище и повторы не устраняют все сбои, поэтому сама задача должна быть надёжной и уметь догонять просроченную работу.
Почему это важно
Если прятать изменения состояния, зависящие от времени, внутри обработчиков запросов или сигналов, корректность бизнеса начнёт зависеть от пользовательского трафика и случайных сохранений. Это приводит к «тихим» сбоям. Пассивная модель и обработка по расписанию перекладывают ответственность на явные, восстанавливаемые задачи, за которыми можно следить, которые можно перезапустить и понять. Данные тоже чище: нет лишних записей «подписка закончилась», вместо этого используются запросы, учитывающие время.
Собираем всё вместе
Храните только необходимые временные данные, вычисляйте «активность» как функцию времени и запускайте периодическую задачу, которая выполняет биллинг и сдвигает маркер следующего списания. Не полагайтесь на сигналы Django для автоматизации по времени и избегайте долгоспящих задач внутри процесса приложения. Используйте cron с management-командой или Celery beat, делайте задачу идемпотентной и способной догонять пропущенное, а логику модели — простой и пассивной.
Статья основана на вопросе на StackOverflow от Adamu Abdulkarim и ответе willeM_ Van Onsem.