Resque的入队与执行属于生产者-消费者模型,Rails控制器只负责把任务参数写入Redis,真正执行任务的Worker进程与Web请求进程完全隔离。在这个边界上,任何依赖线程局部变量保存的数据都不会自动跟随任务进入队列。换句话说,控制器里设置的 Current.user、Thread.current[:request_id] 或请求级的租户ID,在作业执行时都会变成nil。要解决这个问题,必须把上下文当作任务数据的一部分显式传递,并在Worker拉起作业后主动恢复。

一、线程局部状态为什么在Resque中不可用
Rails应用每次处理HTTP请求时,通常会通过 ApplicationController 的钩子设置当前用户、租户和请求ID。这些信息保存在 ActiveSupport::CurrentAttributes 或 Thread.current 中,读取时非常方便。但是Resque Worker拉起任务时,运行的是独立的Ruby进程,甚至可能部署在另一台服务器上。入队前设置的线程局部变量,在Worker进程中从未被赋值,因此作业里读取到的值必然是nil。
更隐蔽的问题是,即便在同一个Worker进程内,Resque也可能用多个线程或进程并发处理任务。如果开发者试图用Ruby全局变量来保存上下文,例如 $current_user,不同任务之间会互相覆盖。A用户的报表任务可能读到B用户的ID,最终造成越权或数据泄露。因此全局变量和类变量同样不适合传递请求上下文。
# 错误示例:依赖当前线程状态
class ReportsController < ApplicationController
def create
Current.user = current_user
Current.tenant_id = current_tenant.id
Resque.enqueue(GenerateReportJob, params[:report_id])
end
end
class GenerateReportJob
def self.perform(report_id)
puts Current.user # nil
puts Current.tenant_id # nil
end
end
上面这段代码在控制器里设置了 Current,但 GenerateReportJob.perform 执行时读取不到任何值。因为入队动作只保存了参数 report_id,没有保存上下文。要修复它,不能靠共享内存,只能依靠任务参数序列化和恢复机制。
二、显式传参是最可靠的起点
最直接的方法是在入队时就把上下文中的关键字段传进作业参数。Resque使用Redis保存任务,参数会被JSON序列化,因此建议只传递字符串、数字、布尔值等基本类型,不要直接传整个ActiveRecord对象。用户ID、租户ID、请求追踪ID都是理想的候选字段。
下面这个作业封装了入队方法,把上下文统一转换为字符串键,并过滤掉无关字段。
class GenerateReportJob
def self.enqueue(report_id, context = {})
safe_context = context.stringify_keys.slice('user_id', 'tenant_id', 'request_id').merge('_context' => true)
Resque.enqueue(self, report_id, safe_context)
end
def self.perform(report_id, context = {})
user = User.find_by(id: context['user_id'])
tenant = Tenant.find_by(id: context['tenant_id'])
request_id = context['request_id']
ReportGenerator.new(report_id, user: user, tenant: tenant, request_id: request_id).run
end
end
控制器调用时只需要构造一个简单的Hash。
GenerateReportJob.enqueue(params[:report_id], {
user_id: current_user.id,
tenant_id: current_tenant.id,
request_id: request.request_id
})
这种方式的优点是直观、可测试,上下文来源和消费位置都在代码里明确可见。缺点是每个作业类都要重复编写字段过滤和恢复逻辑,项目规模变大后容易出现遗漏或不一致。如果已经有几十个Resque作业,逐一手工处理上下文很容易出错,这时就需要中间件来统一收口。
三、用Resque插件统一恢复上下文
Resque支持作业类扩展插件模块,并利用 around_perform 钩子包裹任务执行。借助这个特性,可以把上下文恢复逻辑集中到一个模块里。作业类只需要 extend 该模块,执行时上下文就已经挂载到 Current 上。
# app/models/current.rb class Current < ActiveSupport::CurrentAttributes attribute :user, :tenant_id, :request_id end
module Resque::Plugins::CurrentContext
def around_perform(*args)
ctx = args.last
if ctx.is_a?(Hash)
if ctx['_context']
Current.user = User.find_by(id: ctx['user_id']) if ctx['user_id']
Current.tenant_id = ctx['tenant_id'] if ctx['tenant_id']
Current.request_id = ctx['request_id'] if ctx['request_id']
end
end
yield
ensure
Current.reset
end
end
作业类应用该插件后,就不需要再手动从参数里取上下文了。
class GenerateReportJob
extend Resque::Plugins::CurrentContext
def self.perform(report_id, context = {})
# Current.user、Current.tenant_id 已经可用
ReportGenerator.new(report_id).run
end
end
这里需要特别说明序列化键类型。Resque默认使用JSON序列化Hash,符号键在Worker端会变成字符串键,所以恢复模块统一用 ctx['user_id'] 读取,而不是 ctx[:user_id]。入队时如果传入了符号键,也要在入队辅助方法中做 stringify_keys,保证两端一致。额外的 _context 保留键用来标识这是上下文参数,避免业务Hash恰好包含同名字段时被误判。
插件方式大幅减少了重复代码,但仍有局限:只有显式 extend 了模块的作业类会生效。对于已有大量作业类的项目,需要逐个补充声明。若希望所有作业都自动获得上下文恢复能力,可以进一步使用Resque的服务端中间件,在全局队列处理链路上注入恢复逻辑,不过配置会复杂一些,并且要考虑失败重试等钩子的执行顺序。
四、用CurrentAttributes串联Web请求与后台日志
ActiveSupport::CurrentAttributes 是Rails提供的线程安全局部状态容器,专门用来保存当前请求相关的用户、租户、时区等信息。它默认基于线程局部存储,每个请求或Worker线程都有独立副本,配合 reset 方法可以避免状态泄漏。在Web请求中已经使用 Current 的项目,把它延续到Resque作业里非常自然。
在控制器中设置 Current 仍然采用钩子,保证每次请求结束后清理。
class ApplicationController < ActionController::Base
around_action :set_current_context
private
def set_current_context
Current.user = current_user
Current.tenant_id = current_tenant.id
Current.request_id = request.request_id
yield
ensure
Current.reset
end
end
如果应用启用了Rails的TaggedLogging,并且把 request_id 加入日志标签,后台作业执行期间同样可以把追踪ID写入日志。中间件恢复 Current.request_id 后,可以在作业内部使用 Rails.logger.tagged(Current.request_id) 输出日志,或者在日志格式中统一读取当前请求ID。这样用户在浏览器里发起的一次请求,与它触发的后台报表、邮件发送等异步任务,可以通过同一个请求ID关联起来,排障时不再依赖猜测。
不过要注意,CurrentAttributes 并不是跨进程共享的,它只是提供了一致的读写接口。真正把值从Web进程带到Worker进程,仍然需要第一步的显式传参。中间件的职责是在任务开始前把参数恢复成 Current,在任务结束后调用 reset 清理现场,防止状态串到下一个任务。
五、过滤敏感字段并控制参数体积
请求上下文里经常混着密码、token、Cookie等敏感数据。入队时如果直接把整个请求上下文序列化进Redis,会扩大泄露面。Redis可能有多部门共享访问,任务参数也可能被监控工具采集。因此需要设置允许列表,只提取业务必需的字段,将敏感信息挡在队列之外。
CONTEXT_ALLOWLIST = %w[user_id tenant_id request_id].freeze
def build_job_context(controller)
{
'user_id' => controller.current_user&.id,
'tenant_id' => controller.current_tenant&.id,
'request_id' => controller.request.request_id
}.select { |key, _| CONTEXT_ALLOWLIST.include?(key) }
end
这段代码使用安全导航操作符,只提取允许列表中的字段,避免把无关或敏感信息写入队列。参数体积也需要控制。Resque通常把任务参数存储在Redis中,过大的参数会拖慢入队速度、占用内存。上下文只传ID,业务实体在Worker端通过ID重新查询,这样可以保证数据不是入队时的过期快照。比如用户修改了头像或昵称,后台任务执行时查到的仍然是最新记录,而不是队列里保存的旧对象。
综合来看,可靠的Resque请求上下文注入方案应包含三层:入队时用允许列表过滤并 stringify_keys;作业执行前用插件或中间件恢复 Current;任务结束后 reset 清理线程局部状态。掌握这三层之后,多租户后台任务、审计追溯和分布式追踪才能稳定落地,不会因为进程隔离而丢失上下文。
Ruby Resque请求上下文注入后台作业修改时间:2026-09-19 15:24:23