订单系统到底该怎么建模(二):聚合边界,比你想的重要得多
从做订单系统建模咨询第一天起,我就反复跟团队说一句话:订单建模最难的从来不是表怎么画,而是边界怎么切。上一篇文章里我聊了从业务场景出发建订单模型,评论区问得最多的就是:"聚合到底怎么划?为什么我按聚合写了代码,上线还是到处锁表、状态对不上?"
今天这篇就专门聊聚合边界。先说结论:聚合边界决定了一个系统的性能上限、一致性强弱和后续扩展空间,比你想的重要得多。建模时如果只盯着实体、字段、表关系,没把聚合边界当回事,订单系统迟早会在并发和需求变更面前翻车。这篇文章会从为什么、怎么划、怎么实操三个层面拆开讲,最后用一个"台变"业务聚合根的建模案例,把方法论完整走一遍。
1. 聚合边界没划对,订单系统迟早要还债
1.1 聚合边界到底是什么,一句话讲清楚
聚合边界是领域驱动设计里最容易被低估的概念。很多人觉得"聚合"就是"把一组对象打包在一起",这个理解只对了一半。聚合的本质是一致性边界:边界内所有对象必须保持同步更新,满足同一组业务不变量;边界外和别的聚合之间只能通过聚合根打交道,不能用本地事务把两边捏在一起。
我举个例子。去商场买套餐,套餐包含汉堡、薯条、可乐。三样东西会一起下单、一起出餐、一起取消,它们就是一个聚合。商场不会允许你单独把薯条退掉、汉堡留着,因为套餐作为整体存在,这就是不变量。反过来,你买套餐用的支付凭证、送货的骑手,跟套餐本身是两回事,有各自的生命周期,就不该塞进同一个聚合。
落到订单系统:订单包含订单项、收货信息、金额明细,一起创建、一起变更、一起关单,这是一个聚合。而支付单、物流单、发票这些虽然和订单强关联,但有独立状态机、独立生命周期,硬塞进订单聚合只会带来问题。
判断聚合边界的核心方法,是找业务不变量。不变量就是业务规则里"任何时候都不能被打破"的约束。比如:
- 订单总额必须等于所有订单项的金额之和。
- 订单一旦完成支付,不能从待支付直接改成已取消。
- 一个台变下挂接的表计,不能同时挂到另一个台变下。
这些不变量锁定的对象集合,就是聚合边界。边界外的对象,哪怕有关联,也不是同一个聚合。
1.2 边界错误,我在真实项目里见到的三种症状
第一种症状是跨聚合事务满天飞。很多订单系统早期版本会把订单、支付、库存、积分都放进同一个本地事务,下单时所有操作一把锁。看起来省事,可接口一慢、数据库锁竞争一上来,订单表就是全公司瓶颈。更麻烦的是,一旦服务拆分、跨库部署,本地事务根本没法用,只能靠分布式事务或最终一致性。这时如果你还按"一个事务管所有"的思路设计聚合,代码会越改越拧巴。
第二种症状是聚合根成了摆设,聚合内的对象被外部直接修改。数据建模思维根深蒂固的人,会把订单项、收货地址当成普通表,任何地方都能绕过订单根对象直接改数据。结果就是订单状态和订单项状态互相矛盾,对账天天对不上。问题不是程序员不懂规则,而是边界没立起来,代码里没有任何机制阻止"绕过聚合根改数据"。
第三种症状是聚合太大,锁粒度粗,性能直接崩。我见过一张"订单大宽表",把买家信息、商品快照、优惠明细、支付记录全塞在一行里。单表查询确实方便,但更新时热点行锁竞争严重,大促压测根本过不了。后来把聚合拆细,把订单、支付、物流拆成独立聚合,锁粒度从"整单"降到"单点",性能才起来。
这三种症状本质上都是聚合边界没划清楚,而且常常同时出现:事务乱、锁竞争大、数据不一致。所以我说聚合边界不是理论洁癖,是系统能不能活下去的底线。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 订单系统的聚合边界,到底该怎么划
2.1 先立规则:一个聚合,就是一个事务边界
经常有人问,聚合边界有没有标准答案?我说规则就一条:一个聚合,一个事务边界。边界内的一致性交给数据库本地事务保证;边界外的对象协作,靠领域事件、消息队列和最终一致性完成。
为什么强调这条?因为很多团队设计时不是从一致性边界出发,而是从表关系出发。看到订单表和支付表有外键关联,就以为它们应该在同一个事务里;看到订单项是订单的子表,就放任任何服务都能改订单项。模型没坏在表面,坏在根本逻辑。
正确的做法是倒过来思考:先找业务不变量,再切聚合。订单总额和订单项金额必须一致,所以订单和订单项在同一个聚合里;支付状态和订单状态有联动,但支付有独立的对手方、退款流程和状态机,支付单就是独立聚合。这条规则一定下来,边界怎么划就有了依据。
还有一个容易被忽略的点:聚合边界不是数据表边界。一个聚合的持久化可能落在多张表里,一张表里也可能放着不同聚合的数据,比如通用附件表、操作日志表。建模时看的是对象之间的一致性和约束关系,别被数据库Schema绑架。
2.2 订单主聚合:Order、OrderItem、OrderAddress
典型的订单主聚合,我建议这么划:Order作为聚合根,内部包含OrderItem订单项、OrderAddress收货地址快照、OrderAmount金额明细。这些对象共享同一个生命周期,一起创建、一起修改,订单状态迁移时它们的状态必须同步。
为什么不把收货地址做成独立的"地址聚合"?订单地址是下单那一刻的快照,不需要和用户主数据里的地址保持实时一致。用户改了默认地址,不能影响已经生成的订单。如果做成独立聚合,订单和地址之间就要处理版本同步、变更回溯一堆麻烦事,业务上不值当。这个判断背后同样是不变量在起作用:订单地址的不变量是"下单时点的快照不能变"。
订单项要不要放进独立的"商品聚合"?也不需要。订单项关注的是下单时的商品快照、数量、单价、小计,它跟商品主数据是弱关联。商品主数据自己是独立聚合,价格改了、库存变了,不能反过来改已经生成的订单。所以订单项作为订单聚合内部对象,商品作为独立聚合,两者通过"商品ID加快照字段"建立引用。
有一个地方实际建模时特别纠结:订单优惠。每个订单项可能有独立折扣、分摊金额,整单还有满减。我的建议是,在订单聚合内部用优惠明细对象把订单级别和订单项级别的优惠都收进来,同时维护"金额分摊"计算逻辑。因为优惠分摊的不变量是"所有订单项分摊金额加整单优惠等于订单应付总额",这个约束必须在一个事务里保证,所以必须在订单聚合内部。
2.3 为什么支付单、物流单不能塞进订单聚合
接着上面的规则,我专门说说支付和物流。这两个子域是订单系统最容易搞成"一张大表"的地方。
支付单是独立聚合,原因有三条。第一,支付单有自己完整的生命周期,从创建、待支付、支付中、已支付、退款中、已退款,它和订单状态是两个状态机,不能靠一个状态字段控制。第二,支付单要对接收单渠道,渠道回调、退款、对账这些操作如果全压在订单聚合里,订单聚合的事务会极其脆弱。第三,支付记录在财务上要独立审计,它和订单是一对多关系,一个订单可以发起多次支付尝试,生命周期完全不同。
物流单同理。订单有收货地址,但物流单有自己的运单号、物流轨迹、签收状态。订单只关心"发货没发货、签收没签收"这个高层状态,不关心轨迹里每个节点的变化。把物流单独立成聚合后,订单通过领域事件订阅物流状态变更,再更新自己的聚合状态,两边各走各的节奏。
这里我想重点强调:聚合之间的协作,不要靠"读写对方的私有字段"实现,而是靠聚合根暴露方法加领域事件来衔接。订单聚合在支付完成事件到达后,调用自己的"标记已支付"方法,更新状态并产生"订单已支付"事件;物流聚合收到"订单已发货"事件后开始自己的流程。最终一致性在这套体系里是常态,不是例外。
3. 实操:围绕"台变"这个业务聚合根,把建模方法走一遍
3.1 从业务场景反推聚合边界,台变是个好例子
平时有人问我,搜索"台变建模"能不能直接套用模板?我的回答是,模板没有,方法论有一套,而且和订单系统一模一样。台变就是台区变压器,在电力业务里是最核心的实体之一。一台变压器管一片区域,底下挂着一堆表计、箱变、户表。业务上要围绕台变做负载管理、抄表核算、线损计算、巡检工单。用DDD术语说,台变就是一个典型的业务聚合根。
为什么用台变做例子?因为它和订单系统面临的问题是同构的。订单系统要回答"订单和订单项是不是一个聚合",台变系统要回答"台变和表计挂接关系是不是一个聚合"。判断标准都是同一条:不变量锁定了什么范围。
台变场景里的业务不变量很清晰:
- 一台表计在同一时刻只能挂接在一个台变下。
- 台变下所有表计抄表读数之和与台变总表读数之差,必须落在线损阈值内。
- 台变容量调整时,当前挂接负载不能超过新容量上限。
- 台区档案参数变更后,所有下游核算逻辑必须在同一版本下运行。
这些不变量一旦锁定,聚合的边界就浮出水面了。
3.2 界定聚合边界的四步法
我在实际项目中总结了一套四步法,顺序很重要,不能跳。
第一步,列业务场景。把和台变相关的操作全摆出来:新增台变、调整台区拓扑(挂表/摘表)、抄表、计算线损、负载告警、发起巡检工单。这个阶段先不想字段和表,只描述业务行为。订单系统同理,先列"下单、改地址、支付、发货、取消"这些行为。
第二步,找不变量。逐个场景问:"这个操作会改变哪些对象?哪条规则绝对不能破?"调整拓扑时,"同一表计不能同时挂在两个台变下"不能破;抄表核算时,"各表计读数之和与总表读数之差在阈值内"不能破。把这些规则写下来,聚合的范围基本就定了。
第三步,画聚合边界。把不变量涉及强一致的对象放进同一个聚合,允许延迟一致的放进不同聚合。台变档案、表计挂接关系、台变计费参数放进台变聚合内;抄表记录、线损统计、巡检工单、用户缴费单都独立成聚合,通过领域事件协作。
第四步,定聚合根操作。收敛所有对聚合内对象的修改入口:外部只能调用台变聚合根的方法,比如"挂接表计""摘除表计""更新容量参数""记录抄表读数"。绝不允许某个Service绕过聚合根直接改表计挂接表。
这个方法放到订单系统上,就是把"创建订单""修改收货地址""取消订单""标记已支付"这些操作全部收敛到订单聚合根上,其他Service没有机会直接碰订单项表。
3.3 台变聚合内的模型落地,和订单系统的映射
四步法走完,模型就出来了。台变聚合根可以叫Substation,聚合内部有:
- 台区档案对象,包含型号、容量、电压等级、地址等基础参数。
- 表计挂接点MeterPoint,包含表计ID、挂接时间、状态。
- 计费参数对象,日常核算必需的台变维度参数。
聚合根对外暴露的方法包括:bindMeter(meterId)、unbindMeter(meterId)、adjustCapacity(capacity)、recordReading(readingData)。每个方法在修改状态前都必须校验对应不变量。
具体展开"挂接表计"这个方法。用户在界面上选择一台表计挂到当前台变下,聚合根做的第一件事不是写表,而是问:这台表计当前有没有被其他台变挂接?如果有,直接拒绝,不变量保住了。然后检查挂接点数量、负载预估值是否在台变容量内。全部通过后,才新建MeterPoint,并保证在同一事务里写入。这就是聚合根存在的意义:所有对这个对象的修改,都有机会执行业务规则校验。
记录抄表读数同理。用户传入本台变下所有表计的本期读数,聚合根根据上期读数计算各表计电量,再结合台变总表读数算出线损率,如果超出阈值就触发告警,而不是等下游报表阶段才发现问题。如果这些逻辑散落在不同Service里,线损异常往往要等几天后统计数据出来才能发现,代价完全不一样。
把这个模型和订单系统一一对应:台变对应Order,MeterPoint对应OrderItem,台区档案对应OrderAddress。共同点是聚合根负责不变量维护,内部对象没有独立生命周期,外部只能通过聚合根访问。订单系统里关于聚合边界的讨论,可以原封不动套到台变上。
我还想说一点:聚合根的粒度不是越小越好,也不是越大越好,而是"刚好能维护不变量"最好。如果把台变和所有表计的抄表记录都塞进一个聚合,聚合巨大,锁竞争严重,任何一次抄表都要锁整个台变;如果把台变和表计挂接关系拆成两个聚合,那"同一表计不能重复挂接"就只能靠跨聚合事务或者唯一索引硬撑,非常别扭。所以聚合的合理大小,永远取决于业务规则要求的一致性范围。
4. 常见问题与排查技巧实录
4.1 只有外键没有聚合:数据建模习惯害死人
做业务系统久了,我见过太多团队名义上在搞DDD,实际做的还是数据建模。表现就是:数据库里外键、索引、唯一约束一应俱全,代码里根本没有聚合根,业务逻辑散落在各个Service里,谁都能改订单项状态。
这种问题怎么排查?我有一个很土但管用的方法:看代码里有没有"绕过聚合根"的调用。在订单系统里,如果看到某个Service直接调用了OrderItemRepository.save(),或者直接UPDATE订单项表,说明边界没立住。解决方法是把Repository访问权限收口:订单项的Repository只允许在订单聚合内部使用,外部Service只能通过订单聚合根方法操作。
更根本的解决方案是改变建模顺序。很多团队都是先画表、再写代码、最后补个聚合概念,顺序一颠倒,模型自然歪。正确的顺序是先讲业务规则,找不变量,定聚合边界,再设计持久化。数据库表结构应该由聚合模型映射出来,而不是反过来决定模型。
4.2 动不动就跨聚合事务,我建议你换个思路
有段时间,我们团队组内反复讨论:跨聚合难道就不能开一个本地事务吗?硬把两个聚合塞进一个事务里更新,不就不会数据不一致了?理论上可以,但代价是脏读、死锁、锁竞争、超时重试一大堆。跨聚合事务只在极少数强一致场景才值得用,常规业务不推荐。
实际业务里,跨聚合一致性应该优先用"流程编排加领域事件加最终一致性"来解决。比如订单创建后要扣减库存,这是典型的跨聚合协作:订单聚合和库存聚合是两个边界。正确做法是订单聚合创建成功后发"订单已创建"事件,库存服务订阅事件并扣减库存,扣减失败就走补偿或人工介入。这个过程有短暂的不一致窗口,但对大多数业务可接受,而且通过事件追踪、超时重试和告警,可控性比强一致硬锁好得多。
我反复强调事务边界和聚合边界画等号,意思是:一个聚合内必须强一致,跨聚合允许最终一致。很多架构上难以扩展的系统,问题就出在把所有一致性都当成强一致来做。
4.3 聚合不能一次查全:并发与一致性的取舍
一个订单聚合包含订单、订单项、地址、优惠明细,如果每次操作都一次加载全部数据,性能一定差。所以聚合建模还有一个实践要点:聚合根方法内部按需加载聚合内的子对象,而不是每次把所有子对象都拉出来。
比如"修改收货地址"只需要加载OrderAddress,完全不需要把订单项全部查出来。DDD在这种情况下有个常用手段:按用例划分聚合内的加载范围。订单详情展示、列表查询,可以用读模型来做,不经过聚合根。聚合根只在写操作时使用,读操作可以走专门优化的视图表或者检索服务。
说白了,聚合边界管的是写一致性,不是读性能。读性能用读模型解决,不要在聚合里硬塞一堆查询字段,否则边界又会开始膨胀。
4.4 常见问题速查表
| 症状 | 根本原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 跨库事务多、锁竞争严重 | 聚合边界太大,把独立生命周期对象塞在一起 | 查看下单或支付主链路上事务里涉及的表数量 | 拆细聚合,支付、库存等子域用事件协作 |
| 订单项状态被外部Service直接改 | 聚合根没有收口Repository | 全局搜索OrderItemRepository.save | 收口数据访问,只暴露聚合根方法 |
| 订单和支付状态对不上 | 两个聚合共用一张表或一个状态字段 | 检查支付流水表是否带订单状态字段 | 拆分状态机,用事件同步高层状态 |
| 大促时订单表热点行锁严重 | 订单聚合过大,所有更新打在同一行 | 观察数据库锁等待Top SQL | 拆分订单、支付、物流聚合,降低锁粒度 |
| 需求一改,模型就推倒重来 | 聚合边界与业务规则脱节 | 回顾每次需求变更影响的范围 | 从业务不变量出发重新划分边界 |
这张表我每次做设计评审都会用。它不能代替思考,但能帮你在快速排查的时候找到方向。
说实话,聚合边界这个东西,我真正想明白是某次线上事故之后:一个订单系统因为把所有东西塞进一个大聚合,大促时把数据库拖垮了。那次教训让我彻底理解,聚合边界不是UML图上画几个框的艺术,而是系统在并发压力和需求变化下能不能存活的关键。
最后分享一个我用了很久的小习惯:每次建模前,先找业务方把"绝对不能被打破的规则"问清楚。问不出来的,往往就是聚合边界划错了;问出来的,聚合的框就自然浮出水面。这个习惯我用了五年,没有一次翻车。下一个订单系统、下一张台变档案,你都可以从追问不变量开始试试。
