如何为 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 日到期的记录不会再被选中。
原有代码还有几个风险:
- 邮件发送成功后,
month_reminder_sent可能尚未保存。如果任务此时崩溃,恢复后会重复发送。 - 如果先保存 sent 状态再发邮件,邮件发送失败后可能永远不会重试。
deliver在循环中同步执行,一封邮件阻塞或报错就可能影响整个批次。- 单个布尔字段无法记录发送时间、失败原因和重试次数。
- 多个任务实例同时运行时,可能选中同一条记录并重复处理。
- 如果
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 随时失败,并能在恢复后自动找到尚未完成的工作,安全地继续处理。
备注:内容仅供参考。
