4. 微服务间的同步通信

在微服务架构中,同步通信是最常见的交互方式:调用方发起请求后阻塞等待下游响应,再继续自己的业务逻辑。它适合“必须立刻拿到结果才能继续”的场景,例如下单时校验库存、支付前查询账户余额。Node.js 生态里,同步通信主要有两条技术路线:基于 HTTP/1.1 与 JSON 的 REST,以及基于 HTTP/2 与 Protocol Buffers 的 gRPC。理解两者的差异,并掌握 axios/undici 的调用方式、超时重试与错误传播设计,是构建可靠微服务的关键。

REST 与 gRPC 的核心差异

REST 以资源为中心,用 URL 表达资源、用 HTTP 方法表达动作,报文通常是 JSON,可读性好、调试方便、跨语言门槛低,浏览器和各类网关天然支持。它的代价是文本序列化体积偏大、HTTP/1.1 下存在队头阻塞、接口契约靠文档或 OpenAPI 维护,容易与实现漂移。

gRPC 以方法为中心,用 .proto 文件定义服务契约,编译生成强类型客户端与服务端桩代码,报文用二进制 Protobuf 编码,体积小、解析快,并基于 HTTP/2 支持多路复用、双向流与头部压缩。它更适合内部服务之间高频、低延迟、强契约的调用;但浏览器直连支持有限,需要 grpc-web 或网关转换,调试也不如 JSON 直观。

REST 与 gRPC 同步通信对比 REST(HTTP/1.1 + JSON) 契约:OpenAPI / 文档约定 弱类型,易漂移 编码:JSON 文本 体积大,可读性好 传输:HTTP/1.1 请求-响应 队头阻塞,连接复用有限 调用:axios / undici / fetch 生态成熟,调试直观 适用:对外 API、低频调用 跨团队、跨语言、需人工排查 浏览器/网关直连友好 gRPC(HTTP/2 + Protobuf) 契约:.proto 强类型定义 编译生成桩代码,契约即代码 编码:Protobuf 二进制 体积小,解析快 传输:HTTP/2 多路复用 支持流式、头部压缩 调用:@grpc/grpc-js 客户端 强类型,需生成代码 适用:内部高频、低延迟调用 服务间强契约、流式传输 浏览器需 grpc-web 转换

用 axios 与 undici 调用下游服务

axios 是历史悠久的 HTTP 客户端,基于 XMLHttpRequest/Node http 模块封装,API 友好,支持拦截器、自动 JSON 转换、请求取消。undici 是 Node.js 官方维护的高性能 HTTP/1.1 客户端,也是 Node 18+ 内置 fetch 的底层实现,基于连接池,性能优于传统 http 模块,适合高并发场景。

下面用 axios 调用库存服务,并设置超时与基础重试:

import axios from 'axios';

const inventoryClient = axios.create({
  baseURL: 'http://inventory-svc:8080',
  timeout: 800, // 单次请求超时 800ms
  headers: { 'x-request-id': 'req-123' },
});

async function checkStock(sku, qty) {
  try {
    const res = await inventoryClient.get(`/stock/${sku}`, {
      params: { qty },
    });
    return res.data.available;
  } catch (err) {
    if (err.code === 'ECONNABORTED') {
      throw new Error('INVENTORY_TIMEOUT');
    }
    throw err;
  }
}

undici 的写法更接近原生 fetch,但需要手动处理超时(fetch 本身没有 timeout 选项,需配合 AbortController):

import { request } from 'undici';

async function checkStockUndici(sku, qty) {
  const ac = new AbortController();
  const timer = setTimeout(() => ac.abort(), 800);
  try {
    const { statusCode, body } = await request(
      `http://inventory-svc:8080/stock/${sku}?qty=${qty}`,
      { signal: ac.signal }
    );
    if (statusCode !== 200) throw new Error(`HTTP_${statusCode}`);
    return (await body.json()).available;
  } finally {
    clearTimeout(timer);
  }
}

选择建议:对外或需要拦截器、自动重试封装的场景用 axios;追求吞吐、连接池复用、与内置 fetch 统一风格时用 undici。两者都应显式设置超时,绝不能依赖默认值——Node 默认无超时,下游卡死会拖垮调用方。

超时与重试策略

超时是同步通信的第一道防线。它应小于调用方自身的 SLA 预算,并考虑下游 P99 延迟。例如调用方接口承诺 1s 返回,下游 P99 为 600ms,则单次超时可设 800ms,留出网络与序列化开销。超时值过大会让故障扩散,过小则误杀正常慢请求。

重试只对“可恢复的瞬时故障”有意义,例如连接被拒、超时、502/503/504。对 400、401、404 这类客户端错误重试毫无意义,反而放大无效流量。重试必须满足三个条件:幂等、有上限、有退避。推荐指数退避加抖动,避免重试风暴同时打向下游:

async function withRetry(fn, { retries = 2, base = 100 } = {}) {
  for (let i = 0; i <= retries; i++) {
    try {
      return await fn();
    } catch (err) {
      const retryable = ['ECONNRESET', 'ETIMEDOUT', 'ECONNABORTED']
        .includes(err.code) || [502, 503, 504].includes(err.status);
      if (!retryable || i === retries) throw err;
      const delay = base * 2 ** i + Math.random() * 50;
      await new Promise((r) => setTimeout(r, delay));
    }
  }
}

注意:重试会放大下游压力,若下游已过载,重试可能引发雪崩。因此重试应配合熔断器(如 opossum)与并发限制,当失败率超过阈值时快速失败,给下游恢复时间。

错误传播与状态码设计

同步调用链中,错误会沿调用方向向上传播。若下游返回 500,调用方若直接透传,会让上游误以为是本服务故障,难以定位。正确做法是:在边界处将下游错误转换为本服务语义明确的错误,并保留原始信息用于排查。

状态码设计应遵循 HTTP 语义:2xx 成功;4xx 表示调用方问题(参数错误 400、未认证 401、无权限 403、资源不存在 404、冲突 409、限流 429);5xx 表示服务端问题(500 内部错误、502 网关错误、503 不可用、504 超时)。微服务内部可约定业务错误码放在响应体中,例如 { code: 'STOCK_INSUFFICIENT', message: '库存不足' },HTTP 状态码仍用 409 或 422,便于网关与监控统一处理。

错误传播时建议携带 x-request-id 与 trace-id,让一次调用链上的日志可串联。调用方捕获下游错误后,应记录下游服务名、状态码、耗时与 request-id,再抛出本服务定义的错误类型,避免上游看到的是裸露的 axios 错误对象。
不要把所有下游错误都转成 500。若下游返回 404,调用方应判断是“资源确实不存在”还是“下游路由配置错误”,前者可返回 404,后者应视为 502 并告警。错误分类错误会导致排障方向完全跑偏。

综合来看,同步通信的可靠性来自四层配合:合理的协议选型(REST 或 gRPC)、显式超时、有条件的重试与熔断、以及清晰的错误传播与状态码约定。把这四点落实到每个下游调用封装中,才能让微服务在部分依赖抖动时仍保持整体可用。