PAYATHON 2026

如何在 Rails 4 中高效实现 cron job

支付阿杰

结论

不需要逐条扫描所有 invoice。更稳妥且方便扩展的方案是:

  1. 使用 cron 或 whenever 定期启动一个轻量的扫描任务。
  2. 借助带索引的数据库查询,筛选出未支付、尚未提醒,并且即将在指定时间内到期的 invoice。
  3. 把每封提醒邮件交给后台任务队列发送。
  4. 通过提醒状态和原子更新支持安全重试,避免重复发送。

即使有数千条甚至更多记录,只要查询字段上建有合适的复合索引,数据库就能直接找到符合条件的记录,无需让 Rails 加载所有 invoice 后逐条比较。whenever 只负责生成 cron 配置,通常不会成为性能瓶颈。

推荐的数据结构

以 Mongoid 为例,可以在 invoice 上保存到期时间、支付状态和提醒状态:

class Invoice
  include Mongoid::Document
  include Mongoid::Timestamps

  field :due_at,            type: Time
  field :paid,              type: Boolean, default: false
  field :reminder_sent_at,  type: Time

  belongs_to :customer

  index(
    { paid: 1, reminder_sent_at: 1, due_at: 1 },
    { background: true }
  )
end

复合索引中的字段顺序要根据实际查询方式和数据分布来确定。上面的顺序适用于同时按支付状态、提醒状态和到期时间筛选的情况。正式上线前,应使用 MongoDB 的 explain 检查查询是否命中了索引。

如果业务需要多次提醒,例如分别在到期前 7 天、3 天和当天提醒,单个 reminder_sent_at 无法记录所有状态。可以分别保存:

field :reminded_7_days_at, type: Time
field :reminded_3_days_at, type: Time
field :reminded_due_at,    type: Time

也可以建立单独的 InvoiceReminder 集合,记录每个 invoice、提醒类型和发送状态。

只查询需要处理的记录

假设任务每天运行一次,需要找出未来 3 天内到期且尚未支付的 invoice:

class InvoiceReminderScanner
  def self.run
    now = Time.current
    deadline = now + 3.days

    Invoice.where(
      :paid.ne => true,
      reminder_sent_at: nil,
      :due_at.gte => now,
      :due_at.lt => deadline
    ).only(:id).each do |invoice|
      InvoiceReminderWorker.perform_async(invoice.id.to_s)
    end
  end
end

日期筛选应在数据库中完成:

:due_at.gte => now,
:due_at.lt => deadline

不要写成:

Invoice.all.each do |invoice|
  if invoice.due_at < 3.days.from_now
    # ...
  end
end

后一种写法会遍历全部记录,还可能实例化大量 Mongoid 对象。

扫描任务只做筛选和投递,不要在同一进程中同步发送所有邮件。这样即使邮件服务响应较慢,也不会阻塞下一轮扫描。

使用后台队列发送提醒

下面是一个 Sidekiq 风格的 worker:

class InvoiceReminderWorker
  include Sidekiq::Worker

  def perform(invoice_id)
    invoice = Invoice.where(id: invoice_id).first
    return unless invoice
    return if invoice.paid?
    return if invoice.reminder_sent_at.present?

    InvoiceMailer.due_soon(invoice.id.to_s).deliver_now
    invoice.set(reminder_sent_at: Time.current)
  end
end

Mailer 中再次读取 invoice,并在发送前检查状态:

class InvoiceMailer < ActionMailer::Base
  def due_soon(invoice_id)
    @invoice = Invoice.find(invoice_id)

    mail(
      to: @invoice.customer.email,
      subject: "Invoice #{@invoice.id} is due soon"
    )
  end
end

任务入队后,可能过几分钟才会真正执行。这段时间里 invoice 可能已经支付,所以 worker 必须重新检查状态,不能只依赖扫描时的结果。

用 whenever 定期触发扫描

可以把扫描入口放进 Rake task:

# lib/tasks/invoice_reminders.rake
namespace :invoices do
  desc "Enqueue reminders for invoices that are due soon"
  task enqueue_reminders: :environment do
    InvoiceReminderScanner.run
  end
end

config/schedule.rb:

every 1.day, at: "02:00 am" do
  rake "invoices:enqueue_reminders"
end

部署后,whenever 会生成相应的 cron 配置。也可以直接使用系统 cron、平台自带的 scheduler,或者后台队列提供的周期任务组件。无论选择哪种触发器,整体设计都一样:定时器负责启动扫描,数据库索引负责筛选记录,后台队列负责执行发送任务。

另一种方案:创建 invoice 时直接安排任务

如果后台任务系统支持延迟执行,也可以在创建 invoice 时安排提醒:

class Invoice
  include Mongoid::Document

  after_create :schedule_due_reminder

  private

  def schedule_due_reminder
    run_at = due_at - 3.days
    InvoiceReminderWorker.perform_at(run_at, id.to_s)
  end
end

如果项目使用 Rails 4.2,并且配置的 Active Job adapter 支持计划执行,也可以采用类似的方式:

InvoiceReminderJob
  .set(wait_until: invoice.due_at - 3.days)
  .perform_later(invoice.id.to_s)

Active Job 是 Rails 4.2 引入的。Rails 4.0 或 4.1 项目需要直接调用所选队列库的 API,或者升级 Rails。

这种一张 invoice 对应一个延迟任务的方案不需要周期扫描,但会增加一些管理问题:

  • 用户修改 due_at 后,原任务需要取消或作废。
  • invoice 被删除或提前支付后,已经安排的任务仍可能执行。
  • 很久以后才执行的任务需要由队列系统可靠地持久化。
  • 部署、迁移或队列故障后,需要检查是否存在遗漏的任务。
  • 某些队列实现不适合保存数量庞大、执行时间很远的延迟任务。

因此,延迟任务执行时仍要重新查询 invoice,并检查支付状态。财务提醒通常不适合漏发。与完全依赖远期延迟任务相比,周期性索引扫描更方便恢复和核查。

避免重复发送

后台队列通常保证的是至少执行一次,并非严格的只执行一次。如果 worker 在邮件已经发出、状态还没保存时崩溃,重试时就可能再次发送邮件。

至少要在业务层实现幂等控制。一个简单办法是先通过原子更新占用待发送记录:

invoice = Invoice.where(
  id: invoice_id,
  :paid.ne => true,
  reminder_sent_at: nil
).find_one_and_update(
  { "$set" => { reminder_sent_at: Time.current } },
  return_document: :after
)

return unless invoice

InvoiceMailer.due_soon(invoice.id.to_s).deliver_now

原子更新的具体方法和参数会随 Mongoid、MongoDB driver 的版本变化,需要根据项目使用的版本调整。

先标记再发送可以防止重复,但也有一个问题:如果邮件发送失败,记录可能已经被标记。更完整的做法是建立独立的提醒记录,并维护状态:

pending -> processing -> sent
                   \-> failed

任务领取记录时,原子地把状态从 pending 改为 processing。发送成功后改为 sent,失败时记录错误并允许重试。对于长时间停留在 processing 状态的任务,还可以设置恢复机制。

时间范围要与执行频率匹配

如果任务每天运行一次,而查询条件始终是未来 3 天内到期且尚未提醒,reminder_sent_at 就能避免连续三天重复发送。

时间范围最好统一使用半开区间:

:due_at.gte => range_start,
:due_at.lt  => range_end

同时要明确以下问题:

  • due_at 保存的是具体时间,还是业务日期。
  • 数据库是否统一使用 UTC 存储时间。
  • 到期前 3 天是按 72 小时计算,还是按客户所在时区的自然日计算。
  • 夏令时切换是否会影响任务执行时间。
  • cron 任务失败一天后,下一次扫描能否补发遗漏的提醒。

如果业务不能接受漏发,就不要只查询某一天的精确窗口。更可靠的方式是查询已经进入提醒期、尚未提醒且尚未支付的记录。这样即使任务短暂停止,恢复后仍能补发:

Invoice.where(
  :paid.ne => true,
  reminder_sent_at: nil,
  :due_at.lte => 3.days.from_now
)

还可以设置合理的时间下限,避免处理多年前的历史数据。

实际选择

这类 invoice 提醒适合优先采用周期扫描、复合索引、后台队列和幂等状态的组合。实现相对简单,便于监控,也能在任务暂时失败后补偿执行。

只有当提醒时间必须精确到分钟,而且现有队列能够可靠管理远期任务时,才有必要为每张 invoice 单独安排延迟任务。即便采用这种方式,也可以保留一个低频的索引扫描,用来检查是否有遗漏。

备注:内容仅供参考。