bigywater.tech
爬虫工程 · 67

Redis 任务队列:worker 崩溃后任务怎么重新入队

爬虫的任务队列放在 Redis 里,worker 崩溃了正在处理的任务去哪了?这篇在本机一个独立的 redis-server 上,用 kill -9 逐个试 BRPOP、BLMOVE 加 ack、租约三种取任务方式,以及租约、去重、毒任务这些配套的坑。

1 个
用 BRPOP 取任务时,worker 被 kill -9,丢失的任务数(200 个任务里)
201 对 200
用 BLMOVE + ack 时,全部任务开始处理的总次数与任务数(多出的 1 次是重复)
5 次
租约 0.5 秒、任务 0.8 秒不续约时,同一个任务在 4 秒内被开始处理的次数

取出就删

实测 · 本机执行输出
  200 个任务,3 个 worker(其中 ['w2'] 被信号 [9] 杀掉):完成 199 个,队列里剩 0 个,丢失(既不在队列里也没完成)1 个 ['t50'];t50 被执行了 1 次

用 BRPOP 取任务,任务一出队就只存在于 worker 的内存里。worker 被 kill -9,这个任务就丢了(实验里 200 个任务丢 1 个)。

先移再确认

实测 · 本机执行输出
  处理 t50 中途被杀:worker 结束后(死掉的是 ['w2']) 完成 199 个,队列剩 0 个,死掉的 worker 的"正在处理"列表里留着 ['t50']
     放回队列、w3 接手后:完成 200 个;t50 被执行 2 次;全部任务共执行 201 次(去重后 200 个任务)
  t50 处理完、ack 之前被杀:worker 结束后(死掉的是 ['w2']) 完成 200 个,队列剩 0 个,死掉的 worker 的"正在处理"列表里留着 ['t50']
     放回队列、w3 接手后:完成 200 个;t50 被执行 2 次;全部任务共执行 201 次(去重后 200 个任务)

把任务移进“正在处理”列表,做完再删除,崩溃时任务还在那个列表里,可以放回队列。代价是可能重复:无法区分“没做完”和“做完了没来得及 ack”,两种情况下实验里都是 201 次开始处理。

租约

实测 · 本机执行输出
  (a) 某个 worker 处理 t5 时被杀:最终完成 10/10,丢失 0;t5 两次开始执行相隔 0.66 秒(租约 0.5 秒,监控每 0.1 秒扫一次)
  (b) 租约 0.5 秒,t0 要处理 0.8 秒,不续约,只让 worker 跑 4 秒:t0 被执行 5 次;监控把到期的任务放回队列 5 次,进了 dead 列表 [];t0 的事件顺序 ['w1:start', 'w1:finish', 'w1:start', 'w1:finish', 'w1:start', 'w1:finish', 'w1:start', 'w1:finish', 'w1:start']
  (c) 同上,但处理期间每 0.2 秒续约一次:t0 被执行 1 次;监控把到期的任务放回队列 0 次,进了 dead 列表 [];t0 的事件顺序 ['w1:start', 'w1:finish']
  (d) 同 (b),但监控最多把同一个任务放回 3 次,再多就放进 dead 列表:t0 被执行 3 次;监控把到期的任务放回队列 2 次,进了 dead 列表 ['t0'];t0 的事件顺序 ['w0:start', 'w0:finish', 'w0:start', 'w0:finish', 'w0:start', 'w0:finish']

取任务时记一个到期时间,监控把到期的任务放回队列,就不用知道哪个 worker 死了。恢复至少要等一个租约(实验里 0.66 秒,租约 0.5 秒)。

租约要比任务长,或者续约

实测 · 本机执行输出
  两步取任务,在 RPOP 之后、ZADD 之前崩溃:最终完成 9/10,丢失 1 个 ['t3'](任务不在队列里、也没有租约,监控找不到它)
  Lua 脚本一次完成"取出 + 记租约",在脚本返回之后(开始处理之前)崩溃:最终完成 10/10,丢失 0 个;t3 靠租约到期被放回(监控放回 1 次)

租约比任务耗时短又不续约,任务做完之前就被放回,每次都被重新取走,没有尽头(实验里 4 秒内被处理 5 次)。worker 处理期间定期续约,或者限制最多放回次数,都能收住。

取任务要原子

实测 · 本机执行输出
  任务执行次数 9(6 个任务,t0 被执行 4 次);不去重的副作用计数 8;用"已处理集合"做 SADD 去重后的副作用计数 6(= 不同任务数 6)

取任务和记租约分两个命令,中间崩溃任务就丢了(实验里注入崩溃,丢 t3)。用 Lua 脚本在 Redis 里一次做完。

重复处理与毒任务

实测 · 本机执行输出
  没有重试上限:4.4 秒内 worker 因 t7 崩溃 7 次;t7 被执行 7 次;其他任务完成 9 个;dead 列表 [];队列剩 0 个
  最多 3 次(超过就放进 dead 列表):2.6 秒内 worker 因 t7 崩溃 3 次;t7 被执行 3 次;其他任务完成 9 个;dead 列表 ['t7'];队列剩 0 个

至少一次投递会重复处理,副作用要按任务 id 幂等(实验里去重后副作用数等于不同任务数)。处理时必然崩溃的毒任务没有重试上限就会不断杀死 worker(4 秒内 7 次),要有上限并放进 dead 列表。

分步演示

Redis 任务队列租约
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / requests 2.34.2 / aiohttp 3.14.3 / lxml 6.1.1(系统 Python);所有请求只发给本机自己起的测试服务器(127.0.0.1,端口 9960 以上,不访问任何真实网站);服务器可以按实验需要制造延迟、断连、压缩、重定向等行为,并记录收到的原始请求;计时用 time.perf_counter,数字每次运行会有小幅抖动。redis-server 8.10.2 独立实例(只监听 127.0.0.1 的 9700 以上端口,关闭持久化,实验后关闭;没有碰本机已有的 Redis),redis-py 8.0.1;worker 是独立的 Python 进程,崩溃是用 SIGKILL 杀掉自己(在指定任务处,每个崩溃点只触发一次,毒任务除外);监控和续约在父进程或线程里;带时间上限的情形(第三节 (b)、第五节、第六节没有上限那组)是我到点终止 worker,所以次数是“上限内”的次数;各情形跑一次
    • 没有验证:Redis Streams(XREADGROUP、XAUTOCLAIM)等内置的消费者组机制;Redis 主从或集群切换时的丢任务;持久化(AOF/RDB)打开时的行为;租约用客户端时钟还是 Redis 时钟(这里的到期时间用 Redis 的 TIME);worker 与 Redis 之间网络分区时的行为;大量任务时 ZRANGEBYSCORE 扫描的开销;worker 被杀的时刻在“取出”与“处理”之间的所有可能崩溃点(只测了几个)
    京ICP备2026067502号-1京公网安备11010502063487号