聊聊如何设计一个高效、稳定的 Node.js 接入层

笔者维护的 BFF 服务(技术栈:Koa2 + TypeScript + Node18 + ESM)每天扛着海量 API 代理请求,表面看就是「接请求 → 鉴权 → 转发 → 返回」,真踩过坑才知道:

中间件顺序、请求上下文传递、限流策略 这三件事,任何一个设计不好,线上排查能排查到怀疑人生。

本文把分享内容整理成文,并配套了一个可运行的 Demo 项目:node-bff-practices

下面按「一个请求进来,系统如何在每一层做出正确决策」这条主线,串联 中间件顺序、请求上下文传递、限流策略 这三件事。


搞事背景

同事: 昨天晚高峰部分用户反馈接口慢,日志平台一搜,同一个 reqId 在不同模块对不上,限流阈值也不知道该不该调。

我:嗯...

同事: 你那个接入层,中间件是不是又有人随手加了一个?

我:额...

分析问题后发现,三件事缠在了一起:

  1. 限流要拿客户端 IP / 用户标识 做 key,但提取客户端信息的中间件被放到了限流后面 → 限流形同虚设
  2. 业务代码深层调用打日志,要么透传一堆参数,要么用全局变量 → 请求态传递混乱
  3. 线上想调限流阈值,怕误伤用户不敢直接开 → 缺少安全验证手段

说干就干,逐个击破。


搞事 1.0:中间件顺序 ------ 顺序即架构

问题从哪来

Koa 的中间件是洋葱模型,注册顺序就是执行顺序

很多项目把它当成「功能堆叠」:要限流就 app.use(rateLimit),要日志就 app.use(logger),能跑就行。

但接入层不一样。中间件之间有隐式契约

中间件 前置依赖 如果顺序错了会怎样
extractClientId 限流拿不到客户端 IP / 用户标识,key 全是 undefined
三层限流 extractClientId 限流失效,异常流量直达业务
serverTime 无(但要先于 clientCtxInit clientCtxInit 写分段指标时 ctx.serverTime 不存在
clientCtxInit serverTimeextractClientId 无法建立请求上下文,日志丢失 reqId
requestContext clientCtxInit 生命周期日志不完整

实现

Demo 项目里的中间件装配长这样:

typescript 复制代码
// src/server.ts
export function createServer(): Koa {
  const app = new Koa();

  app.use(health());

  // ① 先提取客户端身份
  app.use(extractClientId);
  // ② 再限流(依赖 clientIp / userId)
  app.use(clientIPLimit);
  app.use(clientIdLimit);
  app.use(globalLimit);

  app.use(errorHandler);
  // ③ 先挂载 serverTime 容器
  app.use(serverTime);
  // ④ 再初始化请求上下文
  app.use(clientCtxInit);

  app.use(bodyParser);
  app.use(requestContext);

  return app;
}

extractClientId 的实现很朴素,就干一件事 ------ 从请求里提取客户端 IP 和用户标识:

typescript 复制代码
// src/middlewares/set-simple-client.ts
export const extractClientId: Middleware = async (ctx, next) => {
  const forwarded = ctx.request.get('x-forwarded-for');
  const clientIp = forwarded?.split(',')[0]?.trim() || ctx.ip || 'unknown';
  const userId = ctx.cookies.get('user_uid') || 'unknow';

  ctx.clientIp = clientIp;
  ctx.userId = userId;

  await next();
};

完整链路:

flowchart TD A[请求进入] --> B[extractClientId<br/>提取 clientIp / userId] B --> C[三层限流<br/>以身份做 key] C --> D[errorHandler<br/>兜底异常] D --> E[serverTime<br/>挂载指标容器] E --> F[clientCtxInit<br/>建立 AsyncLocalStorage 上下文] F --> G[bodyParser] G --> H[requestContext<br/>生命周期日志] H --> I[业务路由]

优化 1.0 完成:顺序不是实现细节,是架构契约。新增中间件前,先搞清楚它依赖谁、谁依赖它。


搞事 1.1:AsyncLocalStorage ------ 请求态怎么传

问题从哪来

clientCtxInit 里会给每个请求生成 reqIdseqId,写入响应头,后面打日志、调下游都要用。

问题来了:业务代码往往套了好几层 async/await,如果靠参数透传:

typescript 复制代码
// 反模式:每个函数都要加参数
function callApi(params, reqId, userId) { ... }
function writeLog(event, reqId, userId) { ... }

改一个字段,签名牵一串。

如果用全局变量:

typescript 复制代码
// 反模式:并发请求互相污染
global.currentReqId = reqId;

Node.js 是单线程 + 异步,两个请求交错执行,全局变量直接串台。

实现

Node.js 提供了 AsyncLocalStorageasync_hooks 模块),可以理解为异步版的 ThreadLocal

css 复制代码
请求 A 进来 → asyncLocalStorage.run(storeA, ...) → 后续所有 await 都在 storeA 里
请求 B 进来 → asyncLocalStorage.run(storeB, ...) → 完全独立,互不干扰

Demo 里的封装:

typescript 复制代码
import { AsyncLocalStorage } from 'async_hooks';

// src/lib/async-context.ts
const asyncLocalStorage = new AsyncLocalStorage<AsyncStore>();

export function getAsyncStore(): AsyncStore | undefined {
  return asyncLocalStorage.getStore();
}

export function runWithinAsyncStore(
  store: AsyncStore,
  callback: () => Promise<void>,
): Promise<void> {
  return asyncLocalStorage.run(store, callback);
}

clientCtxInit 在建立好 reqId 之后,用 runWithinAsyncStore 包裹后续所有中间件:

typescript 复制代码
// src/middlewares/client-init.ts
return runWithinAsyncStore({ lifeInfo }, async () => {
  logger.info({
    event: 'client-init',
    brief: `${method} ${url}`,
    method,
    url,
    lid,
    life,
  });

  await next();
});

这样,不管业务代码嵌套多深,logger 里随时能取到当前请求的 reqId

typescript 复制代码
// src/lib/logger.ts
function formatPayload(payload: LogPayload): string {
  const store = getAsyncStore();
  const reqId = store?.lifeInfo.reqId ?? '-';
  const userId = store?.lifeInfo.userId ?? '-';
  // ...
}

路由里模拟了一次「深层调用」,不用传参也能读到上下文:

bash 复制代码
curl -H 'x-lid: demo01' -H 'x-life: 1200' http://127.0.0.1:3100/api/profile

响应里 fromCtxfromAsyncStorereqId 一致,日志里的 deep-call-log 事件也自动带上了 reqId

四维追踪

除了 reqId,接入层还维护了一套追踪字段:

字段 来源 用途
reqId 服务端 nanoid 生成 单次 HTTP 请求 ID
seqId 服务端 UUID 生成 响应链路标识
lid 请求头 x-lid 串联 SPA 页面生命周期
life 请求头 x-life 距生命周期开始的毫秒数

配合 server-timing 响应头,可以把「前端 → 网关 → BFF → 下游」各段耗时拆开看。

serverTime 必须在 clientCtxInit 之前挂载,后者才能往里写 1_connect2_gateway 等指标 ------ 又回到话题一:顺序 matters


搞事 2.0:三层限流 + 观察模式 ------ 生产环境怎么安全调参

问题从哪来

同事: 限流阈值定多少合适?定高了防不住,定低了误伤正常用户。

我:先上线观察模式看看?

同事: 观察模式是什么,能先验证再真正拦截吗?

我:能。

单层限流不够用:

  • 只按 IP:公司出口 NAT,一个 IP 后面几百人,误伤一整栋楼;
  • 只按用户:爬虫换账号就绕过;
  • 只有全局:单个疯狂用户能把所有人拖下水。

三层叠加才合理:IP → 用户 → 全局

但核心难题是:新阈值上线前,怎么知道会不会误触发?

实现

三层限流

Demo 里用工厂函数统一创建,只是 getId 不同:

typescript 复制代码
// src/middlewares/rate-limit/index.ts
export const clientIPLimit = createRateLimitMiddleware({
  type: RateLimitType.Ip,
  getId: ctx => ctx.clientIp,
});

export const clientIdLimit = createRateLimitMiddleware({
  type: RateLimitType.User,
  getId: ctx => ctx.userId,
});

export const globalLimit = createRateLimitMiddleware({
  type: RateLimitType.Global,
  getId: () => 'global',
});

Demo效果:

观察模式(Shadow Mode)

配置里有个 disable 开关。disable: true 时进入观察模式:

  • 不真正拦截,HTTP 始终 200;
  • 精确模拟 koa-ratelimit 的 memory 算法;
  • 触发时打日志 rate-limit-exceeded-observation
typescript 复制代码
// src/middlewares/rate-limit/create-rate-limit.ts
if (currentConfig.disable) {
  const { isLimited } = checkRateLimit(id, config, observationStore);

  if (isLimited) {
    logger.info({
      event: 'rate-limit-exceeded-observation',
      limitBy: type,
      id,
      message: `${ctx.method} ${ctx.originalUrl} - ${type} exceeded (observation mode)`,
    });
  }

  return next(); // 观察模式:无论是否触发都放行
}

checkRateLimit 的时间算法对齐了 koa-ratelimit 源码,连微秒时间戳处理方式都一致,确保观察到的触发次数和真正开启后一致:

typescript 复制代码
// src/helpers/checkRateLimit.ts
function getMicrotime(): number {
  const diff = process.hrtime(hrtimeStart);
  return timeBase + diff[0] * 1e6 + Math.round(diff[1] * 1e-3);
}

热更新 + 验证闭环

限流阈值通过配置中心热更新(Demo 里用内存 configStore 模拟),无需重启:

bash 复制代码
# 查看当前配置
curl http://127.0.0.1:3100/admin/rate-limit

# 切到观察模式,阈值 max=3
curl -X PATCH http://127.0.0.1:3100/admin/rate-limit/Ip \
  -H 'Content-Type: application/json' \
  -d '{"disable":true,"rateLimitMax":3}'

# 跑观察脚本:HTTP 全 200,日志里有 observation 记录
npm run demo:observation

# 确认无误后,真正开启限流
curl -X PATCH http://127.0.0.1:3100/admin/rate-limit/Ip \
  -H 'Content-Type: application/json' \
  -d '{"disable":false,"rateLimitMax":3}'

# 跑限流脚本:第 4 次起返回 429
npm run demo:rate-limit

完整闭环:

ini 复制代码
配置下发 disable=true(观察模式)
  → 线上跑一段时间,统计 observation 日志
  → 确认无误后 disable=false(真正拦截)
  → 全程无需重启

一个容易忽略的细节

限流发生在 clientCtxInit 之前 ,被限流的请求走不到 clientCtxInitrequestContext,正常生命周期日志是残缺的。

所以触发限流时,需要手动补全 client-init + client-response

typescript 复制代码
// src/utils/rateLimitLogger.ts
export function logRateLimitLifecycle(ctx: ParameterizedContext): void {
  logger.info({
    event: 'client-init',
    note: '补全日志:限流发生在 clientCtxInit 之前',
    // ...
  });

  logger.error({
    event: 'client-response',
    statusCode: 429,
    message: 'Rate limit exceeded',
  });
}

这又是一个「中间件顺序带来副作用,需要显式处理」的例子 ------ 和话题一首尾呼应。


串联:一个请求的完整旅程

把三个话题合拢,一个请求进来后:

markdown 复制代码
1. extractClientId 提取 clientIp / userId     ← 话题一:顺序契约
2. 三层限流决策(观察模式 or 真正拦截)      ← 话题三:安全调参
3. clientCtxInit 建立 AsyncLocalStorage 上下文  ← 话题二:透明传递
4. 业务处理,日志带完整 reqId / 追踪信息
5. requestContext 记录 client-response

三件事解决的是同一个问题:系统在复杂度增长时,每一层的行为仍然可预期、可观测、可调整


Demo 项目

完整代码在 node-bff-practices,本地跑起来:

bash 复制代码
git clone https://github.com/GuangMingZ/node-bff-practices.git
cd node-bff-practices
npm install
npm run dev

服务监听 http://127.0.0.1:3100,配套了演示脚本:

命令 演示内容
npm run demo:observation 观察模式:HTTP 200 + observation 日志
npm run demo:rate-limit 真正限流:第 4 次起 429

小结

  1. 中间件顺序即架构契约,不是功能堆叠;新增中间件前先画清依赖关系;
  2. AsyncLocalStorage 是 Node.js 异步场景下传递请求态的正确姿势,告别参数透传和全局变量;
  3. 三层限流 + 观察模式 让你在生产环境「先验证、再拦截」,调参零风险;
  4. 接入层的复杂度不在「转发请求」,而在每一层决策是否可预期、可观测、可调整 ------ 这三个话题本质上是一件事。

PS:Demo 为便于分享做了简化(内存配置中心、控制台日志、较低阈值),核心设计模式与生产接入层一致。欢迎 Star & Issue 交流。

相关推荐
kyriewen2 小时前
我读了一遍 Babel 编译后的 async/await,终于搞懂了它的原理(附 20 行手写实现)
前端·javascript·面试
IT_陈寒2 小时前
Vite项目build后路由404了?你可能漏了这个小配置
前端·人工智能·后端
lichenyang4532 小时前
AI 聊天从纯文本到结构化卡片:SSE done 帧携带 card + 历史记录卡片恢复实战
前端
宸津-代码粉碎机3 小时前
Spring AI企业级实战|从RAG优化到Agent多工具调度
java·大数据·人工智能·后端·python·spring
吴佳浩3 小时前
AI Infra 的真相:Go 没输,rust也不是取代
后端·rust·go
梦曦i3 小时前
@meng-xi/vite-plugin v0.1.5:告别手动 import,精简工具层
前端
梦曦i3 小时前
Vite 0.1.6重磅更新:智能导入+路由安全
前端
喵个咪3 小时前
实时游戏网络协议深度对比:KCP vs WebRTC vs WebSocket
后端·websocket·webrtc
普通网友3 小时前
springboot之集成Elasticsearch
spring boot·后端·elasticsearch