Monolith 系统是否应该迁移到 microservices based architecture?
结论
现阶段不建议迁移到完整的 microservices based architecture。这个 Node.js 项目仍处于早期阶段,而且由单人维护,更适合按以下方式演进:
- 先将系统重构为 modular monolith。
- 保留 monorepo,将 Web API 和 background workers 分成独立的部署单元。
- 把 Business logic 和 database interaction 提取到可共享的内部模块。
- 通过无停机部署、持久化队列和幂等处理,解决扩容与 webhook 可靠性问题。
- 等某个业务模块确实需要独立发布、拥有独立数据或由独立团队维护时,再把它提取为 microservice。
代码放在同一个 repository 中,不等于所有代码都必须运行在同一个进程、容器或部署包里。代码如何组织,与系统如何部署,是两个不同的问题。
推荐的整体结构
可以继续使用 monorepo,但要明确区分应用入口和业务模块:
project/
├── apps/
│ ├── api/
│ │ └── main.js
│ ├── payment-webhook/
│ │ └── main.js
│ └── workers/
│ ├── email-worker.js
│ └── report-worker.js
├── modules/
│ ├── payments/
│ │ ├── domain/
│ │ ├── application/
│ │ └── infrastructure/
│ ├── users/
│ │ ├── domain/
│ │ ├── application/
│ │ └── infrastructure/
│ └── reports/
├── shared/
│ ├── database/
│ ├── queue/
│ └── observability/
└── package.json
这套结构分为三个层次:
apps存放能够独立启动、部署和扩容的进程。modules存放按业务能力划分的代码。shared只存放真正通用的基础设施,不能成为随意堆放业务代码的目录。
例如,API 负责启动 HTTP server,worker 则只加载处理任务所需的模块:
// apps/api/main.js
import { startApiServer } from "../../modules/http/startApiServer.js";
await startApiServer();
// apps/workers/email-worker.js
import { startEmailWorker } from "../../modules/notifications/startEmailWorker.js";
await startEmailWorker();
即使两者位于同一个 repository,也可以配置不同的启动命令、Docker image 或 deployment:
{
"scripts": {
"start:api": "node apps/api/main.js",
"start:email-worker": "node apps/workers/email-worker.js",
"start:report-worker": "node apps/workers/report-worker.js"
}
}
这样就能按需单独扩容 worker:
API replicas: 2
Email worker replicas: 8
Report worker replicas: 1
没有必要先把它们改造成通过 HTTP 或 RPC 通信的 microservices。
Repository pattern 应该解决什么问题
Repository pattern 用于隔离 Business logic 与 Mongoose,但 Repository 本身不应被理解为远程服务。
Repository 通常是进程内接口:
export class PaymentRepository {
async findByProviderEventId(providerEventId) {
throw new Error("Not implemented");
}
async create(payment) {
throw new Error("Not implemented");
}
async updateStatus(id, status) {
throw new Error("Not implemented");
}
}
Mongoose 的具体实现放在 infrastructure 层:
export class MongoosePaymentRepository {
constructor(PaymentModel) {
this.PaymentModel = PaymentModel;
}
async findByProviderEventId(providerEventId) {
return this.PaymentModel
.findOne({ providerEventId })
.lean()
.exec();
}
async create(payment) {
const document = await this.PaymentModel.create(payment);
return document.toObject();
}
async updateStatus(id, status) {
return this.PaymentModel
.findByIdAndUpdate(
id,
{ $set: { status } },
{ new: true }
)
.lean()
.exec();
}
}
Business logic 只依赖 Repository 接口:
export class ProcessPaymentEvent {
constructor({ paymentRepository }) {
this.paymentRepository = paymentRepository;
}
async execute(event) {
const existing =
await this.paymentRepository.findByProviderEventId(event.id);
if (existing) {
return existing;
}
return this.paymentRepository.create({
providerEventId: event.id,
status: event.status,
amount: event.amount
});
}
}
这种设计有几个直接好处:
- Mongoose 查询不会散落在 controller、worker 和业务代码中。
- API 与 worker 可以复用同一套 Business logic。
- 测试时可以换成内存实现。
- 将来提取 microservice 时,业务边界已经相对清楚。
不过,不要为所有数据操作强行设计一个通用 Repository,例如:
repository.search({
collection,
filters,
joins,
sort,
projection,
pagination
});
这种接口相当于重新发明一套 MongoDB query language,同时把数据库细节泄漏给调用方。更合适的方式是提供面向 use case 的方法:
paymentRepository.findPendingBefore(date, { limit, cursor });
orderRepository.findUnpaidOrdersForCustomer(customerId);
reportRepository.listCompletedReports({ cursor, limit });
复杂查询可以交给专门的 query service,不需要全部塞进通用 Repository。
共享代码可行,但边界要清楚
现阶段,让 Web API 和 background workers 共享内部 package 是合理的。例如:
packages/
├── payment-domain/
├── payment-application/
├── payment-mongoose/
└── queue-client/
API 和 worker 可以依赖相同版本的 package,同时保持独立部署。
共享代码会带来编译期耦合。共享接口发生变化后,相关应用可能都需要升级。对于单团队、单 repository 的项目,这通常比引入网络协议、服务发现、分布式追踪和故障重试更容易管理。
可以遵循这些约束:
- 共享稳定的 domain types、use cases 和基础设施适配器。
- 不允许一个业务模块直接访问另一个模块的 Mongoose model。
- 模块之间通过明确的 application service 或事件交互。
- 禁止跨模块随意更新 collection。
- 通过依赖方向检查或代码评审维护模块边界。
即使暂时共用一个 MongoDB,也应先明确逻辑上的数据所有权。例如,payment 数据归 payments 模块管理,其他模块不能直接调用 PaymentModel.updateOne()。
独立部署不等于 microservices
可以为每个应用构建不同的容器,只安装和打包各自需要的代码。镜像体积能减少多少,取决于项目使用的构建工具和依赖组织方式,但它通常不应成为架构选型的主要依据。
更值得关注的是各应用能否独立运行和扩容:
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY apps ./apps
COPY modules ./modules
COPY shared ./shared
CMD ["node", "apps/workers/email-worker.js"]
API 可以使用另一个 CMD 或 Dockerfile:
CMD ["node", "apps/api/main.js"]
如果项目使用 workspace,还可以让每个应用声明自己的依赖,构建时只包含相关 workspace。具体命令取决于 npm、pnpm、Yarn 或所使用的 bundler。实现独立部署并不要求改变业务架构。
webhook 可靠性不能依赖”部署时不会收到请求”
重新部署期间丢失 webhook 并不是 monolith 特有的问题。Microservice 如果只有一个实例,并且部署时直接终止进程,同样会丢失请求。
这个问题需要从部署和消息处理两个层面解决。
使用滚动部署
部署时先启动新实例,等 readiness check 通过后再停止旧实例。负载均衡器只向 ready 的实例发送流量。
进程收到终止信号后,应停止接收新连接,并等待正在处理的请求完成:
const server = app.listen(port);
let shuttingDown = false;
app.get("/ready", (req, res) => {
if (shuttingDown) {
return res.sendStatus(503);
}
res.sendStatus(200);
});
process.on("SIGTERM", () => {
shuttingDown = true;
server.close((error) => {
if (error) {
process.exitCode = 1;
}
process.exit();
});
});
生产环境还要合理配置 load balancer draining、termination grace period 和请求超时。只监听 SIGTERM,并不能自动实现无停机部署。
webhook 入口只做必要工作
Webhook handler 应尽快完成以下操作:
- 验证签名。
- 保存原始事件,或者将其写入持久化队列。
- 返回成功响应。
- 由 worker 异步完成后续业务处理。
app.post("/webhooks/payment", async (req, res, next) => {
try {
verifyPaymentSignature(req);
await webhookEventRepository.insertIfAbsent({
provider: "payment-provider",
eventId: req.body.id,
payload: req.body,
status: "pending"
});
res.sendStatus(202);
} catch (error) {
next(error);
}
});
对应的 eventId 应建立唯一索引:
WebhookEventSchema.index(
{ provider: 1, eventId: 1 },
{ unique: true }
);
支付平台通常会重试 webhook,因此处理程序必须具备幂等性。不能假设同一个事件只会到达一次,也不能把”返回 200/202”理解为业务处理已经成功。
避免数据库写入与消息发布不一致
下面的执行顺序存在风险:
await orderRepository.markPaid(orderId);
await queue.publish("order.paid", { orderId });
如果数据库更新成功,但消息发布失败,系统就会进入不一致状态。可以考虑使用 transactional outbox:
await session.withTransaction(async () => {
await orderRepository.markPaid(orderId, { session });
await outboxRepository.insert({
type: "order.paid",
payload: { orderId }
}, { session });
});
再由独立的 publisher 扫描 outbox 并发布消息。MongoDB transaction 的可用条件和具体行为与部署拓扑有关,需要根据实际 MongoDB 配置确认。如果暂时不使用 transaction,也要通过状态机、重试和定期对账来保证最终一致性。
什么时候才值得提取 microservice
出现以下信号后,再考虑把某个模块独立出去:
- 某个模块的发布频率与其他模块明显不同。
- 它需要采用与主系统完全不同的扩容方式。
- 它已经形成清晰、稳定的业务边界。
- 它需要独立的数据所有权。
- 故障隔离带来的收益足以覆盖新增的运维复杂度。
- 团队能够承担监控、追踪、重试、超时、版本兼容和部署管理。
- 某种技术确实只有独立运行时才适合采用。
“代码较少”本身不是提取 microservice 的充分理由。把本地函数调用改成网络调用后,还要处理:
- 网络超时和部分失败;
- 重试造成的重复操作;
- API 版本兼容;
- 身份认证与授权;
- 服务发现;
- 分布式日志和 tracing;
- 数据一致性;
- 本地开发和集成测试。
对于单人项目,这些成本往往会抵消 microservices 带来的收益。
将来提取服务时如何避免复杂查询协议
不要把数据库 Repository 直接暴露成远程 API。Microservice 应对外提供业务能力,而不是提供远程版 Mongoose。
不推荐:
POST /query
{
"model": "Payment",
"filter": {
"status": "pending"
},
"sort": {
"createdAt": -1
}
}
更合适的是稳定、含义明确的业务接口:
GET /payments?status=pending&limit=50&cursor=eyJpZCI6Ii4uLiJ9
或者:
POST /payment-attempts/{id}/capture
Cursor 应由服务端生成,并作为 opaque value 使用。客户端不应依赖它的内部结构:
{
"items": [],
"pageInfo": {
"nextCursor": "eyJjcmVhdGVkQXQiOiIuLi4iLCJpZCI6Ii4uLiJ9",
"hasNextPage": true
}
}
如果某项查询需要组合多个模块的数据,可以根据实际情况采用专门的 read model、API composition 或异步投影。没有必要从一开始就设计一套能够远程表达所有 MongoDB 查询的通用协议。
建议的演进顺序
第一阶段,整理现有边界:
- 将散落的 Mongoose 调用移到各业务模块的 infrastructure 层。
- 使用 use case 或 application service 承载业务流程。
- 禁止模块直接访问其他模块的 model。
第二阶段,拆分运行入口:
- 分别启动 API、webhook receiver 和不同类型的 worker。
- 为它们设置独立的 deployment 和扩容配置。
- 继续共享 domain 与 application 层代码。
第三阶段,提高运行可靠性:
- 增加 readiness、graceful shutdown 和 rolling deployment。
- 使用持久化队列或事件表。
- 为 webhook 和后台任务增加幂等键、重试、dead-letter 处理及监控。
第四阶段,观察实际瓶颈:
- 哪些模块经常独立变化;
- 哪些 worker 消耗的资源最多;
- 哪些依赖关系会阻碍发布;
- 哪些故障需要隔离。
第五阶段,只提取确实能带来收益的模块。提取后,让服务拥有自己的数据访问层,并通过面向业务的 API 或事件与外部交互,不要把共享 Repository 包装成远程 CRUD 服务。
按照这条路径,可以保留 monolith 的开发效率,同时让 Web API 与 background workers 独立部署和扩容,也能为将来提取 microservices 划定清楚的边界。
备注:内容仅供参考。
