第5章 有界并发与过载接纳
限制执行数量只是第一步
在每个请求里启动 goroutine,然后在 goroutine 内获取 semaphore,只限制了同时执行的数量。所有等待许可的 goroutine 仍然存在,携带请求数据、栈和计时器。流量持续超过处理能力时,内存和延迟仍会不断增长。真正的边界必须同时约束执行、等待与等待时间。
随书工作池预先启动固定数量 worker,并提供固定容量任务 channel。接纳使用非阻塞发送,队列已满就拒绝,不为每一个等待提交再创建 goroutine。因此 W 个执行槽与 Q 个等待槽直接构成进程内接纳预算。这不是所有业务的唯一方案,但它的资源关系足够简单,适合作为起点。
过载不是偶发错误,而是需求超过可用预算时的必然状态。服务应事先决定拒绝发生在哪里、返回什么、客户端是否可重试,以及重试会不会加重负载。接纳路径不能为了“保证不丢”无限阻塞,否则只是把拒绝推迟为更昂贵的超时。
容量来自等待预算
假设稳定状态下平均到达率为每秒五十个任务,平均在系统内停留一秒,平均在途数约为五十。这个关系可以帮助理解队列与延迟,但不能直接计算安全峰值。任务时长分布、突发流量、上游抖动和重试都可能让平均值失去保护作用。容量设计应使用业务可接受的等待与测量分位数,而不是只看平均处理时间。
当下游变慢,worker 的占用时间增长,队列将更快满。增大队列只能暂时延后拒绝,也会让排在后面的任务更可能过期。对于仍然挂在请求上的工作,等待已经耗尽预算后继续执行通常没有价值。后台任务则需要自己的到期规则,不能因为客户端已经拿到接纳响应就无限有效。
队列中的每项数据也有体积。即使任务数固定,若每项保存十兆字节请求体,内存预算仍可能很大。因此 /tasks 只保存校验后的短描述,而非任意大对象。输入大小限制、队列容量和外呼响应限制是同一资源预算的不同侧面。
接纳和执行不能混淆
提交成功的时间点是责任转移点。之前失败由请求报告;之后失败由工作池记录。处理函数不能在提交成功后因为客户端写响应失败就随意撤回任务,因为 worker 可能已经执行。反过来,如果响应丢失,客户端也不能确信任务未接纳。这是异步接口天然具有的结果不确定窗口。
本书教学工程没有持久化任务 ID 去重,重试提交可能产生第二个任务。对付款、邮件或外部修改等副作用,生产实现必须引入业务幂等标识和可查询状态,必要时使用事务性队列。增加更多 worker 不会解决这一协议问题,返回 202 也不会自动产生可靠投递。
拒绝响应应尽量廉价。不要在队列满时再调用昂贵的依赖查询,或把每次拒绝都同步写进慢日志后端。可以记录采样日志和计数,保留能够解释过载的信息。若返回 Retry-After,要确保它只是合理建议,不能暗示服务保证届时一定有容量。
公平、隔离与扩展
一个全局有界队列能保护进程总量,却不能保证租户公平。某个租户持续占满等待区,其他租户仍可能被拒绝。后续扩展可以按租户设预算、按业务类型隔离 worker,或在共享总预算下调度。每增加一层队列,都要重新计算总等待量并定义关闭顺序。
多个进程各自限制到十个并发,不等于整个系统限制到十个。扩容会扩大对下游的总压力,因此服务预算要与数据库连接、外部配额以及副本数一起讨论。进程内 semaphore 是局部机制,不是分布式限额。书中实验只证明单进程约束,不能直接外推到集群。
工作池的大小也不应按 CPU 核数机械设定。CPU 密集任务、网络等待和大内存转换有不同瓶颈。先测量任务占用什么资源,确定不能超出的那一项,再选择并发预算。一次微基准得出的纳秒成本,不能决定有真实网络与重试的生产容量。
练习:证明上限而不是绘制好看的曲线
使用闩锁占满 worker,把队列填到容量,再执行一次提交。记录成功接纳数量、拒绝是否在短窗口内返回以及运行中的任务数。随后释放闩锁,验证所有已接纳工作终结,且服务能够再次接纳。这个实验同时检查容量边界与故障后的恢复。
再把工作延迟提高十倍,保持队列容量不变,观察拒绝提前出现而资源仍受控。若只关心吞吐量,可能误把拒绝看成失败;从服务保护角度,及时拒绝正是契约兑现。最终报告应同时给出完成、拒绝和等待分布,不能把拒绝请求从统计中无声删除。
本章实测证据
HTTP路径实际返回202、202和503,并检查Retry-After。取消第一个请求后,两个任务仍排空成功。 以下摘自最终本机 go test -race -v ./...,完整命令及环境见附录B;括号中的耗时仅是当次运行记录。
--- PASS: TestHTTPOverloadAndTaskOwnership (0.31s)