在Ruby on Rails项目中,Resque是常用的后台任务队列,负责处理邮件发送、文件导出、第三方API同步等操作。一个经常被忽略的问题是:控制器入队时,通过RequestStore.store、Current.user等保存的请求上下文,在Resque worker进程里完全不可见。Rails应用与Resque worker运行在不同的进程,甚至不同的服务器上,它们不共享内存,所以请求级别的状态不会自动跟随任务进入队列。如果作业代码里直接使用Current.user.id或RequestStore.store[:tenant_id],轻则得到空值,重则因为全局变量残留读到上一个任务的数据。要解决这个问题,必须在入队阶段把上下文信息显式注入到作业参数中,并在作业执行前恢复。下面从原理和具体实现两个层面展开。

为什么请求上下文不会自动进入Resque作业
Resque是基于Redis的队列系统。Rails应用通过Resque.enqueue把作业的类名和参数序列化成JSON存入Redis,worker进程从Redis取出后反序列化并调用对应类的perform方法。整个过程只传递开发者显式给出的参数,不会包含Rails请求环境、会话数据或线程局部变量。
像ActiveSupport::CurrentAttributes和RequestStore这类工具,底层依赖线程局部存储或请求级生命周期。它们在一次HTTP请求结束时会被清理,而Resque worker处理作业的线程与Rails处理请求的线程完全不同,因此即使在同一个进程中,worker线程也读不到请求线程的数据。更危险的是,如果worker进程长期运行,某些全局变量如果没有被及时清空,还可能在多个作业之间串值。举例来说,一个作业读取Current.tenant_id,而前一个作业恰好设置过其他租户的值,就会导致数据泄漏到错误的租户空间。
判断是否需要注入上下文,可以看作业逻辑是否依赖当前用户、租户、请求头或追踪ID。如果作业只是纯计算或操作与请求无关的数据,不注入也可以。但只要涉及权限校验、多租户隔离、审计日志或调用内部API,就应当把必要的上下文传入作业。
手动传入上下文:最直观的参数序列化方案
最直接的办法是在入队时把上下文字段提取出来,作为作业参数的一部分。不要直接传整个请求对象或大对象,只传必要的标量字段,比如用户ID、租户ID、追踪ID、认证令牌等。这样序列化后体积小,也便于Redis存储和问题排查。
可以封装一个上下文值对象,统一负责提取和恢复。下面是一个示例:在Rails应用里定义ApiRequestContext类,从Current和RequestStore中收集字段;在Resque作业类中定义self.perform接收这些参数,并在方法开头恢复上下文。
# app/models/api_request_context.rb
class ApiRequestContext
attr_reader :user_id, :tenant_id, :trace_id, :auth_token
def self.from_current
new(
user_id: Current.user&.id,
tenant_id: Current.tenant_id,
trace_id: RequestStore.store[:trace_id],
auth_token: RequestStore.store[:auth_token]
)
end
def initialize(user_id:, tenant_id:, trace_id:, auth_token:)
@user_id = user_id
@tenant_id = tenant_id
@trace_id = trace_id
@auth_token = auth_token
end
def to_h
{
"user_id" => user_id,
"tenant_id" => tenant_id,
"trace_id" => trace_id,
"auth_token" => auth_token
}
end
def apply!
Current.user = User.find_by(id: user_id) if user_id
Current.tenant_id = tenant_id
RequestStore.store[:trace_id] = trace_id
RequestStore.store[:auth_token] = auth_token
end
end
入队时在控制器或服务对象里创建上下文并传入参数。Resque的Resque.enqueue支持可变参数,这些参数会被序列化。示例:
# app/controllers/api/orders_controller.rb
def create
order = Order.create!(order_params)
context = ApiRequestContext.from_current
Resque.enqueue(GenerateOrderReportJob, order.id, context.to_h)
render json: { order_id: order.id }, status: :accepted
end
作业类从参数中取出上下文并应用。注意参数经过JSON序列化后,符号键会变成字符串键,所以to_h里面统一使用字符串键,避免作业里取不到值。
# app/jobs/generate_order_report_job.rb
class GenerateOrderReportJob
@queue = :reports
def self.perform(order_id, context_hash)
context = ApiRequestContext.new(
user_id: context_hash["user_id"],
tenant_id: context_hash["tenant_id"],
trace_id: context_hash["trace_id"],
auth_token: context_hash["auth_token"]
)
context.apply!
order = Order.find(order_id)
ReportGenerator.new(order).generate
end
end
这种方案简单透明,适合作业数量较少、上下文字段稳定的场景。缺点是每个作业都要手动调用from_current和apply!,重复代码多;如果新增字段,需要同步修改入队和作业两处。对于中大型项目,可以进一步抽象到基类或使用中间件自动处理。
使用Resque中间件自动注入和恢复
Resque提供了插件和中间件机制,可以挂载在入队前、作业执行前、执行后等生命周期节点。通过自定义中间件,可以在before_enqueue阶段把当前请求上下文写入作业参数,在before_perform阶段从参数恢复上下文,在after_perform阶段清理线程局部状态。这样业务代码里就无需手动编写上下文处理逻辑。
下面实现一个RequestContextMiddleware。它假设ApiRequestContext已经具备from_current、to_h和apply!方法。中间件在入队时把上下文合并到参数末尾,在作业执行前从参数中提取并应用。
# lib/resque/request_context_middleware.rb
module Resque
class RequestContextMiddleware
CONTEXT_KEY = "request_context"
def before_enqueue(*args)
context = ApiRequestContext.from_current
args << { CONTEXT_KEY => context.to_h }
true
rescue StandardError => e
Rails.logger.warn("RequestContextMiddleware before_enqueue failed: #{e.message}")
true
end
def before_perform(*args)
context_hash = args.last.is_a?(Hash) ? args.last.delete(CONTEXT_KEY) : nil
return unless context_hash
context = ApiRequestContext.new(
user_id: context_hash["user_id"],
tenant_id: context_hash["tenant_id"],
trace_id: context_hash["trace_id"],
auth_token: context_hash["auth_token"]
)
context.apply!
rescue StandardError => e
Rails.logger.warn("RequestContextMiddleware before_perform failed: #{e.message}")
end
def after_perform(*args)
Current.reset
RequestStore.clear!
end
end
end
注册中间件需要在Resque初始化时配置。Rails项目通常在config/initializers/resque.rb中完成。关键是中间件顺序,如果还有其他修改参数的中间件,要确保上下文参数不会被覆盖。
# config/initializers/resque.rb require "resque/request_context_middleware" Resque.before_enqueue do |*args| Resque::RequestContextMiddleware.new.before_enqueue(*args) end Resque.before_perform do |*args| Resque::RequestContextMiddleware.new.before_perform(*args) end Resque.after_perform do |*args| Resque::RequestContextMiddleware.new.after_perform(*args) end
使用中间件后,控制器里的入队代码可以恢复成普通写法:
def create
order = Order.create!(order_params)
Resque.enqueue(GenerateOrderReportJob, order.id)
render json: { order_id: order.id }, status: :accepted
end
中间件方案的优点是把上下文逻辑集中管理,业务代码更干净。但需要注意,作业参数中混入上下文后,参数结构发生了改变。如果你的作业本身接收多个参数,或者有外部系统直接向Redis队列推消息,可能不包含这个额外参数,中间件需要做兼容判断。另外,上下文中的敏感信息如令牌会进入Redis队列,应当考虑脱敏或加密。
结合CurrentAttributes增强多租户隔离
ActiveSupport::CurrentAttributes是Rails官方提供的线程安全属性容器,适合保存当前用户、租户等请求级数据。默认情况下,它在每个请求结束时自动重置,但在Resque worker中需要手动管理生命周期。中间件的before_perform里设置属性,after_perform里执行Current.reset,可以避免属性残留。
示例定义一个Current类:
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :user, :tenant_id, :trace_id, :auth_token
def user=(user)
super
self.tenant_id = user.tenant_id if user
end
end
在中间件的apply!中,通过Current.user = User.find_by(id: user_id)设置当前用户。这样模型层和业务层就可以直接使用Current.user,而不必显式传递用户对象。不过要注意,worker进程中的数据库连接与Rails请求进程不同,查询User.find_by可能触发额外数据库访问。如果作业频繁执行,应当考虑只设置ID,在真正需要时再查询,或者使用缓存。
另一个容易踩坑的地方是RequestStore.clear!。它在after_perform中调用可以清空所有RequestStore键值,但如果你在同一个worker进程里同时使用了其他依赖RequestStore的库,清空动作可能影响它们。更精准的做法是只清理自己写入的键,比如RequestStore.store.delete(:trace_id)。具体可以根据项目情况调整。
生产环境注意事项与调试技巧
上下文注入虽然逻辑不复杂,但一旦出错往往难以排查,因为worker进程的日志与请求日志是分离的。建议在上下文中带上追踪ID,并在作业执行时统一输出日志。例如在before_perform中把trace_id写入Rails.logger的上下文字段,这样可以通过同一个追踪ID串联请求和后台任务日志。
# lib/resque/request_context_middleware.rb 中的 before_perform
def before_perform(*args)
context_hash = args.last.is_a?(Hash) ? args.last.delete(CONTEXT_KEY) : nil
return unless context_hash
context = ApiRequestContext.new(
user_id: context_hash["user_id"],
tenant_id: context_hash["tenant_id"],
trace_id: context_hash["trace_id"],
auth_token: context_hash["auth_token"]
)
context.apply!
Rails.logger.tagged(context_hash["trace_id"]) if context_hash["trace_id"]
end
此外,注意Redis队列中作业参数的大小。Resque默认使用JSON序列化,参数过大会增加Redis内存压力,也可能导致入队变慢。不要把所有请求头都塞进去,只选择作业真正需要的字段。对于认证令牌,优先考虑短期有效且可以刷新,不要传长期有效的用户密码或密钥。
测试时可以用Resque.inline = true让作业同步执行,这样能快速验证上下文是否恢复。在测试环境中,每个测试用例之后确保执行Current.reset和RequestStore.clear!,防止上下文泄漏到下一个用例。
# test_helper.rb 或 spec_helper.rb
RSpec.configure do |config|
config.before(:each) do
Current.reset
RequestStore.clear!
end
end
总结来说,网络API请求上下文注入Resque作业的核心是显式传递和生命周期管理。手动传参适合简单场景,中间件方案适合统一处理,CurrentAttributes提供线程安全的属性载体。无论采用哪种方式,都要在入队时只传必要字段,在作业执行后及时清理,避免数据串用和敏感信息泄漏。