后台任务队列几乎是所有Web应用的标配:发邮件、生成报表、清理临时文件、调用第三方接口,这些耗时操作如果放在请求周期里同步执行,用户的等待时间会被无限拉长。Resque正是为了解决这个问题而生,它把Redis当作任务存储中枢,通过worker进程池来消费队列中的任务,让Web进程只负责快速入队,真正耗时的部分交给后台异步完成。这篇文章会从Redis的底层结构讲起,一步步拆解Resque的工作机制。

Redis在Resque中扮演什么角色
Resque的全部状态都存储在Redis里,理解这一点非常关键。Resque并没有自己的数据库,也不依赖任何文件系统,队列本身、任务负载、worker注册信息、失败记录、统计计数,通通以键值对的形式存放在Redis实例中。这意味着只要Redis还活着,你的任务队列就是持久可查的。
具体到数据结构,Resque的任务队列使用的是Redis的List类型。每个队列对应一个key,比如名为default的队列对应的key就是resque:queue:default。入队操作调用LPUSH把任务JSON字符串塞进列表头部,worker取任务时调用BRPOP从列表尾部阻塞式弹出。一进一出方向相反,天然形成了先进先出的顺序,而且BRPOP的阻塞特性让worker在没有任务时不需要轮询空转,CPU开销几乎为零。
除了队列本身,Resque还会用Hash存储worker的心跳和任务执行状态,用Set维护所有已注册的队列名单,用另一个特殊的队列failed存放执行失败的任务。你可以直接用redis-cli查看这些key,排查问题时非常直观。
编写一个可运行的Resque任务
Resque的任务定义方式非常朴素:一个Ruby类,提供一个类方法perform,再通过@queue声明自己属于哪个队列。入队时传入类名和参数,参数会被序列化成JSON存储,所以只能是字符串、数字、数组和哈希这类能被JSON表达的简单类型,千万不要把Ruby对象直接丢进去。
require 'resque'
# 定义一个发送欢迎邮件的任务
class WelcomeEmailJob
@queue = :mail # 声明队列名
def self.perform(user_id)
user = User.find(user_id)
Mailer.welcome(user).deliver_now
puts "已向 #{user.email} 发送欢迎邮件"
rescue StandardError => e
# 抛出异常后 Resque 会自动记录到 failed 队列
raise e
end
end
# 在业务代码中入队,立即返回不阻塞
Resque.enqueue(WelcomeEmailJob, 123)
入队这一行代码执行的速度非常快,本质上只是一次Redis的LPUSH调用,耗时通常在1毫秒以内。Web请求把任务ID交给队列后就可以立刻响应用户,剩余的脏活累活全部由worker处理。这种解耦带来的体验提升非常明显,尤其是邮件发送这种动辄几秒的外部调用。
启动worker也很简单,命令行直接运行rake resque:work QUEUE='*'即可。这里的QUEUE参数指定要消费的队列,星号表示消费所有队列。生产环境一般用rake resque:workers COUNT=3 QUEUE='mail,default'一次拉起多个worker进程,形成进程池并行消费。
失败处理与重试机制
任务在执行过程中如果抛出异常,worker不会让进程崩溃,而是把失败的任务连同异常堆栈、worker信息、队列名一起打包成一个JSON,LPUSH进resque:failed这个特殊的列表里。配合resque-web这个监控面板,你可以直观地看到失败列表,还能手动把某条失败任务重新入队。
但自动重试需要额外的插件支持,常用的有resque-retry。它的原理是给任务类混入一个模块,声明重试策略,任务失败时插件自动拦截,按指数退避重新入队,达到上限后才真正写入失败列表。
require 'resque-retry'
class FlakyApiJob
extend Resque::Plugins::Retry # 引入重试能力
@queue = :api
retry_limit = 5 # 最多重试5次
retry_delay = 30 # 首次重试延迟30秒
def self.perform(order_id)
Order.find(order_id).sync_to_remote
end
end
需要注意的是,重试机制要求任务本身是幂等的。如果一个任务执行到一半失败,重试时会从头再跑一遍,凡是涉及到写库、扣款、发消息的操作,都必须考虑重复执行的副作用,否则重试不仅救不了你,反而会造成数据错乱。常见的做法是给任务带上游唯一标记,执行前先检查是否已经处理过。
Resque还是Sidekiq,怎么选
Resque采用多进程模型,每个worker是一个独立的Ruby进程,好处是任务之间完全隔离,一个任务的内存泄漏或C扩展崩溃不会影响其他worker;坏处是每个进程的内存占用不低,百来个worker对服务器的内存压力相当可观,而且进程fork本身的调度开销也不小。
Sidekiq则是多线程模型,单个进程内跑几十个线程并发消费任务,内存效率高出一个量级,吞吐量通常也更大。代价是线程安全要求更严格,任务代码里所有共享状态都必须是线程安全的,数据库连接池也要相应调大。下表做个简单对比:
| 维度 | Resque | Sidekiq |
|---|---|---|
| 并发模型 | 多进程 | 多线程 |
| 内存占用 | 较高 | 较低 |
| 隔离性 | 强,进程级隔离 | 弱,需线程安全 |
| 适合场景 | 任务重、依赖C扩展 | 海量轻量任务 |
选择的原则其实很简单:如果你的任务数量中等、单个任务较重、依赖的库不是线程安全的,Resque的进程隔离反而更省心;如果你的队列量级很大、追求吞吐和资源利用率,Sidekiq明显更合适。两者都基于Redis,任务JSON格式高度相似,从Resque迁移到Sidekiq的成本很低,可以先从Resque起步,等规模上来再平滑切换。
部署时的几个实用经验
生产环境部署Resque有几个细节值得注意。第一,Redis要开启持久化,至少配置AOF的everysec策略,否则Redis重启后所有未执行的任务会全部丢失,这对任务队列来说是灾难性的。第二,worker进程建议用god或systemd托管,worker意外退出时自动拉起,同时借助Resque的心跳机制监控僵死进程,worker执行超时没有更新心跳,就认为它已经卡死,需要强制清理。
第三点关于队列划分。把所有任务塞进一个default队列是大忌,一个慢任务会阻塞后面的快任务。合理的做法是按业务类型和耗时划分队列,比如mail、report、cleanup各自独立,并为不同队列分配数量不等的worker,让慢队列不至于拖垮整个系统。配合Redis的BLPOP多队列监听,一个worker甚至可以同时消费多个队列,灵活性很高。