退避与重试预算
主线位置:不在两条主线上。它们是
Policy的零件:预算在Policy::retry里决定「还能不能重试」,退避给出Waiting状态要等的那个Sleep。重试状态机本身见 Policy 与重试状态机。
flowchart TB
res[内层返回结果] --> pol[Policy::retry]
pol --> ok{算成功吗}
ok -->|成功| dep["budget.deposit()"]
dep --> none1[返回 None]
ok -->|失败| wd{"budget.withdraw()"}
wd -->|false| none2[返回 None,不重试]
wd -->|true| nb["backoff.next_backoff()"]
nb --> some["返回 Some(Sleep)"]
some --> waiting[进入 Waiting]
Retry 不会替你调用这两个模块。上图是 budget 模块文档示例的做法(成功时存、失败时取)再加上退避;存取的时机、退避的节奏,全由你的 Policy 决定。
ExponentialBackoff
两个 trait
pub trait MakeBackoff {
/// The backoff type produced by this maker.
type Backoff: Backoff;
/// Constructs a new backoff type.
fn make_backoff(&mut self) -> Self::Backoff;
}
/// A backoff trait where a single mutable reference represents a single
/// backoff session. Implementors must also implement [`Clone`] which will
/// reset the backoff back to the default state for the next session.
pub trait Backoff {
/// The future associated with each backoff. This usually will be some sort
/// of timer.
type Future: Future<Output = ()>;
/// Initiate the next backoff in the sequence.
fn next_backoff(&mut self) -> Self::Future;
}
MakeBackoff 造出一次「退避会话」,Backoff 每调一次 next_backoff 给出下一段等待。Backoff::Future 的约束和 Policy::Future 一样是 Future<Output = ()>,所以 next_backoff() 的返回值可以直接作为 Policy::retry 的返回值。
这一套是 0.5.0 加的(#685),PR 说 ExponentialBackoff 的实现取自 linkerd2-proxy,是「the initial building blocks for a more fully batteries included retry policy」。到 0.5.3 为止,那个「开箱即用的策略」还没有出现。
参数校验
ExponentialBackoffMaker::new 返回 Result:
pub fn new(
min: time::Duration,
max: time::Duration,
jitter: f64,
rng: R,
) -> Result<Self, InvalidBackoff> {
if min > max {
return Err(InvalidBackoff("maximum must not be less than minimum"));
}
if max == time::Duration::from_millis(0) {
return Err(InvalidBackoff("maximum must be non-zero"));
}
if jitter < 0.0 {
return Err(InvalidBackoff("jitter must not be negative"));
}
if jitter > 100.0 {
return Err(InvalidBackoff("jitter must not be greater than 100"));
}
if jitter.is_nan() {
return Err(InvalidBackoff("jitter must not be NaN"));
}
// ...
InvalidBackoff 的 Display 在原因前加 invalid backoff: 。正负无穷被前两条范围检查拦下,所以最后只需要单独查 NaN(NaN 和任何数比较都是 false,会穿过前面两条)。这条 NaN 检查是 CHANGELOG 里 0.5.3 唯一一条 retry 条目(#843):之前写的是 !jitter.is_finite(),错误信息是 jitter must be finite,PR 的说法是「Infinities are already covered by the bounds checks on jitter, so checking for finiteness should be redundant」。
min 可以是 0,校验不拦,但 0 乘多少都是 0,抖动也按 0 算,于是每次退避都是 0(见下面实验的第 6 组)。
Default 是 min 50ms、max 为 u64::MAX 毫秒(相当于不封顶)、jitter 0.99,随机数用 HasherRng::default()。
每次等多久
fn base(&self) -> time::Duration {
// ...
self.min
.checked_mul(2_u32.saturating_pow(self.iterations))
.unwrap_or(self.max)
.min(self.max)
}
/// Returns a random, uniform duration on `[0, base*self.jitter]` no greater
/// than `self.max`.
fn jitter(&mut self, base: time::Duration) -> time::Duration {
if self.jitter == 0.0 {
time::Duration::default()
} else {
let jitter_factor = self.rng.next_f64();
// ...
let rand_jitter = jitter_factor * self.jitter;
let secs = (base.as_secs() as f64) * rand_jitter;
let nanos = (base.subsec_nanos() as f64) * rand_jitter;
let remaining = self.max - base;
time::Duration::new(secs as u64, nanos as u32).min(remaining)
}
}
fn next_backoff(&mut self) -> Self::Future {
let base = self.base();
let next = base + self.jitter(base);
self.iterations += 1;
tokio::time::sleep(next)
}
- 第 n 次(从 0 数)的基数是
min × 2ⁿ,溢出就取max,再和max取小。指数用saturating_pow,乘法用checked_mul,退避次数再多也不会 panic。 - 抖动是一个 0 到 1 之间(不含 1)的随机数 ×
jitter× 基数,再截到max - base,所以基数加抖动不会超过max。jitter是相对基数的比例:0.99 表示最多再加 99%。 - 秒和纳秒两部分分开乘,纳秒部分用
as u32转回整数。Rust 的浮点转整数是饱和的,jitter大于约 4.29 时纳秒部分可能超过u32::MAX,被截断(第 5 组)。 next_backoff返回tokio::time::Sleep,所以用到ExponentialBackoff就需要 tokio 的计时器,retryfeature 因此依赖tokio/time。
随机数来自 HasherRng:一个 RandomState 加一个计数器,每次 next_u64 把计数器哈希一遍,计数器加一。它 derive(Clone),复制时计数器也一起复制。而 make_backoff 每次都是 self.rng.clone(),maker 自己的 rng 从来不用。
下面的程序在暂停的 tokio 时钟下运行,用 Sleep::deadline() - Instant::now() 读出每次退避的时长。Max 是一个总返回 u64::MAX 的 Rng,让 next_f64() 恒为略小于 1 的值,抖动取到上限:
tower = { version = "=0.5.3", features = ["retry"] }
tokio = { version = "=1.54.1", features = ["macros", "rt", "time", "test-util"] }
use std::time::Duration;
use tokio::time::Instant;
use tower::{
retry::backoff::{Backoff, ExponentialBackoffMaker, MakeBackoff},
util::rng::{HasherRng, Rng},
};
// Always returns the largest value, so next_f64() is just below 1.0
#[derive(Clone)]
struct Max;
impl Rng for Max {
fn next_u64(&mut self) -> u64 {
u64::MAX
}
}
fn delays<B: Backoff<Future = tokio::time::Sleep>>(b: &mut B, n: usize) -> Vec<Duration> {
(0..n).map(|_| b.next_backoff().deadline() - Instant::now()).collect()
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let ms = Duration::from_millis;
println!("1. jitter 0.0, min 100ms, max 1s");
let mut maker = ExponentialBackoffMaker::new(ms(100), ms(1000), 0.0, HasherRng::default()).unwrap();
println!(" {:?}", delays(&mut maker.make_backoff(), 6));
println!("2. jitter 1.0, rng always ~1.0");
let mut maker = ExponentialBackoffMaker::new(ms(100), ms(1000), 1.0, Max).unwrap();
println!(" {:?}", delays(&mut maker.make_backoff(), 6));
println!("3. two backoffs from one maker, HasherRng");
let mut maker = ExponentialBackoffMaker::new(ms(100), ms(10_000), 1.0, HasherRng::default()).unwrap();
let mut a = maker.make_backoff();
let mut b = maker.make_backoff();
println!(" a {:?}", delays(&mut a, 3));
println!(" b {:?}", delays(&mut b, 3));
println!("4. clone after two backoffs");
let mut c = maker.make_backoff();
delays(&mut c, 2);
let mut d = c.clone();
println!(" c {:?}", delays(&mut c, 1));
println!(" d {:?}", delays(&mut d, 1));
println!("5. jitter 10.0, base 1.9s, rng always ~1.0");
let mut maker = ExponentialBackoffMaker::new(ms(1900), ms(60_000), 10.0, Max).unwrap();
println!(" {:?}", delays(&mut maker.make_backoff(), 1));
println!("6. min 0, max 1s, jitter 1.0");
let mut maker = ExponentialBackoffMaker::new(ms(0), ms(1000), 1.0, HasherRng::default()).unwrap();
println!(" {:?}", delays(&mut maker.make_backoff(), 4));
println!("7. validation");
let cases = [
("min > max", ms(2), ms(1), 0.5),
("max == 0", ms(0), ms(0), 0.5),
("jitter < 0", ms(1), ms(2), -0.1),
("jitter > 100", ms(1), ms(2), 100.5),
("jitter inf", ms(1), ms(2), f64::INFINITY),
("jitter NaN", ms(1), ms(2), f64::NAN),
];
for (name, min, max, jitter) in cases {
match ExponentialBackoffMaker::new(min, max, jitter, HasherRng::default()) {
Ok(_) => println!(" {name:<18} Ok"),
Err(e) => println!(" {name:<18} Err({e})"),
}
}
}
M2 Max、rustc 1.99.0、--release,其中一次的输出:
1. jitter 0.0, min 100ms, max 1s
[100ms, 200ms, 400ms, 800ms, 1s, 1s]
2. jitter 1.0, rng always ~1.0
[199.999999ms, 399.999999ms, 799.999999ms, 1s, 1s, 1s]
3. two backoffs from one maker, HasherRng
a [147.230554ms, 397.855367ms, 770.772776ms]
b [147.230554ms, 397.855367ms, 770.772776ms]
4. clone after two backoffs
c [770.772776ms]
d [770.772776ms]
5. jitter 10.0, base 1.9s, rng always ~1.0
[15.194967295s]
6. min 0, max 1s, jitter 1.0
[0ns, 0ns, 0ns, 0ns]
7. validation
min > max Err(invalid backoff: maximum must not be less than minimum)
max == 0 Err(invalid backoff: maximum must be non-zero)
jitter < 0 Err(invalid backoff: jitter must not be negative)
jitter > 100 Err(invalid backoff: jitter must not be greater than 100)
jitter inf Err(invalid backoff: jitter must not be greater than 100)
jitter NaN Err(invalid backoff: jitter must not be NaN)
连跑 5 次,第 3、4 组的具体数值每次都不同(RandomState 每次运行的种子不同),但「a 和 b 完全相同」「c 和 d 完全相同」「c 等于 a 的第三个值」每次都成立;其余各组每次相同。
- 第 1 组:100、200、400、800,然后封顶在 1s。
- 第 2 组:抖动取到上限,每次几乎翻倍,但第 4 次起被
max - base截住,总和正好 1s。 - 第 3 组:同一个 maker 造出的退避,抖动序列完全相同。 原因就是上面说的
self.rng.clone():每个ExponentialBackoff都从 maker 的那个计数器起点开始。抖动的本意是让同时失败的请求错开重试时间(ExponentialBackoff的文档说「in order to avoid retry spikes」),但同一个进程里从同一个 maker 出来的请求,第 n 次重试会等完全一样长。不同进程的RandomState种子不同,彼此之间仍然是错开的。 - 第 4 组:
clone不会重置。Backoff的文档说实现者的Clone「will reset the backoff back to the default state」,但ExponentialBackoff是derive(Clone),iterations和随机数计数器原样复制,d接着c的进度走。要从头开始,得用make_backoff造新的。 - 第 5 组:基数 1.9s,抖动比例接近 10,按公式应该是 1.9s + 约 19s。实际秒部分
1 × 9.99…截成 9,纳秒部分9×10⁸ × 9.99…约 9×10⁹,超过u32::MAX,饱和成 4294967295ns,Duration::new把它进位成 4.29…s。结果 1.9 + 9 + 4.294967295 = 15.194967295s。jitter不超过 1 的常见配置碰不到这个问题。 - 第 7 组:正无穷报的是
greater than 100,不是 NaN 那条。
Budget:重试是放大器
只按「最多重试 N 次」来配置重试,budget 的模块文档指出了两个问题:N 是拍脑袋定的;更要命的是重试风暴。下游故障率一上升,客户端开始重试,重试又加重了下游的负载,失败更多,重试更多。文档的原话:「If each client is configured to retry up to 3 times, this can quadruple the number of requests being sent!」如果客户端的客户端也配了重试,倍数还要相乘。
预算的思路是让重试的总量跟着成功的总量走:成功的请求往预算里存一笔,每次重试从预算里取一笔,取不出来就不重试。下游大面积失败时没有新的存款,重试自然停下来。
Budget trait 只有两个方法:
pub trait Budget {
/// Store a "deposit" in the budget, which will be used to permit future
/// withdrawals.
fn deposit(&self);
/// Check whether there is enough "balance" in the budget to issue a new
/// retry.
///
/// If there is not enough, false is returned.
fn withdraw(&self) -> bool;
}
两个方法都是 &self:预算要被所有请求共享,而策略每个请求一份副本,所以预算通常放在 Arc 里,副本之间共享同一个。什么时候 deposit、什么时候 withdraw,trait 不管。模块文档的示例是:retry 收到 Ok 就 deposit 并返回 None,收到 Err 就 withdraw,取到了才重试。
Budget 以前是一个结构体0.5.0 之前,tower::retry::budget::Budget 是具体的结构体,withdraw 返回 Result<(), Overdrawn>,换不了别的算法。#703(0.5.0)把 Budget 改成 trait,原来的结构体改名叫 TpsBudget,withdraw 改成返回 bool,Overdrawn 类型删掉了。CHANGELOG 的说法:「Budget is now a trait. This allows end-users to implement their own budget and bucket implementations.」PR 描述里还提到一个按重试次数递增成本的 SimpleBucket,0.5.3 里并没有这个类型,TpsBudget 是唯一的实现。
TpsBudget:十个窗口的滑动计数
pub struct TpsBudget {
generation: Mutex<Generation>,
/// Initial budget allowed for every second.
reserve: isize,
/// Slots of a the TTL divided evenly.
slots: Box<[AtomicIsize]>,
/// The amount of time represented by each slot.
window: Duration,
/// The changers for the current slot to be committed
/// after the slot expires.
writer: AtomicIsize,
/// Amount of tokens to deposit for each put().
deposit_amount: isize,
/// Amount of tokens to withdraw for each try_get().
withdraw_amount: isize,
}
文档叫它「Transactions Per Minute config」,又说它「uses a token bucket」。从代码看,参数的单位是秒,结构是把 ttl 均分成 10 个槽的滑动窗口计数器:
writer:当前窗口里存取的净额,存款加、取款减。slots:过去 10 个窗口各自的净额。reserve:一笔固定的底数,永远算在余额里。generation:当前窗口的下标和开始时间,只有它用Mutex,计数都是原子操作。
new:把比例换成整数
new:
pub fn new(ttl: Duration, min_per_sec: u32, retry_percent: f32) -> Self {
// assertions taken from finagle
assert!(ttl >= Duration::from_secs(1));
assert!(ttl <= Duration::from_secs(60));
assert!(retry_percent >= 0.0);
assert!(retry_percent <= 1000.0);
assert!(min_per_sec < i32::MAX as u32);
let (deposit_amount, withdraw_amount) = if retry_percent == 0.0 {
// If there is no percent, then you gain nothing from deposits.
// Withdrawals can only be made against the reserve, over time.
(0, 1)
} else if retry_percent <= 1.0 {
(1, (1.0 / retry_percent) as isize)
} else {
// Support for when retry_percent is between 1.0 and 1000.0,
// meaning for every deposit D, D * retry_percent withdrawals
// can be made.
(1000, (1000.0 / retry_percent) as isize)
};
let reserve = (min_per_sec as isize)
.saturating_mul(ttl.as_secs() as isize) // ttl is between 1 and 60 seconds
.saturating_mul(withdraw_amount);
// AtomicIsize isn't clone, so the slots need to be built in a loop...
let windows = 10u32;
// ...
window: ttl / windows,
// ...
}
- 参数不合法直接
assert!panic,不像ExponentialBackoffMaker::new返回Result。ttl只能在 1 到 60 秒之间,注释说这些限制来自 Finagle。 - 比例换成「存一次加几、取一次减几」两个整数。
retry_percent为 0.2 时存 1 取 5,五次成功换一次重试。除法结果用as isize截断,所以1 / retry_percent不是整数时,实际能重试的比例比配置的高。 reserve = min_per_sec × ttl秒数 × withdraw_amount,正好够min_per_sec × ttl次取款。它照顾刚启动、还没攒下成功记录的客户端。- 窗口数写死为 10,每个窗口
ttl / 10。
Default 是 TpsBudget::new(Duration::from_secs(10), 10, 0.2):存 1 取 5,reserve = 10 × 10 × 5 = 500,窗口 1 秒。
问: min_per_sec 是 10,是不是说每秒至少(或者最多)能重试 10 次?
答: 都不是。reserve 不是每秒补充一次的令牌,而是一个常数,一直算在余额里;真正随时间变化的是取款记录,它在 writer 和 slots 里待满大约一个 ttl 后才被清掉。所以默认配置下,一个请求都没成功过的客户端可以一口气重试 100 次(min_per_sec × ttl),然后要等这些取款记录过期,大约 10 秒后才能再重试。下面实验的第 1、2 组就是这个情形。
存、取、过期
put 和 try_get 都先调 expire,再改 writer:
fn put(&self, amt: isize) {
self.expire();
self.writer.fetch_add(amt, Ordering::SeqCst);
}
fn try_get(&self, amt: isize) -> bool {
debug_assert!(amt >= 0);
self.expire();
let sum = self.sum();
if sum >= amt {
self.writer.fetch_add(-amt, Ordering::SeqCst);
true
} else {
false
}
}
sum 是 writer + 所有槽 + reserve,都用饱和加法。try_get 先算余额、再扣,两步之间没有锁,并发的取款可能同时看到同一个余额。
expire 负责让窗口往前走:
fn expire(&self) {
let mut gen = self.generation.lock().expect("generation lock");
let now = Instant::now();
let diff = now.saturating_duration_since(gen.time);
if diff < self.window {
// not expired yet
return;
}
let to_commit = self.writer.swap(0, Ordering::SeqCst);
self.slots[gen.index].store(to_commit, Ordering::SeqCst);
let mut diff = diff;
let mut idx = (gen.index + 1) % self.slots.len();
while diff > self.window {
self.slots[idx].store(0, Ordering::SeqCst);
diff -= self.window;
idx = (idx + 1) % self.slots.len();
}
gen.index = idx;
gen.time = now;
}
- 距上次推进不到一个窗口,什么都不做。
- 否则把
writer的净额写进当前下标的槽,writer清零。槽是覆盖写入的,上一轮(约 10 个窗口前)留在这个槽里的数就此作废,这就是「过期」。 - 中间整段跳过的窗口,对应的槽清零。条件是严格的
diff > self.window。 - 新的下标指向的槽不清零,它的旧值(约 10 个窗口前的)要等下一次推进时才被覆盖,在那之前仍然算在余额里。
gen.time = now,窗口从这一刻重新算,不和整秒对齐。
now 来自 tokio::time::Instant,所以可以用 tokio 的暂停时钟精确地推时间:
tower = { version = "=0.5.3", features = ["retry"] }
tokio = { version = "=1.54.1", features = ["macros", "rt", "time", "test-util"] }
use std::time::Duration;
use tokio::time::{advance, Instant};
use tower::retry::budget::{Budget, TpsBudget};
fn drain(b: &TpsBudget) -> u32 {
let mut n = 0;
while b.withdraw() {
n += 1;
}
n
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
println!("1. default");
let b = TpsBudget::default();
println!(" {b:?}");
println!(" withdrawals with no deposits: {}", drain(&b));
for _ in 0..100 {
b.deposit();
}
println!(" after 100 deposits: {}", drain(&b));
println!("2. when does the reserve come back? (one withdraw attempt every 500ms)");
let start = Instant::now();
loop {
advance(Duration::from_millis(500)).await;
if b.withdraw() {
println!(" first success at +{:?}", Instant::now() - start);
break;
}
}
println!("3. one jump after exhausting");
for jump in [10_000, 10_001] {
let b = TpsBudget::default();
drain(&b);
advance(Duration::from_millis(jump)).await;
println!(" after {jump}ms: {} withdrawals", drain(&b));
}
}
M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同:
1. default
Budget { deposit: 1, withdraw: 5, balance: 500 }
withdrawals with no deposits: 100
after 100 deposits: 20
2. when does the reserve come back? (one withdraw attempt every 500ms)
first success at +11s
3. one jump after exhausting
after 10000ms: 0 withdrawals
after 10001ms: 100 withdrawals
- 第 1 组:
Debug里的balance就是sum(),起始 500 全是reserve。没有存款时能取 100 次(500 / 5)。之后 100 次成功存进 100,正好再换 20 次重试,这就是 20% 的比例。 - 第 2 组:每 500ms 试一次,11 秒时才第一次成功,比
ttl多一个窗口。时间是暂停的,推进全靠withdraw里的expire:每隔一次调用推进一个窗口,第 10 秒时下标转回 0,0 号槽里还是 0 秒时那 −500,没被清掉(新下标的槽不清零);到第 11 秒的推进才把它覆盖成 0。 - 第 3 组:一次跳 10000ms,循环里
diff > window在diff减到正好 1 秒时停下,下标又落回 0 号槽,旧的 −500 还在,一次都取不出;多 1ms,0 号槽也被清零,reserve全部回来。
所以一笔存款或取款的有效期大约是 ttl,视调用的时机,最多再多一个窗口。
retry_percent 设成 0.6,存 10 次之后能取几次?下面用 ttl 1 秒、min_per_sec 0(没有 reserve),对几个比例各存 10 次,再取到取不出为止。0.6 那一行的取款次数是 6 吗?
tower = { version = "=0.5.3", features = ["retry"] }
tokio = { version = "=1.54.1", features = ["macros", "rt", "time", "test-util"] }
use std::time::Duration;
use tower::retry::budget::{Budget, TpsBudget};
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
// ttl 1s, min_per_sec 0, then 10 deposits
for pct in [0.2_f32, 0.3, 0.4, 0.6, 2.0] {
let b = TpsBudget::new(Duration::from_secs(1), 0, pct);
for _ in 0..10 {
b.deposit();
}
let mut n = 0;
while b.withdraw() {
n += 1;
}
println!("{pct}: {b:?} -> {n} withdrawals");
}
}
动脑筋答案
M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同:
0.2: Budget { deposit: 1, withdraw: 5, balance: 0 } -> 2 withdrawals
0.3: Budget { deposit: 1, withdraw: 3, balance: 1 } -> 3 withdrawals
0.4: Budget { deposit: 1, withdraw: 2, balance: 0 } -> 5 withdrawals
0.6: Budget { deposit: 1, withdraw: 1, balance: 0 } -> 10 withdrawals
2: Budget { deposit: 1000, withdraw: 500, balance: 0 } -> 20 withdrawals
(balance 是取完之后打印的剩余。)
答案是 10,不是 6。
- 0.2:1 / 0.2 = 5,没有截断,10 次存款换 2 次重试,正好 20%。
- 0.3:1 / 0.3 = 3.33…,截成 3。10 次存款换 3 次,剩 1。比例接近配置值,只是向上偏了一点。
- 0.4:1 / 0.4 = 2.5,截成 2,10 次存款换 5 次,实际 50%。
- 0.6:1 / 0.6 = 1.67,截成 1,每次存款换一次重试,实际 100%。0.5 到 1.0 之间的所有配置都会变成 100%。
- 2.0:走第三个分支,存 1000 取 500,每次存款换 2 次重试,正好 200%。
要让比例准确,就选 1 除以它是整数的值(0.1、0.2、0.25、0.5、1.0),或者大于 1 时选 1000 除以它是整数的值。
小结
ExponentialBackoff:第 n 次等min × 2ⁿ加[0, jitter × 基数)的抖动,总和封顶max,返回tokio::time::Sleep。参数错误返回InvalidBackoff,min为 0 时每次都是 0。- 同一个 maker 造出的退避抖动序列完全相同,
clone不会重置进度;jitter大于约 4.29 时纳秒部分会被截断。 Budget只有deposit/withdraw,都是&self,放在Arc里由策略的各个副本共享;存取时机由策略决定。TpsBudget把ttl切成 10 个窗口滑动计数,余额 = 当前窗口 + 10 个槽 + 固定的reserve。默认 10 秒、reserve够 100 次取款、成功 5 次换 1 次重试。retry_percent被截断成整数比,1 除以它不是整数时实际比例偏高;参数不合法直接 panic。