Rust in Everything
基于 tower 0.5.3

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))
组件文件做什么
Selecthedge/select.rs能复制就把副本交给 b,原件交给 a,两个 future 谁先完成用谁
Latencyhedge/latency.rs从 call 到 future 成功完成计时,写进直方图
Delayhedge/delay.rs先睡 DelayPolicy 给出的时间,再调用内层
AsyncFilterfilter/睡醒后用 can_retry 决定真发还是永远不发
RotatingHistogramhedge/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 里。

📝NOTE
冷知识:Delay 以前会替对冲请求提前占位

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 里是上一个完整周期的数据。三个推论:

  1. 第一个周期里永远不对冲,不管已经完成了多少请求,那些数据还在 write 里。
  2. 每个周期开始时,可用的数据整体换一批。
  3. 流量中断超过两个周期,数据全部清空,恢复之后又要等一个周期才开始对冲。

下面的程序把这三点放在一条时间线上。周期 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(())
}
❗IMPORTANT
动脑筋:id=100、101、102 三个慢请求,哪几个会被对冲?被对冲的那个,第二份在第几毫秒发出?

发 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 的分位数。

💬 关联讨论

发起讨论
加载中...