bigywater.tech
MongoDB · 52

findAndModify 做任务队列

想用 MongoDB 做一个任务队列,怎么保证每个任务只被一个 worker 领走?这篇对比原子领取、两步领取、带条件更新三种写法,再验证优先级、索引和租约。

0 对 6,377
原子领取与先 find 再 update 两步领取下被重复领取的任务数
7,462 个/秒
8 个 worker 原子领取的吞吐
0.1 对 17 ms
队列有 (status, prio, _id) 索引与没有索引时,5 万个 pending 里领一个的耗时

原子领取

实测 · 本机执行输出
  耗时 0.27 s(7,462 个/秒);领取总数 2,000,不同的任务 2,000(应为 2,000,没有重复领取);各 worker 领到: [259, 240, 239, 253, 246, 264, 257, 242]
  数据库里 tries>1 的任务数 0;status 分布 [{'_id': 'running', 'n': 2000}]

findOneAndUpdate 把“找一个 pending 的”和“改成 running”合成一个原子操作,2,000 个任务 8 个 worker 零重复。

两步做法

实测 · 本机执行输出
  耗时 2.26 s;领取总数 8,377,不同的任务 2,000(应为 2,000);被多个 worker 同时领到(重复处理)的任务有 6,377 个

先 find 再 update 中间没有保护,多个 worker 找到同一个任务:8,377 次领取对应只有 2,000 个不同任务,6,377 个任务被重复处理。

带条件 update

实测 · 本机执行输出
  耗时 2.27 s;领取 2,000,不同 2,000(没有重复);抢占失败(白做的 find + update)共 6,223 次

update 加上 status: "pending" 条件并检查 modified_count 可以避免重复,但抢占失败 6,223 次,白做了很多工作,慢约 8 倍。

优先级

实测 · 本机执行输出
  300 个任务的领取顺序里 prio 序列是否单调不增: True;前 10 个 prio [4, 4, 4, 4, 4, 4, 4, 4, 4, 4],第 100 / 200 / 300 个 3, 1, 0

用 sort 指定 {prio: -1, _id: 1} 领取,验证了领取顺序是 prio 从高到低。

索引

实测 · 本机执行输出
  有索引:0.1 ms(中位数)
  无索引:17 ms(中位数)

队列必须有 (status, prio, _id) 索引,否则每次领取要扫描(17 ms 对 0.1 ms)。

崩溃的 worker

实测 · 本机执行输出
  4 个任务被领走(租约 1 秒)后 worker 崩溃: [{'_id': 'pending', 'n': 6}, {'_id': 'running', 'n': 4}]
  1.2 秒后清扫:把 4 个过期租约的任务放回 pending;[{'_id': 'pending', 'n': 10}];这 4 个任务的 tries = [1, 1, 1, 1](领取过 1 次)

领取时写入租约时间,清扫线程把过期的 running 放回 pending,配合 tries 限制重试次数。

分步演示

用 findAndModify 做任务队列
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / MongoDB 9.0.2(Homebrew 的 mongod、mongos)/ mongosh 2.12.0 / pymongo 4.18.3;每个实验在临时目录里起全新的 mongod(独立 dbpath,端口 9900 以上,只监听 127.0.0.1,WiredTiger 缓存 0.25 GB),需要副本集或分片集群时在本机起多个进程;命令输出来自真实的 mongosh,批量写入、计时与多线程用 pymongo;计时用 time.perf_counter,数字每次运行会有小幅抖动,ObjectId、时间戳等每次运行都不同。8 个 worker 是 8 个线程,每个线程自己的连接;吞吐与失败次数每次运行都不同
    • 没有验证:副本集上带 w:majority 的领取延迟;worker 在处理完成时没有及时确认导致的重复处理(至少一次语义);任务极多(百万级)时的队列分片;change stream 驱动的推送式消费;死信队列
    京ICP备2026067502号-1京公网安备11010502063487号