PAYATHON 2026

如何为 billing system 设计故障保护机制?

支付老李

结论

现有实现的问题在于,查询条件只匹配某一天。一旦 nightly script 当天没有成功运行,就会永久漏掉对应的 subscriptions:

expires = Date.today + 30.days

更可靠的做法包括:

  • 使用”时间窗口 + 未处理状态”查询,避免按精确日期匹配;
  • 将邮件发送拆成可重试的后台任务;
  • 保证任务幂等,即使重复执行也不会重复提醒;
  • 持久化每次提醒的处理状态和失败信息;
  • 监控任务失败、积压和漏发;
  • 如果涉及实际扣款,优先交给支付服务商处理,并使用唯一键、幂等键和对账机制。

subscription 是否有效,仍可根据 expires_at 判断,不必依赖定时任务将状态改为 expired。定时任务负责提醒、扣款编排和失败补偿,不应决定时间是否已经过去。

为什么精确日期查询容易漏数据

假设某个 subscription 在 6 月 30 日到期,月度提醒任务本应在 5 月 31 日处理它。如果当天服务器宕机,任务到 6 月 1 日才恢复:

Subscription.where(expires: Date.today + 30.days)

此时查询的是 7 月 1 日到期的数据,6 月 30 日到期的记录不会再被选中。

原有代码还有几个风险:

  1. 邮件发送成功后,month_reminder_sent 可能尚未保存。如果任务此时崩溃,恢复后会重复发送。
  2. 如果先保存 sent 状态再发邮件,邮件发送失败后可能永远不会重试。
  3. deliver 在循环中同步执行,一封邮件阻塞或报错就可能影响整个批次。
  4. 单个布尔字段无法记录发送时间、失败原因和重试次数。
  5. 多个任务实例同时运行时,可能选中同一条记录并重复处理。
  6. 如果 expires 是时间字段而不是日期字段,直接与 Date 比较还可能受到时区和数据库转换的影响。

使用时间窗口扫描待处理记录

月度提醒不应只查找”恰好 30 天后到期”的记录,而应查找”已经进入提醒窗口,但尚未成功提醒”的记录。

cutoff = 30.days.from_now.end_of_day

Subscription
  .where(month_reminder_sent_at: nil)
  .where(expires_at: Time.current..cutoff)
  .find_each do |subscription|
    MonthlyReminderJob.perform_later(subscription.id)
  end

即使任务停了两天,恢复后仍能找到已经进入 30 天窗口但尚未处理的 subscriptions。

查询时还应明确下界,避免把历史脏数据全部加入队列:

Subscription
  .where(month_reminder_sent_at: nil)
  .where(expires_at: Time.current..30.days.from_now.end_of_day)
  .find_each do |subscription|
    MonthlyReminderJob.perform_later(subscription.id)
  end

这里需要先确定一项业务规则:已经过期的 subscription 是否仍要发送补偿提醒。如果需要,应单独设置宽限窗口,而不是直接排除所有过期记录:

Subscription
  .where(month_reminder_sent_at: nil)
  .where(expires_at: 2.days.ago..30.days.from_now.end_of_day)

一周提醒可以采用同样的设计,只需使用对应的时间窗口和状态字段。

将扫描和邮件发送拆开

定时任务只负责发现待处理记录,并为每条记录创建独立的后台任务。这样即使某封邮件发送失败,也不会中断整个批次。

class MonthlyReminderJob < ApplicationJob
  queue_as :mailers

  retry_on StandardError, wait: :polynomially_longer, attempts: 8

  def perform(subscription_id)
    subscription = Subscription.find_by(id: subscription_id)
    return unless subscription

    subscription.with_lock do
      return if subscription.month_reminder_sent_at.present?
      return unless subscription.expires_at.between?(
        Time.current,
        30.days.from_now.end_of_day
      )

      BillingMailer.monthly_reminder(subscription.user).deliver_now
      subscription.update!(month_reminder_sent_at: Time.current)
    end
  end
end

可用的重试参数取决于 Rails 和 Active Job adapter 的版本,不能假定所有版本都支持相同的配置。生产环境还必须使用持久化队列,因为只保存在进程内存中的队列无法承受服务器重启。

with_lock 可以降低并发 worker 同时处理同一 subscription 的概率,但无法让数据库更新和外部邮件服务调用成为真正的原子事务。因此,这种实现通常只能接近”至少发送一次”,在极端情况下仍可能重复发送。

用事件表建立更可靠的幂等边界

如果业务不能接受重复提醒,可以将提醒建模为独立记录,并通过数据库唯一约束阻止重复创建:

create_table :subscription_reminders do |t|
  t.references :subscription, null: false, foreign_key: true
  t.string :kind, null: false
  t.datetime :scheduled_for, null: false
  t.datetime :sent_at
  t.integer :attempts, null: false, default: 0
  t.text :last_error
  t.timestamps
end

add_index :subscription_reminders,
          [:subscription_id, :kind, :scheduled_for],
          unique: true,
          name: "idx_unique_subscription_reminder"

扫描任务只负责创建 reminder:

Subscription
  .where(expires_at: Time.current..30.days.from_now.end_of_day)
  .find_each do |subscription|
    SubscriptionReminder.create_or_find_by!(
      subscription: subscription,
      kind: "one_month",
      scheduled_for: subscription.expires_at - 30.days
    )
  end

然后由 worker 处理所有已到期但尚未完成的 reminder:

SubscriptionReminder
  .where(sent_at: nil)
  .where(scheduled_for: ..Time.current)
  .find_each do |reminder|
    SendSubscriptionReminderJob.perform_later(reminder.id)
  end

与 month_reminder_sent 相比,这种模型更便于审计,也更适合处理续订后到期日变化的情况。不过,旧提醒的处理方式必须提前确定,例如取消、重新排期或保留为历史记录。

扣款必须比邮件提醒更严格

如果 billing 还包括自动扣款,就不应把扣款逻辑直接放进普通的 nightly loop。收费流程至少需要具备以下能力:

  • 每次收费都有稳定且唯一的业务标识,例如 invoice_id;
  • 调用支付服务商时使用 idempotency key;
  • 在本地保存 pending、succeeded、failed 等状态;
  • 网络超时后先查询支付结果,不能直接再次扣款;
  • 通过 webhook 更新异步结果,并定期主动对账;
  • 对长期处于 pending 状态的支付记录进行补偿扫描;
  • 在数据库层使用唯一约束,防止同一账单被创建两次;
  • webhook 按事件 ID 去重,并验证签名。

常见的幂等键形式如下:

idempotency_key = "invoice-#{invoice.id}-charge-v1"

不能只依靠 charged = true 之类的布尔字段。进程可能在支付成功后、本地状态保存前崩溃,此时再次调用支付接口就可能造成重复扣款。

具体参数和恢复流程应以所用支付服务商的 API 文档为准。

过期状态应按时间计算

如果业务规则是”到期时间已过且未续订即失效”,可以直接计算 active 状态:

def active?
  expires_at.present? && expires_at > Time.current
end

查询时也可以直接使用 scope:

scope :active, -> { where("expires_at > ?", Time.current) }
scope :expired, -> { where(expires_at: ..Time.current) }

这样,即使服务器停机,时间越过 expires_at 后,subscription 也会自然失效,不需要依靠某个任务及时写入 expired 状态。

如果业务还包括宽限期、暂停、退款或手动禁用等规则,就应把这些条件明确加入状态模型,不能只比较日期。

必须设置的补偿和监控

至少需要检查以下项目:

  • 定时任务最后一次开始和成功完成的时间;
  • 本轮扫描的记录数、入队数和失败数;
  • 队列中最老任务的等待时间;
  • 重试耗尽或进入 dead-letter queue 的任务;
  • 已进入提醒窗口但仍未发送的记录数;
  • 已到期但状态异常的 subscriptions;
  • 支付记录与支付服务商账单之间的对账差异。

还可以设置独立的补偿任务,定期查找遗漏项:

Subscription
  .where(month_reminder_sent_at: nil)
  .where(expires_at: Time.current..30.days.from_now)
  .find_each do |subscription|
    MonthlyReminderJob.perform_later(subscription.id)
  end

补偿任务和正常扫描任务可以共用同一套幂等逻辑。重复扫描没有问题,问题在于任务能否安全地重复执行。

其他注意事项

  • 使用 Time.current,并统一应用、数据库、worker 和 scheduler 的时区约定。
  • 为扫描条件建立合适的复合索引,例如:
add_index :subscriptions, [:month_reminder_sent_at, :expires_at]
  • 更新状态时使用 update!,避免保存失败后被静默忽略。
  • 不要用一个全局事务包住大量邮件或支付请求,否则事务会长期占用连接和锁。
  • 邮件内容应根据任务执行时重新读取的数据生成,避免发送已经续订或取消的旧信息。
  • 部署、维护和数据库故障都可能导致调度器跳过执行,因此不能把 scheduler 的”每天运行”当成交付保证。
  • 对实际收费流程进行故障注入测试,例如模拟超时、worker 重启、重复 webhook 和数据库更新失败。

可靠的设计允许扫描任务和 worker 随时失败,并能在恢复后自动找到尚未完成的工作,安全地继续处理。

备注:内容仅供参考。