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