如何在 Rails 4 中高效实现 cron job
结论
不需要逐条扫描所有 invoice。更稳妥且方便扩展的方案是:
- 使用 cron 或
whenever定期启动一个轻量的扫描任务。 - 借助带索引的数据库查询,筛选出未支付、尚未提醒,并且即将在指定时间内到期的 invoice。
- 把每封提醒邮件交给后台任务队列发送。
- 通过提醒状态和原子更新支持安全重试,避免重复发送。
即使有数千条甚至更多记录,只要查询字段上建有合适的复合索引,数据库就能直接找到符合条件的记录,无需让 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 单独安排延迟任务。即便采用这种方式,也可以保留一个低频的索引扫描,用来检查是否有遗漏。
备注:内容仅供参考。
