4、架构设计原则:从巨石到微服务再到函数——如何拆解?如何定义函数边界?

好,咱们进入正题。这一章,我打算聊聊拆解这件事。

很多团队一上来就问我:「老师,我们的单体应用怎么拆成函数?」

我的回答通常是:别急,先想清楚你要拆成什么。

从巨石到微服务,再到函数,这不是一步到位的。它是个渐进的过程。我个人习惯把这条路分成三个阶段来看:

  • 第一阶段:巨石拆微服务 —— 按业务领域切分
  • 第二阶段:微服务拆函数 —— 按职责粒度切分
  • 第三阶段:定义函数边界 —— 按触发事件切分

说白了,每个阶段的拆解逻辑都不一样。你想想看,把一个大楼拆成房间,和把房间拆成家具,用的工具能一样吗?

4.1 巨石拆微服务:先画业务边界

我记得有一次,帮一家电商公司做架构改造。他们的单体应用有几十万行代码,订单、支付、库存、用户全揉在一起。老板说:「直接拆成函数吧,一步到位。」

我当时就笑了。我说:「你连微服务都没拆清楚,拆成函数只会更乱。」

拆微服务的核心原则是什么?领域驱动设计(DDD)里的限界上下文。

简单来说,就是按业务领域画圈。每个圈里,数据模型和业务逻辑是内聚的。圈和圈之间,通过接口通信。

举个例子:

业务领域 核心职责 数据归属
订单服务 创建订单、查询订单 订单表
支付服务 处理支付、退款 支付流水表
库存服务 扣减库存、补货 库存表
用户服务 注册、登录、权限 用户表

这里要注意:每个微服务要有自己的数据库。 别共享一个库,否则拆了等于没拆。

我曾经踩过一个坑: 把订单和支付放在同一个库里,只是逻辑上分了表。结果订单服务一升级,支付那边也跟着出问题。后来花了整整两周才把数据拆开。嗯,血的教训。

4.2 微服务拆函数:按职责粒度切分

微服务拆好了,接下来才是拆函数。

但这里有个误区:很多人以为函数就是微服务的缩小版。其实不是。

微服务是一个进程,函数是一个执行单元。微服务可以包含多个函数,但函数不能包含微服务。

那怎么拆?我总结了一个原则:一个函数只做一件事,并且把这件事做好。

具体来说,可以从这几个维度判断:

  • 职责单一: 这个函数只处理一种输入,输出一种结果。
  • 无状态: 函数不保存任何状态,所有状态都交给外部存储。
  • 可独立部署: 修改这个函数,不影响其他函数。
  • 触发明确: 函数被什么事件触发,是HTTP请求?消息队列?还是定时任务?

举个例子,一个订单微服务,可以拆成这些函数:

// 订单微服务 → 拆成多个函数

// 函数1:创建订单(HTTP触发)
exports.createOrder = async (event) => {
  const { userId, items } = JSON.parse(event.body);
  // 校验参数、生成订单号、写入数据库
  return { orderId, status: 'created' };
};

// 函数2:取消订单(消息队列触发)
exports.cancelOrder = async (event) => {
  const { orderId } = event;
  // 检查订单状态、更新数据库、通知库存
  return { status: 'cancelled' };
};

// 函数3:订单超时处理(定时触发)
exports.timeoutOrder = async (event) => {
  // 查询超时未支付的订单,自动取消
  const timeoutOrders = await db.query('SELECT * FROM orders WHERE status = "pending" AND createTime < NOW() - 30min');
  // 批量取消
};

你看,这三个函数,各自只做一件事。触发方式也不同。这就是函数粒度的拆解。

4.3 如何定义函数边界?

这是最核心的问题。很多团队拆到最后,发现函数之间互相调用,比微服务还乱。

为什么会这样?因为边界没定义清楚。

我个人的经验是:函数的边界,由它的触发事件和输出结果共同决定。

具体来说,有四个判断标准:

  1. 事件驱动: 函数应该由外部事件触发,而不是由另一个函数直接调用。如果A函数必须调用B函数才能完成工作,那说明它们应该合并成一个函数。
  2. 数据局部性: 函数操作的数据,应该尽量在同一个数据库或同一个缓存中。如果跨多个数据源,就要考虑是否拆得太细了。
  3. 超时限制: 云函数通常有超时限制(比如5分钟)。如果一个业务逻辑需要10分钟才能完成,那它不适合做成一个函数。应该拆成多个步骤,用消息队列串联。
  4. 冷启动容忍度: 对延迟敏感的业务(比如用户点击按钮后立即响应),不适合拆成太细的函数。因为每个函数都有冷启动时间。
核心原则: 函数的边界,就是业务事务的边界。一个事务内的操作,尽量放在同一个函数里。跨事务的操作,用异步消息解耦。

4.4 实战案例:一个订单系统的函数拆解

咱们来看一个完整的例子。假设我们要把一个订单微服务,拆成Serverless函数。

原始微服务里有这些接口:

  • 创建订单(同步)
  • 支付回调(异步)
  • 取消订单(同步)
  • 查询订单(同步)
  • 订单超时处理(定时)

拆成函数后,应该是这样的:

函数名称 触发方式 输入 输出 数据依赖
createOrder HTTP POST 用户ID、商品列表 订单ID 订单数据库
paymentCallback 消息队列 订单ID、支付结果 更新订单状态 订单数据库、支付流水
cancelOrder HTTP DELETE 订单ID 取消结果 订单数据库、库存服务
queryOrder HTTP GET 订单ID 订单详情 订单数据库(只读)
timeoutHandler 定时任务(每5分钟) 批量取消超时订单 订单数据库

这里有个细节:cancelOrder 函数需要调用库存服务。 但库存服务本身也是一个函数。那怎么办?

我的做法是:不要直接调用库存函数的HTTP接口。 而是通过消息队列发送一条「释放库存」的消息。库存函数订阅这个消息,异步处理。

这样,两个函数之间就解耦了。cancelOrder 函数不会因为库存函数挂了而失败。

一个小技巧: 函数之间的通信,尽量用异步消息(比如Kafka、RabbitMQ、AWS SQS)。同步调用会引入依赖,增加冷启动的连锁反应。我见过一个项目,因为函数间同步调用太多,导致整个链路延迟从200ms飙升到3秒。

4.5 避坑指南:拆得太细 vs 拆得太粗

拆得太细,你会得到「函数地狱」。每个函数只有几行代码,但互相调用,部署配置一大堆。

拆得太粗,你又回到了微服务的老路。一个函数里塞了几百行代码,什么逻辑都有。

怎么平衡?我有个经验公式:

  • 一个函数的代码行数: 50-200行之间。少于50行,说明拆得太细;多于200行,说明拆得太粗。
  • 一个函数的依赖数: 不超过3个外部服务(数据库、消息队列、其他函数)。超过3个,就要考虑合并或重新设计。
  • 一个函数的触发事件: 只有一种触发方式。如果一个函数既被HTTP调用,又被消息队列调用,那它应该拆成两个。

嗯,这些数字不是绝对的。但可以作为参考。我在实际项目中,基本按照这个标准来,很少出大问题。

4.6 总结

这一章,咱们聊了从巨石到微服务再到函数的拆解思路。核心就三句话:

  1. 先拆业务边界,再拆技术粒度。 别跳级。
  2. 函数的边界由事件定义。 一个事件对应一个函数。
  3. 函数之间用异步消息解耦。 别搞同步调用链。

下一章,咱们会深入聊函数之间的通信模式。到时候我会分享一个我亲手搭过的异步事件总线架构,保证让你少走弯路。