一、后台任务是个啥玩意儿
在实际开发里,我们经常会遇到这样一种情况:用户点了某个按钮,结果页面一直转圈圈,好几分钟才反应过来。为啥呢?因为服务器在拼命计算,可能是发邮件、生成报表、处理图片、同步数据……这些活又慢又耗时间。如果把这件事直接塞给用户请求,那用户就被堵住了,体验非常糟糕。
后台任务要解决的就是这个事儿:把那些慢活、累活从请求里挪出来,放到后台慢慢做。用户那边呢,点完按钮马上得到回应“我收到了,马上去办”,然后就该干嘛干嘛,等后台悄悄干完。这种感觉就像你点了杯奶茶,店员告诉你“做好了叫你”,而不是让你一直站在柜台前干等。
1.1 举个生活例子
我举个例子。你去餐厅吃饭,点完菜,厨房开始炒菜。你不需要跑到后厨盯着菜下锅,你就在座位上等着,菜好了服务员端上来。这里的“你”就是用户请求,“厨房”就是后台任务,“服务员”就是任务队列。假如没有这套流程,你点完菜得自己去后厨炒,那餐厅早乱套了。
1.2 处理后台任务常见的方式
Rails里做后台任务,最原始的做法就是写个脚本,然后用 cron 定时跑。但这样有个问题:任务跑多跑少、失败重试、监控状态,全靠自己搞。后来大家喜欢用 Active Job 统一接口,再配上 Sidekiq 这种强大的队列系统。这一套组合拳,基本能应付绝大多数场景。
二、Rails自带的Active Job
Active Job 是 Rails 官方出的一个“任务统一接口”。它本身不是队列,它只是帮你把任务定义好,至于任务到底存哪、怎么执行,它不管。它后面可以接好多种队列系统,比如 Sidekiq、Resque、Delayed Job 等等。好处是:你写的任务代码可以不改,换个队列系统照样跑。
2.1 Active Job的作用
你可以把 Active Job 想象成一张“任务单”,只要把要做的事情写在上面,然后把任务单交给某个执行机构(比如Sidekiq),就完事了。Active Job 还自带一些便利功能,比如重试、延迟执行、优先级等,不过具体还是得看底层队列支不支持。
2.2 怎么用Active Job
下面所有示例统一使用 Ruby on Rails + Sidekiq 这套技术栈。咱们先写一个最简单的任务,用来给用户发欢迎邮件。
# 定义一个后台任务,用来给用户发欢迎邮件
class SendWelcomeEmailJob < ApplicationJob
queue_as :default # 任务放到默认队列里
def perform(user_id)
# 从数据库把用户查出来
user = User.find(user_id)
# 调用邮件发送方法,这里假设有 UserMailer 这个类
UserMailer.welcome_email(user).deliver_now
end
end
这段代码很简单,queue_as :default 表示这个任务在默认队列里排队。perform 就是真正干活的方法。注意,这里我们传的是 user_id,而不是整个 user 对象。为啥呢?因为任务将来要在后台进程里跑,如果传对象,对象可能已经过期或者序列化有问题,传 id 最稳妥,用的时候再查一遍。
然后我们在注册控制器里,把发邮件这件事交给后台任务。
class RegistrationsController < ApplicationController
def create
@user = User.new(user_params)
if @user.save
# 不直接在这里发邮件,交给后台任务去做,页面马上返回成功
SendWelcomeEmailJob.perform_later(@user.id)
redirect_to root_path, notice: "注册成功!"
else
render :new
end
end
end
perform_later 就是“稍后执行”的意思。这里用户一注册,系统立刻把任务丢到队列里,页面秒回“注册成功”。那封邮件呢,后台慢慢发,发个几秒一点也不影响用户。
三、把任务队列交给Redis和Sidekiq
Active Job 只是接口,真正干活的是队列。这里我们选 Sidekiq。Sidekiq 是一个特别流行的后台任务处理器,它用 Redis 来存任务。Redis 是个内存数据库,读写快得惊人,所以任务排队和取出几乎是瞬间的事。
3.1 为什么要选Sidekiq
Sidekiq 有俩特别招人喜欢的地方。第一,它是多线程的,一个进程里能同时跑多个任务,特别省内存。第二,它自带一个控制面板,可以看任务跑到哪了、有没有失败、有没有卡住。而且它搞了十来年了,特别稳定,坑少。
3.2 安装和配置
首先在 Gemfile 里加上 Sidekiq 和 Redis。
# Gemfile 文件中添加两个 gem
gem 'sidekiq', '~> 7.0' # 后台任务处理器
gem 'redis', '~> 5.0' # Redis 客户端
然后跑一下 bundle install 安装。接着要告诉 Rails,用 Sidekiq 作为 Active Job 的队列。
# config/application.rb 文件里,把 adapter 改成 Sidekiq
config.active_job.queue_adapter = :sidekiq
然后新建一个 Sidekiq 的配置文件,放在 config/sidekiq.yml。这个文件主要用来指定队列和并发数。
# config/sidekiq.yml
:concurrency: 5 # 同一时刻最多跑5个任务,别让CPU太累
:queues:
- [critical, 3] # 高优先级队列,权重是3
- [default, 2] # 默认队列,权重是2
- [low, 1] # 低优先级队列,权重是1
这里要说一下,队列权重是什么意思。Sidekiq 会按权重比例去队列里取任务。比如权重3的队列,取任务频率就是权重1的三倍。这样重要任务就能先被处理。
3.3 写一个后台任务
现在咱们把之前那个欢迎邮件任务指定到高优先级队列里。
class SendWelcomeEmailJob < ApplicationJob
queue_as :critical # 关键任务放这个队列
def perform(user_id)
user = User.find(user_id)
UserMailer.welcome_email(user).deliver_now
end
end
启动 Sidekiq 也非常简单。只要在项目目录下运行下面的命令。
# 启动 sidekiq,指定使用配置文件
bundle exec sidekiq -C config/sidekiq.yml
启动成功后,你会看到控制台刷出日志,表示 Sidekiq 已经开始监听队列了。
四、架构上的优化手段
有了基础功能,咱们得聊聊优化。后台任务跑得好不好,不仅影响用户,还影响着服务器资源。接下来我分享几个特别实用的优化方向。
4.1 队列拆分和优先级
第一步,就是把不同任务分到不同队列。比如发短信、发邮件、计算报表、清理旧数据,这些任务重要性不一样。你可以把要求响应快的任务放到 critical 队列,把不着急的放到 low 队列。
比如给用户发优惠券、处理订单支付,这些影响钱和用户体验的任务,必须走 critical。
# 处理订单支付的任务,放到最高优先级
class ProcessPaymentJob < ApplicationJob
queue_as :critical
def perform(order_id)
order = Order.find(order_id)
# 调用支付网关,这里假装有,实际要接真实接口
PaymentGateway.charge(order.amount)
# 更新订单状态
order.update!(paid: true)
end
end
而清理临时文件这种事,放到 low 队列就非常合适。
# 清理临时文件的任务,放到低优先级
class CleanupTempFileJob < ApplicationJob
queue_as :low
def perform
# 删除三天前的临时文件,这里瞎写个路径意思一下
Dir.glob(Rails.root.join('tmp', 'mytmp', '*')).each do |file|
File.delete(file) if File.mtime(file) < 3.days.ago
end
end
end
4.2 批量处理,别一个一个来
任务最怕的是“一个任务里反复查数据库”。比如你要给一万个用户发通知,一次查一个用户,查一万次,数据库得累死。正确做法是把数据先查出来,一次性处理。
# 每月生成报表的任务,用批量查询代替循环查库
class MonthlyReportJob < ApplicationJob
queue_as :default
def perform
# 一次性查出上个月所有订单
orders = Order.where(created_at: DateTime.current.prev_month.beginning_of_month..DateTime.current.prev_month.end_of_month)
# 按日期分组,减少统计次数
grouped = orders.group_by { |o| o.created_at.to_date }
grouped.each do |date, day_orders|
# 单个日期就一条统计结果,避免反复查询
Report.find_or_create_by(date: date) do |report|
report.total_amount = day_orders.sum(&:amount)
end
end
end
end
这样代码执行速度会成倍上升。记住一个原则:后台任务里能批量数据库操作,绝不一个一个来。
4.3 控制并发,让出资源
有时候任务太多了,Sidekiq 默认并发数可能很大,比如25个线程同时跑。如果任务里要调外部 API,并发太高容易把对方接口打挂;如果任务重计算,并发太高会让 CPU 爆炸。咱们得根据任务类型调整。
调整并发数很简单,在 config/sidekiq.yml 里改 :concurrency 就行。
# config/sidekiq.yml
:concurrency: 10
另外,有些任务本身特别重,你想让它在独立的进程里跑,不跟其他任务挤。比如有个“生成大数据报表”的任务,它跑起来容易占满内存。你可以单独起一个 Sidekiq 进程,专门处理这类任务。怎么区分呢?还是靠队列。
先定义一个专门的高开销队列:
# config/sidekiq.yml
:queues:
- [critical, 3]
- [default, 2]
- [low, 1]
- [heavy, 1] # 专门放重任务的队列
然后启动两个 Sidekiq 进程,一个进程只处理 heavy 队列,另一个处理其他队列。
# 第一个进程:只处理 heavy 队列
bundle exec sidekiq -q heavy
# 第二个进程:处理其余队列
bundle exec sidekiq -q critical -q default -q low
这样即使重任务把内存吃满了,也只影响它自己所在的进程,其他任务不受牵连。
4.4 定时任务和延迟任务
很多业务需要“等一会儿再执行”或者“定时执行”。Sidekiq 本身就支持延迟执行,比如用户下单后,如果30分钟没付款就取消订单。你可以直接在任务里用 set(wait:)。
# 订单超时取消的任务
class CancelOrderJob < ApplicationJob
queue_as :critical
def perform(order_id)
order = Order.find(order_id)
# 如果订单已经支付就什么都不做
return if order.paid?
# 超时了,取消订单
order.update!(status: :cancelled)
end
end
# 用户下单后,安排这个任务,30分钟后执行
CancelOrderJob.set(wait: 30.minutes).perform_later(order.id)
注意,wait: 是相对时间,如果你想在固定时刻执行,用 perform_at。比如每天早上6点跑一次报表。
# 每天的凌晨2点执行一次清理任务
CleanupTempFileJob.perform_at(Date.tomorrow.midnight + 2.hours)
但如果你有更复杂的定时需求,比如“每个星期一的早上9点生成周报”,那建议用 whenever 或者 sidekiq-cron 这类插件。这里简单说一下 sidekiq-cron 的配置思路。它让你在配置里写 cron 表达式,然后到点自动往队列里塞任务。
# config/sidekiq.yml
:cron:
generate_weekly_report:
cron: "0 9 * * 1" # 每周一早上9点
class: GenerateWeeklyReportJob
queue: default
记得在 Gemfile 加上 gem 'sidekiq-cron',然后在 Sidekiq 初始化文件里启动它。这样定时任务也能统一管理了。
五、性能提升的实践细节
上面说的是架构层面的优化,接下来再讲几个实操中容易踩坑的细节。
5.1 Redis连接池
Sidekiq 要用 Redis,但如果任务里也频繁使用 Redis 的话,连接数可能不够用。咱们可以把 Redis 连接池调大一点。配置在 config/initializers/redis.rb 里。
# config/initializers/redis.rb
require 'redis'
# 创建全局 Redis 连接池,让多个线程共用
$redis = ConnectionPool.new(size: 10, timeout: 5) do
Redis.new(url: ENV['REDIS_URL'], ssl: false)
end
然后在任务里使用 $redis.with 来操作。
class CacheRefresherJob < ApplicationJob
queue_as :default
def perform(user_id)
# 从连接池里取出一个连接,用完自动还回去
$redis.with do |conn|
# 设置一个键值,过期时间10秒
conn.set("user_#{user_id}_logged_in", true, ex: 10)
end
end
end
这样能避免连接被耗尽,尤其在高并发时会明显。
5.2 任务幂等性
什么是幂等性?就是同一个任务执行一次和执行十次,结果是一样的。这是后台任务最最最重要的事情。因为网络抖动、进程挂掉,任务很可能会被重复执行。比如支付回调用,如果不做幂等,重复扣款就惨了。
咱们写个简单的幂等例子。
class ProcessPaymentJob < ApplicationJob
queue_as :critical
def perform(order_id)
order = Order.find(order_id)
# 如果已经支付过,直接返回,不重复扣钱
return if order.paid?
# 这里调用真实的支付接口
result = PaymentGateway.charge(order.amount)
# 更新订单状态,并保存支付记录
order.update!(paid: true, payment_id: result.id)
end
end
关键就是这个 return if order.paid?。咱们在处理任何业务之前,先检查一下状态,已经处理过就跳过。这样哪怕任务被多跑几次,也不会出乱子。
5.3 监控告警
任务跑得好不好,你总得心里有数。Sidekiq 自带的控制面板可以看队列长度和失败任务。你也可以用 redis-cli 直接看队列里积压了多少任务。
# 查看名为 default 的队列长度
redis-cli llen queue:default
# 查看 critical 队列
redis-cli llen queue:critical
如果队列长度一直在涨,说明任务积压了,可能某个环节堵住了。另外,Sidekiq 还有一个 sidekiq-web 接口,在路由文件里挂一下就能看网页版面板。
# config/routes.rb
require 'sidekiq/web'
Rails.application.routes.draw do
mount Sidekiq::Web => '/sidekiq' # 访问 /sidekiq 查看面板
end
不过记得给这个路由加上登录权限,不然别人随随便便就能看到你的任务数据,不安全。
5.4 海量数据注意内存
后台任务处理大量记录时,如果用 User.all.each 会一次性把所有记录加载进内存,很可能导致内存爆炸。正确做法是用 find_each 分批处理。
# 每天给所有用户发推送的任务
class DailyPushJob < ApplicationJob
queue_as :low
def perform
# find_each 会一次取1000条记录,处理完再取下一批
User.find_each(batch_size: 1000) do |user|
PushService.send_to(user)
end
end
end
这个很重要,别小看它。很多任务挂掉就是因为内存不够被系统杀了。
5.5 避免长事务
后台任务里经常会更新数据库,如果任务特意包了一个大事务,执行很久,就会锁住数据表,让其他请求干等。咱们要尽量把任务拆小,或者在事务里只做必要的操作。比如处理一批订单,不要把所有订单放在一个事务里,而是每处理一个就单独提交。
class BatchUpdateJob < ApplicationJob
queue_as :default
def perform(order_ids)
order_ids.each do |id|
# 每个订单单独开事务,不会锁住其他订单
Order.transaction do
order = Order.lock.find(id)
order.update!(status: :shipped)
end
end
end
end
这样更安全,至少一个订单出问题,不会让整批都回滚。
六、应用场景和优缺点
6.1 适合用后台任务的场景
首先,所有“必须做但不急着做”的事情都适合后台任务。比如发邮件、发短信、推送通知、生成缩略图、导入导出 Excel、生成报表、数据分析、定时备份,等等。只要是用户不盯着看的操作,你都可以扔到后台。
再一个,像支付回调、订单超时取消这种跟时间有关的任务,用延迟队列特别方便。还有第三方接口调用,比如对接微信、支付宝,接口响应慢,放后台做比在请求里干等强太多。
6.2 优缺点分析
用后台任务处理肯定有好处,也有坏处。我先说说优点。
好处是用户响应快,页面秒开,体验好。同时任务可以重试,失败了也不至于直接影响用户。而且通过队列拆分、并发控制,资源利用更高效。缺点是架构变复杂了,你得维护 Redis、Sidekiq 进程,还要考虑任务失败的重试策略、幂等性、监控告警。一旦队列崩了,任务积压,麻烦也比较大。
再一个,后台任务不是银弹。如果某个任务特别耗时且频繁,你得考虑是不是该用消息队列(比如 Kafka)或者专门的异步计算框架,而不是只依赖 Sidekiq。不过对于大多数中小型 Rails 项目,Sidekiq 已经足够优秀。
七、注意事项
在实际使用中,有几个坑我特意列出来,提醒一下大家。
第一,任务里千万不要传 Ruby 对象,参数要简单。通常传 id 或者字符串,不然序列化会失败,或者取到的数据可能是脏数据。
第二,写任务的时候尽量保证它“可重入”。也就是说,同一任务执行很多次也没问题。前面说的幂等性就是这个意思。
第三,记得给任务设置超时时间。如果某个任务卡死了,长时间占着线程,会拖垮整个进程。可以给 Sidekiq 配置任务超时。
# config/sidekiq.yml
:timeout: 25
第四,启动 Sidekiq 的进程建议用 systemd 或者 foreman 之类的工具管理,别只用命令行挂在那里,万一崩了没人拉起来。
第五,数据库连接数要规划好。Sidekiq 是多线程的,每个线程都可能用数据库,如果线程数设置得比数据库连接池还大,很快就会“数据库连接不够”报错。所以 :concurrency 别乱调太高。
八、总结
Rails 后台任务这件事,看起来简单,真正做好还是需要一点心思的。从定义任务、选择队列,到拆分优先级、控制并发,再到幂等性和监控,每一步都在跟“复杂”和“性能”做博弈。
我这里给一个比较稳的默认配置,大家可以根据项目实际情况调整。队列至少分 critical/default/low 三个,并发数先设成5,观察一段时间再慢慢调。任务里坚持用 find_by 并检查状态,保证幂等。启动两个 Sidekiq 进程,一个跑重任务,一个跑普通任务。这样架构已经能扛住大部分业务。
优化没有终点,你要学会观察、调参、测试,知道哪里是瓶颈。后台任务系统就像家里的水管,平时不觉得,一旦堵了才知道麻烦。希望这篇文章能帮你把水管理得顺顺畅畅。
评论
围绕“Ruby on Rails后台任务处理的架构优化与性能提升”参与讨论