一、后台任务是个啥玩意儿

在实际开发里,我们经常会遇到这样一种情况:用户点了某个按钮,结果页面一直转圈圈,好几分钟才反应过来。为啥呢?因为服务器在拼命计算,可能是发邮件、生成报表、处理图片、同步数据……这些活又慢又耗时间。如果把这件事直接塞给用户请求,那用户就被堵住了,体验非常糟糕。

后台任务要解决的就是这个事儿:把那些慢活、累活从请求里挪出来,放到后台慢慢做。用户那边呢,点完按钮马上得到回应“我收到了,马上去办”,然后就该干嘛干嘛,等后台悄悄干完。这种感觉就像你点了杯奶茶,店员告诉你“做好了叫你”,而不是让你一直站在柜台前干等。

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 进程,一个跑重任务,一个跑普通任务。这样架构已经能扛住大部分业务。

优化没有终点,你要学会观察、调参、测试,知道哪里是瓶颈。后台任务系统就像家里的水管,平时不觉得,一旦堵了才知道麻烦。希望这篇文章能帮你把水管理得顺顺畅畅。