服务边界与业务编排
业务代码写久了,最常见的结局是:一个 OrderService 文件三千行,什么都管;改一个小规则,要在五个方法里分别改;事务里夹着各种网络调用,高峰期数据库连接被占满。更隐蔽的问题是这种:
- 下单事务里调用了支付渠道的接口,渠道一慢,数据库连接全被占住,整个系统卡死;
- 用户收到了“下单成功”的短信,可订单其实因为后续步骤失败被回滚了。
这些问题都和“服务怎么划分、一个业务动作的步骤怎么安排”有关。
读完这篇你会
- 把业务代码分成“编排”和“规则”两部分
- 正确划定事务边界,不在事务里做慢操作和外部调用
- 用发件箱模式保证“数据提交”和“通知发送”的一致
1. 原理:编排和规则
一个业务动作的代码,可以分成两种角色:
- 编排:安排步骤的先后——先读什么、再检查什么、在哪里开启事务、完成后触发什么后续动作。像项目经理,安排“谁在什么时候做什么”。
- 规则:判断某件事能不能发生——“购物车为空不能下单”“已发货的订单不能取消”。像专家,只回答“行不行”。
js
async function submitOrder(command, actor) {
// 编排:读取数据
const cart = await cartRepo.get(command.cartId)
// 规则:判断能不能下单(纯函数,不读写数据库,容易测试)
orderPolicy.assertSubmittable(cart, actor)
// 编排:在一个事务里完成需要一起成功的写入
return db.transaction(async (tx) => {
const order = await orderRepo.create(tx, fromCart(cart))
await stockRepo.deduct(tx, cart.items)
await outbox.add(tx, { type: 'order.submitted', orderId: order.id }) // 见坑二
return order
})
}js
// 规则集中在一处,下单接口、后台补单、自动续订都复用它
const orderPolicy = {
assertSubmittable(cart, actor) {
if (cart.items.length === 0) throw new BusinessError('购物车是空的')
if (cart.ownerId !== actor.id) throw new ForbiddenError('不能提交别人的购物车')
if (cart.items.some((item) => item.quantity > 99)) throw new BusinessError('单个商品最多购买 99 件')
}
}分开之后,规则可以被多个入口复用,也可以脱离数据库直接写单元测试;编排代码则一眼就能看出整个流程。
2. 事务边界:围绕一个业务承诺
事务保证的是“这几步写入要么全部成功,要么全部不发生”。它应该只包住同一个业务承诺下的本地数据库写入:
| 事务内 | 事务外 |
|---|---|
| 写订单、扣库存、记录状态变化 | 调用支付渠道、发短信、推送 |
| 校验版本号、唯一约束 | 上传文件、生成报表 |
| 记录“待发送的事件”(发件箱) | 任何可能很慢、可能失败、需要重试的动作 |
3. 坑一:事务里调用外部接口,数据库连接被占满
现象:促销高峰期,整个系统突然卡住,大量接口超时。监控显示数据库连接池被占满,但数据库的 CPU 并不高。
js
await db.transaction(async (tx) => {
const order = await orderRepo.create(tx, input)
await stockRepo.deduct(tx, input.items) // 锁住了库存行
await paymentGateway.createPayment(order) // ❌ 外部接口,慢的时候要好几秒
})原因:事务从开始到提交,一直占用着一个数据库连接,并持有它锁住的行。支付渠道响应慢的时候,每个事务都要多挂几秒。连接池里的连接很快被这些“等外部接口”的事务占光,其他所有请求(哪怕只是查一下商品)都拿不到连接。被锁住的库存行,也让其他下单请求排队等待。
修复:事务里只做本地数据库操作,外部调用移到事务之外:
js
const order = await db.transaction(async (tx) => {
const created = await orderRepo.create(tx, { ...input, status: 'pending_payment' })
await stockRepo.deduct(tx, input.items)
return created
}) // 事务在这里提交,连接和锁立即释放
const payment = await paymentGateway.createPayment(order) // 事务外调用
await orderRepo.attachPayment(order.id, payment.id)外部调用失败时,订单停留在“待支付”状态,可以重试或者超时自动取消。这比把整个系统卡死好得多。
怎么避免再犯:代码评审时,看到事务回调里出现 fetch、axios、第三方 SDK 调用、文件操作,都要问一句:这一步真的必须在事务里吗?
4. 坑二:通知发出去了,事务却回滚了
现象:用户收到短信“您的订单已提交成功”,打开 App 却找不到这个订单。
js
await db.transaction(async (tx) => {
const order = await orderRepo.create(tx, input)
await sms.send(user.phone, '订单已提交成功') // 短信已经发出
await couponRepo.use(tx, input.couponId) // 这里失败了,整个事务回滚
})原因:数据库写入可以回滚,但已经发出去的短信收不回来。反过来,如果把发送放在提交之后,又可能出现“订单提交了,服务恰好重启,短信没发出去”的情况。
修复:使用发件箱模式(Transactional Outbox):
- 在同一个事务里,除了写业务数据,再往一张“发件箱”表里写一条“待发送的事件”;
- 事务提交后,由一个独立的后台进程读取发件箱,把事件发出去(发短信、投递到消息队列),成功后标记为已发送。
text
同一个事务:写订单 + 写发件箱事件 ──提交──► 二者要么都在,要么都不在
│
后台发送进程:读取未发送的事件 → 发短信 / 投递消息 → 标记已发送(失败就重试)这样就保证了:只有事务成功提交,事件才存在;事件一旦存在,就一定会被发送(可能重复发送,所以接收方要能处理重复,见 消息队列与异步任务)。
5. 坑三:一个服务什么都管
现象:OrderService 有三千行、五十个方法,同时处理订单、库存、优惠券、积分和通知。每次修改都牵一发而动全身,测试越来越慢,新人不敢动。
信号:
- 一个类管理着多个不同业务概念的规则;
- 方法参数有十几个,彼此没什么关系;
- 改一个功能,大量不相关的测试失败;
- 同一个词(比如“状态”)在不同方法里含义不同。
修复:按业务能力而不是数据库表来拆分:订单、库存、优惠券、积分各自成为模块,各自负责自己的规则和数据;“下单”这样跨多个模块的流程,由一个专门的编排函数来调用它们。
6. 坑四:服务之间互相调用,形成循环
现象:订单服务调用库存服务扣库存,库存服务在库存不足时又调用订单服务取消订单。改动任何一边都可能引发意想不到的连锁反应,有时还会无限递归。
修复:让依赖保持单向。常见做法:
- 由上层的编排代码依次调用订单和库存,两者互不知道对方;
- 或者通过事件解耦:库存不足时发布“库存不足”事件,由订单模块订阅并处理。
7. 别急着拆成微服务
模块边界清楚了,不代表要拆成独立部署的微服务。一次本地函数调用变成网络调用后,就要面对超时、重试、部分失败、分布式事务、链路追踪……复杂度是成倍上升的。
先在一个应用内部把模块边界划清楚(模块化单体),等到某个模块确实需要独立扩容、独立发布、由独立团队维护时,再沿着已经清晰的边界拆出去。
排查思路
- 连接池耗尽、接口集体超时:查看数据库里当前运行的长事务(MySQL 可以查询
information_schema.innodb_trx,按开始时间排序),找到它们对应的代码,看事务里是否夹着外部调用或慢操作。 - 数据和通知不一致:对比发件箱、消息队列和业务表的数据,看是“事件没写入”“写入了没发送”还是“发送了没处理”。
- 规则不一致:全局搜索某条业务规则的关键判断(比如某个状态值),看它是否散落在多个地方。
- 依赖关系:用工具画出模块之间的依赖图,找出循环依赖。
检查清单
- [ ] 业务规则集中在策略或领域对象中,可以独立测试
- [ ] 事务只包含同一业务承诺下的本地数据库写入
- [ ] 事务中没有外部接口调用、文件操作和长时间计算
- [ ] 需要“提交成功才发送”的通知和事件使用发件箱模式
- [ ] 模块按业务能力划分,依赖是单向的
- [ ] 拆分微服务前,先在单体内部把模块边界划清楚