第 11 / 23 篇

第 10 章:让等待和重试跨过进程生命周期

函数里的一次 sleep 能够暂停当前 goroutine,却不能让下一位 worker 知道原任务应该什么时候继续。如果长任务需要等待、退避或控制执行预算,相关决策必须成为持久状态,而不是隐藏在当前调用栈里。

10.1 保存下次就绪时间

示例用 ready_at 表示一项就绪任务最早允许被领取的时间。领取查询只接受 ready_at 已到的候选项。worker 退出后,另一个进程仍能从数据库得到相同调度条件。

定时器唤醒通常只是提示。最终能否执行仍由数据库条件裁决,否则一个迟到的本地定时器可能在任务已经取消或完成后继续发起工作。这里不要求每项任务永久占有一个 goroutine;可以通过有界轮询或独立调度设施发现到期项。

ready_at 不是精确执行时间。数据库负载、轮询间隔和其他任务都会让实际开始更晚。若业务要求在某个时刻前完成,需要另建端到端期限与容量合同,不能从“到了时间可以领取”推导准时完成保证。

10.2 重试前先辨别已知拒绝

教学工具可以在产生效果之前明确返回 429,并携带约定的拒绝证据。因为这是受信任工具的已知合同,worker 可以安排退避,再按原操作键尝试。任意上游返回同样字段,不应自动获得相同信任。

网络错误、回执不匹配和含义不明确的服务器错误则进入 unknown,先核查。把所有异常统一交给重试装饰器,会把通信失败和已知未执行混为一谈,也会让不支持幂等的工具获得没有依据的新机会。

HTTP 状态码本身不总是完整业务证据。一个代理可能在下游已经提交后返回 502;某个工具也可能在副作用之后才做额外检查。接入时应验证拒绝发生在什么边界,必要时依旧依赖稳定键与结果查询。

10.3 使用持久预算限制自动化

示例为工作流保存 max_attempts,成功领取推进 attempt。所有 worker 和恢复器读取同一份预算,不靠各自进程中的默认值猜测还剩多少机会。一个新版本进程接手,也不能悄悄扩大旧任务的自动执行范围。

可信前置拒绝用完预算,可以记录失败,因为实验合同明确说明这些尝试没有产生效果。结果未知用完预算,则保留核查责任。两者最后都不再自动执行,但状态的业务含义不同。

预算还应有不同维度。现实服务可能需要限制总等待时长、模型费用、工具调用次数、人工审批次数和每租户在途数量。本书实现的是持久尝试预算、审批有效期和退避就绪时间,没有实现覆盖整个工作流的全局完成期限或完整费用账本。

10.4 退避需要边界与拥塞考虑

示例使用有上限的指数形式计算教学退避,避免失败后立即高速重发。指数、起点和最大值必须有界,防止整数溢出或把下一次执行推到不合理的未来。计算后的等待作为 ready_at 保存,不依赖原 worker 继续存活。

真实系统常加入抖动,减少大量任务同一时刻重试。还需要考虑服务返回的重试建议、租户公平性、整体并发和排队上限。仅给每项任务独立退避,无法保证一个共享下游不会被总重试量压垮。

测试退避时,不应只断言算出了某个毫秒数。应在数据库时间尚未到期时尝试领取,确认没有得到任务;实际经过期限后再领取,并检查尝试次数、POST 数量和账本效果。随书相关实验保留真实时钟等待与观察输出。

10.5 审批期限、租约和全局期限分别设计

审批期限约束行动许可,租约约束当前执行资格,ready_at 约束最早开始时间。三者不能合并成一个含义模糊的 timeout 字段。它们即使都使用时间戳,也分别影响不同状态变更。

全局任务期限需要额外回答:到期以后已经发出的调用怎样核查,尚未执行的步骤怎样停止,已有副作用是否需要补偿,以及操作者怎样看到部分完成。不能在任务到期时直接删除所有记录,因为核查可能在期限之后仍然有必要。

本书把没有实现的全局期限列为综合项目扩展。读者可以增加持久 deadline,并让领取和重排在同一事务中检查它,再用“到期前已提交但回执迟到”的实验验证事实保存。新增字段的价值来自它真正参与了执行协议,而不是界面能够显示倒计时。

练习。 某工具连续返回三次可信的前置拒绝,另一工具第三次调用响应丢失。两项任务都已经耗尽尝试预算。请分别写出终态或待核查状态,并说明为什么预算用完不等于已经知道业务没有发生。