第6章 超时、取消与重试共享同一份预算
一次请求包含多种等待
HTTP 请求可能先在应用接纳处等待,再等 DNS、连接建立、TLS、请求发送、响应头和响应体。日志里的一条 timeout 无法说明哪段最慢。Go 提供多个超时字段,但这些字段覆盖的阶段不同。把所有字段都设置成同一个值,既不能保证总时长,也很难解释失败。
请求 context 通常承担整次操作的截止。Transport 的连接和响应头相关配置限制特定阶段,Client.Timeout 则覆盖包含读取响应体在内的整个客户端交换过程。不同限制会相互竞争,最先触发的那个结束当前路径。服务应在日志中记录阶段,而不是仅凭错误字符串猜测哪个参数生效。
随书外呼器使用传入 context,使 worker 取消或调用预算能够终止连接等待与读取。这个设计的核心不在某个固定秒数,而在所有阻塞点均受一份可传播的预算约束。教学默认值便于实验,生产值需要依据依赖行为和业务响应要求重新制定。
超时并不回滚副作用
客户端超时可能发生在服务执行之前、执行之中或执行完成之后。对于读取接口,重试通常只是增加负载;对于修改接口,重试可能产生第二次效果。context 取消无法回滚远端已经提交的事务。因此错误处理应区分明确失败和结果未知,而不是把所有超时都翻译成“操作未发生”。
如果业务支持幂等键,应让同一个逻辑操作的重试复用该键,并由实际执行业务的一方持久化去重。仅在网关缓存响应,无法覆盖缓存写入前进程崩溃的窗口。对于无法查询也无法去重的副作用,需要对账或人工处理,不应依赖不断重试获得心理上的确定性。
后台任务也有类似边界。服务取消时,worker 返回错误并不代表远端动作没有发生。任务记录需要允许“状态待核查”,而不是强行归入成功或失败。本书内存实验没有实现完整业务账本,但会持续提醒这个边界,避免把资源释放等同于业务回滚。
重试次数必须受总预算限制
一次三秒预算的操作,不能重试三次且每次都等三秒。合理流程是每次尝试前计算剩余预算,为本次尝试设上限,为退避预留时间,并在 context 取消时立即结束等待。退避应使用可取消计时器,不能无条件 Sleep 让服务退出被延后。
指数退避可以降低同步重试,但加入随机抖动也不能创造容量。过载时,如果客户端、网关、服务和 SDK 各自重试三次,放大倍数会很快失控。负责重试的一层应了解幂等性和预算,其他层尽量清楚暴露失败。是否重试应依据错误阶段与业务语义,而非统一处理任何非二百响应。
服务器返回明确的限流或过载状态时,重试应尊重建议并设置最大次数。客户端必须能承受最终失败;无限重试只是把故障转化为无限排队。对后台恢复型任务,可以持久化下一次执行时间,释放当前 worker,而不是让 worker 长时间睡眠占据执行槽。
错误分类服务于行动
工程需要知道的是:调用者已取消、总截止已到、连接阶段失败、响应状态不可接受,还是响应体超过允许大小。分类应足以决定是否记录、重试、报警或归因。过细的字符串匹配脆弱,过粗的“网络错误”又无法指导行动。通过 errors.Is 与结构化错误保留原因链,通常比重新拼接文本可靠。
取消与超时的日志级别也要结合上下文。用户主动离开不一定是服务故障,持续大量 deadline exceeded 则可能意味着预算不足或下游退化。将它们都作为高优先级报警会淹没真正异常;全部忽略又会看不到容量问题。用速率、阶段和影响范围来判断,比单条日志更稳妥。
实验中的超时应留出合理余量。要证明操作在一秒内响应取消,不需要断言恰好十毫秒返回。CI 的负载和 race 插桩会放大时延。测试可以设明确上限、用事件同步确认操作已进入阻塞,再触发取消。这样既有界,也不会把偶然调度当成协议失败。
练习:为一个外呼画预算账本
为外呼列出总预算、最长排队、单次连接、响应头和响应体阶段。故意让上游先慢响应头,再快速响应头但慢响应体,比较触发的限制。保存每次命令、阶段与返回错误。最后加一次受限重试,确认总时长仍受父预算限制,而不是随尝试次数线性增长。
把外呼换成有副作用接口时,补充结果未知的处理说明。不要在实验中真实重复付款等操作,可以用本地计数器模拟服务提交后延迟响应。验收客户端超时之后计数器是否已经变化,并据此解释为何“超时就安全重试”不是通用规则。
本章实测证据
任务在worker开始后获得独立截止,超时工作响应取消。这个实验不证明排队时间包含在TASK_TIMEOUT中,事实上本工程不包含。 以下摘自最终本机 go test -race -v ./...,完整命令及环境见附录B;括号中的耗时仅是当次运行记录。
--- PASS: TestPoolTaskDeadline (0.02s)