Rust in Everything
基于 tower 0.5.3

取舍汇总

主线位置:两条主线的总账。前三条取舍在就绪主线上(谁来等、在哪里等),后三条在请求主线上(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 时资源已经在手里,不会失败在半路。

代价:

谁来补: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 里。

💬 关联讨论

发起讨论
加载中...