从一段 JavaScript 到一个可以承接真实流量的服务,我们已经依次处理了运行时调度、异步容量、模块边界、HTTP 契约、业务结构、错误、安全和单进程扩展。最后的问题不再发生在某一个函数或实例里,而是这些能力怎样跨模块、进程和服务继续成立。
换句话说,现代架构不是给系统多画几个方框,而是把前面建立的边界带到更大的尺度上。只要所有权、失败语义和观测链路没有一起放大,拆分本身就会制造新的不确定性。
那次改造上线前,架构图看起来几乎无可挑剔:入口有网关,Web 和移动端各有 BFF,订单、库存、优惠都成了独立服务,耗时操作也接入了消息队列。和那个仍在一个进程里运行的旧应用相比,新系统显得很“现代”。
流量升高后的第一个晚上,事情却开始失控。创建订单接口的 p99 延迟突然超过 4 秒,前端大量提示“系统繁忙”,库存预占量却在继续上涨。少数用户刷新后看见两笔内容相同的订单,值班群里同时出现三种判断:Node 单线程扛不住了、数据库连接池太小、网关限流误伤了正常请求。
我一开始也盯着 CPU 和连接池。它们确实都比平时高,但远没到饱和;扩容两个 BFF 实例后,错误率只短暂下降,重复预占反而更多。这个现象逼着我换了一个问题:不是“哪个组件坏了”,而是“一次创建订单到底由谁负责完成”。

麻烦在于,几个服务都记录了 requestId,却各自重新生成,没法直接把日志串起来。我们只好用用户 ID、订单金额和几十毫秒的时间窗口拼接记录,最后还原出这样一条链路:
关键转折不是找到某行慢 SQL,而是我们在白板上把“调用图”改成了“责任图”。创建订单的状态机写在订单服务里,流程编排却在 BFF;库存是否可用由库存服务决定,BFF 又为了减少调用直接读取它的旧表;网关、BFF 和订单服务各有一套超时与重试,却没有人定义端到端截止时间。每个局部实现都能解释,组合起来却没有一个完整的负责人。
当天的止血动作很克制:关闭写请求的透明重试,让客户端复用同一个幂等键,把创建订单收敛为订单应用服务的一个用例,并暂停 BFF 对库存数据的直连。随后才补齐请求上下文、事务性 Outbox、补偿任务和尾延迟告警。我们没有继续拆服务,因为这次故障证明,部署单元越多不等于边界越清楚。
这次事故后来成了我评审 Node.js 后端设计时的起点。一个架构是否可靠,先看五件事:业务规则由谁拥有,数据由谁写,失败由谁恢复,调用能等多久,发生问题后能否还原现场。框架、容器和消息中间件都要排在这些问题后面。
业务边界还在变化时,我更愿意从模块化单体开始。它仍然是一个部署单元,却按订单、库存、用户等业务能力划分模块。订单模块不能绕过库存模块直接改库存表,用户模块也不能导入订单模块的内部仓储。进程没有拆开,责任已经拆开。
这样做保留了本地调用、单库事务和短调试链路的优势,也给未来留下了接缝。某个模块确实需要独立扩容或隔离故障时,可以把它公开接口背后的本地实现换成远程适配器。要是一个模块在单体里都无法禁止其他模块越界写表,把它搬到另一个仓库通常只会得到一个网络化的循环依赖。

我接手过不少按技术类型分目录的项目:所有 Controller 在一起,所有 Service 在一起,所有 ORM 模型又在另一处。规模小时很整齐,规模一大,修改“取消订单”要横跨几个巨型目录,规则也很容易散落在 Controller、数据库 Hook 和定时任务里。
更容易守住边界的组织方式,是先按业务能力切模块,再按需要组织模块内部:
src/
├── modules/
│ ├── orders/
│ │ ├── interface/ # HTTP Controller、DTO、消息入口
│ │ ├── application/ # 用例编排:创建订单、取消订单
│ │ ├── domain/ # 订单规则、实体、值对象
│ │ ├── infrastructure/ # 数据库仓储、外部支付适配器
│ │ └── orders.module.ts # 模块装配与公开出口
│ ├── inventory/
│ └── users/
├── shared/ # 少量、稳定、与业务无关的能力
└── main.ts这些目录不是必须凑齐的四件套,它们表达的是依赖方向:接口层把 HTTP、WebSocket 或消息转换成用例输入;应用层编排一次操作;领域层保存不依赖框架的规则;基础设施层接入数据库、缓存和外部系统。领域对象不该知道请求来自 Express 还是 Kafka,也不该把 ORM 查询对象当成自己的语言。
假设创建订单需要预占库存,订单应用服务应依赖 InventoryPort.reserve(),而不是导入库存表模型自行更新。接口带来的价值不只是方便 Mock,它明确了“库存是否充足、怎样扣减、失败意味着什么”属于库存能力。将来这个端口背后是函数调用、HTTP 还是消息,不会改变订单用例表达的意图。
shared
最容易成为没有所有者的垃圾场。时钟接口、请求上下文、统一错误基类这类语义稳定且与业务无关的能力可以共享;OrderHelper、UserUtils
这类带业务含义的代码应留在所属模块。共享代码越多,模块越难独立演进。
我判断一层是否值得存在,会问两个问题:它有没有独立语义和测试价值?它能不能阻止某类变化向内扩散?HTTP 参数改变时,影响应停在接口层附近;数据库实现改变时,领域规则不应跟着改;订单状态流转改变时,规则也不该散到路由和 SQL 里。
如果一次字段新增要穿过十几个只做原样转发的类,说明分层已经变成仪式。小而稳定的功能完全可以合并应用层和领域层;复杂状态机则值得拥有明确的实体、值对象和领域服务。层数取决于变化成本,而不是架构图的对称程度。
事故里的 BFF 会失控,是因为它从“页面适配者”变成了“订单总指挥”。网关和 BFF 虽然都位于客户端与后端之间,职责不应混在一起。
网关回答的是入口治理问题:TLS 在哪里终止,请求往哪里路由,单个调用方能发多少请求,请求体能有多大,令牌是否明显非法。它不需要知道订单页展示哪些字段。BFF 回答的是某类前端怎样获得合适的数据:并发查询哪些下游,怎样聚合和裁剪字段,旧客户端需要怎样兼容,哪些非关键内容可以降级。
Web 和移动端的数据体积、交互方式与发布节奏确实长期不同时,分别设置 BFF 很合理;如果两个端长期共用同一套接口,就没必要复制服务。BFF 是隔离前端差异的工具,不是每张架构图都要摆上的方框。

下面这个首页聚合保留了 BFF 真正擅长的工作:三个查询并发发出,用户资料是页面成立的前提,订单和推荐失败时可以局部降级;每个下游都有独立截止时间,请求上下文继续向下传递。
type Profile = { id: string; displayName: string };
type RecentOrder = { id: string; status: string };
type Recommendation = { id: string; title: string };
type PageData = {
profile: Profile;
真实系统还要记录每个依赖的耗时和失败原因,并限制单个请求的扇出数量。一次页面请求调用二十个下游,即使平均延迟不错,最慢那个依赖仍会放大尾延迟。Promise.all() 只会提前拒绝,并不会自动取消其余请求;全部结果不可缺时,应共享 AbortController 并在失败后取消在途调用。允许部分降级时,Promise.allSettled() 才符合语义。
写操作则不该由 BFF 拼出半个分布式事务。创建订单、扣减库存、确认支付这类用例应有唯一负责人,入口只提交命令并携带幂等键。负责人可以同步协调,也可以运行 Saga,但状态迁移、重试规则和补偿责任必须收敛在一个明确边界内。
网关可以校验令牌签名、签发者、受众和有效期,挡住明显非法的请求;真正的业务授权仍由拥有数据的模块完成。“这个用户能否取消这笔订单”依赖订单归属和当前状态,入口层没有足够信息替订单模块裁决。
内部服务不能信任客户端自带的 x-user-id。可信入口应根据已验证令牌建立身份上下文,覆盖或移除外部伪造的内部头;服务间还需要自己的身份认证和最小权限。只要进入内网就能冒充任何用户,不是边界,而是一块更大的信任盲区。
我不再用“Node 做轻业务、Java 做重业务、Python 做 AI”来划分系统。更可靠的依据是业务所有权、团队能力、生态、延迟目标、事务范围和故障隔离。同一个订单状态机被三种语言各实现一部分,问题不会因为技术栈丰富而变简单。
Node 很适合承担 BFF、实时连接和高并发 I/O,也完全能承载边界清晰的核心业务;Java 可以延续稳定的交易模型;Python 可以提供数据处理或推理能力。关键是每项业务能力只有一个权威实现,对外暴露稳定契约,并能独立测试与部署。
跨语言后,TypeScript 类型不再是系统契约。HTTP 接口可以使用 OpenAPI,RPC 可以使用 Protobuf,消息则应有独立 Schema。契约要版本化并检查兼容性:新增可选字段通常安全,删除字段、改名或改变旧字段含义则需要迁移窗口;消费者也不应因为收到未知字段就直接失败。
查询用户资料、计算运费、确认库存是否可售,都可能要求调用方立即得到答案,HTTP 或 gRPC 很合适。但一次同步调用会把双方绑定在同一段时间里:下游变慢,上游连接、内存和用户等待时间都会增加。
我会在接口契约之外再写清五条运行规则:
超时不等于取消,更不等于失败事务已经回滚。调用方放弃等待时,下游可能仍在执行;事故中的重复写入就是这样发生的。所有可能超时重试的写接口,都要先设计幂等语义,而不是事后用日志去猜到底执行了几次。
发送通知、生成报表、更新搜索索引、通知多个订阅者,不必占住用户的 HTTP 请求。消息让生产者和消费者不必同时在线,但它没有消灭失败,只是把失败处理、可见中间状态和最终一致性移到了后台。

命令表达“请一个明确的处理者做某件事”,事件表达“某件事已经发生”。SendWelcomeEmail 是命令,OrderPaid 是事件。这个区分很实用:前者要知道谁负责处理以及失败如何升级,后者的发布者不应依赖某个订阅者存在。
消息系统通常提供至少一次投递,因此消费者必须假设同一消息会重复到达,用消息 ID 或业务键保证幂等。短暂失败可以退避重试,超过阈值后进入死信区并触发告警;处理成功的确认时机也要与业务写入匹配。若要求“业务记录提交后,事件最终一定发出”,可以在同一数据库事务中写业务数据和 Outbox 记录,再由发布器转发。它解决的是提交与发布之间的原子缝隙,不负责自动解决消费者的业务冲突。
引入队列前,我会先要求说清三个状态:用户立即看到什么,消息重复时系统做什么,长期失败后由谁补偿。答不出来时,异步化只会让故障离请求现场更远。
如果服务 A 发出的消息必须由 B 立即处理,B 随后同步调用 C,C 又直接查询 A 的数据库,那么系统虽然有多个仓库和进程,变化与故障仍然绑在一起。它承担了网络、发布和观测的复杂度,却没有获得独立演进能力。
我通常用三个问题检查这种风险:服务是否独占自己的数据写入权?一次常规发布是否总要同时修改多个服务?一个服务不可用时,其他服务能否降级或积压任务?长期给不出好答案时,优先合并服务或重划所有权,而不是再增加一层 RPC。
exports 就是公开面NestJS 的 @Module() 通过 imports、controllers、providers 和 exports 建立模块图。Provider 默认封装在所属模块内,只有显式导出的能力才能被其他模块使用。因此,评审一个模块时,我会先看 exports:它暴露的是少数稳定用例,还是为了省事把所有 Service 都公开了。
下面的订单模块用注入令牌隔开应用服务和数据库实现:
import { Module } from "@nestjs/common";
import { CreateOrderService } from "./application/create-order.service";
import { PrismaOrderRepository } from "./infrastructure/prisma-order.repository";
import { OrdersController } from "./interface/orders.controller";
export const ORDER_REPOSITORY = Symbol("ORDER_REPOSITORY");
@Module({
controllers: [OrdersController],
providers: [
CreateOrderService,
{
provide: ORDER_REPOSITORY
应用服务依赖仓储能力,而不是直接依赖 Prisma:
import { Inject, Injectable } from "@nestjs/common";
import type { CreateOrderCommand } from "./create-order.command";
import { Order } from "../domain/order";
import type { OrderRepository } from "../domain/order.repository";
import { ORDER_REPOSITORY } from "../orders.module";
@Injectable()
export class CreateOrderService {
constructor(
@Inject(
测试环境可以把 ORDER_REPOSITORY 替换为内存实现,运行环境则绑定真实仓储。依赖注入的价值是集中装配和替换实现,不会自动产生好架构。大量互相注入的 Service 和反复出现的 forwardRef(),通常是在提醒我们:模块职责形成了环。
Nest Provider 默认是单例。连接池、配置读取器、无请求状态的业务服务很适合这种作用域,但“单例可共享”不等于“实例字段可随便写”。把当前用户或当前订单放进单例字段,并发请求就会互相覆盖。
REQUEST 作用域会为每个请求创建实例,依赖它的上层 Provider 也可能被带入请求作用域,增加对象创建与解析成本。只有多租户上下文、请求级缓存等确实需要隔离实例时才使用它。追踪 ID 这类上下文更适合放入 AsyncLocalStorage,让同一异步调用链中的日志取到当前值,而不必把大量 Provider 改成请求作用域。
Nest 请求大致按“中间件 → 守卫 → 拦截器前置 → 管道 → Controller → Service → 拦截器后置”流动,未捕获异常交给异常过滤器处理。这个顺序不是要背诵的名词表,它直接决定代码的归属:

把同一项鉴权同时写进中间件、守卫和 Controller,会让一次权限变更出现三个版本。Controller 最好只做协议转换和用例调用,这样创建订单的同一规则才能被 HTTP、消息消费者或定时任务复用。
onModuleInit() 适合在模块依赖解析后初始化自身,onApplicationBootstrap() 发生在所有模块初始化完成之后;退出阶段可以通过 onModuleDestroy()、beforeApplicationShutdown() 和 onApplicationShutdown() 清理资源。要让 SIGTERM 触发关闭钩子,需要显式启用:
import { ValidationPipe } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform:
数据库连接或消息订阅初始化失败时,进程就不该宣告可用。退出时则先让 readiness 失败并停止领取新流量,再等待在途 HTTP 请求和消息处理结束,最后关闭连接池与长连接。整个清理过程需要总超时,因为编排平台的宽限期不会无限等待。
那次事故最浪费时间的地方,是每个服务“都有日志”,却无法回答同一次操作经过了什么。三类信号需要互相接力:指标发现影响范围和趋势,追踪定位慢在哪一段,结构化日志解释具体事件与业务上下文。
日志适合记录离散事件,例如订单创建为何被拒绝;指标适合聚合请求速率、错误率、延迟分位数、事件循环延迟和队列积压;分布式追踪用多个 Span 串起网关、BFF、领域服务与数据库的因果关系。只收集其中一种,都很难快速从“用户失败”走到“哪个依赖、哪次操作、为什么失败”。
入口应接受或生成 trace 上下文,并把它放入 AsyncLocalStorage。下游 HTTP、gRPC 调用继续传播标准追踪头;消息生产者把上下文写进消息头,消费者提取后创建新的处理 Span。requestId 可以用于日志检索,traceId 用于跨服务追踪,两者可以关联,但不要让每一层覆盖上游值。
日志使用稳定字段比拼接长句更容易检索:
{
"level": "error",
"service": "web-bff",
"requestId": "req_7d2f",
"route": "GET /home",
"dependency": "order-service",
"durationMs": 812,
"errorCode": "DOWNSTREAM_TIMEOUT"
}密码、访问令牌、Cookie 和身份证号不能进入日志。用户 ID、订单 ID 这类业务标识也应按数据规范采集、脱敏和设置保留时间。追踪越完整,越要防止把敏感请求体顺手塞进 Span。

“CPU 超过 80%”不应直接等价于故障。CPU 高可能代表实例正在有效工作,用户真正感知的是成功率、延迟和关键流程是否完成。核心接口可以围绕请求速率、错误率、延迟和饱和度定义服务目标;消息消费者还要看积压量、最老消息等待时间、重试次数和死信数量。
Node 服务还应观察事件循环延迟、堆内存、GC、活动句柄、进程重启和连接池耗尽情况。平均延迟会藏住少量极慢请求,p95、p99 更容易暴露扇出调用和停顿。指标标签则要控制基数:服务名、路由模板、状态码适合做标签,用户 ID、订单 ID、原始 URL 应留在日志或追踪里,否则监控成本会随着业务数据无限增长。
会话、任务进度和跨请求共享的数据应进入数据库、缓存或消息系统,而不是留在某个 Node 实例的内存里。这样平台才能滚动发布、故障迁移和水平扩容。容器只是交付形式,不会自动提供这种能力。
应用至少要区分两类健康信号:liveness 判断进程是否陷入不可恢复状态,readiness 判断实例现在能否接收新流量。数据库短暂抖动时,让所有实例的 liveness 同时失败可能制造重启风暴;更常见的策略是暂时撤掉 readiness,等待依赖恢复,同时保留足够证据诊断问题。
运行环境还要固定受支持的 Node 版本、锁定依赖、检查已知漏洞,密钥通过环境注入而不写入镜像。入口代理负责 TLS 和连接限制,应用自身仍要限制输入大小、处理 socket 异常,并给每个下游调用设置截止时间。安全和可靠性都是多层共同完成的属性。
收到 SIGTERM 后,HTTP 服务停止接收新请求并等待在途请求;消息消费者停止领取新消息,完成或安全放回正在处理的任务;WebSocket 服务通知客户端重连,再关闭长连接。这三种工作负载可以在同一仓库里,但往往适合不同进程和扩容曲线。
实时服务扩到多个实例后,房间成员与广播不能只存在单实例内存。它需要共享的发布订阅适配器,并根据传输方式决定是否使用会话亲和。客户端也要实现断线重连、事件去重和状态补偿,不能把长连接误当成永不失败的管道。
我会用单元测试覆盖领域规则和应用用例,不启动 HTTP 服务;用集成测试验证真实仓储、消息发布器和外部适配器;用契约测试确认不同语言对接口与消息 Schema 的理解一致;端到端测试只保留少量关键链路。
如果所有依赖都被 Mock,测试可能只证明 Mock 会按预期返回,发现不了 SQL、序列化和依赖装配问题;如果信心全部押在端到端测试上,运行又慢,失败后也很难定位。更实用的组合是大量快速规则测试、适量模块集成测试、少量跨服务关键流程测试。Node 内置测试运行器和 Jest 都能胜任,重要的是验证边界行为,而不是绑定某个类的内部调用顺序。
经历那次拆分失败后,我不再从“要不要微服务”开始讨论,而是沿着一组可验证的问题推进:
先写出关键业务用例和不变量:谁能改变订单状态,库存以谁的数据为准,重复请求必须得到什么结果。没有单一答案的规则,先收敛所有权。
在模块化单体中建立边界:限制跨模块导入和跨模块写库,让协议、应用、领域和基础设施的依赖方向可检查。边界先在代码里成立,再考虑网络隔离。
为每次跨边界调用写清失败语义:截止时间、取消、幂等、重试、降级和补偿分别由谁负责。同步与异步不是性能标签,而是用户何时需要结果的选择。
建立贯穿 HTTP、RPC 和消息的追踪上下文,用服务目标和尾延迟验证瓶颈。无法观察的边界不要急着独立部署,否则排障成本会先于扩展收益到来。
最后我还会做一次“断线推演”:库存服务慢三秒会怎样,消息重复三次会怎样,实例收到 SIGTERM 会丢什么,某个字段分两周迁移会不会同时兼容新旧消费者。能把这些答案说清楚,架构才不只是在正常路径上好看。
可复用的判断顺序是:先明确业务与数据所有权,再选择模块和部署边界;先定义失败语义,再选择通信方式;先让故障可观测、可恢复,再扩大系统规模。Node.js 可以是完整业务应用,也可以是 BFF、实时网关或多语言系统中的服务,真正决定可维护性的始终是边界是否清楚。
这条架构判断并不是最后才出现的新知识。最开始那个被同步循环拖死的单进程,已经暴露了执行边界;事件循环和并发管理补上容量边界;模块、API 与设计模式补上代码和业务边界;错误处理、安全与扩展又补上失败、信任和资源边界。系统从一个文件长成多个服务以后,变化的只是尺度,判断问题的方法没有变。
所以一套现代 Node.js 后端真正连贯的主线,不是从语法一路堆到微服务,而是不断回答同一个问题:当前工作由谁负责,它可以占用多少资源,失败会传播到哪里,边界外的人和系统又能看到什么。 能持续回答这四件事,架构图上的每个方框才有意义。
只有当模块长期存在不同扩容曲线、独立发布需求、明确故障隔离价值或稳定团队所有权时,才拆为服务;同时转移数据写入权、契约测试、监控和恢复责任。