第 11 / 23 篇

第10章 后台任务的接纳、执行与结果

从请求独立出来需要新的主人

后台任务的目的通常是缩短请求等待,或把执行安排到受控资源中。它并不意味着工作可以不受管理。处理函数一旦返回,请求 context 结束;如果任务继续依赖它,就可能立即取消。若改成无期限 Background,又会让任务绕过服务关闭。正确转移需要独立数据、服务拥有的根 context 和任务自身的截止。

随书 Pool.Submit 接受调用者 context,但只在接纳边界使用它。接纳成功之后,执行由工作池根 context 和每任务超时共同管理。这个语义使客户端在收到接纳之后断开,不会任意取消已经归服务负责的工作;服务退出却仍可取消它。两种取消分别对应不同责任。

如果业务希望用户可以撤销任务,就应提供任务状态与取消接口,而不是借用已结束请求的 context。撤销与完成可能竞争,需要状态机决定谁先获得终结权。对于已经发出的外部副作用,撤销成功也只能说明后续步骤停止,不能隐含远端已经回滚。

接纳结果不是业务结果

/tasks 返回任务 ID,代表本进程接纳。任务随后可能成功、失败、超时或随服务关闭而取消。监控应区分这些结果,客户端若需要可靠查询,就必须引入持久化状态。本书的内存统计用于实验,不提供跨重启查询能力,不能作为生产订单处理记录。

任务描述应尽量不可变。延迟、目标地址等字段经过校验后交给 worker,不再由请求修改。若任务依赖大文件,队列中应保存受控存储引用和必要版本信息,避免把整个文件放在内存。若依赖用户授权,应决定是在接纳时验证一次,还是执行前根据业务要求重新授权。

错误也应成为任务结果。仅在 worker 中 log.Println(err),无法让上层区分是否已终结;一条日志可能丢失、被采样或与任务 ID 脱节。即便教学工程不持久化,也应让计数和完成通知遵守一致路径。统计修饰不了错误语义,但可以暴露未终结任务。

任务截止与队列等待

任务超时从何时开始必须写清。若只从 worker 开始算,排队时间可能没有上限;若从接纳时开始算,任务可能在开始前就过期。两种选择都合理,关键是符合业务承诺。延迟敏感任务通常需要把排队纳入总截止;离线批处理可能容忍长等待,却仍要限制保留时间。

本工程的任务超时从 worker 开始执行时计算,不包含排队时间;排队总量受队列容量约束,退出时所有等待又受共享清理预算约束。若业务要求接纳到终结的总时限,需要新增接纳时绝对截止。具体计时位置是实验契约的一部分。读代码时不要凭字段名猜测,应追踪 context 创建发生在 Submit 还是 worker。扩展时可以在任务里记录 acceptedAt 与绝对截止,在 worker 开始前拒绝已过期任务,同时把它们记为终结而不是悄悄丢弃。

任务也可能具有多步骤外呼。每一步应共享总预算,不能每步刷新成完整超时。工作池根取消则是更高层的停止边界。若某步骤必须在退出时完成不可中断提交,就需要在业务状态机中显式表示,不应通过忽略 context 偷偷实现。

崩溃后的保证需要持久化

内存工作池在进程崩溃或机器断电后丢失排队任务,这是明确能力边界。若要恢复,应将任务状态写入数据库或消息系统,并设计领取、重试、幂等与结果未知处理。持久化并不会自动消除重复执行:worker 在副作用成功后、标记完成前崩溃,恢复执行可能重复产生效果。

因此从本书工程升级到可靠任务系统时,首先补业务标识和状态转换,而不是先换一种队列库。任务是否至少投递一次,与业务效果是否只发生一次是不同层次的承诺。对无法幂等的依赖,应保留核查入口。不要用“最终一致”掩盖缺少终结策略。

任务隔离也要基于资源。长任务与短任务共享同一个小工作池,会发生队首延迟;网络任务与 CPU 大任务共享预算,则可能互相拖累。可以按资源类型设置独立池,但每个池都要有有界队列、关闭顺序与监控。简单复制几个无限队列只会分散故障。

练习:画出任务状态机

画出未接纳、排队、执行、成功、失败和取消状态,标明由谁改变状态、哪一刻可以重试。增加“响应丢失但已接纳”与“外部副作用结果未知”两种情形,检查现有接口能否让调用方正确行动。无法判断的地方就明确写为当前工程不提供保证。

接着验证请求断开不取消已接纳任务,而服务根取消可以使它结束。再构造一个排队时就过期的任务,决定它是否应执行,并为这个决定增加行为断言。练习应留下语义说明与测试结果,而不只是新增几个状态常量。

本章实测证据

工作属于池之后不再依赖请求context。这个通过结果不支持跨重启恢复或业务副作用恰好一次。 以下摘自最终本机 go test -race -v ./...,完整命令及环境见附录B;括号中的耗时仅是当次运行记录。

--- PASS: TestAcceptedTaskOutlivesCaller (0.00s)