负载均衡、超时与重试:一次调用怎样变成故障放大器
同一个 GET 请求,第一次遇到 503,换一台实例后仍然是 503。调用者看到一次失败,两台服务器却各处理了一次请求。如果入口代理和业务客户端都允许再试,故障期间的实际流量还会继续增加。
要计算一次调用的成本,需要知道请求发给谁、在哪些阶段等待,以及失败后是否还会再次发送。三个问题分别落在实例选择、超时控制和重试策略上。
从实例集合中选一个目标
负载均衡发生在哪一层
客户端负载均衡在发起请求的进程内选址。应用先取得服务实例列表,再把 http://workers/work 中的逻辑服务名转换为某台机器的地址。服务端负载均衡则提供一个统一入口,由代理把连接或请求转交给后端。两种方式可以同时存在,例如浏览器先经过入口代理,订单服务再用客户端负载均衡调用库存服务。
四层负载均衡主要依据连接的网络信息分配目标。一个已建立的 TCP 连接通常持续关联同一目标;连接里发送许多 HTTP 请求时,增加后端实例不会自动把这些请求重新均分。七层代理能够解释 HTTP,按 Host、路径或 Header 选择上游,并在不同请求之间复用后端连接。具体产品还可能支持 UDP、TLS 透传、连接排空等模式,不能只根据“四层/七层”推导所有默认行为。AWS Network Load Balancer和 NGINX HTTP 负载均衡分别给出了连接分配与 HTTP upstream 的实际实现。
常见算法关注的量不同:
| 算法 | 选择依据 | 容易出现的问题 |
|---|---|---|
| 轮询 Round Robin | 按顺序轮转实例 | 请求数接近,CPU 时间和数据库成本仍可能相差很大 |
| 随机 | 随机选择候选项 | 少量请求容易偏斜,观察窗口太短会误判 |
| 加权 | 为容量较大的节点分配更多机会 | 静态权重不能及时反映节点故障或下游拥塞 |
| 最少连接/并发 | 优先选择正在处理工作较少的目标 | 长连接数量与实际请求负载可能不一致 |
| 一致性哈希 | 按用户、资源等 key 稳定选址 | 热点 key 仍然集中;实例变更会迁移部分映射 |
| 延迟感知 | 根据完成时间和错误反馈调整 | 小样本、冷启动和快速切换可能造成反馈震荡 |
算法之前还有一道更基本的处理:哪些实例有资格参加选择。区域、版本、健康、灰度标签可以依次缩小候选集合。选择顺序因此很重要——先按租户限定范围再轮询,与先轮询再检查租户,结果完全不同。
Spring Cloud LoadBalancer 的对象关系
Spring Cloud LoadBalancer 用 ServiceInstanceListSupplier 提供实例,用 ReactorServiceInstanceLoadBalancer 选择一个目标,默认实现是 RoundRobinLoadBalancer。每个 serviceId 有独立的客户端上下文,可以单独配置实例供应和算法。LoadBalancer 参考文档列出了这些扩展点。
逻辑地址:http://workers/work
│
▼
ServiceInstanceListSupplier
获取 workers 的实例
可选:健康检查、区域或请求标签筛选
│
▼
RoundRobinLoadBalancer
选择 a:8080 或 b:8080
│
▼
WebClient HTTP 连接池
获取到选定地址的连接 → 发送请求注册中心和负载均衡器各有可能缓存实例。Eureka Client 已保留本地列表,再叠加另一层较长缓存,会增加摘除故障节点的延迟。配置缓存时要沿实际供应链确认来源、刷新周期及停机通知,而不是分别把每个组件的缓存都打开。
无注册中心的本地实验可以使用 SimpleDiscoveryClient:Spring 从配置读取实例 URI,再提供标准 DiscoveryClient 接口。它没有注册、心跳和动态推送;地址改变后,需要相应配置生效机制或重启。Spring Cloud Commons说明了这一静态实现及 @LoadBalanced WebClient.Builder 的接入方式。
健康检查的返回策略要按版本确认
健康检查可以过滤实例,也可能在全部失败时采用“仍返回全部”的可用性优先策略。这一选择会直接改变故障时的表现:空列表在发送请求前拒绝;返回故障地址则继续产生连接或 HTTP 错误。
锁定到 Spring Cloud LoadBalancer 5.0.3,HealthCheckServiceInstanceListSupplier 的实际实现会在所有健康请求失败时发出空列表;一台恢复后,只返回恢复的实例。本地工程通过两台真实 HTTP /health 验证这一行为。参考页和类注释里仍可见“全失败时返回全部”的描述,排查该版本应结合 5.0.3 的 healthCheckFlux 实现核对。
健康过滤器自身的检查周期,与实例发现的刷新周期也不同。只重复检查最初拿到的列表,会错过后来加入的节点;是否重新获取列表应与上游 Supplier 是否持续推送配合设置。
一次调用会等待哪些阶段
几个 timeout 约束的是不同时间段
把“HTTP 超时设为两秒”写进配置前,先查清这个两秒覆盖什么。
| 阶段 | 常见控制项 | 超过限制后的含义 |
|---|---|---|
| 入口及业务排队 | 接入队列、执行器队列、应用整体时限 | 请求可能还没开始业务处理 |
| 等连接池 | pending acquire timeout、等待队列长度 | 当前连接不足,或已有调用长期占用连接 |
| DNS 解析 | resolver query timeout 等 | 尚未取得可连接的地址 |
| TCP 建连 | connect timeout | 与目标建立连接未及时完成 |
| TLS 握手 | handshake timeout | TCP 已建立,安全协商还未完成 |
| 请求发送 | 写入超时或整体时限 | 请求可能已经部分发送 |
| 服务处理与读取 | response/read timeout | 必须查具体客户端定义,可能是两次读取之间的间隔 |
| 一组尝试及退避 | 外层整体 timeout 或 deadline | 整组操作不能无限延长 |
Reactor Netty 的 responseTimeout 约束读取响应时网络读操作之间允许的间隔,不能代替连接池等待、DNS、建连以及整次重试链的总时限。连接池还有独立的最大连接数、待获取队列和获取超时。Reactor Netty HTTP Client对这些阶段分别提供配置。
复用已有连接时,不会每次重新支付 DNS、TCP 和 TLS 的全部成本。冷启动首个请求、连接闲置后被对端关闭、连接池扩容,都会让同一接口的耗时构成发生变化。平均耗时相同的两个实例,也可能有截然不同的尾延迟。
总时限应包住重试和响应体消费
工程中的调用返回一个 Mono<ResponseEntity<String>>,只有响应体读完才发出结果。把 timeout 放在这个 Mono 外层,可以限制选址、连接、重试退避和完整响应体消费组成的这一段操作。
return client.get().uri("http://workers/work?mode=" + mode)
.exchangeToMono(response ->
response.bodyToMono(String.class).defaultIfEmpty("")
.map(body -> ResponseEntity.status(response.statusCode())
.contentType(MediaType.APPLICATION_JSON).body(body)))
.timeout(Duration.ofMillis(overallMs));这里的起点是订阅这一条调用链的时刻,不含请求到达 Controller 之前的排队,也不含 ResponseEntity 返回后向上游客户端写完响应的时间。Mono.timeout 的准确语义可查 Reactor API。流式 Flux 若只限制相邻元素间隔,仍需另外考虑总持续时间、最大消息量和用户取消。
跨服务传递 deadline 时,下游要扣除已经消耗的时间和返回所需余量。使用绝对时间需要考虑时钟偏差;传剩余时长也要明确网络传输、队列及本地计时的处理。gRPC 有专用 deadline 机制;普通 HTTP Header 则需要服务双方约定并校验,不能接受外部用户传入任意长的时限。
取消等待后,远端可能继续工作
客户端超时会结束当前等待,HTTP 客户端通常还会取消相应订阅或连接操作。但服务端可能正在执行不检查取消信号的工作,或已经提交事务。
判断是否重试写操作,需要知道业务能否识别重复请求。创建订单通常使用稳定幂等键、唯一约束和结果查询;超时后换一台机器,再生成一个新请求编号,会绕过原本的去重。HTTP 的幂等定义关注多次相同请求的预期效果,不要求每次响应完全相同;具体方法语义见 RFC 9110。
后面的慢请求实验使用进程内计数,专门观察“调用者已结束,服务端仍完成”的时间差,不涉及数据库提交。
观察真实选址、重试和整体超时
准备源码和运行身份
下载 完整实验工程 ZIP,源码入口见解压目录中的 README.md。
使用 Linux、Bash、Docker Engine 与 Compose 插件,安装 curl、jq、unzip。Java 由容器提供:Maven 3.9.12 / JDK 25 构建,编译目标 Java 17;应用运行镜像为 Temurin 25.0.4_7。Boot 4.1.1 与 Cloud 版本 2025.1.3 的组合属于 Spring Cloud 支持版本线。
以下假定 ZIP 已保存到当前目录。
set -euo pipefail
test "$(id -u)" -ne 0 || exit 1
for tool in docker curl jq unzip; do command -v "$tool" || exit 1; done
docker info >/dev/null || exit 1
docker compose version
LAB_ROOT=$(mktemp -d)
unzip microservice-loadbalance-lab.zip -d "$LAB_ROOT"
cd "$LAB_ROOT/microservice-loadbalance-lab"
mkdir -p .m2
chmod -R a+rX worker caller宿主普通用户必须有 Docker 使用权限;这项权限本身具有很高的主机控制能力。构建容器映射当前 UID/GID,Maven 缓存写入当前工程的 .m2。
docker run --rm \
--user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/m2 -e MAVEN_OPTS=-Duser.home=/tmp \
-v "$PWD:/work" -v "$PWD/.m2:/m2" -w /work \
maven:3.9.12-eclipse-temurin-25 \
mvn -B -ntp -Dmaven.repo.local=/m2 clean verify应得到 BUILD SUCCESS,六个测试通过。测试实际启动两台 HTTP Worker,以及有重试、无重试、无实例三种 Spring 客户端;健康过滤器也使用真实 HTTP 响应。若依赖下载失败,先检查镜像仓库和 Maven 网络;若是断言失败,查看 caller/target/surefire-reports,不要跳过测试继续演示。
构建产物是 worker/target/worker.jar 和 caller/target/caller.jar。运行时五个进程均为 UID/GID 10001,JAR 只读挂载,写入目录限于临时文件系统。端口仅绑定宿主回环地址。
docker compose up -d
docker compose ps
docker compose exec -T retry idid 应显示 UID 10001。三个客户端端口为 18171、18172、18175;Worker a/b 为 18173、18174。启动并不意味着立即就绪,先等待各客户端健康端点:
for port in 18171 18172 18175; do
ready=0
for i in $(seq 1 60); do
if curl -q --noproxy '*' --fail-with-body --silent \
--max-time 2 "http://127.0.0.1:$port/actuator/health" |
jq -e '.status=="UP"' >/dev/null; then
ready=1
break
fi
sleep 1
done
test "$ready" -eq 1 || { docker compose logs --tail 80; exit 1; }
donecurl 的 -q 必须放在首个选项位置以跳过默认配置文件;--noproxy '*' 保证这些回环检查不经过环境代理。其他选项及退出码见 curl 手册。
第一次成功:观察两个真实目标
for i in 1 2 3 4; do
curl -q --noproxy '*' --fail-with-body --silent --show-error \
--max-time 8 http://127.0.0.1:18171/call
printf '\n'
done响应包含 {"node":"a","result":"OK"} 或 {"node":"b","result":"OK"}。四次顺序请求应覆盖 a 和 b,首次从哪台开始没有固定承诺。每台 Worker 的 /counts 记录真实开始和完成次数。
Compose 向客户端注入的地址等价于:
spring.cloud.discovery.client.simple.instances.workers[0].uri=http://a:8080
spring.cloud.discovery.client.simple.instances.workers[1].uri=http://b:8080实际发送路径仍然是 @LoadBalanced WebClient.Builder。删除该注解后,普通 HTTP 客户端会把 workers 当作网络主机解析,不再经过 Spring Cloud 的逻辑服务选址。
一次 503 产生两次下游请求
启用重试的客户端有以下关键配置:
spring.cloud.loadbalancer.retry.enabled=true
spring.cloud.loadbalancer.retry.max-retries-on-same-service-instance=0
spring.cloud.loadbalancer.retry.max-retries-on-next-service-instance=1
spring.cloud.loadbalancer.retry.retryable-status-codes=503
spring.cloud.loadbalancer.retry.avoid-previous-instance=true
spring.cloud.loadbalancer.retry.backoff.enabled=true
spring.cloud.loadbalancer.retry.backoff.min-backoff=10ms
spring.cloud.loadbalancer.retry.backoff.max-backoff=20ms
spring.cloud.loadbalancer.retry.backoff.jitter=0.5这里是首次请求加一次换实例重试,最多两次尝试。max-retries-on-next-service-instance 表达重试次数。默认只对 GET 开放这一类操作重试;异常和响应码还要通过各自条件。准确字段见 5.0.3 LoadBalancerProperties,真实响应码重试发生在 RetryableLoadBalancerExchangeFilterFunction。
先定义一个计数读取函数,以及对预期错误分别检查传输和 HTTP 状态的函数:
counts() {
local a b
a=$(curl -q --noproxy '*' --fail-with-body --silent --show-error \
--max-time 3 http://127.0.0.1:18173/counts) || return 1
b=$(curl -q --noproxy '*' --fail-with-body --silent --show-error \
--max-time 3 http://127.0.0.1:18174/counts) || return 1
jq -n --argjson a "$a" --argjson b "$b" \
'{a:$a.started,b:$b.started,started:($a.started+$b.started),completed:($a.completed+$b.completed)}'
}
negative() {
local code
code=$(curl -q --noproxy '*' --silent --show-error --max-time 8 \
-o response.json -w '%{http_code}' "$1") || return 1
test "$code" = "$2" || { printf 'unexpected HTTP %s\n' "$code"; return 1; }
}让每台 Worker 都返回 503,检查两个实例各增加一次:
before=$(counts) || exit 1
negative 'http://127.0.0.1:18171/call?mode=fail' 503 || exit 1
jq -e '.result=="UNAVAILABLE"' response.json
after=$(counts) || exit 1
jq -ne --argjson a "$after" --argjson b "$before" \
'$a.a-$b.a==1 and $a.b-$b.b==1'应得到 true。客户端最后仍返回 503,但请求已到达两台 Worker。若计数增加超过两次,先停止其他调用,再检查实际配置和底层客户端是否还有独立重试,不要只依据终态响应推算次数。
18172 客户端唯一相关差异是 spring.cloud.loadbalancer.retry.enabled=false:
curl -q --noproxy '*' --fail-with-body --silent --show-error \
--max-time 8 http://127.0.0.1:18172/call
before=$(counts) || exit 1
negative 'http://127.0.0.1:18172/call?mode=fail' 503 || exit 1
after=$(counts) || exit 1
jq -ne --argjson a "$after" --argjson b "$before" \
'$a.started-$b.started==1'此时应只增加一次。预热成功请求在基线之前执行,不计入后面的差值。
18175 客户端没有实例配置。它返回 503,正文为 LoadBalancer does not contain an instance for the service workers,下游计数保持不变:
before=$(counts) || exit 1
negative http://127.0.0.1:18175/call 503 || exit 1
grep -q 'workers' response.json || exit 1
after=$(counts) || exit 1
jq -ne --argjson a "$after" --argjson b "$before" \
'$a.started==$b.started'这两个 503 的产生位置不同:前一个来自实际 Worker,后一个在客户端无实例时构造。诊断时应结合服务名、实例地址和下游是否收到请求区分。
整体 200ms 超时后,远端 600ms 动作仍然完成
Worker 的 mode=slow 会等待 600ms,然后增加完成计数。它没有收到取消后撤销动作的合作逻辑。
before=$(counts) || exit 1
negative 'http://127.0.0.1:18171/call?mode=slow&overallMs=200' 504 || exit 1
jq -e '.error=="OVERALL_TIMEOUT"' response.json
for i in $(seq 1 30); do
after=$(counts) || exit 1
if jq -ne --argjson a "$after" --argjson b "$before" \
'$a.completed-$b.completed==1' >/dev/null; then break; fi
sleep 0.1
done
jq -ne --argjson a "$after" --argjson b "$before" \
'$a.started-$b.started==1 and $a.completed-$b.completed==1'应看到调用端 504,随后计数判断为 true。外层 timeout 结束了这组尝试,未再发起第二次;已在 Worker 中执行的任务继续完成。
超时会受到机器调度影响,因此先完成上面的正常请求以预热。若下游开始计数为零,可能在选址或连接前已经超时;该次结果只能说明“未观察到 Worker 开始”,不能用来展示远端残留工作。确认机器负载和正常调用后重新执行。
最后恢复普通请求:
curl -q --noproxy '*' --fail-with-body --silent --show-error \
--max-time 8 http://127.0.0.1:18171/call
bash verify.sh
docker compose down普通请求应重新得到 result=OK。verify.sh 是已启动环境下的整组复验入口。down 删除这一工程的容器和网络,进程内计数随 Worker 消失;源码、缓存及 JAR 留在 LAB_ROOT 中供继续查阅。
处理流量偏斜、重试风暴与慢请求残留
先确认请求到底在哪一层分配
扩容后只有旧节点忙,常见原因包括客户端实例缓存未更新、长连接仍指向旧节点、灰度筛选排除了新节点,或新实例没有通过健康检查。应依次查看调用进程持有的实例列表、实际选中的地址及连接复用情况。只看注册中心“节点已上线”,还不足以知道调用端是否已使用它。
连接池满时增加连接数可能暂时减少获取等待,也可能立即把更多并发压到已变慢的依赖。同时观察 active/pending connection、请求耗时和服务端正在执行的请求数,找出占用来自正常吞吐还是慢调用堆积。每个进程的连接上限还要乘实例数;十台客户端各允许一百条连接,下游面对的是一千条潜在连接。
给重试一个明确的入口
三层调用若每层“重试两次”,每层最多三次尝试,最坏可以产生 3 × 3 × 3 = 27 次末端请求。若每层规定“总共尝试两次”,才是 2 × 2 × 2 = 8。计数名称必须分清 retry 和 attempt。
重试只应覆盖业务允许且仍有时间完成的失败。鉴权拒绝、参数非法、确定的余额不足通常需要用户或数据变化,立即重复没有帮助。对 429、503 的 Retry-After,还要解析其时长/日期形式,并与调用剩余时限比较;不要把较长的等待直接塞进同步请求线程。
固定间隔让大量客户端同时重发,指数退避可以拉开尝试时间,随机抖动进一步分散同时失败的调用。退避不能增加下游容量;持续故障时仍需限制重试总量和并发。AWS 的超时、重试与抖动退避实践讨论了这些机制在过载中的相互作用。
实际应用可以在一个掌握业务幂等性和调用时限的位置统一决定重试,同时检查代理、Feign、HTTP 客户端和 SDK 的内置重试。不能仅关闭 Spring Cloud 的开关,就宣布整条链路不再重发。
区分失败、慢调用和结果未知
| 观察 | 下一步 |
|---|---|
| 503 且没有下游请求 | 检查候选列表、筛选规则和无实例错误 |
| 503 且两个实例各处理一次 | 核对重试条件;比较两台失败原因是否共用同一依赖 |
| 等连接超时 | 检查池中占用时长、等待队列和调用方并发 |
| 读取超时,但服务端继续执行 | 检查服务端取消合作、事务结果和幂等查询 |
| 新实例流量很少 | 检查发现缓存、标签、连接分配层以及冷启动预热 |
| 超时率上升同时下游请求数暴涨 | 对照入口请求数与实际 attempt 数,优先收紧重试扩散 |
对冲请求 Hedging 会在首个尝试迟迟未完成时向另一实例并行发送,以更早返回的结果结束等待。它适合可安全重复、下游尚有容量的读取,并且需要限制触发比例、并行数和取消后的残留工作。把所有慢请求都复制一份,会在过载时进一步消耗剩余容量。
生产观察中,把最终调用耗时与每次 attempt 耗时分开记录;为一次调用关联实例、失败阶段、重试编号及剩余时限。指标标签使用有限服务名和错误类型,订单号、完整 URL 等高基数字段留在受控日志或追踪里。这样既能知道用户遇到了什么,也能算清服务端为这次结果付出了多少实际工作。
