一次完整微服务调用链:从代理到响应的内部原理
业务代码中的一次 inventoryClient.reserve() 并不是普通方法调用。它至少经过客户端代理、服务发现、负载均衡、连接池、编码、网络、服务端协议栈、线程调度、过滤器、业务事务和响应解码。理解这条链,才能回答“为什么调用超时”“扩容为什么没生效”“下游成功了上游为什么仍重试”。
一、完整调用主链
flowchart TD
A["Controller进入订单用例"] --> B["Feign、Dubbo或gRPC客户端代理"]
B --> C["拦截器加入认证、Trace和Deadline"]
C --> D["服务发现获得可用实例列表"]
D --> E["负载均衡选择目标实例"]
E --> F["连接池获取或建立连接"]
F --> G["序列化并写入网络"]
G --> H["服务端协议栈解码请求"]
H --> I["过滤器、鉴权和参数校验"]
I --> J["业务线程执行事务和数据库访问"]
J --> K["提交事务并序列化响应"]
K --> L["客户端解码并返回业务结果"]每个箭头都可能失败,调用方看到的异常也不一定能证明下游业务事实。
二、客户端代理做了什么
声明式客户端通常为 Java 接口生成动态代理。调用接口方法时,代理根据方法元数据构建请求,而不是执行接口中的业务代码:
- 读取方法、路径、参数和返回类型元数据。
- 把参数编码为 Path、Query、Header 或 Body。
- 执行认证、Trace、日志、超时和重试拦截器。
- 根据服务名获取实例并选择地址。
- 调用传输客户端。
- 根据协议状态解码结果和异常。
代理会隐藏网络边界。接口看起来像本地方法,不表示它具有本地调用的延迟、异常和 exactly-once 语义。
三、服务发现与负载均衡
3.1 客户端发现
Nacos、Eureka、Consul 等注册中心返回逻辑服务名对应的实例集合。客户端一般维护本地缓存,避免每次请求都查询注册中心。
flowchart TD
A["实例启动并注册"] --> B["注册中心保存实例和健康状态"]
B --> C["客户端订阅或定期拉取列表"]
C --> D["客户端维护本地实例缓存"]
D --> E["负载均衡从健康实例中选择"]“实例已下线”到“所有调用方都不再选择它”存在传播窗口。优雅停机必须先摘流、等待缓存或 Endpoint 更新,再结束进程。
3.2 服务端发现
通过 Gateway、Kubernetes Service 或独立负载均衡器时,客户端只知道统一地址,代理层负责选择后端。客户端更简单,但多一跳,代理本身也需要高可用和容量治理。
3.3 长连接为什么会让扩容不均衡
负载均衡通常在选连接或建连接时生效,不一定对每个业务请求重新选实例。HTTP/2、gRPC 都使用长连接;新增实例后,旧连接仍绑定旧节点,短期流量可能不会自动均摊。需要结合连接年龄、连接排空、客户端重连或请求级均衡策略治理。
四、连接池不是线程池
| 资源 | 限制什么 | 耗尽表现 |
|---|---|---|
| 业务线程池 | 同时执行多少阻塞任务 | 排队、拒绝、线程等待 |
| HTTP 连接池 | 到目标地址的并发连接 | 等待租借连接,请求尚未发出 |
| 数据库连接池 | 同时执行多少数据库会话 | 获取连接超时 |
| HTTP/2 Stream | 单连接并发逻辑流 | Stream 排队或新建连接 |
必须区分三个超时:
- Connect Timeout:建立 TCP/TLS 连接的上限。
- Pool Acquire Timeout:从连接池等待可用连接的上限。
- Read/Response Timeout:请求发出后等待响应的上限。
日志若只写“调用超时”,就无法判断应扩连接池、修网络还是优化下游业务。
五、序列化和网络阶段
REST/JSON 可读性和生态好,但字段名、文本解析和报文通常更大;Protobuf 等二进制协议通常更紧凑,但契约演进规则更严格。选型还要考虑跨语言、调试、Schema 兼容和安全边界。
flowchart TD
A["解析DNS或获得实例IP"] --> B["TCP三次握手"]
B --> C["TLS握手与证书校验"]
C --> D["HTTP或RPC协议协商"]
D --> E["发送请求头和请求体"]
E --> F["服务端读取并解码"]连接复用可以省去重复握手,但 DNS 变化、证书轮换、NAT 空闲回收和服务端排空,都要求客户端正确处理失效连接。
六、服务端从 Socket 到业务事务
以 Spring MVC 为例:Tomcat 接收连接和报文,将请求交给工作线程;Filter 完成 Trace、认证和 CORS;DispatcherServlet 查找 Handler;参数解析器和消息转换器构造入参;AOP 代理开启事务并调用 Service。
flowchart TD
A["Tomcat接收并解析HTTP"] --> B["Filter链"]
B --> C["DispatcherServlet"]
C --> D["HandlerMapping和HandlerAdapter"]
D --> E["参数解析与校验"]
E --> F["Controller"]
F --> G["Service代理开启事务"]
G --> H["Repository和数据库"]
H --> I["提交或回滚本地事务"]
I --> J["序列化响应"]事务提交成功后,响应仍可能在网络中丢失。此时服务端事实是“成功”,调用方观察是“超时”,形成不确定结果。写接口必须支持幂等键或按业务号查询结果,不能把网络异常直接解释为业务失败。
七、Deadline 怎样沿调用链分配
如果网关总预算是 2 秒,订单服务不能给库存、优惠券和支付分别配置 2 秒并顺序调用。每一跳应从上游 Deadline 计算剩余预算,并为自身处理、网络抖动和响应留出时间。
| 阶段 | 示例最大预算 |
|---|---|
| 网关排队和鉴权 | 100ms |
| 订单本地处理 | 150ms |
| 库存与价格并行调用 | 700ms |
| 数据库提交 | 300ms |
| 响应编码与网络余量 | 250ms |
| 抖动与保护余量 | 500ms |
下游收到已经过期的 Deadline 应尽快取消,而不是继续执行无人在等待的昂贵查询。取消不等于事务自动回滚:若业务已经提交,事实仍然成功,需要幂等查询处理重试。
八、重试应该放在哪一层
Gateway、Service Mesh、Feign、Dubbo 和业务代码都可能配置重试。每层 3 次不是总共 3 次,而可能相乘成几十次物理请求。
flowchart TD
A["一次用户请求"] --> B["网关最多尝试2次"]
B --> C["服务客户端每次尝试3次"]
C --> D["Mesh每次再尝试2次"]
D --> E["最坏形成12次下游尝试"]治理原则:
- 明确唯一主要重试层,其他层关闭或严格限制。
- 只重试瞬时故障和可安全重放操作。
- 写操作必须有幂等键。
- 使用指数退避、随机抖动和 Retry Budget。
- 剩余 Deadline 不足时不发起新尝试。
- 记录逻辑请求 ID、尝试次数和最终结果。
九、JDK 8 Demo:显式区分连接和读取超时
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
public final class InventoryHttpClient {
public int reserve(String baseUrl, String json, String requestId) throws IOException {
HttpURLConnection connection = (HttpURLConnection)
new URL(baseUrl + "/api/inventory/reservations").openConnection();
connection.setRequestMethod("POST");
connection.setConnectTimeout(500);
connection.setReadTimeout(1500);
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Idempotency-Key", requestId);
connection.setDoOutput(true);
byte[] body = json.getBytes(StandardCharsets.UTF_8);
connection.getOutputStream().write(body);
try {
return connection.getResponseCode();
} finally {
connection.disconnect();
}
}
}这是教学用最小 Demo。生产项目通常使用带连接池、TLS、指标和观测拦截器的成熟客户端。Java 11+/17+ 可以使用标准 java.net.http.HttpClient,但 API 更新不会改变网络调用的不确定结果与幂等要求。
十、调用链失败窗口
| 失败窗口 | 服务端可能状态 | 调用方观察 | 正确处理 |
|---|---|---|---|
| 选实例前失败 | 未执行 | 明确失败 | 可在预算内换实例 |
| 建连接失败 | 通常未执行 | 连接异常 | 瞬时故障可退避重试 |
| 请求写到一半断开 | 未知 | IO 异常 | 写操作按幂等键查询或重试 |
| 服务端校验失败 | 未提交 | 4xx/业务错误 | 修正请求,不应重试 |
| 数据库提交前异常 | 回滚或未知 | 5xx/超时 | 查业务流水确认 |
| 提交成功响应丢失 | 已成功 | 超时 | 查询同一幂等键结果 |
| 响应解码失败 | 已执行 | 客户端异常 | 保存原始状态和协议证据 |
十一、远程调用变慢排查 Runbook
- 用 Trace 定位变慢 Span,确认慢在客户端等待还是服务端执行。
- 检查客户端连接池租借等待、活动连接、每路由上限。
- 检查服务发现列表与实例健康状态,确认是否命中已摘除节点。
- 检查 DNS、TCP 建连、TLS 握手和网络重传。
- 检查服务端入口队列、Tomcat/Netty 线程池与 CPU。
- 检查数据库连接池等待、慢 SQL、锁等待和下游调用。
- 检查是否有多层重试放大物理请求。
- 按实例查看延迟分布,避免平均值掩盖单个坏节点。
- 修复后用相同流量模型验证 P95/P99、错误率和资源水位。
