PAYATHON 2026

Monolith 系统是否应该迁移到 microservices based architecture?

支付老李

结论

现阶段不建议迁移到完整的 microservices based architecture。这个 Node.js 项目仍处于早期阶段,而且由单人维护,更适合按以下方式演进:

  1. 先将系统重构为 modular monolith。
  2. 保留 monorepo,将 Web API 和 background workers 分成独立的部署单元。
  3. 把 Business logic 和 database interaction 提取到可共享的内部模块。
  4. 通过无停机部署、持久化队列和幂等处理,解决扩容与 webhook 可靠性问题。
  5. 等某个业务模块确实需要独立发布、拥有独立数据或由独立团队维护时,再把它提取为 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 应尽快完成以下操作:

  1. 验证签名。
  2. 保存原始事件,或者将其写入持久化队列。
  3. 返回成功响应。
  4. 由 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 划定清楚的边界。

备注:内容仅供参考。