你在前端点击一次“提交订单”,浏览器只发出了一条请求。可当这条请求到了后端,它可能先经过负载均衡器和 API 网关,再穿过七八个中间件,才终于进入创建订单的业务代码。返回时,它还要沿着相反方向再走一遍。
第一次看到这样的架构,很容易产生一个疑问:直接让请求调用创建订单的函数,不是更快吗?
单看一次请求,当然是少绕一层就少一点开销。问题是,真实系统还得回答一串业务代码不擅长单独回答的问题:这个域名应该交给哪个服务?客户端有没有带合法凭证?这一秒的请求是不是太多了?后端实例是否健康?响应出错后该用什么格式返回?这次调用跨过三个服务后,日志还能不能串起来?
于是,请求链被拆成了不同的集成点。网关管理“流量如何进入系统”,应用服务器执行“这次业务到底要做什么”,中间件处理“许多请求都需要的横切规则”。它们并不是层数越多越先进,而是在用一点额外延迟换统一控制、故障隔离和可维护性。
更反直觉的是:这些层的代码即使一行不改,只调整顺序,系统行为也可能完全不同。把授权放到身份认证前面,系统不知道该检查谁的权限;把错误处理放错位置,异常就越过统一响应直接暴露出去;在追踪上下文建立之前记录日志,后面再完整的链路图也接不上开头那一段。
这节不准备把名词一个个摆出来背。我们就跟着一次 POST /orders 请求走完全程,看看每个集成点为什么存在、应该做到哪里为止,以及它的便利最终会带来什么代价。

这三个词经常出现在同一张架构图上,却不处在同一个维度。先把边界理顺,后面才不会把所有能力都塞进“网关”这个大筐。
网关通常是一个独立进程或托管服务,位于客户端与一组后端服务之间。中间件则通常属于某个应用的请求管道,和业务处理运行在同一个应用进程里。应用服务器是承载路由处理器和业务代码的运行环境,它可能采用多进程、多线程、事件循环或协程模型。
边界并不意味着功能绝不重叠。网关和应用都可能校验令牌,也都可能限流;网关和中间件都能加响应头。真正重要的是明确每一层承担哪一种保证。例如,网关先挡掉明显无效的凭证,可以节省后端资源,但应用仍要根据订单归属关系做业务授权。否则,只要有人绕过网关访问内部地址,或者网关规则配置出错,业务数据就失去最后一道边界。
判断职责放在哪里,可以连续问三个问题:它是否对大多数服务都相同?它是否必须在请求进入应用前完成?它是否依赖具体业务数据?越靠近前两个问题,越适合网关;越依赖业务数据,越应该留在应用里。

假设客户端发送下面这条请求:
POST /orders HTTP/1.1
Host: api.example.test
Authorization: Bearer eyJhbGciOi...
Content-Type: application/json
Idempotency-Key: order-7f2a
{
"sku": "BOOK-001",
"quantity": 2
}它的旅程通常可以拆成八段。
客户端先和入口建立连接。TLS 可能在网关终止,也可能一直传到应用。网关拿到 HTTP 方法、主机名、路径、请求头和请求体后,才有条件执行后面的路由与策略。
“TLS 在网关终止”不等于“网关后面可以裸奔”。如果网关到应用之间跨主机、跨网络或属于不同信任域,内部链路仍应加密,并对调用方身份做验证。否则只是把攻击面从公网移到了内网。
网关可能根据 Host、路径、方法或请求头把请求交给订单服务。例如,/orders 进入订单服务,/catalog 进入商品服务;同一路径还可以按版本头把少量请求送到新版本实例。
路由规则必须有明确优先级。更具体的规则如果被宽泛的前缀规则抢先匹配,请求就会进入错误服务。配置变更还要支持校验、灰度和回滚,因为入口规则的影响范围往往比一段业务代码更大。
网关可以先检查令牌的签名、签发方、受众和有效期,再按客户端、租户或 IP 执行限流。验证失败时,它直接返回 401;超过配额时,直接返回 429。此时请求不会占用订单服务的线程、事件循环任务或数据库连接。
不过,网关只能做它掌握信息范围内的判断。它可以知道“这个令牌代表用户 42”,却未必知道“用户 42 能否修改订单 9001”。后一个问题需要查询订单所有者和业务状态,应该由订单服务判断。
路由只决定服务,负载均衡还要从这个服务的健康实例中选一个。连接池里若有可复用的上游连接,网关就直接发送;没有空闲容量时,可能新建连接、短暂排队,或者因为连接数和并发请求达到上限而快速失败。
这一步解释了一个常见现象:应用 CPU 还不高,请求却已经在网关超时。瓶颈可能根本不在业务代码,而在上游连接池、排队长度、健康检查或熔断阈值。
应用收到的并不一定是客户端原始连接信息。客户端 IP、协议和主机名可能由受信任代理写入转发头。应用只有在确认请求确实来自受信任代理时,才能使用这些头;否则攻击者可以自己伪造“原始 IP”或“原始协议”。
随后,请求标识、追踪上下文、截止时间、用户身份和区域信息会进入应用的请求上下文。后面的日志与外部调用都应从同一份上下文取值,不要每层重新生成一套互不相认的 ID。
请求依次经过解析、认证、授权、参数校验等中间件,路由处理器再调用订单服务。订单服务可能检查库存、开启数据库事务、写入订单和发出领域事件。
这时要特别注意两种“成功”不是一回事:数据库提交成功,表示订单状态已经持久化;消息发送成功,表示下游系统有机会继续处理。如果两者之间没有可靠的交接设计,就可能出现“订单已经创建但消息丢了”或“消息发了但订单回滚了”。
业务处理器生成响应后,控制权会沿中间件管道反向返回。靠近业务的中间件先看到结果,靠近入口的中间件后看到结果。计时、压缩、统一响应头和访问日志常在这个回程阶段完成。
响应头一旦发送,状态码和多数头字段就不能再安全修改。错误处理如果此时才发现问题,往往只能中断连接,而无法重新包装成一份漂亮的 JSON 错误。这也是为什么流式响应、文件下载和服务器推送需要单独设计错误路径。
网关可能补充缓存策略、安全头或请求统计,再把响应交还客户端。到这里,一次请求才真正结束。任何一层的计时若只算自己的处理时间,都不等于用户看到的总延迟。
网关最有价值的地方,是把稳定、通用、与流量入口紧密相关的规则集中起来。集中带来了统一,也带来了更大的故障半径。
网关对客户端暴露稳定地址,对内把请求转发给会动态增减的实例。客户端不必知道订单服务今天有 3 个实例还是 30 个,也不需要跟踪每个实例的地址。
路由还可以做按权重分流、按请求头灰度、故障实例摘除和区域就近选择。代价是网关配置本身成为生产逻辑。一次错误的通配规则,可能让多个服务同时不可用,所以路由配置要像代码一样经过验证、审查和逐步发布。
入口认证适合验证凭证是否合法,减少每个服务重复解析令牌的成本。网关可以把经过验证的身份写入内部上下文,但应用不能无条件信任客户端直接提交的同名请求头。常见做法是网关先删除外部传入的身份头,再写入自己的值,并通过受保护的内部连接把请求交给应用。
业务授权不能只靠网关。是否允许取消某个订单,可能取决于订单是否属于当前用户、是否已经发货、调用者是否是客服,以及本次操作是否需要二次确认。这些信息和规则属于订单域,放到网关会让入口层迅速变成另一个难以发布的业务系统。
网关可以改写路径、请求头、查询参数、状态码和数据格式。这对于兼容旧客户端、桥接不同协议或隐藏内部服务拆分很有用。
转换越强,契约也越隐蔽。后端看到的请求不再等于客户端发送的请求,排错时必须同时查看转换前后两份数据。复杂脚本还会占用网关 CPU,并可能让新字段在转换过程中被意外丢弃。能用简单代理完成时,就不要为了“统一”而重写整个请求体。
移动端首页可能需要用户、优惠券、推荐和订单摘要。如果客户端分别请求四次,弱网络下的握手和往返会很明显。网关或专门的聚合服务可以并行调用四个后端,再组装成一个响应。
但聚合没有消灭复杂性,只是搬了位置。四个下游中最慢的那个决定整体延迟;任意一个失败,都需要回答“整个接口失败,还是返回部分结果”;同一份数据还可能来自不同时间点,无法天然保证事务一致性。
如果聚合只为某类客户端塑造视图,可以单独建立面向前端的服务,让它拥有清晰版本和独立伸缩策略。不要让所有业务聚合都堆进共享网关,否则网关会同时承担流量入口和业务编排,任何修改都牵动全局。

这几种机制经常一起出现,但它们处理的故障不同。
限流保护的是入口预算。熔断保护的是已经出问题或已经饱和的下游。超时给等待设上限,重试则重新消耗一次预算。它们要一起算,不能各自配置。
假设客户端重试 2 次、网关重试 2 次、应用调用下游时再重试 2 次。一次原始请求在最坏情况下可能变成很多次下游尝试。故障期间,下游最缺容量,盲目重试却正好给它增加压力。因此,重试应有总时间预算、次数上限、退避与随机扰动,并只用于明确可安全重试的操作。
创建订单这类写操作,即使使用 POST,也可以通过幂等键避免同一业务动作被重复执行。应用要持久化幂等键及对应结果,而不是只在单个进程内记几秒钟。否则实例重启或请求落到另一台机器时,保护就失效了。

所有请求都经过网关,意味着它的可用性和容量必须高于单个后端服务。网关需要多实例部署、健康检查、连接复用、容量预留和安全的配置发布。日志不能无限记录完整请求体,认证服务也不能成为每次请求都同步依赖的单点。
每增加一个网关插件,都要付出延迟、CPU、内存和排错成本。统一能力值得集中,业务差异则要克制。网关不是“什么都能放”的地方,它首先是一条高频数据通道。
请求进入应用后,事情才从“管理流量”转为“执行业务”。应用服务器通常会完成路由匹配、建立请求作用域、调用业务服务、管理数据库事务,并把结果序列化为 HTTP 响应。
多线程服务器可能让每个活跃请求占用一个工作线程;事件驱动服务器用少量线程协调大量非阻塞 I/O;多进程模型则通过多个独立进程利用多核并隔离故障。没有一种模型对所有工作都更快。
I/O 密集的请求会花大量时间等待数据库或网络,异步 I/O 可以让执行线程在等待时处理别的请求。CPU 密集的图片处理、压缩或复杂计算却不会因为写了 await 就变快。它仍然占用 CPU,在事件循环模型中甚至会拖住其他请求,需要拆分任务、使用工作线程或交给独立计算服务。
所以,“改成异步”不是性能结论。你还要问:等待的是非阻塞 I/O,还是代码正在持续计算?依赖驱动是否真的支持异步?一次请求虽然不占线程了,会不会仍占着数据库连接和事务?
数据库连接建立通常涉及网络握手、认证和会话初始化。应用不会为每次查询重新连接,而是从连接池借一个连接,用完后归还。
连接池满时,请求不会凭空消失。它们要么等待连接,要么在等待超时后失败。于是,一个看似普通的慢查询可能长期占住连接,随后越来越多请求堆在池外,最终表现成整个 API 变慢。
连接池不是越大越好。数据库能高效并行执行的工作有限,连接太多会增加内存、上下文切换和锁竞争。更容易忽略的是总量:如果每个应用实例允许 30 个连接,扩容到 20 个实例,数据库面对的潜在连接上限就是 600,而不是 30。
调连接池时至少要一起观察四组数据:正在使用的连接数、空闲连接数、等待连接的请求数、获取连接的等待时间。再把它们和查询时长、事务时长、数据库 CPU 与锁等待对照。只看应用吞吐,很容易把慢查询造成的拥堵误判成“连接不够”。
下面这个模式能避免最常见的连接泄漏:无论查询成功还是抛出异常,连接都会归还;事务也会明确提交或回滚。
async function createOrder(pool, input) {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const [result] = await connection.execute(
'INSERT INTO orders (user_id, sku, quantity) VALUES (?, ?, ?)',
[input.userId, input.sku, input.quantity],
);
await connection.commit();
真正的代码还应处理事务提交结果与消息发布之间的一致性,但连接的生命周期原则不变:尽量晚借、尽量早还,不要在等待无关外部 API 时一直占着数据库连接。

用户登录后,浏览器通常在 Cookie 中携带一个会话标识,应用再用它查找服务端会话数据。这里有一个容易混淆的地方:Cookie 在客户端,不代表完整会话一定在客户端。很多系统只把不可预测的标识放进 Cookie,身份与状态仍保存在服务器端。
单机内存会话实现最简单、读取也快,可请求一旦落到另一实例就找不到。粘性会话可以暂时保证同一用户回到同一实例,但实例故障、扩缩容和流量不均仍然麻烦。共享缓存或数据库能让任意实例读取会话,却增加一次网络访问和新的可用性依赖。
自包含令牌减少了会话查询,但并没有消灭所有状态问题。注销、撤销、权限即时变更、密钥轮换和令牌泄露仍要处理。选择方案时,不要只问“是否无状态”,还要问“状态变更需要多快生效”。
会话 Cookie 应限制传输与访问范围,设置安全传输、脚本不可读和合适的跨站策略,并设置合理的空闲与绝对过期时间。用户登录、权限提升或其他敏感状态变化后,还应轮换会话标识,避免旧标识继续代表新的权限。

中间件不是一串各做各事、互不相干的函数。更准确的理解是:每个中间件包住后面的处理过程。
一个中间件通常先处理请求,然后决定是否调用下一个中间件。下游完成后,控制权再回到它这里处理响应。因此,请求按注册顺序向内走,响应按相反顺序向外走。这就是中间件常被画成“洋葱”的原因。
请求 → 请求标识 → 计时 → 认证 → 授权 → 业务处理
响应 ← 补充响应头 ← 记录耗时 ← 已有身份 ← 已有权限 ← 业务结果如果授权规则需要当前用户,认证就必须先建立用户身份。如果访问日志要记录最终状态码和耗时,它必须覆盖后续处理并在响应完成时读取结果。如果错误处理要把下游异常转成统一 JSON,它就必须处在能够接住这些异常的位置。
下面是一条常见思路,不是一份跨框架通用的固定清单:
限流放在哪里取决于它使用什么身份。按 IP 的廉价入口限流可以很早执行;按用户套餐计算的配额必须等认证后才有用户信息。实际系统常做两层:入口先挡明显洪峰,认证后再执行精细配额。
错误处理的写法也由框架决定。有些框架要求错误处理中间件在管道外层、较早注册;Express 则通过四参数错误处理函数识别错误中间件,通常在路由之后注册。不要只记“错误处理放最前”或“放最后”,要确认框架如何把异常传给它。
中间件不调用下一步,就发生了短路。认证失败、限流命中、命中静态文件或缓存时,都可以直接生成响应,避免后续工作。
短路带来性能收益,也可能绕过必要规则。假如静态文件中间件放在授权之前,那么它服务的文件通常不会再经过授权。若这些文件本应受保护,顺序就形成了安全漏洞。每加入一个会短路的中间件,都要明确它跳过了哪些后续步骤。
客户端需要稳定、可处理的错误契约,例如状态码、错误代码、简短消息和请求标识。堆栈、SQL、内部文件路径和密钥信息不应该出现在生产响应中,它们属于受控日志和追踪系统。
错误处理还要先判断响应是否已经开始。普通 JSON 响应在发送前还能更换状态码;流式响应已经发出一半时,再抛出异常就不能假装从头返回另一份 JSON。此时通常只能结束流,并依靠日志和指标记录原因。
下面的例子把请求标识、访问日志、JSON 解析、认证、授权、短路和错误映射放进同一条管道。示例把错误处理注册在路由之后,这是 Express 的分派约定,并不代表所有框架都如此。
{
"name": "middleware-order-demo",
"private": true,
"type": "module",
"scripts": {
"start": "node server.mjs"
},
"dependencies": {
"express": "^5.1.0"
}
}import { randomUUID } from 'node:crypto';
import express from 'express';
const app = express();
app.disable('x-powered-by');
app.use((req, res, next) => {
req.requestId = randomUUID();
res.setHeader('X-Request-Id', req.requestId);
next
你可以观察到几个有意安排的顺序:请求标识最早建立,所以解析 JSON 失败也能返回 requestId;认证先把用户写入 req.user,授权中间件才有信息可查;访问日志监听响应完成事件,因此短路、正常响应和错误响应都能记录最终状态。

系统出问题时,最没用的描述之一是“网关说后端慢,后端说自己只用了 30 毫秒”。两边可能都没说错:请求也许在网关等待连接用了 700 毫秒,进入应用后业务代码确实只跑了 30 毫秒。
要看清完整路径,需要把日志、指标和追踪放在一起。
结构化访问日志至少应包含请求标识、追踪标识、方法、路由模板、状态码、耗时、上游目标和错误类别。记录路由模板而不是原始 URL,可以避免把订单 ID 等高基数字段变成不可控的索引维度。
日志不应默认保存完整令牌、Cookie、密码、支付信息或请求体。调试需要更多上下文时,也要使用字段白名单、脱敏、采样和访问控制。入口层“什么都记录”看似方便,最后常变成数据泄露和存储成本问题。
请求量、错误率和延迟分布是入口指标,连接池等待、队列深度、熔断拒绝数和下游超时则能指出压力在哪里。只看平均延迟会掩盖尾部问题,应同时关注高分位延迟。
状态码也不能单独解释故障。网关返回的 503 可能是没有健康上游、熔断打开或连接池耗尽;应用返回的 503 可能是数据库不可用。相同状态码需要配合“响应由哪一层生成”和原因标签来判断。
分布式追踪通过上下文传播把网关、应用和下游调用连接成一棵调用树。入口收到追踪上下文后要验证并提取,发起下游请求时再注入当前上下文。追踪 ID 用来关联整条链路,跨度 ID 标识其中一次具体操作。
不要把用户身份、令牌或任意业务数据塞进可跨服务传播的追踪字段。传播范围越广,泄露面越大。追踪也要有采样策略;若只随机保留少量请求,罕见错误可能被错过,可以结合错误优先或尾部采样补足。
超时与取消信号也应该沿调用链传播。客户端已经离开、上游截止时间只剩 50 毫秒时,下游再执行一个两秒查询通常没有价值。尽早停止无效工作,释放连接和并发槽,系统才有机会服务仍在等待的请求。

有些操作不适合让客户端一直等。例如生成大型报表、批量发送通知、转码视频。应用可以先把任务可靠地交给消息系统,再返回 202 Accepted 和任务查询地址。
HTTP/1.1 202 Accepted
Content-Type: application/json
Location: /jobs/job-81c9
{
"jobId": "job-81c9",
"status": "accepted",
"statusUrl": "/jobs/job-81c9"
}202 只表示任务已被接受处理,不表示任务最终成功。HTTP 响应发出后,服务器不能隔一小时再补发另一个状态码。因此,系统要提供轮询、回调、WebSocket 或事件通知,让客户端获得最终状态。
真正可靠的异步交接至少要考虑这些问题:
异步边界把用户等待变短了,却把一致性、重试和状态查询变复杂了。不要把“消息已经放进队列”写成“报表已经生成”。接口命名、状态字段和监控都要诚实反映这段时间差。
数据库写入与消息发布之间还有一个缝隙:数据库提交后,进程可能在发消息前崩溃。常见处理思路是把业务变更和待发送事件写入同一个本地事务,再由后台发布器可靠转发。这样仍可能重复发布,但可以通过幂等消费处理,不会悄悄丢掉已经提交的业务事件。
网关是第一道门,但不是唯一一道门。每跨过一个集成点,都应该重新确认数据来源、调用方身份、允许的操作和输出范围。
网关适合检查协议层限制:方法是否允许、请求体是否过大、内容类型是否支持、凭证结构是否有效。应用再检查业务约束:数量是否大于零、库存是否充足、订单是否属于当前用户、状态是否允许修改。
格式正确不代表业务安全。orderId 是合法字符串,也可能指向别人的订单。只做 JSON Schema 校验,挡不住越权访问。
“来自内网”不是可靠身份。内部服务可能被攻破,网络也可能配置错误。服务间连接应有可验证的工作负载身份,并执行最小权限授权。订单服务调用库存查询,不代表它自然拥有修改财务记录的权限。
网关写入的身份头、租户头和客户端地址只有在受保护链路上才可信。应用应拒绝绕过入口的未认证访问,或通过网络策略与服务身份确保只有授权入口能够调用。
认证依赖超时,不能自动当作认证成功;授权规则加载失败,不能默认放行;限流服务不可用时是开放还是拒绝,需要按接口风险明确决定。公共只读接口和资金操作可以采用不同策略,但不能把这个决定留给运行时偶然发生。
错误响应要避免泄露内部拓扑,跨服务日志要避免复制敏感字段,网关转换要防止重复或冲突请求头。安全不是再加一个“安全中间件”,而是每个边界都对信任做一次明确收缩。
当接口变慢或报错,不要一上来就扩大线程池和连接池。按请求实际经过的路径检查,通常更快找到根因。
查看请求标识、网关原因码和应用日志。401、429、502 或 503 可能在请求到达应用前就已生成。如果应用完全没有对应请求记录,先查路由、入口策略、上游健康和网关连接。
总延迟可以粗略看成入口处理、网关排队、网络、应用排队、业务执行和响应传输的总和。应用处理只有 30 毫秒,不代表用户只等了 30 毫秒。把各层跨度放到同一条追踪里,才能知道时间消失在哪一段。
连接池等待上升时,先找慢查询、长事务和连接泄漏;工作线程或事件循环拥堵时,找阻塞 I/O 和大块 CPU 工作;队列堆积时,看消费者吞吐、失败重试和下游容量。扩容可以争取时间,但如果每个实例都把更多连接压向同一个数据库,扩容也可能让故障更严重。
如果只有某类请求异常,检查路由条件、转换规则和中间件分支。如果认证信息偶尔丢失,检查代理头信任、上下文传播和异步回调。如果错误没有统一格式,检查异常是否发生在响应发送后,以及当前框架的错误传播约定。
你会发现,所谓“集成点问题”往往不是某一层完全坏了,而是两层对同一件事的理解不同:网关认为请求可重试,应用却把它当成不可重复写入;应用认为身份头可信,网关却没有清掉客户端伪造值;生产配置调整了中间件顺序,却没人意识到短路路径绕过了授权。
理解这些边界后,请求经过很多层就不再显得多余。每一层都应该有可说清的责任、可观测的代价和明确的失败方式。下一步再讨论认证时,我们就能把注意力放在凭证、会话和授权本身,而不会把“在哪里验证”和“验证什么”混成同一个问题。