Skip to content

一次完整微服务调用链:从代理到响应的内部原理

业务代码中的一次 inventoryClient.reserve() 并不是普通方法调用。它至少经过客户端代理、服务发现、负载均衡、连接池、编码、网络、服务端协议栈、线程调度、过滤器、业务事务和响应解码。理解这条链,才能回答“为什么调用超时”“扩容为什么没生效”“下游成功了上游为什么仍重试”。

一、完整调用主链

mermaid
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 接口生成动态代理。调用接口方法时,代理根据方法元数据构建请求,而不是执行接口中的业务代码:

  1. 读取方法、路径、参数和返回类型元数据。
  2. 把参数编码为 Path、Query、Header 或 Body。
  3. 执行认证、Trace、日志、超时和重试拦截器。
  4. 根据服务名获取实例并选择地址。
  5. 调用传输客户端。
  6. 根据协议状态解码结果和异常。

代理会隐藏网络边界。接口看起来像本地方法,不表示它具有本地调用的延迟、异常和 exactly-once 语义。

三、服务发现与负载均衡

3.1 客户端发现

Nacos、Eureka、Consul 等注册中心返回逻辑服务名对应的实例集合。客户端一般维护本地缓存,避免每次请求都查询注册中心。

mermaid
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 兼容和安全边界。

mermaid
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。

mermaid
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 次,而可能相乘成几十次物理请求。

mermaid
flowchart TD
    A["一次用户请求"] --> B["网关最多尝试2次"]
    B --> C["服务客户端每次尝试3次"]
    C --> D["Mesh每次再尝试2次"]
    D --> E["最坏形成12次下游尝试"]

治理原则:

  1. 明确唯一主要重试层,其他层关闭或严格限制。
  2. 只重试瞬时故障和可安全重放操作。
  3. 写操作必须有幂等键。
  4. 使用指数退避、随机抖动和 Retry Budget。
  5. 剩余 Deadline 不足时不发起新尝试。
  6. 记录逻辑请求 ID、尝试次数和最终结果。

九、JDK 8 Demo:显式区分连接和读取超时

java
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

  1. 用 Trace 定位变慢 Span,确认慢在客户端等待还是服务端执行。
  2. 检查客户端连接池租借等待、活动连接、每路由上限。
  3. 检查服务发现列表与实例健康状态,确认是否命中已摘除节点。
  4. 检查 DNS、TCP 建连、TLS 握手和网络重传。
  5. 检查服务端入口队列、Tomcat/Netty 线程池与 CPU。
  6. 检查数据库连接池等待、慢 SQL、锁等待和下游调用。
  7. 检查是否有多层重试放大物理请求。
  8. 按实例查看延迟分布,避免平均值掩盖单个坏节点。
  9. 修复后用相同流量模型验证 P95/P99、错误率和资源水位。

十二、关联知识点