取舍汇总
主线位置:两条主线的总账。前三条取舍在就绪主线上(谁来等、在哪里等),后三条在请求主线上(future 怎么包、错误怎么传、请求发给谁、失败了发几次)。
flowchart TB d1["poll_ready 与 call 分开"] --> c1[实现要守四条规则] c1 --> f1["ServiceExt::ready / oneshot"] d2[中间件改类型不装箱] --> c2[类型名长、分支难写] c2 --> f2["BoxService 一族"] d3["错误统一成 BoxError"] --> c3[调用方要 downcast] d4[P2C 随机两选一] --> c4[不保证选到最空闲] d5[重试] --> c5[故障时放大流量] c5 --> f5["Budget 限额"]
每一节的格式相同:做了什么决定,换来了什么,代价是什么,代价由谁来补。机制都在别的页讲过,这里只给结论和链接。
poll_ready 与 call 分开
决定:Service 有两个方法。调用方先用 poll_ready 问「能接吗」,得到 Ready(Ok) 才能 call。
换来的:
- 背压。任何一层返回
Pending,整条链就停在调用方那里,请求根本没发出去(见怎么读这一卷里的程序)。 - 先占资源,再发请求。
ConcurrencyLimit在poll_ready里拿许可,Buffer在poll_ready里预留通道位置,call时资源已经在手里,不会失败在半路。
代价:
- 每个实现都要守
poll_ready的四条规则,守不住就是 panic。最常见的报错 poll_ready must be called first 就出在这里。 - 就绪状态存在服务实例身上,
Clone出来的副本不继承它。axum 每个请求都 clone 一次服务,所以挂在 axum 上的中间件每次都从「没就绪」开始(见在本站中:中间件栈)。 poll_ready返回Ready之后call可能永远不来,占住的资源就一直占着。LoadShed 被拒绝过的实例、Buffer 卡死、Steer 的队头阻塞 都是这一条的不同面目。
谁来补:ServiceExt 的 ready() 和 oneshot() 把「先就绪再调用」写成一个调用,用的人不必自己守规则;tower-test 的 mock 在没就绪就 call 时直接 panic,让违规在测试里暴露。
中间件改类型,不装箱
决定:每个中间件都是一个新类型,Timeout<ConcurrencyLimit<S>>;每个响应 future 也是一个新类型。map 一族的 future 要么直接借用内层的类型,要么是不装箱的新类型(见 service_fn 与 map 一族)。
换来的:零成本组合。一摞中间件编译成一个大结构体,一次请求不需要任何堆分配。
代价:
- 类型名很长,报错也很长。
ServiceBuilder::check_service这类只在编译期起作用的方法,就是为了在长类型里定位错误。 - 两个分支返回不同的中间件栈时,类型对不上,直接写会得到 E0308(见类型擦除)。
谁来补:BoxService 一族在需要的地方擦掉类型,代价是每个请求一次堆分配(实测 1000 次调用、1000 次分配);只有两个分支时,Either 不装箱也能解决。默认不装箱,需要时自己选择装箱:成本由用到的人付。
Buffer 回传 future,不回传响应
决定:Buffer 的 worker 调用内层服务后,把内层返回的 future 通过 oneshot 交回调用方,响应在调用方自己的任务里完成。
换来的:worker 只做「等就绪、调用」两件事,不会被一个慢响应卡住。请求之间的并发由各个调用方任务提供,不受 worker 这一个任务限制。
代价:
- 每个请求在
Buffer::call里多建一个oneshot通道。 - 内层的 future 必须能跨任务移动(
Send)。 - 排队发生在 worker 里,不在
poll_ready里,所以Timeout放在Buffer外面就会把排队时间算进去(见超时算不算排队)。
错误统一成 BoxError
决定:大部分中间件的错误类型是 BoxError(Box<dyn Error + Send + Sync>)。比如 Timeout 的错误类型直接写成 crate::BoxError,超时时装进去的是 Elapsed。
换来的:任意中间件都能叠在一起。如果每层都定义自己的错误枚举,Timeout<RateLimit<Retry<S>>> 的错误类型就要嵌套三层,换一下顺序整个类型都变。
代价:
- 调用方要
downcast_ref/is::<T>()才知道是哪一层出的错,编译器不再帮你检查是否处理了所有情况。 - 把两个不会出错的服务组合起来,类型上也变成「会出错」。
Either在 0.5.0 去掉了这一点,改为要求两边的错误类型相同(见 Either 与 FutureService)。 - axum 要求挂上去的服务错误类型是
Infallible,带BoxError的中间件必须先映射成响应(见 service overloaded)。
P2C 与 PeakEwma
决定:P2C 每次随机取两个就绪的服务,选负载小的那个;负载用 PeakEwma 估计,延迟变大时立刻采用新值,变小时按时间慢慢衰减。
换来的:
- 每次挑选的成本固定:两个随机数、两次
load(),与后端数量无关。 - 负载最大的那个永远不会被选中。负载数字过时的时候也不会让所有请求涌向同一个后端:在 P2C 页的实验里,三个后端负载固定为 0、1、10,9000 个请求的分布大约是 6000、3000、0,而全局最小会把 9000 个全给第一个。
PeakEwma宁可高估:一个变慢的后端会马上被避开,恢复之后要过一段时间才重新拿到流量。
代价:
- 不保证选到最空闲的那个。
PeakEwma::load()不是只读的:每读一次都会把估计值往 0 衰减一次,并重置时间点,读得越频繁结果越不同。被取消的请求也算一次延迟观测,快速失败的节点看起来更「便宜」。- 只有经过
Load包装的服务才有负载。Timeout<PeakEwma<S>>放进Balance编译不过,PeakEwma<Timeout<S>>才行。
重试与预算
决定:Retry 只认 Policy 这一个 trait:要不要重试、隔多久、能不能再发一次,全由 Policy 决定;Budget 只提供存取两个操作,什么时候存、什么时候取也由 Policy 决定。
换来的:重试策略完全可替换,ExponentialBackoff 和 TpsBudget 都是可选的积木,Retry 本身不依赖它们。
代价:
- 重试本身是放大器。下游已经出故障时,每个失败的请求再发几次,流量成倍增长。预算就是给这个放大器设上限:
TpsBudget默认允许一次性用掉 100 次重试,之后大约 11 秒内不再允许,直到成功请求存回额度或旧的取款过期。 - 每个请求都要把整个
Retry(策略和内层服务)clone 一次,内层服务必须Clone(见 Clone 约束不满足)。 - 层的顺序决定资源怎么占:
ConcurrencyLimit在Retry里面时,等待重试期间许可会归还;在外面时,整个重试过程占着同一个许可。
两个取舍叠在一起:issue #856
ReadyCache 为 P2C 提供「按下标取服务」的接口,这是为了让挑选只花固定成本。但下标不稳定:删除服务时用的是 swap_remove,把最后一个元素挪到空位(见 ReadyCache)。
另一方面,poll_ready 和 call 分开意味着调用方可以在 Ready 之后、call 之前再调一次 poll_ready,这是契约允许的。
两条叠在一起:Balance::poll_ready 选中下标 i,缓存起来;下一次 poll_ready 先读 Discover,恰好删掉了选中的那个服务,原来排在最后的服务被挪到 i;复查 i 时它是就绪的,于是请求发给了一个没有经过负载比较的服务。请求不会发给已经删除的后端,但 P2C 的选择被绕过了。P2C 页有完整的复现。
这个问题在 tag 之后由 master 上的 df06d70(PR #874)修复,修复方式是 evict 之后丢掉缓存的下标。0.5.3 和至今的任何发布版本都不包含这个修复。
小结
poll_ready与call分开换来背压和「先占资源」,代价是四条规则、副本不继承就绪、call可能不来;ServiceExt和 tower-test 把规则交给工具来守。- 默认不装箱,零成本组合;需要擦类型时用
BoxService(每请求一次分配)或Either(不分配)。 Buffer回传 future,worker 不被慢响应卡住,代价是每请求一个oneshot,以及排队时间落在poll_ready之外。BoxError让任意中间件可叠,代价是调用方要 downcast。- P2C 成本固定、不扎堆,
PeakEwma宁可高估;load()有副作用。 - 重试是放大器,预算给它设上限。
- issue #856 是「下标式访问」和「
poll_ready可以重复调用」叠出来的 bug,修复不在 0.5.3 里。