ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

DDD落地复盘:用事件风暴+限界上下文破解微服务拆分难题

DDD落地复盘:用事件风暴+限界上下文破解微服务拆分难题 大概从三年前开始我带的团队陷入了一场“微服务拆分之乱”。不是技术栈不够新也不是发布管道不够顺而是每次新需求一来所有人都要在会议室里吵服务边界这个字段该放在订单服务还是履约服务为什么商品服务要关心促销的规则为什么改一个下单流程牵扯了七个服务、改了四个表后来我把整套系统重新用 DDD 领域驱动设计做了一次从战略到战术的梳理才真正意识到混乱的根源不在代码而在于我们一直在“看表拆服务”从来没有认真回答过“业务的边界到底在哪里”。这篇文章不打算讲一堆概念定义而是我做完一次订单模块重构后的完整复盘。内容包括事件风暴怎么开、限界上下文怎么定、聚合怎么建模、领域事件怎么落地以及老系统如何用防腐层一步步演进。适合正打算引入 DDD 但不知道怎么下手的架构师、技术负责人也适合后端开发者把它当成一条可执行的路线图来读。1. 微服务拆分的混乱根源从来不是技术1.1 “按功能拆服务”看着合理做起来全是坑很多人对微服务的第一直觉是用户相关放用户服务商品相关放商品服务订单相关放订单服务这不就拆好了吗最早我们也是这么拆的结果最痛的还不是分布式事务而是“业务逻辑被接口调用切成了碎片”。举一个特别典型的例子下单时要校验商品是否在促销活动中、是否可以叠加优惠券、库存是否足够。这些规则分散在商品服务、促销服务、库存服务、订单服务里。每下一个单订单服务要依次远程调用四个服务把数据拼回来再写库。遇到一次大促流量所有依赖方都慢链路一动就超时。后来复盘问题时我们发现所有服务的代码里都散落着“状态判断”“金额计算”“库存判断”的复制粘贴片段。按模块拆服务并没有拆出清晰的边界只是把一个单体里的耦合从代码层面搬到了网络层面。1.2 康威定律系统边界是组织结构的镜像后来我才真正理解康威定律——一个组织的沟通结构会直接决定它能设计出的系统结构。如果团队是按“前端组、后端组、测试组、DBA组”来组织的那么你很难拆出围绕业务的微服务因为分工逻辑天然是“技术分层”。如果团队是按“用户业务组、订单业务组、支付业务组”来组织的边界就更容易落到业务能力上。我们当时的情况刚好是最差的一种几个人维护一个服务但需求是按业务线派的一个订单需求要同时改订单服务、库存服务、促销服务于是研发之间互相等接口、开会协调成了常态。真正该做的不是继续调服务划分而是先改变“以功能模块为边界”的惯性让每个小团队拥有一个完整业务闭环。1.3 DDD 要终结的是没有业务依据的“拍脑袋拆分”一开始我对 DDD 也有些误解以为它是另一套代码分层规范学了之后发现照样一堆概念。直到我把战略设计那套东西用在真实系统上才明白它的价值DDD 不是帮你把服务拆得更细而是帮你找到“哪些东西不该被拆开”“哪些东西必须隔离开”。比如订单和订单项过去我们会把它们拆成两个服务吗大概率不会因为同一张订单下的条目天然在一个事务里。但如果我们跟着数据库表去拆看订单表在库里购物车表在库里就照着拆那“提交订单”这个业务动作就会被拦腰截断。DDD 要求你先回答“业务发生了什么”再决定“代码里的边界怎么画”。可以说它的核心价值是结束了没有业务依据的混乱拆分。2. 战略设计不打草稿就画架构图等于在沙漠里盖楼2.1 统一语言先让所有人都说同一套词汇做 DDD 战略设计时第一个动作往往不是画架构图而是建立统一语言。你可能觉得这很虚但实际干起来比想象中重要。举个真实例子业务人员口中的“下单”在产品原型里叫“提交订单”后端表里叫“创建订单主表”前端埋点叫“checkout”。看似都在说同一件事实际上背后隐藏的规则不一样。业务人员默认下单成功后要自动清空购物车产品觉得只要生成订单就算提交而后端的订单主表里还包含“预下单”这种草稿状态。如果没有统一语言这些歧义会一直潜伏到线上。统一语言不是简单的术语表它至少要包含三样东西核心词汇的准确定义、业务动作的执行规则、状态流转的合法路径。比如“下单”要统一描述成“客户确认购物车内容后创建正式订单并触发支付”那么“创建正式订单”和“生成草稿订单”就是两件事。最终这些词会原封不动地变成代码里的类名、方法名、事件名我后面写代码时几乎不需要做名词翻译。2.2 事件风暴用便利贴把全业务流程捋出来有了统一语言的概念下一步就是把它变成团队共识。我最推荐的方式是事件风暴这是一种把干系人拉到同一个房间用便利贴按时间线贴出业务流程的协作方法。操作方式不复杂。准备四种颜色的便利贴橙色代表“领域事件”就是业务中已经发生的事实比如“订单已提交”“支付已完成”蓝色代表“命令”也就是触发事件的指令比如“提交订单”“确认收货”绿色代表“聚合”或业务对象黄色留给外部系统和依赖。参与角色尽量包括业务方、产品、前后端、测试缺了任何一方都可能漏掉关键规则。开始前先在墙上画一条从左到右的时间轴请大家按业务发生顺序贴出橙色事件。贴的时候有几个容易犯的错第一把命令当成事件写成“客户提交订单”这其实是动作事件应该写成“订单已提交”第二过早讨论数据库字段和技术实现主持人要不断拉回来第三业务方参与不充分程序员自己凭想象补齐流程最后做出一个大家都不认识的系统。一场事件风暴控制在90分钟以内时间到了还没捋完说明范围太大把子流程切成下一场。我们第一次给订单模块做事件风暴时从“用户浏览商品”一直贴到“售后完成”墙上贴了七十多张便利贴当场就暴露了一个大问题“提交订单”并不是单一动作它其实是“将购物车生成订单、清空购物车、预占库存、创建支付单”的组合而这些步骤分别归属于不同业务能力。这一张便利贴的发现直接决定了后面服务该怎么拆。2.3 限界上下文让同一个词在不同的边界内有不同的含义事件风暴跑完墙上出现一堆领域事件和命令接下来就要把它们分堆确定每个业务模型的适用范围。这就是限界上下文——模型中所有的概念只在特定边界内部才有意义。典型例子是“商品”。在商品管理上下文里商品有标题、详情、品类、上下架状态、规格参数在订单上下文里订单行只需要保留商品ID、名称、下单时的单价快照在库存上下文里它可能只剩下SKU编码和批次库存数量。你很难用一个全局统一的“商品模型”同时满足所有场景。如果强行让所有服务共享同一张商品表或同一个商品类就会形成大量无关字段和耦合。划分限界上下文时我会问三个问题这些事件是否属于同一个业务能力它们是否会因为同一个业务变化而频繁一起修改它们的核心概念在不同场景下是否有明显不同的含义如果答案都是肯定的就该拆成不同上下文。反过来如果两个概念变化频率高度一致就别为了拆而拆。2.4 上下文映射服务之间的关系不是画线而是定策略上下文划分清楚之后需要明确它们之间怎么协作。这就是上下文映射常用的模式有合作关系、共享内核、客户-供应商、防腐层、开放主机服务等。选错模式后续会很痛苦。这里重点说防腐层因为老系统改造几乎绕不开它。假设你们保留了一个用户旧系统而新订单上下文需要读取用户等级和地址信息。最直接的做法是让订单上下文直连旧库但旧表结构混乱、字段语义不明确而且旧系统随时可能改表。正确做法是在订单上下文和旧系统之间加一个防腐层把所有对旧系统的访问收口在一个适配模块里提供新上下文需要的接口内部再去翻译旧系统的数据结构。上下文映射模式适用场景主要代价合作关系两个上下文强耦合需要同步开发有持续联调成本共享内核共享一小部分通用模型要严格控制变化权限客户-供应商下游依赖上游接口上游要保证契约稳定防腐层隔离外部混乱系统有额外适配层开发量开放主机服务把上下文能力发布为稳定API契约设计要谨慎上下文映射还决定了微服务之间的接口风格。如果两个上下文属于“客户-供应商”下游消费者需要上游提供稳定的API如果希望解耦就可以引入领域事件让下游订阅而不是同步调用。3. 战术设计把边界稳定成代码而不只是PPT3.1 实体与值对象最基础也最容易被搞反的一步战略设计的成果最终要落到代码战术设计就是要决定代码长什么样。最容易翻车的建模决策就是分清实体和值对象。实体有唯一标识和完整的生命周期比如一个订单、一个用户、一张发票它们的状态会随事件变化需要被追踪。值对象描述某种属性没有唯一标识也不应该被单独修改比如金额、地址、电话号码。判断标准很简单你在乎它“是谁”还是“是什么”。如果在系统中把商品价格单独建一张价格表并给它一个主键ID多半就是在把值对象硬做成实体。实际编码里最值钱的一个实践是用值对象封装金额。业务里到处是金额的加减和比较如果不封装BigDecimal变量会在代码里裸奔每个方法都可能写出“if(money 100)”这种硬伤。我会定义一个 Money 值对象包含金额和币种并让所有涉及钱的计算都收敛到它身上这样币种不匹配的问题可以在模型层直接拦截。3.2 聚合与聚合根事务边界才是服务拆分的原子单位聚合是 DDD 战术设计中最重要的概念。一个聚合是一组必须保持业务一致性的对象集合外部不能直接操作聚合内部的子对象所有访问都要通过聚合根。比如“订单”聚合里包含订单基本信息、订单项、收货地址、支付记录订单项不能绕过订单直接更新。聚合的真正意义在于定义“事务边界”。业界常说一句话一个事务只修改一个聚合。这不是为了避免数据库锁竞争而是为了保证业务不变式不被跨模块的分布式事务搅浑。判断两个对象是否属于同一聚合就看它们是否必须保证同步修改。如果必须同生同灭、金额必须对得上那它们属于同一聚合如果允许短暂不一致那就可以拆成多个聚合用后续事件来收敛。对应到微服务上服务内部的代码结构应该由聚合驱动。先识别业务里有哪几个聚合再决定一个上下文如何对外暴露操作入口。你拆出去的不应该是几张表而应该是“围绕一个聚合根的一组完整业务能力”。3.3 领域服务与应用服务别把业务逻辑写在 Controller 里战术设计落地的常见分层是接口层、应用层、领域层、基础设施层。依赖方向永远是向领域层收敛也就是基础设施层依赖领域层接口而不是反过来。应用层负责用例编排、开启工作单元、事务边界、领域事件发布但不要把业务规则堆在应用层里。领域层承载核心业务逻辑。能放进实体的规则就放在实体方法里当规则不属于某个实体或值对象时才建模为领域服务。基础设施层负责数据库、消息队列、外部API只实现领域层声明的仓储端口。举一个典型反模式有人把状态判断写在Controller里例如“如果订单状态是已支付并且时间在三天内就允许退款”。结果业务规则淹没了接口代码换一个入口还要再补一遍。正确做法是让订单聚合根提供canRefund()或refund()方法Controller 只调用应用服务应用服务加载订单聚合、执行order.refund()、保存结果。3.4 领域事件用事件把拆分出的服务重新织成网当服务被拆到不同进程原本一次本地事务里的动作变成跨服务操作你不可能每个业务动作都去做分布式事务。领域事件在这里登场它是聚合在状态变化后发布的业务事实比如“订单已支付”“库存已扣减”。事件命名统一用过去时事件内容要携带聚合ID、时间戳、以及下游真正需要的数据。不要一股脑把整个聚合序列化扔出去那会导致消费者依赖你的内部结构。发布时机也很讲究不能把事件发布放在业务代码的后面裸发否则数据库事务提交失败MQ里已经有一条事件了会造成“假事件”。我目前使用最多的是 Outbox 模式思路是把业务写入和事件记录放到同一个本地事务里。先在自己的业务库里建一张 outbox 事件表业务更新和事件插入一起成功或一起失败再由一个后台任务或 CDC 组件把 outbox 表里的记录发布到消息队列。这样既保证事件不会丢也不需要分布式事务。事件是业务概念MQ 只是传送方式两者应该分开。4. 一次完整的拆分演练电商订单模块到底怎么拆4.1 先做事件风暴看清楚“提交订单”到底干了什么理论说了这么多我用一个电商订单实例把流程串起来方便你直接参考。我们当时组织的订单事件风暴沿着时间轴贴出的核心事件大概是触发命令领域事件归属上下文提交订单订单已创建订单上下文预占库存库存已锁定库存上下文创建支付单支付单已生成支付上下文完成支付支付已完成支付上下文仓库发货订单已发货履约上下文客户签收订单已签收订单/履约上下文真正跑完一轮你才知道“提交订单”不是订单服务自己的一件事。客户点击下单后订单要创建同时购物车要被清空库存要被锁定支付单要生成。如果这些步骤全都放进一个“订单服务”里等于让订单服务协调所有流程它最终还是会膨胀成小单体。4.2 从限界上下文到微服务不是每个上下文都要独立成服务基于事件结果我们识别出购物车上下文、订单上下文、库存上下文、支付上下文、商品上下文。再结合业务核心程度和团队规模决定哪些上下文独立成微服务订单上下文核心域业务流程的主节点必须独立服务。支付上下文核心域强合规、外部依赖多独立服务对外只暴露支付结果。库存上下文支撑子域但变化频繁、性能要求高独立服务。购物车上下文支撑子域早期和订单合在一起并不影响边界后续流量大了再拆。商品上下文通用能力分为内部的写模型和对外的只读接口。这个决定说明了一个容易忽略的点限界上下文是逻辑边界微服务是物理部署边界与团队所有权边界。逻辑上 A 和 B 可以很清晰但物理上要不要拆成服务还要看团队是否跟得上、部署频率是否差异化、数据是否真有必要分开。先保证了模型边界清晰物理拆分就是顺水推舟的事。4.3 订单上下文内部建模画聚合和代码包结构订单上下文内部我们把“订单”作为聚合根。订单基本信息、订单项、收货地址、支付流水记录都收敛到这个聚合内部。订单项没有独立的仓储必须通过订单仓储加载再通过订单的方法去修改。代码结构大致如下// 订单聚合根 public class Order { private OrderId orderId; private CustomerId customerId; private OrderStatus status; private ListOrderItem items; private Money totalAmount; private Address shippingAddress; public void confirm() { if (this.status ! OrderStatus.CREATED) { throw new IllegalStateException(只有已创建状态的订单才能确认); } if (this.items.isEmpty()) { throw new IllegalStateException(订单不能没有任何条目); } this.status OrderStatus.CONFIRMED; addDomainEvent(new OrderConfirmedEvent(orderId, totalAmount)); } public void cancel() { if (this.status OrderStatus.SHIPPED || this.status OrderStatus.COMPLETED) { throw new IllegalStateException(已发货订单不能直接取消); } this.status OrderStatus.CANCELLED; addDomainEvent(new OrderCancelledEvent(orderId)); } } // 值对象 public record Money(BigDecimal amount, Currency currency) { public Money { Objects.requireNonNull(amount); Objects.requireNonNull(currency); if (amount.compareTo(BigDecimal.ZERO) 0) { throw new IllegalArgumentException(金额不能为负数); } } public Money add(Money other) { if (!currency.equals(other.currency)) { throw new IllegalArgumentException(币种不一致); } return new Money(amount.add(other.amount), currency); } }对应到包结构我习惯这样组织order-context/ interfaces/ controller/OrderController.java facade/OrderFacade.java application/ OrderApplicationService.java domain/ model/aggregate/Order.java model/entity/OrderItem.java model/valueobject/Money.java model/valueobject/Address.java service/OrderDomainService.java event/OrderConfirmedEvent.java repository/OrderRepository.java infrastructure/ repository/JpaOrderRepository.java repository/OrderOutboxPublisher.java4.4 服务间集成不共享表只通过接口或事件协作跨上下文的数据关系要格外克制。订单服务需要读取商品信息可以在订单项里保存商品ID、名称、下单时价格的快照如果界面需要展示最新的商品图片那就通过商品上下文开放查询接口来补充而不是两个服务共享一张商品表。跨服务的数据一致性也遵循“谁拥有数据谁负责更新”原则其他上下文需要数据时要么通过API查要么订阅对方发布的领域事件维护自己的只读副本。这样做有代价比如同一份数据在不同库里有副本需要处理同步延迟但换来的是每个上下文都能独立修改内部模型。5. 老项目改造不要推倒重来用外科手术式演进5.1 用防腐层把老系统的混乱挡在门外很多团队看到 DDD 的第一反应是“要不要重构”。我强烈建议不要推倒重来尤其是已经稳定运行的老系统正确的做法是先用防腐层把旧系统隔离起来再在新代码上逐步建立新模型。假设老系统有一张orders表里面既存了订单信息又冗余了用户昵称、商品标题、支付金额。新订单上下文需要从老系统获取历史订单数据时不要直接让新代码访问老表。在交界处定义一个针对新模型查询接口例如HistoricalOrderRepository内部实现负责把老表字段翻译成新模型的OrderSnapshot。翻译过程再烂也只能烂在防腐层里新模型的代码不会被老的怪异字段拖累。5.2 找高价值业务切口小步替换不是所有地方都适合一次性用 DDD 重写。找切入口时我建议选满足“核心度高、逻辑复杂、改动频繁”三个条件的模块。如果一个模块纯粹是增删改查建再多聚合也榨不出价值。替换过程可以按“双写—只读切换—写切换—删除老逻辑”四步走。先让新模块和旧模块同时接收数据写入跑一段时间做数据比对比对通过后把读流量切到新模块业务验证没问题再切换写流量最后删掉老代码。这个过程不需要一次变更就把所有表拆出去而是把“订单上下文”作为一个独立可演进单元一厘米一厘米地推进。5.3 Outbox 模式用本地事务解决“业务更新和发消息不一致”老系统改造中最怕出现“业务提交成功了MQ消息没发出去或者业务回滚了MQ消息却发出去”。发生这类问题不是缺一个分布式事务而是缺一个可靠的本地事务边界。Outbox 方案的落地步骤是在同一个数据库里新建一张outbox_event表字段至少包括id、aggregate_type、aggregate_id、event_type、payload、status、created_at。业务数据的更新和 outbox 事件的插入在同一个本地事务中执行。部署一个轮询任务或订阅数据库 CDC把状态为pending的记录投递到消息队列成功后更新状态为published。这样实现的理由很朴素要么业务数据和待发布事件一起成功要么一起失败。你可以把 outbox 想象成“业务操作产生的真实凭据”业务没完成之前事件不可能流到外面业务一旦完成事件记录一定存在。5.4 每个上下文分配一支“战术小队”战略设计定了上下文战术设计定了聚合但真正执行的是人。我们后来在组织上做了一次调整让每个限界上下文对应一支跨职能小队成员包括产品、后端、前端、测试。每个小队对自己上下文内的业务规则和代码结构拥有完整决策权上下文外部只认接口契约和事件协议。这就是我口头说的“战术小队”——每个小队有清晰靶区能独立作战杜绝了以前一个需求改半天还要摇人开会的问题。这里有一点提醒战术小队自治不代表完全隔离。公共规范、代码风格、质量标准仍然由平台组统一把控否则每个上下文会发展出完全不同的工程文化。6. 常见问题、判断标准与避坑速查表6.1 聚合边界老把握不好问自己三个问题聚合建模是新手最容易纠结的地方。我总结了一套判断题每次拿不准就问三个问题。第一个问题A 和 B 如果出现短暂不一致业务方能不能接受如果能接受说明它们不必属于同一聚合。比如订单状态和短信通知短信晚几分钟完全没关系没必要做成一个聚合。第二个问题A 和 B 是否必须同生同灭比如一个订单如果没有任何订单项那它就不成立订单和订单项就必须在同一聚合里。第三个问题如果 A 的操作成功而 B 失败业务状态是否合法如果必须全部回滚那就不能拆成多个聚合更不要放成两个微服务。6.2 系统几乎全是 CRUD要不要上 DDD我的答案是不一定。如果业务核心很简单比如做一个字典管理、配置管理、报表展示强行引入 DDD 只会增加大量样板代码和沟通成本。DDD 最大的价值体现在业务规则密集、状态流转复杂、术语充满歧义的领域。如果一个模块三句话就能说清数据流向你不需要建模出实体、值对象、聚合这些概念。判断指标很简单你写的新需求里有多少是“if/else 业务判断”有多少逻辑需要多个研发反复和业务确认如果答案很少说明该模块复杂度低DDD 是杀鸡用牛刀。6.3 事件风暴里业务方不配合怎么办这是最现实的问题。业务方不可能理解什么叫事件风暴更不可能配合你贴两天便利贴。我的经验是不要强推概念把流程包装成业务方熟悉的形式。第一轮先由产品和核心开发根据已知需求梳理出一个初版流程把它整理成用户能读懂的业务步骤。第二轮才邀请业务方核对重点问他们“如果某一步失败了你希望怎么办”“有没有特殊流程你们没提”而不是问“你觉得这个领域事件应该由哪个聚合发布”。业务方不配合的本质是他们觉得这场会议跟自己没关系。你要让会议直接解答他们最关心的问题系统里的业务规则对不对。6.4 服务拆分粒度自检清单我最终把服务拆分是否合理的判断收敛成了一张自检清单服务是否由限界上下文驱动而不是由数据表驱动服务内部是否可以独立演进需要跨服务同步改代码吗是否只有一个团队在改这个服务的大多数代码一个业务用例是否最多经过两个或三个服务数据所有权是否清晰有没有多个服务同时写同一张表跨服务的数据一致性能否接受最终一致如果答案都是“是”大概率拆分方向是对的。6.5 给团队培训时我用的记忆锚点为了让团队少走弯路我把整套方法压成了一句话先找事件后找词词分上下文上下文找人人领聚合作决策。先找领域事件再围绕事件定义统一语言根据概念歧义划分上下文每个上下文交给一个战术小队负责小队长根据业务模式确定聚合和事务边界。这句话听起来简单但是足够把 DDD 从战略到战术的主线串起来了。7. 额外聊点DDD 的边界思维对 AI Agent 设计同样适用最近我在看一些 AI Agent 的项目发现一个有意思的现象很多人把 Agent 理解成一个能随意调用工具的“大模型”结果业务一复杂Agent 的编排就乱成一团一会儿把订单状态判断交给模型自由发挥一会儿又把太多工具塞进同一个 System Prompt。这个问题的本质其实是“边界”缺失。DDD 里的限界上下文、统一语言、聚合这些思想在 Agent 设计里同样能对应上每个 Agent 能处理什么、不能处理什么应该像限界上下文一样被显式定义Agent 之间的通信应该像领域事件一样有固定的消息结构而不是丢一大段自然语言过去。当你把 DDD 的思维方式迁移到 AI Agent 的顶层设计其实就是先梳理业务有哪些能力域再定义每个 Agent 的职责边界最后才考虑提示词和工具集。这个观察不完全算技术方案但它让我更加相信DDD 的价值不会因为架构风格变化而过时。边界问题永远比工具问题更值得先想清楚。
返回列表