Hedge
主线位置:两条主线都在,而且请求主线被分成两份。就绪主线上,
Hedge::poll_ready只替原请求问内层,对冲那一份不预留任何东西;请求主线上,call同时造出两个 future,第二个先睡一段时间再自己走一遍poll_ready和call,两个 future 谁先完成用谁。
flowchart TB hedge[Hedge] --> sel[Select] sel -->|原请求 a| la[Latency] la --> sa[内层 S 的克隆] sel -->|对冲 b| dl[Delay] dl --> af[AsyncFilter] af --> lb[Latency] lb --> sb[内层 S 的克隆] histo[(RotatingHistogram)] la -.记录延迟.-> histo lb -.记录延迟.-> histo dl -.读分位数.-> histo sel -.数据点够不够.-> histo
尾延迟(p99 这一类)常常不是因为后端整体慢,而是个别请求碰上了慢的那台机器、一次 GC、一次丢包。对冲请求(hedged request)的思路是:一个请求等得比「平常」久,就把它再发一遍,大概率第二份会走到不慢的地方。代价是多出来的那份负载,所以只对等得久的请求做,「平常」用历史延迟的分位数来定义。
用法:Policy 和五个参数
Policy 有两个方法:
pub trait Policy<Request> {
/// Called when the request is first received to determine if the request is retryable.
fn clone_request(&self, req: &Request) -> Option<Request>;
/// Called after the hedge timeout to determine if the hedge retry should be issued.
fn can_retry(&self, req: &Request) -> bool;
}
clone_request在call时调用:能复制就返回一份,返回None表示这个请求不对冲(比如非幂等的写请求)。can_retry在等够时间之后调用,最后决定发不发。
Hedge::new 的参数:
| 参数 | 含义 |
|---|---|
service: S | 内层服务,要求 S: Clone |
policy: P | 上面的 Policy,要求 P: Clone |
min_data_points: u64 | 直方图里至少有这么多个数据点才对冲 |
latency_percentile: f32 | 用哪个分位数当等待时间,取值 0 到 1(测试里用 0.9) |
period: Duration | 直方图轮换的周期 |
hedge 模块没有 Layer,ServiceBuilder 上也没有对应的方法,只能直接 Hedge::new。另有一个 new_with_mock_latencies,预先往直方图里写一组延迟,给测试用。
拼装:四个小中间件
Hedge 本身只是个新类型,真正的服务类型是这个别名(hedge/mod.rs):
type Histo = Arc<Mutex<RotatingHistogram>>;
type Service<S, P> = select::Select<
SelectPolicy<P>,
Latency<Histo, S>,
Delay<DelayPolicy, AsyncFilter<Latency<Histo, S>, PolicyPredicate<P>>>,
>;
new_with_histo 按这个类型把它们一层层装起来:
// Clone the underlying service and wrap both copies in a middleware that
// records the latencies in a rotating histogram.
let recorded_a = Latency::new(histo.clone(), service.clone());
let recorded_b = Latency::new(histo.clone(), service);
// Check policy to see if the hedge request should be issued.
let filtered = AsyncFilter::new(recorded_b, PolicyPredicate(policy.clone()));
// Delay the second request by a percentile of the recorded request latency
// histogram.
let delay_policy = DelayPolicy {
histo: histo.clone(),
latency_percentile,
};
let delayed = Delay::new(delay_policy, filtered);
// If the request is retryable, issue two requests -- the second one delayed
// by a latency percentile. Use the first result to complete.
let select_policy = SelectPolicy {
policy,
histo,
min_data_points,
};
Hedge(Select::new(select_policy, recorded_a, delayed))
| 组件 | 文件 | 做什么 |
|---|---|---|
Select | hedge/select.rs | 能复制就把副本交给 b,原件交给 a,两个 future 谁先完成用谁 |
Latency | hedge/latency.rs | 从 call 到 future 成功完成计时,写进直方图 |
Delay | hedge/delay.rs | 先睡 DelayPolicy 给出的时间,再调用内层 |
AsyncFilter | filter/ | 睡醒后用 can_retry 决定真发还是永远不发 |
RotatingHistogram | hedge/rotating_histogram.rs | 一读一写两个直方图,按周期轮换 |
三个 policy 都只是把 Policy 和直方图粘起来的胶水:SelectPolicy 决定复制不复制,DelayPolicy 决定等多久,PolicyPredicate 决定睡醒后发不发。除了 AsyncFilter,其余组件都是 hedge 模块私有的;这也是 hedge feature 依赖 filter 的原因。
Hedge 的 poll_ready 和 call 直接转发给 Select,响应 future 只做一次 map_err(Into::into)。
Select:两份请求,先到先得
Select::poll_ready 两边都问,都 Ready(Ok) 才算就绪,任一边报错就报错。call:
fn call(&mut self, request: Request) -> Self::Future {
let b_fut = if let Some(cloned_req) = self.policy.clone_request(&request) {
Some(self.b.call(cloned_req))
} else {
None
};
ResponseFuture {
a_fut: self.a.call(request),
b_fut,
}
}
先问 SelectPolicy::clone_request,它在用户的 clone_request 之外加了一道门:
fn clone_request(&self, req: &Request) -> Option<Request> {
self.policy.clone_request(req).filter(|_| {
let mut locked = self.histo.lock().unwrap();
// Do not attempt a retry if there are insufficiently many data
// points in the histogram.
locked.read().len() >= self.min_data_points
})
}
注意看的是 read(),也就是上一个周期的直方图,不是正在写的那个。下面讲直方图时再说这意味着什么。
复制成功就先 b.call(副本) 再 a.call(原件)。b 是 Delay,它的 call 只创建一个睡眠中的 future,内层还没被调用。
响应 future 每次先 poll a_fut,再 poll b_fut,谁先 Ready 就返回谁,另一个随 ResponseFuture 一起被 drop。「先 Ready」包括出错:对冲的那一份先失败,整个请求就失败,不会再等原请求。
下面的程序让原请求 500 ms 后成功、对冲的那一份立刻失败。依赖 tower = { version = "=0.5.3", features = ["hedge"] },tokio(macros、rt、time、test-util):
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use tokio::time::Instant;
use tower::hedge::{Hedge, Policy};
use tower::{BoxError, Service, ServiceExt, service_fn};
#[derive(Clone)]
struct Always;
impl Policy<u32> for Always {
fn clone_request(&self, req: &u32) -> Option<u32> {
Some(*req)
}
fn can_retry(&self, _: &u32) -> bool {
true
}
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() -> Result<(), BoxError> {
let t0 = Instant::now();
let attempts = Arc::new(AtomicU32::new(0));
// The first attempt succeeds after 500 ms; the hedged attempt fails at once.
let inner = service_fn(move |req: u32| {
let n = attempts.fetch_add(1, Ordering::SeqCst) + 1;
async move {
if n == 1 {
tokio::time::sleep(Duration::from_millis(500)).await;
Ok::<_, BoxError>(req)
} else {
Err::<u32, BoxError>("hedged attempt failed".into())
}
}
});
// Ten recorded latencies of 100 ms, min_data_points = 5, quantile 0.9.
let mut hedge =
Hedge::new_with_mock_latencies(inner, Always, 5, 0.9, Duration::from_secs(60), &[100; 10]);
let res = hedge.ready().await?.call(7).await;
println!("t={}ms result = {:?}", t0.elapsed().as_millis(), res.map_err(|e| e.to_string()));
Ok(())
}
M2 Max、rustc 1.99.0、--release,暂停的时钟,跑 3 次输出相同:
t=100ms result = Err("hedged attempt failed")
p90 是 100 ms,对冲在 100 ms 时发出、立刻失败,原请求还要再等 400 ms 才会成功,但已经没人等它了。所以对冲的后端如果容易快速失败(比如过载时直接拒绝),Hedge 会把这些失败原样交给调用方。
Delay:不替对冲预留资源
Delay::poll_ready 不问内层,总是就绪:
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
// Calling self.service.poll_ready would reserve a slot for the delayed request,
// potentially well in advance of actually making it. Instead, signal readiness here and
// treat the service as a Oneshot in the future.
Poll::Ready(Ok(()))
}
所以 Select::poll_ready 虽然两边都问,实际只有 a 那边问到了内层。call 在这一刻算出等待时间,克隆一份内层服务放进 future;future 先等 Sleep(见 Sleep),睡醒后用 Oneshot 对这份克隆完整地走一遍 poll_ready 和 call:
StateProj::Delaying { delay, req } => {
ready!(delay.poll(cx));
let req = req.take().expect("Missing request in delay");
let svc = this.service.take().expect("Missing service in delay");
let fut = Oneshot::new(svc, req);
this.state.set(State::called(fut));
}
对冲那一份的就绪等待发生在它真要发出的时候,而且是在调用方的响应 future 里,不在 Hedge::poll_ready 里。
PR #472 之前,Delay::poll_ready 无条件调用内层的 poll_ready。PR 描述了后果:对应的 S::call「only happens after much delay, if ever」,而 Buffer 正是用 poll_ready 预留通道位置的;内层是 Buffer 时,「the inner Buffer::poll_ready is called twice, but only one slot is used immediately. This causes exhaustion of all of the Buffer slots.」
修法就是现在的代码:「return Poll::Ready(Ok(())) from Delay::poll_ready and use a Oneshot inside of the Delay future.」这个改动在 tower 0.4.0 发布。它也是 Hedge 要求 S: Clone 的原因之一:future 里要有一份自己的服务。
AsyncFilter:睡醒后再问一次
Delay 包的是 AsyncFilter,谓词是 PolicyPredicate,它的 check:
fn check(&mut self, request: Request) -> Self::Future {
if self.0.can_retry(&request) {
Either::Left(future::ready(Ok(request)))
} else {
// If the hedge retry should not be issued, we simply want to wait
// for the result of the original request. Therefore we don't want
// to return an error here. Instead, we use future::pending to ensure
// that the original request wins the select.
Either::Right(future::pending())
}
}
can_retry 返回 false 时不能返回错误:上一节说过,Select 会把 b 的错误当结果交出去。所以这里返回一个永远不完成的 future,b 永远不会 Ready,结果只能来自 a。
AsyncFilter 本身怎么工作见 Filter。放在 Delay 里有一个细节:Oneshot 先调 AsyncFilter::poll_ready(一路问到内层的克隆),就绪后才 call,can_retry 是在 call 里调用的。所以即使 can_retry 说不发,那份克隆的 poll_ready 已经做过了,它预留的资源会一直被永远 Pending 的 future 拿着,直到原请求完成、整个响应 future 被 drop。
Latency:只记成功完成的
Latency::call 记下 Instant::now(),future 完成时记录:
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.project();
let rsp = ready!(this.inner.poll(cx)).map_err(Into::into)?;
let duration = Instant::now().saturating_duration_since(*this.start);
this.rec.record(duration);
Poll::Ready(Ok(rsp))
}
从这几行能读出直方图里有什么、没有什么:
- 错误在
?处就返回了,失败的请求不记。 - 只有被 poll 到完成的 future 才记。
Select返回后,输掉的那个 future 被 drop,它的延迟不记。被对冲赢了的慢请求,正是最慢的那批,它们不会出现在直方图里。 - 对冲那一份的
Latency在Delay睡醒之后才被调用,记的是它自己从发出到完成的时间,不含等待。
记录时用 millis 换算成毫秒,不足 1 毫秒的部分向上取整。
RotatingHistogram:一读一写,按周期轮换
RotatingHistogram 有两个 hdrhistogram 直方图:
pub struct RotatingHistogram {
read: Histogram<u64>,
write: Histogram<u64>,
last_rotation: Instant,
period: Duration,
}
Latency 往 write 里写,Delay 和 Select 从 read 里读。两个直方图都用 Histogram::<u64>::new(3) 创建(3 位有效数字,自动扩容,不需要事先定一个最大延迟)。read() 和 write() 每次被调用都先检查要不要轮换,没有后台任务。maybe_rotate:
fn maybe_rotate(&mut self) {
let delta = Instant::now().saturating_duration_since(self.last_rotation);
// TODO: replace with delta.duration_div when it becomes stable.
let rotations = (nanos(delta) / nanos(self.period)) as u32;
if rotations >= 2 {
trace!("Time since last rotation is {:?}. clearing!", delta);
self.clear();
} else if rotations == 1 {
trace!("Time since last rotation is {:?}. rotating!", delta);
self.rotate();
}
self.last_rotation += self.period * rotations;
}
| 距上次轮换 | 动作 | 结果 |
|---|---|---|
| 不到 1 个周期 | 无 | 照旧 |
| 1 到 2 个周期 | rotate:交换两个直方图,清空新的 write | 刚过去那个周期的数据变成可读的 |
| 2 个周期以上 | clear:两个都清空 | 中间有一整个周期没有数据,旧数据全部作废 |
last_rotation 每次加整数个周期,所以周期边界始终对齐 RotatingHistogram::new 的那一刻,不会因为访问时间不同而漂移。时间用的是 tokio::time::Instant,在暂停的时钟下可以精确测试。
分位数与 min_data_points
等多久由 DelayPolicy::delay 决定:
fn delay(&self, _req: &Request) -> Duration {
let mut locked = self.histo.lock().unwrap();
let millis = locked
.read()
.value_at_quantile(self.latency_percentile.into());
Duration::from_millis(millis)
}
参数名叫 latency_percentile,但它被原样传给 hdrhistogram 的 value_at_quantile,那是 0 到 1 之间的分位数。按 hdrhistogram 7.6.0 的源码,大于 1.0 的值会被截成 1.0(「Cap at 1.0」),空直方图返回 0。所以:
- 想要 p90 要写
0.9。写成90.0不会报错,会变成「上一周期的最大延迟」。 min_data_points设成 0 时,read()为空也满足len() >= 0,等待时间是 0:每个请求一进来就同时发两份。
后一条可以直接验证。Always 和上一节一样,内层每次固定 50 ms,min_data_points 为 0,顺序发 3 个请求:
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use tokio::time::Instant;
use tower::hedge::{Hedge, Policy};
use tower::{BoxError, Service, ServiceExt, service_fn};
#[derive(Clone)]
struct Always;
impl Policy<u32> for Always {
fn clone_request(&self, req: &u32) -> Option<u32> {
Some(*req)
}
fn can_retry(&self, _: &u32) -> bool {
true
}
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() -> Result<(), BoxError> {
let t0 = Instant::now();
let calls = Arc::new(AtomicU32::new(0));
let c = calls.clone();
let inner = service_fn(move |req: u32| {
c.fetch_add(1, Ordering::SeqCst);
println!(" t={}ms inner call req={req}", t0.elapsed().as_millis());
async move {
tokio::time::sleep(Duration::from_millis(50)).await;
Ok::<_, BoxError>(req)
}
});
// min_data_points = 0: no history needed before hedging.
let mut hedge = Hedge::new(inner, Always, 0, 0.9, Duration::from_secs(1));
for req in 1..=3 {
hedge.ready().await?.call(req).await?;
}
println!("3 requests -> {} inner calls", calls.load(Ordering::SeqCst));
Ok(())
}
M2 Max、rustc 1.99.0、--release,暂停的时钟,跑 3 次输出相同。内层被调用了 6 次,每对都在同一毫秒:
t=0ms inner call req=1
t=0ms inner call req=1
t=50ms inner call req=2
t=50ms inner call req=2
t=100ms inner call req=3
t=100ms inner call req=3
3 requests -> 6 inner calls
min_data_points 和分位数都只看 read,而 read 里是上一个完整周期的数据。三个推论:
- 第一个周期里永远不对冲,不管已经完成了多少请求,那些数据还在
write里。 - 每个周期开始时,可用的数据整体换一批。
- 流量中断超过两个周期,数据全部清空,恢复之后又要等一个周期才开始对冲。
下面的程序把这三点放在一条时间线上。周期 1 秒,min_data_points = 5,分位数 0.5。内层服务第一次收到某个 id 时按请求里给的延迟睡眠,同一个 id 第二次来(也就是对冲的那份)只睡 10 ms。依赖 tower = { version = "=0.5.3", features = ["hedge"] },futures,tokio(macros、rt、time、test-util):
use std::collections::HashSet;
use std::convert::Infallible;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use futures::future::join_all;
use tokio::time::{Instant, sleep_until};
use tower::hedge::{Hedge, Policy};
use tower::{BoxError, Service, ServiceExt, service_fn};
// Request: (id, latency of the first attempt in ms). Any later attempt with
// the same id takes 10 ms.
type Req = (u32, u64);
#[derive(Clone)]
struct Always;
impl Policy<Req> for Always {
fn clone_request(&self, req: &Req) -> Option<Req> {
Some(*req)
}
fn can_retry(&self, _: &Req) -> bool {
true
}
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() -> Result<(), BoxError> {
let t0 = Instant::now();
let ms = move || t0.elapsed().as_millis();
let seen = Arc::new(Mutex::new(HashSet::new()));
let inner = service_fn(move |(id, slow): Req| {
let first = seen.lock().unwrap().insert(id);
let lat = if first { slow } else { 10 };
if id >= 100 {
println!(" t={:>4}ms inner call id={id} attempt={}", ms(), if first { 1 } else { 2 });
}
async move {
tokio::time::sleep(Duration::from_millis(lat)).await;
Ok::<_, Infallible>(lat)
}
});
// min_data_points = 5, quantile 0.5, period = 1s
let mut hedge = Hedge::new(inner, Always, 5, 0.5, Duration::from_secs(1));
// Ten 100 ms requests at once, then the histogram's write half has 10 points.
let mut futs = Vec::new();
for id in 0..10 {
futs.push(hedge.ready().await?.call((id, 100)));
}
join_all(futs).await;
println!("t={:>4}ms 10 requests of 100ms done", ms());
for (id, at) in [(100, 100), (101, 1200), (102, 3500)] {
sleep_until(t0 + Duration::from_millis(at)).await;
println!("t={:>4}ms send id={id} (first attempt 500ms)", ms());
let lat = hedge.ready().await?.call((id, 500)).await?;
println!("t={:>4}ms id={id} answered by the {lat}ms attempt", ms());
}
Ok(())
}
发 id=100 时,已经有 10 个 100 ms 的请求完成,超过了 min_data_points = 5。完整输出在文末。
动脑筋答案
M2 Max、rustc 1.99.0、--release,暂停的时钟,跑 3 次输出相同:
t= 100ms 10 requests of 100ms done
t= 100ms send id=100 (first attempt 500ms)
t= 100ms inner call id=100 attempt=1
t= 600ms id=100 answered by the 500ms attempt
t=1200ms send id=101 (first attempt 500ms)
t=1200ms inner call id=101 attempt=1
t=1300ms inner call id=101 attempt=2
t=1310ms id=101 answered by the 10ms attempt
t=3500ms send id=102 (first attempt 500ms)
t=3500ms inner call id=102 attempt=1
t=4000ms id=102 answered by the 500ms attempt
只有 id=101 被对冲,第二份在 1300 ms 发出。
- id=100 没有对冲。 10 个数据点都在
write里,read还是空的,read().len() >= 5不成立,clone_request返回None,只发了一份,等满 500 ms。 - id=101 在 1300 ms 对冲。 1200 ms 时
clone_request调read(),距last_rotation(0)1.2 个周期,触发rotate:read里是第一个周期的 11 个点(10 个 100、1 个 500),last_rotation变成 1000 ms。数据够了,中位数是 100,于是 100 ms 后发出对冲,那一份 10 ms 就回来了,原请求被丢掉。 - id=102 没有对冲。 3500 ms 时距
last_rotation(1000 ms)2.5 个周期,clear把两个直方图都清空。 - id=101 的原请求被丢掉了,它的 500 ms 没有被记下来;记下来的是对冲那份的 10 ms。
小结
Hedge=Select(a: Latency(S), b: Delay(AsyncFilter(Latency(S)))),共用一个Arc<Mutex<RotatingHistogram>>;要求S: Clone、P: Clone,没有Layer。Delay::poll_ready总是就绪,对冲那一份睡醒后才用Oneshot问就绪(#472,0.4.0)。Select的 future 先到先得,包括错误;can_retry为false时 b 变成永远Pending,而不是错误。Latency只记成功完成的 future;被对冲赢掉的慢请求不记。- 分位数和
min_data_points都只看上一周期的read直方图:第一个周期不对冲,空闲两个周期以上清零。latency_percentile其实是 0 到 1 的分位数。