13 项检查清单 + 一个上游没修完的功能 + 6 处事实更正,方法可复用到任何任务库
给一个只有 5 次请求/分钟、20 并发的 AI 响应队列选库,候选三个:BullMQ Python、RQ、Celery。
量级这么小,本来以为照文档挑一个就行。结果一轮实测下来,文档里写的、二手文章里传的、lib 自己声称的,各错了一层。
这篇记的是方法,不是结论 —— 换成任何队列库都用得上。
一开始拿到的一份调研里写着「RQ 是 BSD-3-Clause」。听着合理,因为 Redis 生态里 BSD-3 很常见。查 PyPI 的 license_expression 字段:
RQ 2.12.0 → BSD-2-Clause
是 BSD-2,不是 BSD-3。 差别不是学术问题:BSD-3 多一条「不得用作者名义背书」,法务审的时候写法不同。
同一个文档里还有一条更离谱的:
「RQ 的
RateLimit表示的是并发数,不是 RPM」
这句话听起来像内行在纠错。实际去 wheel 里 grep:
grep -r "rate_limit\|RateLimit" rq/ # 0 命中
python -c "import rq; print(hasattr(rq,'RateLimit'))" # False
RQ 2.12.0 里根本没有 RateLimit 这个东西。 不是"语义和你想的不一样",是这个对象不存在。并发只由 worker 进程数决定。
结论没变(限流还是得自己写),但依据完全错了。依据错了意味着:将来换版本、换库,你会用错误的理由做出正确的决定,然后在真正需要判断的时候判错。
做法:许可证、版本号、发版时间,一律查 PyPI / crates.io / npm 的官方 API,不看博客、不看 README 转述。
这是最贵的一条。
BullMQ Python 文档里有 setGlobalRateLimit,看起来就是标准的全局限流。按 max=1 / duration=2000ms 投 3 个任务:
| 任务 | 实际启动时刻 |
|---|---|
| A | 0.02s |
| B | 16.05s(而且是我补投了第 4 个任务才被唤醒的) |
| C | 全程未执行 |
一个"全局限流"把队列卡死了。
顺着代码找根因,两步:
worker.py:140 初始化 self.limitUntil = 0worker.py:300 赋值 self.limitUntil = max(limit_until, 0)run() 里从来不读它传导链很清楚:
限流命中 → moveToActive 返回 limit_until + job=None
→ 外层 loop 掉进 waitForJob() 的阻塞弹出
→ 窗口到期不会自恢复
→ 只有"新任务入队"才把它唤醒
再去上游仓库翻 PR,找到 taskforcesh/bullmq 的 #3321「feat(worker): support rate limit」,分支名 python-rate-limit,2025-06-28 起开着,一直没合。
到这里结论才敢下:不是我用错,是没做完。
做法:文档声称的每个功能,写一行最小用例真跑。尤其"限流/重试/延迟/去重"这种会影响正确性的,不能只看有没有这个 API。
顺带一条判断:这个缺陷不构成否决。因为 RQ 根本没有限流器,两边都得自建,成本一样。反而说明限流不该下沉到队列库里,放业务侧更合适。
有两个坑,功能全正常,只是语义和直觉相反。这类最难发现,因为测试全绿。
入队顺序:无优先级 → 低优先级 → 高优先级
执行顺序:无优先级 → 高优先级 → 低优先级
没设优先级的那条抢跑了。 我用 Python 和 Node 各测一遍,行为一致 —— 不是移植 bug,是设计语义。
后果很实际:如果你指望"给紧急任务设高优先级就能插队",线上会看到"普通任务先跑"。要么给所有任务设默认优先级,要么别用优先级。
重试配 fixed 400ms,实测尝试间隔 1.03s。
如果你按配置值写断言("间隔应约 400ms"),测试必然失败。
验收断言必须留一个量级的余量。
bullmq Python 版在 PyPI 上标的是 Development Status 3 - Alpha。
两个动作:
==3.4.3),不写 >=msgpack、semver、croniter 都做了 == 精确钉,混进主环境会打架另外发现一个打包卫生问题:wheel 里混进了 node_modules(120 个文件,约 1 MB)。查过没有 subprocess / Popen 调用,纯 Python 实现、运行时不需要 Node,所以不影响使用,但说明打包流程没清干净。
如果选这个库的理由包含"和 Node 侧共用同一套 Redis 队列",那必须两个方向都测:
| 方向 | 结果 |
|---|---|
| Python 入队 → Node 消费 | 2/2 |
| Node 入队 → Python 消费 | 2/2 |
| 跨语言去重(两边用同一 dedup id) | 指向同一 job |
只测一个方向,等于没测 —— 队列的坑经常是不对称的。
kill -9 掉 worker,看新 worker 多久接手。
第一次测大概率"失败",原因不是恢复不行,而是生产默认的 lockDuration 是 30 秒。等 10 秒就判失败,纯属冤枉。
把 lockDuration / stalledInterval 调小(比如 2000ms)再测,实测 4.02 秒接手并完成。
这类"默认值对生产友好、对测试不友好"的参数,验收脚本里必须显式改掉。
选任何队列/任务库,按这张表走一遍:
| # | 检查项 | 怎么验 |
|---|---|---|
| 1 | 许可证 | 官方 API 的 license_expression,不看转述 |
| 2 | 版本成熟度 | PyPI 的 Development Status,Alpha 就钉死 |
| 3 | 依赖是否隔离 | 看它有没有 == 精确钉,有就别混环境 |
| 4 | 幂等 | 同一 job id 重复投递,看状态是否被重置、数据是否被覆盖 |
| 5 | 去重 | 两次投递返回同一 id?计数是否只有 1? |
| 6 | 延迟 | 入队后是否立刻进入 delayed?实际延迟与配置差多少? |
| 7 | 重试 | 尝试次数、间隔、最终状态;注意秒级粒度 |
| 8 | 限流 | 真跑,不是看有没有 API;测 3 条以上看会不会卡死 |
| 9 | 优先级 | 混入不带优先级的任务,看会不会抢跑 |
| 10 | 事件 | 事件订阅能不能收到?初始化是自动还是要显式等就绪? |
| 11 | 崩溃恢复 | kill -9 worker,先把超时参数调小再看秒数 |
| 12 | 跨语言 | 双向各测一遍,别只测一个方向 |
| 13 | 打包卫生 | 有没有混进无关目录、体积是否异常 |
一是别动生产。 所有实测跑在一个临时 Redis 实例上,无持久化、maxmemory-policy=noeviction,跟生产实例完全分开。测坏了删掉重来,不影响任何在线服务。
二是证据要能落地成文件。 每个测试项都输出一份 JSON(入队 id、状态、耗时、计数、最终 state),连同脚本一起存下来。
这样做的价值不在于"证明我对",而在于结论可以被推翻。别人拿到脚本和原始输出,可以指出我哪里测错了 —— 这比一份写得漂亮的结论有用得多。
后来这份实测确实改掉了前两轮调研里的 6 处事实错误,也发现了一个上游没修完的功能。这两件事都是"只查文档"永远得不到的。
本文所有数字均为本人本机实测。涉及具体项目内部的信息(部署端口、进程名、路径)已剔除。