订阅支付系统是否有更可靠的动态调度方案?
结论
不建议为每个订阅单独创建或修改动态定时任务,也没必要因此引入 Apache Airflow。
更可靠的做法是:
- 以数据库中的
expires_at作为判断订阅是否有效的唯一时间依据。 - 运行一个固定频率的 Celery 周期任务,批量扫描已过期订阅。
- 使用数据库索引、短事务和
SELECT ... FOR UPDATE SKIP LOCKED减少锁竞争。 - 将通知、退款、资源回收等副作用拆成幂等的异步任务。
- 通过支付平台 webhook 更新订阅,并用周期性对账任务修复漏单。
采用这种设计后,即使 celery-beat 或 RabbitMQ 暂时延迟,用户也不会因为”过期任务尚未执行”而继续获得订阅权限。Airflow 更适合数据管道和存在依赖关系的批处理工作流,无法解决订阅状态建模、数据库锁和消息可靠性这些根本问题。
不要把定时任务当作订阅状态
如果系统必须等 expiration task 执行后才把用户标记为过期,那么任务延迟 60 秒,权限状态也会出错 60 秒。
订阅是否有效,应直接根据时间判断:
from django.utils import timezone
def has_active_subscription(subscription) -> bool:
return (
subscription.status == "active"
and subscription.expires_at > timezone.now()
)
如果业务中有宽限期,也应明确建模:
def has_subscription_access(subscription) -> bool:
now = timezone.now()
if subscription.status not in {"active", "past_due"}:
return False
access_until = subscription.grace_period_ends_at or subscription.expires_at
return access_until > now
status 用来表达业务状态,expires_at 决定时间边界。系统不能依赖后台任务刚好在某一秒执行。
所有时间字段都应包含时区信息,并启用 Django 的时区支持:
USE_TZ = True
TIME_ZONE = "UTC"
向用户展示时间时,再转换到用户所在的时区。
用固定任务批量处理过期订阅
不要为每个用户创建一条 django_celery_beat.models.PeriodicTask。系统只需保留一个固定周期任务,例如每 10 秒或每分钟扫描一次。
# celery.py
from celery import Celery
app = Celery("project")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
app.conf.beat_schedule = {
"expire-subscriptions": {
"task": "subscriptions.tasks.expire_subscriptions",
"schedule": 10.0,
"options": {
"queue": "subscriptions",
},
},
}
订阅表至少要为扫描条件建立索引:
from django.db import models
class Subscription(models.Model):
class Status(models.TextChoices):
ACTIVE = "active"
PAST_DUE = "past_due"
EXPIRED = "expired"
CANCELED = "canceled"
user = models.ForeignKey(
"auth.User",
on_delete=models.CASCADE,
related_name="subscriptions",
)
status = models.CharField(
max_length=20,
choices=Status.choices,
db_index=True,
)
expires_at = models.DateTimeField(db_index=True)
expired_at = models.DateTimeField(null=True, blank=True)
class Meta:
indexes = [
models.Index(
fields=["status", "expires_at"],
name="subscription_expiry_idx",
),
]
周期任务每次只处理有限数量的数据,以免单次事务持锁过久:
from celery import shared_task
from django.db import transaction
from django.utils import timezone
from .models import Subscription
BATCH_SIZE = 500
@shared_task(
bind=True,
autoretry_for=(Exception,),
retry_backoff=True,
retry_jitter=True,
max_retries=5,
)
def expire_subscriptions(self):
now = timezone.now()
with transaction.atomic():
subscriptions = list(
Subscription.objects
.select_for_update(skip_locked=True)
.filter(
status=Subscription.Status.ACTIVE,
expires_at__lte=now,
)
.order_by("expires_at")[:BATCH_SIZE]
)
if not subscriptions:
return {"expired": 0}
subscription_ids = [item.id for item in subscriptions]
Subscription.objects.filter(
id__in=subscription_ids,
status=Subscription.Status.ACTIVE,
expires_at__lte=now,
).update(
status=Subscription.Status.EXPIRED,
expired_at=now,
)
dispatch_expiration_side_effects.delay(subscription_ids)
return {"expired": len(subscription_ids)}
skip_locked=True 可用于 PostgreSQL 等支持该功能的数据库。不同数据库及版本的行为并不完全相同,使用前要确认当前数据库后端是否支持。
这种实现允许多个 worker 并行领取不同批次,无需互相等待。事务内只执行必要的状态变更,发送邮件、关闭外部资源等慢操作则放在事务外处理。
副作用必须幂等
Celery 通常采用”至少一次”语义。任务可能重试,也可能因 worker 在确认消息前退出而再次执行。因此,订阅过期后的操作不能假设自己只会执行一次。
from celery import shared_task
from .models import Subscription
@shared_task
def dispatch_expiration_side_effects(subscription_ids):
subscriptions = Subscription.objects.filter(
id__in=subscription_ids,
status=Subscription.Status.EXPIRED,
)
for subscription in subscriptions.iterator():
send_expiration_notification.delay(subscription.id)
通知任务可以通过唯一记录避免重复发送:
from django.db import transaction
from .models import Subscription, SubscriptionEvent
@shared_task
def send_expiration_notification(subscription_id):
subscription = Subscription.objects.get(id=subscription_id)
with transaction.atomic():
event, created = SubscriptionEvent.objects.get_or_create(
subscription=subscription,
event_type="expiration_notification",
defaults={"status": "pending"},
)
if not created:
return
# 调用邮件或消息服务。
# 如果外部服务支持 idempotency key,应使用 event.id 作为幂等键。
对于”写数据库并发送消息”这类操作,数据库提交成功和消息发送之间仍然存在一个故障窗口。如果业务对可靠性要求较高,可以采用 transactional outbox:在同一个数据库事务中写入订阅状态和 outbox 事件,再由独立任务持续投递 outbox 中尚未发送的事件。
仅使用 transaction.on_commit() 可以防止事务回滚后仍然发送任务,但无法完全消除进程在提交后、发送前崩溃所造成的消息丢失。
支付状态应以 webhook 加对账为主
订阅续期不能只靠本地定时器推算。通常需要同时使用两条路径:
- 支付平台 webhook:及时处理支付成功、扣款失败、退款和取消等事件。
- 周期性对账:定期查询支付平台,修复 webhook 延迟、丢失或处理失败造成的数据不一致。
Webhook 也必须幂等。可以用支付平台提供的事件 ID 建立唯一约束:
class PaymentWebhookEvent(models.Model):
provider = models.CharField(max_length=32)
external_event_id = models.CharField(max_length=255)
received_at = models.DateTimeField(auto_now_add=True)
processed_at = models.DateTimeField(null=True, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["provider", "external_event_id"],
name="unique_payment_webhook_event",
),
]
续期成功后,直接更新 expires_at。即使周期扫描任务已经读到旧数据,也要在更新时再次检查条件:
Subscription.objects.filter(
id=subscription_id,
status=Subscription.Status.ACTIVE,
expires_at__lte=now,
).update(
status=Subscription.Status.EXPIRED,
expired_at=now,
)
如果 webhook 已经将 expires_at 延长到未来,这条更新不会误把订阅设为过期。
排查当前 60~150 秒延迟
先区分延迟发生在哪个环节:
- 调度延迟:任务到期后,beat 过了很久才发送消息。
- 队列延迟:消息已经进入 RabbitMQ,但 worker 很久以后才开始执行。
建议为任务记录三个时间点:
{
"scheduled_at": "...",
"published_at": "...",
"started_at": "...",
}
然后计算:
scheduler_delay = published_at - scheduled_at
queue_delay = started_at - published_at
如果是调度延迟,重点检查以下问题:
- 是否只运行了一个 celery-beat 实例。
- beat 是否与耗时服务运行在同一个进程或同一个资源受限的容器中。
django_celery_beat_periodictask等表是否存在长事务或锁等待。- 系统是否频繁创建、修改大量
PeriodicTask。 - 数据库连接是否耗尽,查询是否缺少索引。
- 系统时钟、容器时钟和 Django 时区是否一致。
如果是队列延迟,则重点检查:
- RabbitMQ 的队列堆积量和最老消息年龄。
- worker 并发数是否不足。
- 长任务是否与订阅任务共用队列。
- worker 是否频繁重连、内存不足或进程阻塞。
- 消息是否被限流、重试或重新入队。
- RabbitMQ 节点是否触发内存或磁盘告警。
订阅状态任务最好使用独立队列:
celery -A project worker \
--queues=subscriptions \
--concurrency=4 \
--loglevel=INFO
长耗时任务应放进其他队列,避免占满所有 worker:
CELERY_TASK_ROUTES = {
"subscriptions.tasks.*": {"queue": "subscriptions"},
"reports.tasks.*": {"queue": "reports"},
}
DatabaseScheduler 阻塞的常见原因
DatabaseScheduler 不适合承担高频、海量的”一用户一任务”调度。大量动态修改周期任务时,容易出现以下问题:
- 更新调度表的事务持锁时间过长。
- 应用事务尚未提交,scheduler 读取数据时需要等待锁。
- 多个进程同时修改任务和调度变更标记。
- 调度表不断增长,查询和同步成本随之上升。
- 数据库连接池耗尽,调度进程无法及时查询。
如果确实需要修改调度记录,应缩小事务范围,并在业务事务提交后再执行调度操作:
from django.db import transaction
def update_subscription(subscription, new_expires_at):
subscription.expires_at = new_expires_at
subscription.save(update_fields=["expires_at"])
transaction.on_commit(
lambda: notify_subscription_changed.delay(subscription.id)
)
不过,在订阅过期这个场景中,更合适的做法仍然是取消每个订阅对应的动态任务,只保留一条固定扫描任务。
是否需要替换 celery-beat
先改掉”一订阅一调度任务”的模型,通常比更换 scheduler 更重要。
如果改成固定扫描任务后仍有更高的可靠性要求,可以考虑:
- 使用 Kubernetes
CronJob、systemd timer 或平台提供的 cron,定期调用 Django management command。 - 使用 PostgreSQL advisory lock,确保多个扫描进程中只有一个主扫描器运行。
- 让多个扫描 worker 配合
SKIP LOCKED并行处理。 - 使用托管消息队列和独立监控,降低自行运维 RabbitMQ 的风险。
- 对规模极大且需要精确定时的场景,使用专门的延迟队列或时间轮服务。
无论选择哪种调度器,都不能让调度器决定用户当前是否有权限。调度器只负责最终一致性的状态整理和副作用处理。
为什么不建议使用 Airflow
Airflow 擅长有向无环图、数据处理流水线、批处理依赖、任务重跑和历史管理。订阅系统需要处理的则是:
- 低延迟的状态判断;
- 高频业务事件;
- webhook 幂等;
- 数据库并发控制;
- 消息重试与最终一致性。
引入 Airflow 会增加 metadata database、scheduler、executor、部署和监控等运维成本,但不会自动解决数据库锁、重复消息或过期权限判断错误。
更稳妥的架构如下:
支付 webhook
│
▼
更新 Subscription.expires_at
│
├── 请求时实时检查 expires_at
│
└── 固定周期扫描任务
│
▼
批量标记过期
│
▼
幂等副作用任务
周期对账任务 ───────► 修复支付状态不一致
对于小型和中型 Django 项目,这种设计更简单,也更容易测试、监控和恢复。系统不需要一个”绝不延迟”的调度器,而要保证在调度延迟、任务重复、消息丢失或进程重启时,订阅状态仍然正确。
备注:内容仅供参考。
