PAYATHON 2026

自动续订订阅的 System design pattern 与触发方式

支付小周

结论

可以避免“每隔几秒查询整张表”,但不能完全取消时间调度。

订阅到期只是一个时间条件,本身不会产生事件。系统需要通过以下方式之一,将“时间已到”转换为续订命令:

  1. 创建订阅时,同时注册延迟任务或持久化定时任务。
  2. 使用支持延迟消息的消息队列,在到期时投递续订消息。
  3. 使用调度系统按 expires_at 触发任务。
  4. 定期查询数据库,并通过到期时间索引、批量领取和合理的扫描周期控制成本。

大多数业务系统可以采用以下组合:

持久化调度或延迟消息 + 幂等的 RenewSubscription + 数据库状态校验 + 定期补偿扫描。

延迟任务负责按时触发,补偿扫描则找回因进程崩溃、消息丢失或调度失败而漏掉的续订。只依赖进程内的 Timer、内存队列或一次性 Lambda 调用,通常不够可靠。

到期时间为什么不会自动触发代码

数据库中的 expires_at 只是一个字段。例如:

expires_at = 2026-09-01T00:00:00Z

系统时间超过该值后,数据库记录只是在业务语义上过期了,并没有发生 UPDATE、INSERT 等数据变更。因此:

  • 数据库触发器通常不会因为时间流逝而执行。
  • Change Data Capture 无法捕获“时间到了”,因为数据没有变化。
  • 应用程序必须提前安排未来任务,或者定期查找已经到期的记录。

因此,需要解决的不是如何彻底取消检查,而是把检查放在哪里,并保证它可靠、可扩展且不会造成重复扣款。

方案一:持久化任务调度器

创建或更新订阅时,注册一个在 expires_at 执行的任务:

await scheduler.ScheduleAsync(
    jobName: $"renew-subscription:{subscription.Id}",
    executeAt: subscription.ExpiresAt,
    payload: new RenewSubscriptionCommand(
        subscription.Id,
        subscription.ExpiresAt
    )
);

订阅到期后,调度器调用:

await RenewSubscription(
    command.SubscriptionId,
    command.ExpectedExpiresAt
);

这里应使用持久化调度器,而不是普通的进程内 Timer。持久化调度器会将任务保存在数据库或其他可靠存储中,应用重启后任务依然存在。

可用于这种方案的组件包括:

  • 云平台的 scheduler 或定时任务服务
  • 支持持久化任务的后台作业框架
  • 基于数据库实现的 job queue
  • 支持指定执行时间的任务平台

不同产品和版本提供的能力不一样,选型时需要确认是否支持:

  • 指定未来执行时间
  • 持久化
  • 自动重试
  • 失败队列
  • 任务取消和改期
  • 至少一次投递语义

订阅延期、取消或更换套餐时,应取消旧任务。也可以保留旧任务,但要在执行时通过版本检查让它自动失效。

方案二:延迟消息

如果系统已经使用消息队列,可以在订阅生效时发送一条延迟消息:

{
  "type": "RenewSubscription",
  "subscriptionId": "sub_123",
  "expectedExpiresAt": "2026-09-01T00:00:00Z",
  "operationId": "sub_123:2026-09-01T00:00:00Z"
}

将消息的可见时间或投递时间设置为订阅到期时间。消费者收到消息后执行:

public async Task Handle(RenewSubscriptionCommand command)
{
    await RenewSubscription(
        command.SubscriptionId,
        command.ExpectedExpiresAt,
        command.OperationId
    );
}

这种设计将“什么时候执行”与“如何续订”分开:

订阅服务
    └── 创建延迟消息
            └── 到期后进入可消费状态
                    └── Renewal Worker
                            └── RenewSubscription

并非所有消息队列都适合保存延迟数月或数年的消息。有些产品会限制最大延迟时间、消息保留时间或定时消息数量。遇到这些限制时,可以采用分层调度:

  • 较远的任务保存在数据库中。
  • 调度器只加载未来一段时间内的任务。
  • 接近执行时间后,再将任务投递到消息队列。

方案三:基于数据库的高效扫描

定期查询数据库并不是错误的做法,很多可靠的生产系统都会保留这种机制。重点是不要每次都扫描整张订阅表。

可以为待续订状态和到期时间建立索引:

CREATE INDEX ix_subscriptions_renewal_due
ON subscriptions (expires_at, id)
WHERE status = 'ACTIVE'
  AND auto_renew = TRUE;

然后领取一批已经到期的任务:

SELECT id, expires_at
FROM subscriptions
WHERE status = 'ACTIVE'
  AND auto_renew = TRUE
  AND expires_at <= CURRENT_TIMESTAMP
ORDER BY expires_at, id
LIMIT 100
FOR UPDATE SKIP LOCKED;

FOR UPDATE SKIP LOCKED 的具体语法和行为因数据库而异,需要根据实际使用的数据库确认。它常用于让多个 worker 并行领取任务,避免重复处理同一批记录。

领取任务后,应在同一事务中修改状态:

UPDATE subscriptions
SET status = 'RENEWING',
    renewal_started_at = CURRENT_TIMESTAMP
WHERE id = :subscription_id
  AND status = 'ACTIVE'
  AND auto_renew = TRUE
  AND expires_at = :expected_expires_at;

只有受影响行数为 1 时,worker 才算取得处理权。

索引设置正确时,这类查询的成本主要取决于当前已经到期的记录数量,而不是订阅总量。扫描间隔可以设为几十秒或几分钟,具体取决于业务允许的延迟,没有必要固定为每几秒一次。

推荐的组合设计

较稳妥的实现一般包括主触发链路和补偿链路。

主触发链路

创建或续订订阅
  → 写入新的 expires_at
  → 注册持久化定时任务或延迟消息
  → 到期后发送 RenewSubscriptionCommand
  → Renewal Worker 执行续订

补偿链路

每隔一段时间
  → 查询 expires_at <= now 且仍未处理的订阅
  → 重新发送 RenewSubscriptionCommand

补偿任务不需要频繁运行。它不负责正常调度,只处理异常情况,例如:

  • 调度服务短暂不可用
  • 消息超过重试次数
  • worker 领取任务后崩溃
  • 部署或配置错误导致任务没有注册
  • 订阅记录与调度任务不一致

这是典型的“至少一次执行”设计,所以 RenewSubscription 必须能够安全地接受重复调用。

RenewSubscription 必须幂等

自动续订通常涉及支付,重复执行往往比延迟执行更危险。不要假设消息只会投递一次,也不能将“收到续订消息”直接等同于“立即扣款”。

可以根据订阅周期生成稳定的幂等键:

var idempotencyKey =
    $"renewal:{subscription.Id}:{expectedExpiresAt:O}";

处理逻辑可以写成:

public async Task RenewSubscription(
    Guid subscriptionId,
    DateTimeOffset expectedExpiresAt,
    string operationId)
{
    var subscription = await repository.GetForUpdate(subscriptionId);

    if (subscription is null)
        return;

    if (!subscription.AutoRenew)
        return;

    // 订阅已被延期,说明这是一条旧任务。
    if (subscription.ExpiresAt != expectedExpiresAt)
        return;

    // 当前周期已经成功续订。
    if (await renewalRepository.ExistsSuccessful(operationId))
        return;

    var paymentResult = await paymentProvider.ChargeAsync(
        customerId: subscription.CustomerId,
        amount: subscription.RenewalPrice,
        idempotencyKey: operationId
    );

    if (!paymentResult.Succeeded)
    {
        await HandlePaymentFailure(subscription, operationId);
        return;
    }

    await repository.CompleteRenewal(
        subscriptionId,
        expectedExpiresAt,
        newExpiresAt: expectedExpiresAt.AddMonths(1),
        operationId
    );
}

支付服务是否支持幂等键,要根据具体 API 确认。如果不支持,系统应在本地维护唯一的 renewal operation,并通过唯一约束防止同一周期创建多个扣款操作:

CREATE UNIQUE INDEX ux_renewal_operation
ON renewal_operations (subscription_id, period_end);

只靠“先查询是否扣过款,再发起扣款”仍然会有并发竞争。唯一约束、行锁、状态机和支付方幂等键需要配合使用。

使用状态机管理续订过程

续订并不是一个瞬间完成的动作,涉及外部支付系统时尤其如此。建议明确记录各阶段的状态,例如:

ACTIVE
  → RENEWING
      → ACTIVE
      → PAYMENT_RETRY
      → PAST_DUE
      → EXPIRED
      → CANCELED

更新状态时,应使用条件更新或乐观锁:

UPDATE subscriptions
SET status = 'RENEWING',
    version = version + 1
WHERE id = :id
  AND status = 'ACTIVE'
  AND version = :expected_version;

如果受影响行数为 0,说明另一个 worker 已经处理过该订阅,或者订阅状态已经改变。

续订时间不一定等于到期时间

“到期后立即续订”并不一定符合业务需要。很多订阅系统会在到期前数小时或数天尝试扣款,以便:

  • 在支付失败后留出重试时间
  • 避免用户权益突然中断
  • 提醒用户更新支付方式
  • 处理银行或支付渠道的延迟

可以分别保存以下时间:

current_period_end
next_renewal_attempt_at
grace_period_end

此时,触发条件应使用 next_renewal_attempt_at,而不是简单地使用 current_period_end。

支付结果也可能通过 webhook 异步返回。在这种情况下,定时任务只负责发起续订,最终状态由支付回调推进:

到达 next_renewal_attempt_at
  → 创建 payment attempt
  → 调用支付服务
  → 接收 webhook
  → 验证签名和事件幂等性
  → 更新订阅周期

首次续订不能只依赖 webhook 触发,因为支付平台通常不会自动知道本地订阅何时到期。除非订阅周期完全由支付平台托管,此时本地系统应主要消费支付平台的订阅事件,并定期进行数据对账。

时间处理注意事项

所有调度和时间比较都建议使用 UTC:

DateTimeOffset.UtcNow

数据库字段也应具有明确的时区语义。不要直接使用服务器本地时间判断是否到期,否则可能遇到:

  • 夏令时造成时间重复或跳过
  • 多区域服务器的时区不一致
  • 用户时区与计费时区混淆

如果产品承诺“按用户当地时间续订”,应先根据业务规则计算对应的 UTC 时间,再将其保存到 next_renewal_attempt_at。

任务也不应被要求在某个时间点绝对零延迟执行。对于分布式系统,更合理的约定是:

任务在目标时间之后的可接受延迟范围内至少执行一次。

可接受的延迟范围应由业务 SLA 决定,可以是秒级、分钟级或更宽松,而不是由技术实现随意确定。

实际选型建议

小型系统可以直接采用:

  • 带索引的数据库到期扫描
  • 多 worker 安全领取
  • 幂等续订
  • 失败重试和告警

已经具备消息基础设施的系统可以采用:

  • 以持久化调度器或延迟消息作为主触发
  • 由消息消费者执行续订
  • 使用数据库补偿扫描兜底

大规模系统还可以使用:

  • 按时间分片或分区的调度表
  • 分层时间轮
  • 相互分离的调度服务与执行服务
  • 死信队列和自动重放
  • 续订记录与订阅记录的对账任务

无论选择哪种触发方式,幂等、并发控制、重试、补偿和监控都不能省略。可靠性的重点不是“完全不查询数据库”,而是保证任务即使重复、延迟或中断,也不会漏掉续订或造成重复扣款。

备注:内容仅供参考。