自动续订订阅的 System design pattern 与触发方式
结论
可以避免“每隔几秒查询整张表”,但不能完全取消时间调度。
订阅到期只是一个时间条件,本身不会产生事件。系统需要通过以下方式之一,将“时间已到”转换为续订命令:
- 创建订阅时,同时注册延迟任务或持久化定时任务。
- 使用支持延迟消息的消息队列,在到期时投递续订消息。
- 使用调度系统按
expires_at触发任务。 - 定期查询数据库,并通过到期时间索引、批量领取和合理的扫描周期控制成本。
大多数业务系统可以采用以下组合:
持久化调度或延迟消息 + 幂等的
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 安全领取
- 幂等续订
- 失败重试和告警
已经具备消息基础设施的系统可以采用:
- 以持久化调度器或延迟消息作为主触发
- 由消息消费者执行续订
- 使用数据库补偿扫描兜底
大规模系统还可以使用:
- 按时间分片或分区的调度表
- 分层时间轮
- 相互分离的调度服务与执行服务
- 死信队列和自动重放
- 续订记录与订阅记录的对账任务
无论选择哪种触发方式,幂等、并发控制、重试、补偿和监控都不能省略。可靠性的重点不是“完全不查询数据库”,而是保证任务即使重复、延迟或中断,也不会漏掉续订或造成重复扣款。
备注:内容仅供参考。
