写批量任务脚本时,你可能遇到过这种情况:服务器临时挂了,一堆客户端同时检测到失败,然后——按照同一个固定间隔——在完全相同的时刻发起重试。结果不是问题解决了,而是把服务器直接冲垮了。这种现象叫重试风暴(Thundering Herd),也叫惊群效应。问题的根源不在重试逻辑本身,而在重试时机太整齐。
固定间隔为什么会引发风暴
假设100台机器同时检测到API超时,约定每隔5秒重试一次。这100台机器从同一秒开始计时,下一次重试全部发生在T+5s、T+10s……每5秒就有100个请求同时砸过来。
指数退避能缓解这个问题——每次失败后等待时间翻倍。但指数退避有一个隐藏的前提:各客户端的初始延迟基准相同。如果大家的等待时间都是base×2^n,那么在某个时间窗口内,2^n相同的机器还是会撞在一起。
真正的解决方案是在等待时间上再加一层随机性——这就是jitter(抖动)。
jitter的核心思路:把"同步"变成"散列"
jitter的原理很简单:在计算下次等待时间时,加一个随机偏移量。把原来固定的时间线打散,让每个客户端的重试时间点尽量分散开。
常见的jitter策略有三种:
- 完全随机(Full Jitter):等待时间 = 随机(0, 当前间隔)。最激进,退避效果最强。
- 均匀抖动(Equal Jitter):等待时间 = 当前间隔/2 + 随机(0, 当前间隔/2)。保守一些,但仍有分散效果。
- 固定比例抖动:等待时间 = 当前间隔 × (1 + 随机系数)。折中方案。
bash实战:完全随机jitter
先看最暴力的完全随机写法:
#!/bin/bash
max_retries=5
base_delay=1
max_delay=32
attempt=0
call_api() {
curl -s -o /dev/null -w "%{http_code}" http://example.com/api
}
retry_with_jitter() {
local delay=$base_delay
for ((i=1; i<=max_retries; i++)); do
status=$(call_api)
if [[ "$status" -ge 200 && "$status" -lt 300 ]]; then
echo "请求成功 (状态码: $status)"
return 0
fi
echo "第${i}次失败,状态码: $status,准备重试..."
# 关键:jitter在这里——完全随机
jitter=$((RANDOM % delay))
sleep_time=$jitter
echo "等待 ${sleep_time}秒后重试..."
sleep $sleep_time
# 指数退避:下次等待时间翻倍,但不超过max_delay
delay=$((delay * 2))
[[ $delay -gt $max_delay ]] && delay=$max_delay
done
echo "重试次数耗尽,放弃"
return 1
}
retry_with_jitter
这里的逻辑很直接:每次失败后,等待时间是0到当前delay之间的随机值。因为完全随机,有些客户端可能下一秒就重试,有些要等30秒,整体流量就散开了。
bash实战:均匀抖动
完全随机可能导致某些请求等太久,影响用户体验。均匀抖动在退避力度和响应速度之间取了个平衡:
#!/bin/bash
max_retries=5
base_delay=1
max_delay=32
retry_equal_jitter() {
local delay=$base_delay
for ((i=1; i<=max_retries; i++)); do
# 模拟API调用
if ! curl -s --fail http://example.com/api > /dev/null 2>&1; then
echo "第${i}次请求失败"
# 均匀抖动:delay/2 + 随机(0, delay/2)
half=$((delay / 2))
jitter=$((RANDOM % (half + 1)))
sleep_time=$((half + jitter))
echo "均匀抖动: 等待 ${sleep_time}秒"
sleep $sleep_time
delay=$((delay * 2))
[[ $delay -gt $max_delay ]] && delay=$max_delay
else
echo "请求成功"
return 0
fi
done
return 1
}
retry_equal_jitter
均匀抖动的数学含义是:等待时间 = 基础值 + 随机偏移,范围是[delay/2, delay]。这样平均等待时间大约是3/4×delay,既比纯指数退避更快响应,又比完全随机更可控。
实战对比:有无jitter的流量差异
用一段模拟脚本直观感受一下区别。假设有50个客户端同时失败,记录它们各自首次重试的时间点:
#!/bin/bash
# 模拟50个客户端,首次重试时间分布
echo "=== 无jitter(固定5秒间隔)==="
for i in $(seq 1 50); do
echo "客户端$i 重试时间: 5秒"
done | sort -t: -k2 -n | awk -F: '{print NR " " $0}' | head -5
echo "...(50个客户端全部在5秒时刻重试)"
echo ""
echo "=== 完全随机jitter(0~5秒)==="
for i in $(seq 1 50); do
rnd=$((RANDOM % 6))
echo "$rnd"
done | sort -n | awk '{print NR ": " $1 "秒"}'
无jitter时50个请求集中在一个时间点;有jitter时,时间点均匀分散在0~5秒区间,峰值流量只有原来的1/10。
什么时候用哪种jitter
- 对延迟敏感、请求量不大:用均匀抖动,等待时间有下限,不会让请求拖太久。
- 对稳定性要求高、请求量大:用完全随机,最大程度分散流量。
- 第三方API有明确限流说明:看文档是否有推荐jitter策略,遵循官方建议。
一个避坑提醒
bash的$RANDOM范围是0~32767,适合小规模并发场景。如果你要在脚本里做精确的毫秒级jitter,可以借助date +%N(纳秒级精度)来生成随机数:
#!/bin/bash
# 毫秒级完全随机jitter
base_ms=1000
jitter_ms=$(( $(date +%N) / 1000000 % base_ms ))
sleep_sec=$(echo "scale=3; ($jitter_ms)/1000" | bc)
echo "等待 ${sleep_sec}秒"
sleep $sleep_sec
不过bc依赖不是所有系统都有,更通用的做法是用awk或者直接用秒级精度——批量任务对毫秒级差异本来就不敏感。
总结
重试风暴的根本原因是多个客户端在相同时间点做相同的事情。jitter通过对重试间隔加入随机偏移,把"同步"变成"散列",从根本上瓦解了流量尖峰。bash里实现jitter只需要一行随机数计算,完全随机或均匀抖动根据场景二选一,就能让批量任务的重试行为变得"文明"许多。
相关阅读:
发表评论