ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

领域驱动设计实战:以返利系统为例的聚合根与事件流设计

领域驱动设计实战:以返利系统为例的聚合根与事件流设计

不少人一听到“领域驱动设计”,第一反应是那些分层架构、依赖倒置、仓储模式的术语堆砌,总觉得这套方法论离自己很远。但实际做业务系统做久了你会发现,真正让一个系统长期能改、敢改、改不崩的,恰恰是最开始对业务边界的判断和对关键概念建模的功力。这篇就以一个真实的“淘客返利系统”为例,拆一拆我当初是怎么用领域驱动设计来划分边界、确定聚合根、定义领域事件,以及在这个过程中踩过的坑。

淘客返利,通俗点说就是一个中间撮合角色:用户通过推广链接去电商平台下单,平台把推广佣金结算给返利方,返利方再把其中一部分返还给用户。整个链路涉及订单同步、佣金计算、结算打款、用户提现等多个环节,听起来不复杂,但真做起来,业务规则和状态流转能把人绕晕。这个系统天然适合用领域驱动设计来建模,因为它的核心不在于“接口怎么调”或“表怎么建”,而在于订单生命周期、返利计算规则、结算状态迁移这些业务概念的准确表达和边界隔离。

这篇会带你从领域划分一直走到事件流设计,重点讲聚合根怎么选、领域事件怎么定、幂等怎么处理、查询模型怎么和写模型分离,中间穿插我在实际项目中踩过的坑和验证过的方案。

1. 先看清领域全貌:返利系统到底在做什么

1.1 核心业务链路与子域划分

返利系统表面上就是一个“转发链接、拿佣金”的流程,但把业务流程拆开后,你会发现它实际由三条不同生命周期、不同关注点的子链路组成。

第一条是订单追踪域。用户从点击推广链接开始,到电商平台产生订单,到订单支付成功,再到订单确认收货,这整条链路的核心是“外部订单状态在返利系统内的可靠同步”。这跟物流查询系统很像,本质上是把一个外部实体(电商订单)的状态变化,持续、可靠地映射到我们自己的数据模型里。

第二条是返利计算域。这条链路的核心是一笔订单究竟能产生多少佣金、按什么比例返给用户。这里涉及商品类目、平台活动、用户等级、返利阶梯等各种规则,复杂程度远超想象。我见过不少系统初期直接把返利比例写死在订单表的一个字段里,等到活动运营要上“满返”“限时加佣”时,数据库那层逻辑直接没法看了。

第三条是结算支付域。用户申请提现、我们内部做结算审核、打款通道执行付款、回调更新状态,这是一条偏资金处理的链路,对账和审计要求极高,每一步都马虎不得。

这三个子域的业务特征、变化频率、一致性要求各不相同。订单域要处理外部不确定性,返利域要处理规则复杂度,结算域要处理资金一致性。如果硬把它们塞进同一个模型,最直接的后果就是订单表上挂着返利比例字段、结算状态字段、佣金明细字段,任何一个环节改需求,整张表都得跟着动。

我第一次设计这个系统时犯过类似的错误,那时候图省事,把返利比例直接作为冗余字段存在订单表里,导致平台每次调佣金比例,都要写一个数据订正脚本来更新历史订单,往后每个月都得重复“对明细—算差额—补记录”的操作,维护成本非常高。

做完领域梳理后,我确定了三个限界上下文:订单上下文、返利上下文、结算上下文。每个上下文有独立的数据模型、独立的领域服务、独立的状态机,上下文之间通过领域事件进行异步通信。

1.2 为什么这里不推荐“一张大表打通关”

很多中小团队的潜意识是“先建几张表,把数据存下来,功能能跑就行”,这在返利系统上特别不推荐。因为返利链路天然存在大量“外部状态不确定”的场景——平台回调延迟、回调丢失、回调重复、金额不一致、订单取消后佣金又被追回。

一张大表风格的设计会把所有这些不确定性压缩进单一状态字段里,导致订单状态字段可能出现几十种取值组合,比如“已支付但商品已下架待人工审核”“已确认但佣金比例调整待重算”等等。一旦这种组合状态多到一定程度,任何一次需求变更都会变成全表逻辑大改,测试成本也非常高。

而基于限界上下文的领域驱动设计,核心思路是把“外部世界的事实”(平台订单状态变了)和“内部世界的决策”(这笔单能返多少、能不能结算)分开处理,互不干扰。订单上下文只管同步外部事实,返利上下文消费订单事实后做规则计算并维护自己的状态机,结算上下文则是又一个独立的闭合流程。

这种边界感是后面一切设计的地基,也是领域驱动设计里最值得花时间想清楚的部分。

2. 聚合根是怎么定出来的

2.1 候选聚合的识别过程

当时我梳理聚合根时,列出的候选对象有:订单、返利单、商品、用户、结算单、支付记录、佣金明细。

逐个分析后,我的结论是这样:商品和用户虽然业务上很重要,但它们不是返利流程的核心操作单元,商品是返利规则的一个输入参数,用户更像是一个归属标签加资金账户的组合体,都不适合作为聚合根。佣金明细单独做聚合根也没必要,因为它脱离返利单之后没有独立的不变式要维护,放在返利单内部作为值对象集合即可。

最后真正确定为聚合根的只有三个:订单、返利单、结算单。

订单聚合负责管理外部订单在系统内的生命周期,核心属性包括订单号、外部平台单号、商品快照、支付金额、订单状态、关联的用户ID等。它必须保证“同一外部平台单号只能生成一笔内部订单”,避免重复回调导致重复单,这是它的核心不变式。

返利单聚合是整个返利计算域的核心,包含外部订单引用、商品明细快照、返利比例快照、计算出的佣金金额、当前状态。它必须保证“同一订单在同一规则版本下只能有一个有效的返利计算结果”,防止佣金被重复计算、重复入账。

结算单聚合承接返利单到资金出账之间的全部行为,包含用户账户信息、本次结算的返利单集合、结算金额、结算状态。它必须保证“一笔返利单的金额只能被结算一次”,不能因为用户连续点击提现按钮就触发多笔打款。

2.2 三个聚合根的职责边界与不变式

订单聚合最核心的职责是吸收外部不确定性。外部通道的报文格式五花八门,时效性也不稳定,订单模块要做的是把它统一成一个规范化的内部模型,并对外提供一个稳定的状态视图。

比如某平台下发的订单回调里,状态字段可能叫“PAID”,另一平台可能叫“交易成功”,订单模块需要字段映射后生成统一的已支付状态。这个转换逻辑放在聚合根里最合适,因为聚合根是唯一能够判断“内部订单当前状态在外界看来是否正常推进”的地方。

它的关键不变式是:同一外部订单号在同一业务来源下,内部订单记录必须唯一。这个约束是后面所有消费者事件处理的前提。

返利单聚合的核心职责是算清楚一笔订单到底该返多少钱,但它聪明的做法是:把商品快照、比例快照、金额计算依据全部保存在自己的状态里。这样即使未来运营调整了佣金比例,已生成返利单的历史结果不受影响,不会因为规则变更而产生“需要重算”的历史包袱。

如果真发生需要追溯调整的场景,也是通过新生成一个“调整单”来处理,而不是去改原返利单,这是保障资金流水可审计性的关键。

结算单聚合是我觉得最容易被忽略但恰恰最关键的聚合根。结算涉及一组返利单条件的判断、一个金额汇总动作、一次外部打款请求的发起。它必须捕捉“哪几笔返利单参与了本次结算”“每笔返利单当时的金额是多少”“本次结算的请求编号是什么”,并用状态机管理自身生命周期。

我不建议把结算作为返利单的一个方法,因为从业务语义上讲,结算是一个独立的业务事件,它的生命周期比单笔返利单更长,且要承载对账和重复支付防护这类独立不变式。

2.3 这里的一个关键判断:返利单需不需要“累计已结算金额”

设计返利单聚合时,不少团队的直觉是“需要在返利单内维护累计已结算金额,当申请结算的时候和佣金金额比对一下,防止超结”。这个思路本身没问题,但它会带来一个严重问题:所有并发结算请求都会抢同一个聚合根的写锁,结算吞吐量上不去,而且职责也偏了——返利单应该表达“能返多少钱”,至于“已经返了多少、还剩多少”,本质上是查询和统计模型。

我实际采用的方案是,在结算上下文里单独维护每个用户的“可结余额”投影模型,由结算单事件异步更新。用户申请提现时只锁定这个投影值,并发压力大幅下降,业务上也够用了。这里想强调的是聚合的设计对象不是数据结构,而是事务边界,放到哪里取决于效率和一致性之间的真正权衡。

3. 领域事件的定义与流转

3.1 从下单到结算的完整事件流

下面把一条完整的业务链路串起来,看看领域事件在整个流程里扮演的角色。

用户点击推广链接到某平台完成支付,平台通过异步回调通知订单模块,订单聚合收到后创建一笔内部订单记录(实际上订单模块在打开链接时就可以预创建一笔订单),状态为待支付。用户完成支付,平台再次回调,订单状态更新为已支付,领域事件“订单已支付”发布。返利上下文消费该事件后创建返利单聚合,按商品快照和当时的返利规则计算佣金,形成“返利单已确认”事件。用户满足结算条件后发起提现,结算上下文消费所有满足条件的“返利单已确认”事件,创建结算单聚合,进入“结算已发起”状态。结算单内记录打款流水号,支付通道回调成功后更新为“结算已完成”,发布“结算已完成”事件供通知和税单消费。

3.2 事件命名与消费边界

领域事件命名要表达“已发生的事实”,用过去时。比如订单模块内部确认已支付后发布“OrderSettled”或“OrderVerified”,而不是“VerifyOrder”或“CreateRebateOrder”。这个细节直接决定系统的语义边界是否清晰,用命令式命名容易让下游消费者产生误解,以为自己在驱动上游动作,实际上本质是一个异步事实传递。

回到返利系统的场景,我还会在事件风暴环节和整个团队明确每个事件的“生产者”和“消费者”。每个事件都应有明确的产生时机、承载内容和消费方,如果一个事件定义后永远没有消费者,或者一个事件要同时服务于多个毫不相干的场景,那设计就需要反思了。

比如一开始我把“订单已支付”直接同时发给返利上下文和结算上下文,后来发现结算上下文根本不需要关心支付事实,它只需要消费“返利单已确认”事件就够了。砍掉多余的消费者之后,结算上下文的输入边界会清爽很多,排查问题也更容易。

3.3 外部事件与防腐层

返利系统很特殊的一点是,大量事件来自外部平台,它们本身就是“外部世界的事实快照”,比如“订单已支付”“订单已退款”“订单已确认收货”。这些事件不能直接进入我们内部的事件总线,因为在不同来源、不同报文格式下,同一事实的表达方式完全可能不一样。

所以我在订单上下文入口加了一个防腐层,职责是统一接收外部回调、做报文转换、调用订单聚合创建或更新内部订单,再由聚合根内部领域事件发布器对外发布统一事件。这个防腐层要单独测试,因为格式解析失败、字段缺失、状态映射出错这些情况,都发生在这一层。

4. 命令查询职责分离与幂等设计

4.1 返回状态与查询模型分离

传统的分层架构常让人误以为一个实体的数据模型既能承担写入又适合查询,但实际上读模型和写模型的需求非常不一样。返利系统的用户端经常会看到“我的佣金明细”这类页面,里面要展示订单号、商品名、返利比例、佣金金额、状态、打款时间,这些数据来自多个上下文。

如果直接让前端查询聚合根,查询效率很低,而且会把聚合根的通用逻辑暴露给只读场景。我采用的方式是让每个上下文都维护一套只读的查询模型,由领域事件异步更新。订单上下文的投影表里保存订单的简要信息,返利上下文全部用返利单已确认事件更新佣金明细表,结算上下文通过结算已完成事件更新余额和流水表。用户端每次请求查询时直接读投影表,不触碰聚合根数据,接口响应速度和稳定性都有保障。

这里需要重点说清楚:命令查询职责分离不是把数据库拆成两个物理库,更多是一种逻辑模型上的分离。写入侧的业务规则和读取侧的展示需求本来就是两种不同的建模思路,强行共用一套模型只是省了开发时间,后续优化和扩展时会处处碰壁。

4.2 幂等、去重与重试

返利系统对幂等的要求比普通业务系统严格得多,因为外部回调、异步消息、支付回调都有可能在网络异常下重复到达。我必须保证每个消息被处理多次时,最终状态和一次处理完全一致。

方案是给每个聚合都设定一个幂等键。比如返利单聚合在处理“订单已支付”事件时,必须检查“外部订单号+业务来源”是否已有有效返利单,一旦存在就直接返回,不再重复计算。结算单聚合在创建时则用“请求编号”校验,同一用户同一个请求编号最多只能初始化一次,后续的重复请求直接返回已存在的结果。

在实现层我还会定义一个“事件消费记录表”,记录每个消费方已处理的“事件编号+事件类型”组合。当重复事件到达时,消费方通过唯一索引拦截,跳过处理。这个表在分布式事务模拟测试中表现良好,是消息队列重复投递场景下的兜底机制。

4.3 金额计算的精度控制

在这个系统里有一条绝对规则,所有金额字段都用“分”(长整型)存储,不允许出现浮点数。佣金计算逻辑如果有折扣比例,必须换算成分后再计算,最后结果四舍五入到分。这条规则本身很简单,但实际项目中很多问题都是在浮点计算上跑偏导致的。例如一笔99.99元的订单按36%返利,浮点数计算可能得到35.9964,如果不按分处理,最终展示就会出现几分钱的误差,长年累月下来对账会非常麻烦。

我还引入了一个独立的金额计算服务,所有上下文的金额换算都通过它来进行,保证口径统一。

5. 实践中的常见问题与排查技巧

5.1 事件丢失了怎么查

异步架构最容易碰到的问题就是“事件到底发没发”“发到哪里去了”。我的排查套路是保证每个领域事件带全局唯一编号,把事件ID贯穿到日志、消息体、消费记录表。排查时直接根据事件ID过滤整条链路,看生产端有没有落盘、消息中间件里有没有记录、消费端有没有接收,三步定位。

如果发现消费端确实没有收到,优先检查消息队列的重试和死信队列配置。以前就出过一次事故:某平台的回调在凌晨批量补发,我们的订单上下文瞬时吞吐过高,消费线程池满,消息积压了二十分钟,好在死信队列保住了所有消息,最终消费完成。后来我把订单上下文的消费线程池独立拆分,隔离了消息队列内部的相互影响。

5.2 状态不一致时怎么处理

分布式架构下,几个上下文之间的状态不一致是常态,但一部分不一致是可以通过补偿调整恢复的。例如返利单已确认时平台可能还没回调退款接口,但已经确认收货又发生退款的情况,就要引入“退款冲正”机制:当订单上下文发现订单被退款,发布“订单已退款”事件,返利上下文消费后,对已经确认的返利单做撤销或冲正操作,同时更新投影表。

这里一个很重要的教训是不要把冲正逻辑做成“直接改初始值”,而是新增状态转移记录,保留审计轨迹。比如返利单从“已确认”到“已冲正”,而不是把已确认状态直接删掉,因为对账和审计需要看出完整的生命周期。

5.3 对迭代开发的两个提醒

第一点是聚合不是越细越好。我看到过有团队为了追求“高内聚”,连地址、用户标签都做成聚合根,导致一次业务操作要加载五六个聚合,性能和一致性都难以保证。返利系统里订单、返利单、结算单三个聚合就足够支撑完整链路,不需要再拆。

第二点是领域事件使能驱动,但别让它绑架流程。事件只是业务事实的异步传播方式,不代表所有流程都要强异步化。比如用户发起提现时,结算单的创建还是需要同步等待部分校验结果,只是校验通过后的“后续行为”才通过事件解耦。

6. 最后分享一点实操体会

如果你也要做一个类似的返利系统或者分销系统,我建议先从业务事件风暴开始,把所有“外部世界可能导致状态变化”的点都列出来,再思考哪些事实需要在系统内部沉淀为事件。这个过程比选框架、定表结构重要得多,因为边界判断错了,后面每一层代码都会别扭。

我自己做下来最大的感受是,领域驱动设计不是一种代码风格,而是帮你在介入一个复杂业务场景时建立一个稳定的思维框架。你只需要先花半天时间把“订单是什么”“返利单是什么”“结算单是什么”定义清楚,后面代码再怎么写都很难逃到哪里去。

最后再提醒一个细节:无论怎么设计事件和聚合,一定、一定、一定要在日志里把“交易编号”和“外部订单号”关联起来,无论是排查问题时还是对账时,这都能帮你节省整整一个通宵的时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进