第 6 章:让多个 worker 领取同一份持久队列
只有一个 worker 时,“先查询下一项任务,再改成执行中”通常看起来能够正常工作。增加第二个进程后,两者可能同时读到同一行,并分别开始调用工具。领取必须是一项数据库竞争协议,不能只是一段顺序执行的应用代码。
6.1 查到候选不等于已经拥有
普通 SELECT 返回的是查询时刻可见的数据。它不会自动阻止另一个连接读取相同行,也不会让之后的 UPDATE 和之前的结果构成一个整体。两个 worker 都看到 ready,只能说明两者都观察到候选资格。
一种常见错误是先读取任务编号,离开事务,再更新状态。即使后面的更新只允许 ready,调用方如果没有检查受影响行数,失败的竞争者仍可能带着先前查询结果继续执行工具。
领取者需要获得排他变更资格,确认当前条件,推进围栏与尝试次数,并把新拥有者、租约和事件共同提交。成功返回的领取结果应来自这个事务,而不是事务之前的缓存对象。
6.2 使用行锁与 SKIP LOCKED
示例在短事务中选择一项已经到达就绪时间的 ready 任务,使用 FOR UPDATE SKIP LOCKED。被其他领取者锁住的行会被跳过,使多个 worker 可以继续寻找别的候选项。
SELECT id, state, fence
FROM workflows
WHERE state = 'ready' AND ready_at <= clock_timestamp()
ORDER BY ready_at, created_at
FOR UPDATE SKIP LOCKED
LIMIT 1;PostgreSQL 官方资料明确指出,跳过锁会得到不一致的数据视图,适合队列式访问,不适合作为一般业务查询的一致性方案。它也没有消除所有锁:行级冲突被跳过,相关表锁仍然按数据库规则获取。
因此,“没有取到任务”不等于队列为空。候选项可能正在被其他事务锁住、尚未到就绪时间,或者需要先处理失效审批。调度器可以稍后继续查询,但需要有空闲等待与停止机制,避免高速空转消耗数据库。
6.3 领取事务要短于实际业务执行
在领取后一直持有事务与行锁,直到工具调用完成,会把网络等待带进数据库事务。外部服务变慢时,连接、锁与事务版本都可能长时间占用。取消一个 worker 也未必能及时结束远程效果。
示例在领取事务中完成状态变更后立即提交,再在事务之外调用工具。租约和围栏承担接下来需要的恢复与写入资格检查。这种设计缩短了数据库临界区,但也明确产生了“领取已提交、工具还没调用”的故障窗口。
这个窗口不能被忽略。worker 如果现在崩溃,另一位执行者需要等租约失效,再依据持久记录核查。对有权威查询的幂等工具,可以在查不到结果后按原键重排;没有此合同的工具,系统无法仅凭本地记录保证安全重放。
6.4 尝试次数在取得执行权时推进
尝试预算应当统计什么,需要在合同中固定。示例在一次成功领取时增加 attempt,即使进程随后在调用工具之前崩溃,这次领取也消耗预算。它统计的是执行尝试,不是仅统计成功发出的 HTTP 请求。
这种口径会让高频崩溃较早进入核查或人工处理,但能够约束一个永远出问题的任务持续占用系统。若业务希望把规划、领取、网络发送与已知拒绝分开计费,可以保存多个计数器,不能在不同组件中对同一个 attempt 字段作出不同解释。
预算也不能覆盖事实。一项第三次尝试在工具生效后崩溃,即使不再允许自动执行,仍然应当核查并记录成功。预算限制的是新的执行机会,不是要求系统拒绝承认已经存在的效果。
6.5 通过真实竞争检查领取合同
实验应当让两个进程实际面对同一个候选项,而不是顺序调用两次领取方法。随书测试使用独立 worker 子进程,并用实验屏障稳定控制竞争窗口;最终检查领取次数、工具效果和工作流状态。
进程标记只用于让测试知道它到达了哪个故障位置,恢复的业务事实仍来自 PostgreSQL 与工具账本。标准输入门与标准输出不是生产检查点,进程退出后也不承担工作流恢复责任。
同样,领取竞争通过不代表已经验证公平性、全局顺序或高吞吐。某些热点任务可能持续被跳过,多租户之间也可能相互挤占。本书后面会把容量与租户问题作为新的设计约束讨论,而不会从一项行锁实验推导完整调度保证。
练习。 worker A 锁住了最早的一项任务,worker B 使用 SKIP LOCKED 取得后面的任务。请解释为什么 B 的成功不证明严格 FIFO,再设计一个不会把“当前没取到”误判为“所有任务已结束”的调度循环。