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 如何定义函数边界?
这是最核心的问题。很多团队拆到最后,发现函数之间互相调用,比微服务还乱。
为什么会这样?因为边界没定义清楚。
我个人的经验是:函数的边界,由它的触发事件和输出结果共同决定。
具体来说,有四个判断标准:
- 事件驱动: 函数应该由外部事件触发,而不是由另一个函数直接调用。如果A函数必须调用B函数才能完成工作,那说明它们应该合并成一个函数。
- 数据局部性: 函数操作的数据,应该尽量在同一个数据库或同一个缓存中。如果跨多个数据源,就要考虑是否拆得太细了。
- 超时限制: 云函数通常有超时限制(比如5分钟)。如果一个业务逻辑需要10分钟才能完成,那它不适合做成一个函数。应该拆成多个步骤,用消息队列串联。
- 冷启动容忍度: 对延迟敏感的业务(比如用户点击按钮后立即响应),不适合拆成太细的函数。因为每个函数都有冷启动时间。
4.4 实战案例:一个订单系统的函数拆解
咱们来看一个完整的例子。假设我们要把一个订单微服务,拆成Serverless函数。
原始微服务里有这些接口:
- 创建订单(同步)
- 支付回调(异步)
- 取消订单(同步)
- 查询订单(同步)
- 订单超时处理(定时)
拆成函数后,应该是这样的:
| 函数名称 | 触发方式 | 输入 | 输出 | 数据依赖 |
|---|---|---|---|---|
| createOrder | HTTP POST | 用户ID、商品列表 | 订单ID | 订单数据库 |
| paymentCallback | 消息队列 | 订单ID、支付结果 | 更新订单状态 | 订单数据库、支付流水 |
| cancelOrder | HTTP DELETE | 订单ID | 取消结果 | 订单数据库、库存服务 |
| queryOrder | HTTP GET | 订单ID | 订单详情 | 订单数据库(只读) |
| timeoutHandler | 定时任务(每5分钟) | 无 | 批量取消超时订单 | 订单数据库 |
这里有个细节:cancelOrder 函数需要调用库存服务。 但库存服务本身也是一个函数。那怎么办?
我的做法是:不要直接调用库存函数的HTTP接口。 而是通过消息队列发送一条「释放库存」的消息。库存函数订阅这个消息,异步处理。
这样,两个函数之间就解耦了。cancelOrder 函数不会因为库存函数挂了而失败。
4.5 避坑指南:拆得太细 vs 拆得太粗
拆得太细,你会得到「函数地狱」。每个函数只有几行代码,但互相调用,部署配置一大堆。
拆得太粗,你又回到了微服务的老路。一个函数里塞了几百行代码,什么逻辑都有。
怎么平衡?我有个经验公式:
- 一个函数的代码行数: 50-200行之间。少于50行,说明拆得太细;多于200行,说明拆得太粗。
- 一个函数的依赖数: 不超过3个外部服务(数据库、消息队列、其他函数)。超过3个,就要考虑合并或重新设计。
- 一个函数的触发事件: 只有一种触发方式。如果一个函数既被HTTP调用,又被消息队列调用,那它应该拆成两个。
嗯,这些数字不是绝对的。但可以作为参考。我在实际项目中,基本按照这个标准来,很少出大问题。
4.6 总结
这一章,咱们聊了从巨石到微服务再到函数的拆解思路。核心就三句话:
- 先拆业务边界,再拆技术粒度。 别跳级。
- 函数的边界由事件定义。 一个事件对应一个函数。
- 函数之间用异步消息解耦。 别搞同步调用链。
下一章,咱们会深入聊函数之间的通信模式。到时候我会分享一个我亲手搭过的异步事件总线架构,保证让你少走弯路。