写 HTTP 调用时,axios 是多数人的默认选择;但 Node 18 之后,内置的 fetch(底层就是 Undici) 已经是更现代、更快、更省内存的答案。本文从 fetch 与底层实现讲起,深入连接池、流式响应、拦截器、超时重试,最后给出 SSRF 防护的实战注意。
1. 为什么选择 Undici
1.1 背景与现状
Undici 是 Node.js 官方维护的 HTTP/1.1 客户端,从 Node 18 起内置为全局 fetch 的实现。它比老的 http.request 与 axios 更快:
axios(http.request 封装) → 每次请求可能新建连接
Undici → 连接池复用 + pipelining,性能显著更高
官方基准里 Undici 的吞吐通常比 axios 高 20% 到 60%,且内存占用更低,因为它不用维护插件链和适配层。
1.2 能力对比
| 维度 | Undici | axios | node-fetch |
|---|---|---|---|
| 连接池 | Agent 内置 | 需外部封装 | 无 |
| 流式响应 | 一等公民 | 支持一般 | 支持 |
| 拦截器 | Interceptor | 中间件 | 无 |
| 中断 | AbortSignal | AbortSignal | AbortSignal |
| 内置 | Node 18+ 自带 | 依赖 | 依赖 |
1.3 何时仍用 axios
已有生态依赖(拦截器、实例化)、团队习惯、mock 工具链成熟度。新项目或性能敏感场景,优先 Undici。
一句话:Undici = Node 内置 fetch 的底层引擎——连接池、流式、拦截器、中断全部原生,性能优于 axios,Node 18 起零依赖可用。
2. fetch 基础用法
2.1 最简单的 GET
const res = await fetch('https://api.example.com/users?page=1');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const users = await res.json();
console.log(users);
res.ok 在 200-299 时为 true,其余一律要显式抛错——fetch 对 4xx/5xx 不会自动 reject。
2.2 POST 与请求体
const res = await fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ name: 'plume', age: 18 }),
});
const created = await res.json();
2.3 错误类型区分
try {
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
// 1. 网络错误:DNS 失败、连接拒绝 → 抛 TypeError
// 2. 超时:AbortSignal.timeout 触发 → AbortError
// 3. 业务错误:4xx/5xx → 不抛,靠 res.ok 判断
} catch (err) {
if (err.name === 'AbortError') console.error('超时或已取消');
else console.error('网络错误', err);
}
一句话:fetch 只对网络层错误和中断抛异常,4xx/5xx 必须自己判断
res.ok;超时用AbortSignal.timeout最省事。
3. 连接池与 Agent
3.1 复用全局 Agent
默认 fetch 使用全局 dispatcher,连接池自动复用 keep-alive 连接。频繁请求时不要每次新建 Agent,要显式传入共享实例:
import { Agent } from 'undici';
const agent = new Agent({
connections: 100, // 最大并发连接数
pipelining: 1, // 每连接管道请求数
keepAliveTimeout: 30 * 1000, // keep-alive 空闲存活 30 秒
keepAliveMaxTimeout: 60 * 1000,
});
// 把 agent 传给所有请求,实现连接复用
const res = await fetch(url, { dispatcher: agent });
3.2 按目标分流 Agent
不同上游配不同连接池,避免慢服务占满全局连接:
const agent = new Agent({ connections: 100 });
// 慢速上游单独一个池,互不影响
const slowAgent = new Agent({
connections: 10,
headersTimeout: 60 * 1000, // 等响应头最长 60 秒
bodyTimeout: 60 * 1000, // 等响应体最长 60 秒
});
一句话:连接池 = Agent(fetch 用)+ Pool(低层 API),核心参数
connections与pipelining;多个不同特性的上游要拆成多个池隔离故障。
4. 请求性能优化
4.1 并发批量请求
串行等待是最常见的性能杀手。用 Promise.all 并发:
const ids = [1, 2, 3, 4, 5];
const results = await Promise.all(
ids.map((id) => fetch(`https://api.example.com/users/${id}`).then((r) => r.json())),
);
4.2 限流并发思路
无脑并发会打爆上游或触发限流。手写一个简单限流器:
async function mapLimit(items, limit, fn) {
const queue = [...items];
const workers = Array.from({ length: limit }, async () => {
while (queue.length) await fn(queue.shift());
});
await Promise.all(workers);
}
await mapLimit([1, 2, 3, 4, 5, 6, 7, 8], 3, async (id) => {
await fetch(`https://api.example.com/users/${id}`);
});
4.3 压缩与缓存
请求时声明 Accept-Encoding: gzip, deflate 接受压缩;高频同 URL 响应再叠加本地缓存(见 nodejs-caching-strategies),减少不必要的网络往返。
一句话:性能三板斧 = 并发
Promise.all+ 限流器防打爆上游 + keep-alive 连接复用;同一 URL 再叠加本地缓存。
5. 流式响应
5.1 大文件下载
直接 await res.text() 会把整个文件读进内存,大响应会爆内存。用流式写入磁盘:
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
const res = await fetch('https://example.com/big-file.zip');
await pipeline(res.body, createWriteStream('/tmp/big-file.zip'));
5.2 逐行读取 SSE 场景
LLM 流式输出、日志流等场景要按行消费:
import { createInterface } from 'node:readline';
const res = await fetch('https://api.example.com/stream');
const lines = createInterface({ input: res.body });
for await (const line of lines) {
if (line.startsWith('data:')) {
const payload = line.slice(5).trim();
if (payload === '[DONE]') break;
console.log(JSON.parse(payload));
}
}
5.3 半程中断释放
流式响应中途不再需要时,必须取消 body 释放连接:
const reader = (await fetch(url)).body.getReader();
await reader.read();
reader.cancel(); // 取消剩余读取,释放底层连接
一句话:流式 =
res.body接 pipeline 落盘 / 接 readline 逐行;半程不用必须reader.cancel(),否则连接泄漏。
6. 拦截器与扩展
6.1 拦截器写法
Undici 拦截器是洋葱模型:请求经过每个拦截器,可加日志、鉴权、重试:
import { Agent, Interceptor } from 'undici';
const agent = new Agent();
agent.addInterceptor(new Interceptor(async (opts) => {
opts.headers['User-Agent'] = 'plume-bot/1.0'; // 请求前注入
const start = performance.now();
const res = await opts.handler(opts);
console.log(`[http] ${opts.method} ${opts.path} 耗时 ${Math.round(performance.now() - start)}ms`);
return res;
}));
6.2 统一鉴权头
把 token 注入收敛到一处,业务代码不重复写:
const authInterceptor = new Interceptor(async (opts) => {
opts.headers['Authorization'] = `Bearer ${getToken()}`;
opts.headers['X-Request-Id'] = crypto.randomUUID();
return opts.handler(opts);
});
agent.addInterceptor(authInterceptor);
6.3 装饰器函数方案
不想引入拦截器语法时,包一层函数同样能收敛公共逻辑:
async function apiFetch(path, opts = {}) {
const res = await fetch(`https://api.example.com${path}`, {
...opts,
headers: { 'Authorization': `Bearer ${getToken()}`, ...opts.headers },
dispatcher: agent,
});
if (!res.ok) throw new ApiError(res.status, await res.text());
return res.json();
}
一句话:拦截器适合统一注入与横切逻辑(鉴权、日志、追踪),装饰器函数适合小团队快速收敛;两者本质都是把重复代码抽到一处。
7. 超时重试与 SSRF 防护
7.1 三层超时
const agent = new Agent({ headersTimeout: 10 * 1000, bodyTimeout: 30 * 1000 });
const res = await fetch(url, {
dispatcher: agent,
signal: AbortSignal.timeout(15 * 1000), // 整请求总超时
});
7.2 指数退避重试
网络抖动是常态,安全重试要有 backoff + jitter:
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
async function fetchWithRetry(url, { retries = 3, baseDelay = 200 } = {}) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(10 * 1000) });
if (res.ok) return res;
if (res.status < 500 && res.status !== 429) throw new Error(`HTTP ${res.status}`);
} catch (err) {
if (attempt === retries) throw err;
const delay = baseDelay * 2 ** attempt + Math.random() * 100; // 指数 + 抖动
await sleep(delay);
}
}
}
7.3 SSRF 防护注意
接受用户传入 URL 的抓取服务必须防 SSRF:校验协议、解析后核对 IP,并防 DNS 重绑定:
import { lookup } from 'node:dns/promises';
async function assertSafeHost(rawUrl) {
const url = new URL(rawUrl);
if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('协议不允许');
const { address } = await lookup(url.hostname);
const ip = address.split('.').map(Number);
if (ip[0] === 127 || ip[0] === 10 || ip[0] === 169) throw new Error('内网地址不允许');
}
一句话:可靠性 = headers/body/总时三层超时 + 指数退避重试;安全性 = 用户 URL 必须做协议与 IP 校验,防 SSRF 与 DNS 重绑定。
8. 踩坑清单
| 坑 | 现象 | 对策 |
|---|---|---|
| 4xx/5xx 不抛错 | 拿到 500 却继续处理 | 显式判断 res.ok |
| 每次新建 Agent | 连接不复用,性能差 | 全局共享一个 Agent |
| 大响应直接 text() | 内存暴涨 OOM | 流式 pipeline 落盘 |
| 流式半程不取消 | 连接泄漏 | reader.cancel() |
| 串行发请求 | 吞吐上不去 | Promise.all 并发 |
| 无脑并发打爆上游 | 上游限流 429 | 限流器 + 重试 |
| 重试无退避 | 雪崩式重试 | 指数退避 + 抖动 |
| 信任用户 URL | 被 SSRF 打内网 | 协议/IP 校验 |
| 拦截器顺序错 | 头没注入成功 | 确认 addInterceptor 顺序 |
9. 总结
| 环节 | 要点 |
|---|---|
| 选型 | Node 18+ 内置 Undici,性能优于 axios |
| 基础 | fetch 不抛 4xx/5xx,必须判 res.ok |
| 连接池 | Agent 全局复用,慢上游单独池 |
| 并发 | Promise.all + 限流器 |
| 流式 | res.body 接 pipeline / readline,半程 cancel |
| 拦截器 | 洋葱模型,统一鉴权日志 |
| 超时重试 | 三层超时 + 指数退避抖动 |
| SSRF | 协议校验 + IP 校验防内网探测 |
一句话记住:Undici 是 Node 官方的现代 HTTP 客户端——连接池复用、流式消费、拦截器扩展一应俱全,配合「三层超时 + 退避重试」的可靠性设计与「SSRF 校验」的安全底线,就能写出一套能上生产的内网与公网调用层。
延伸阅读
- Node.js 异步与并发 — 并发与限流背后的异步模型
- Node.js 缓存策略 — 高频响应本地缓存降往返
- Node.js 可观测性 — 请求链路追踪与指标
- Node.js 错误处理与日志 — 调用方异常归类与记录
- Node.js 性能调优 — 客户端瓶颈定位
- Node.js 安全与部署 — 请求安全基线
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。