我接触过不少团队,一提“DDD落地”就兴奋地画出漂亮的聚合模型,可一到分布式系统里就卡壳:聚合的强一致性被网络分区撞碎,仓储的事务边界被多个服务共享,原本在单体里自洽的模型,拆开部署后变得怎么都不对味。这篇想聊的,就是这些卡壳背后的真实原因,以及我在实际项目里怎么调整思路、把D D D重新用在分布式系统上。
我会从几个层面展开:先讲分布式对传统DDD战术设计带来了哪些根本性挑战,再谈怎么重新划分模块边界、怎么处理跨进程的事务与一致性,然后是领域事件和事件驱动如何补位,最后用一个从单体拆分布式的真实复盘,把整个改造思路串起来。适合正在做微服务拆分、或者在分布式架构下实践DDD的架构师和开发同学参考。
1. 分布式系统里“标准DDD”为什么会失灵
1.1 聚合的强一致性假设首先被打破
Eric Evans提出聚合(Aggregate)这个概念时,背后隐含了一个很重要的前提:一个聚合应该作为一个整体被加载、修改和保存,事务边界基本等同于聚合边界。在单体应用里这个前提成立,因为所有对象都在同一个进程、同一个数据库连接里,ACID事务能把一切罩住。
但到了分布式系统,数据被拆到不同的数据库实例甚至不同的存储引擎里,一个“下单”流程可能要同时改订单库、库存库、账户库。这时候聚合作为事务边界的假设就被打破了。你没法再用一个数据库事务去保证“扣库存+创建订单+扣余额”的原子性。如果还死守着“一个聚合一次事务提交”的习惯,你会发现要么需要引入分布式事务中间件,要么只能把原本一个聚合硬拆成多个独立聚合去各自保存,可后者又破坏了业务语义的完整表达。
我在一个模拟项目X里就踩过这个坑。订单聚合一开始包含订单、订单项、支付记录,后台还有库存服务、账户服务,整个流程还想用单事务去操作多个服务的数据,结果就是代码里到处是分布式事务调用,性能差、耦合高,一有网络抖动整个链路就回滚一大堆。后来我们才意识到,分布式环境下DDD战术层面的“聚合即事务边界”必须重新解释,不能直接照搬。
1.2 仓储的“整体持久化”在跨库场景下成为负担
标准DDD里,仓储(Repository)负责把聚合整体取出、整体保存,对上层屏蔽数据库细节。这在单一数据源里是优秀的抽象。但服务拆分后,一个服务内可能只持有部分数据,另一个服务持有另一部分。聚合如果要引用其他服务的数据,仓储就不可能在一个事务里把整个聚合持久化。
更麻烦的是,聚合内部的实体可能分布在不同的库表、甚至不同的数据库中。比如“订单聚合”里订单基本信息在PostgreSQL,订单项在MongoDB,支付状态在另一个服务里Redis缓存。此时仓储接口仍然可以把这些细节封住,但“整体保存”时就免不了做多源写操作,事务保证从哪儿来?只能靠业务补偿而不是原子提交。
有些团队为了解决这个问题,把仓储实现改成“先写主库、再同步其他库”或者干脆让聚合变成只读视图,这实际上已经偏离了DDD的初衷:聚合应该承载不变量和业务规则,而不只是查询投影。
1.3 限界上下文之间的进程边界被“想当然”地画错
分布式系统里,服务边界本质上就得对应限界上下文(Bounded Context)。但在实际画边界时,很多人容易从数据库表结构或现有代码划分入手,而不是从业务语言和业务能力入手。结果就是两个限界上下文挤在一个服务里,或者一个限界上下文被硬拆成好几个微服务,导致模块之间通过网络做大量高频本地调用,性能和信息量双双受损。
在我参与的一个项目中,最初的“用户上下文”和“会员上下文”被拆成了两个独立服务,可是用户注册和会员权益发放是强耦合流程——每次注册都要同步调用会员服务开卡,否则用户无法下单。结果注册链路多了一次网络RTT,还引入了分布式事务。后来我们把会员开卡这个动作作为领域事件异步处理,把注册和开卡之间的强依赖变成最终一致,才算理顺。
这其实说明一个问题:上下文的进程边界不只是一个技术选择,它本质上是业务事务边界、一致性边界、团队沟通边界三者叠加的结果。只靠技术维度去切,很容易画错。
2. 重新划分边界:从聚合到限界上下文与模块拆解
2.1 先在业务层面把边界画对,再谈服务拆分
在分布式系统里做DDD,第一步反而不是去精修某个聚合的内部结构,而是把整个业务域拆成若干个限界上下文,并明确它们之间的协作关系。我通常的做法是先用事件风暴(Event Storming)梳理核心业务流,把每个命令、每个事件、每个聚合都贴到时间线上,然后看哪些命令和事件天然属于同一个业务能力。
举个例子,对于一个电商系统,订单、库存、支付、物流、营销通常是不同的上下文。订单上下文管订单生命周期,库存上下文管库存变动,支付上下文管资金流水,物流上下文管履约。每个上下文有独立的业务语言和数据模型,上下文之间通过防腐层(Anti-Corruption Layer)做映射,不直接共享数据库表。
一旦界限画对了,就能很容易地确定服务边界:一个限界上下文对应一个或多个服务。多数情况下我建议一个上下文对应一个服务,除非某个上下文内部有明确的不同扩展速率和故障隔离需求,再把它进一步拆成多个服务。
这里容易走极端。有人恨不得每个聚合都拆一个服务,结果是N个微服务互相调用,链路像蜘蛛网。记住一个原则:服务边界至少应该是限界上下文级别的,聚合是服务内部的战术构造,不应该直接暴露成外部服务。这样可以避免“一个类一个服务”的病态设计。
2.2 聚合的粒度在分布式场景下必须缩小并明确Owner
边界确定后,回到聚合设计。单体时代聚合可以稍微大一点,因为事务开销不大。但分布式环境下,聚合越大,意味着它可能跨越的数据源和更新面积越大,出问题的概率越高。我们需要重新审视聚合规模,原则是:聚合内的对象必须满足业务不变量的一致性要求,且能够在一个事务边界内完成更新。
我在做订单上下文时,最初的订单聚合包含订单头、订单行、收货地址、支付记录、退款记录等一大堆。后来因为支付和退款涉及外部支付渠道的回调,状态变更无法完全在本地事务内闭环,我们就把支付记录拆成独立的支付聚合,订单聚合只关心订单本体和订单行,支付状态通过领域事件同步回来,再由订单聚合做状态迁移的决策。
这样做之后,每个聚合都不再依赖跨库的强一致写,边界的Owner也清晰了——订单聚合归订单模块,支付聚合归支付模块。未来即使支付模块变成独立服务,订单模块的代码也不需要大改,因为两个模块之间只有事件交互,没有表共享。
还需要注意的是,聚合虽小,不变量不能丢。比如订单取消时,必须检查订单状态是否允许取消,且关联的有效支付记录不得超过阈值。这些约束仍然放在订单聚合中,不由外部服务去断言。
2.3 防腐层与上下文映射:别让外部模型污染你的核心域
跨上下文协作必然涉及数据模型的转换。如果下游服务直接复用上游的DTO,或者更糟——直接连接上游的数据库表,领域模型的完整性很快就会被侵蚀。防腐层的职责就是把外部模型翻译成自己上下文内部的领域模型,把外部规则的复杂度挡在外面。
我在实践中通常给每个需要访问外部能力的服务配置一个防腐层接口,比如“库存网关”。内部代码只依赖库存网关接口,而库存网关实现里做模型映射、远程调用、错误翻译、超时与降级处理。如果库存服务从V1升级到V2,改造范围被限制在网关实现里,上层业务逻辑完全不动。
上下文映射也是同样的思路。对于上游上下文传过来的事件,订阅方只需要把它转换成自己的内部事件或内部命令,不要直接拿上游的JSON结构塞进自己的领域逻辑。这不只是解耦,更是业务语言隔离——每个上下文都应该保持自己的通用语言(Ubiquitous Language),只翻译、不抄写。
2.4 从数据模型反推聚合的错误示范与正确姿势
还有一个常见的错误,是从“表关系”推聚合结构。比如订单表和订单项表是主外键关系,就觉得订单聚合应该包含订单项集合。这种思维本质上是数据建模,不是领域建模。聚合应该从业务不变量的角度出发:订单项和订单是否总是一起被修改?它们是否必须满足同一个业务约束?如果是,放一个聚合;如果不一定,就别硬凑。
我曾经把“用户”和“用户地址”放在同一个聚合里,理由是它们同库表、有关联。后来需求变了——用户地址需要支持批量导入,事务就要频繁修改超大聚合。我们花了两周重构才把它拆开。回头看,这个问题从一开始就应该问:用户地址有没有自己的生命周期?用户和地址是不是必须在同一时间点满足强一致?答案都是否定的,就应该直接拆成两个聚合,而不是等性能报警了再改。
3. 事务与一致性:从本地事务到最终一致的落地手法
3.1 本地事务只覆盖单一聚合的持久化
在分布式系统里实践DDD,第一步调整心态:不要试图让一个事务跨越两个服务或两个限界上下文。DDD战术设计里有一个经典建议——“一个事务只更新一个聚合实例”,在分布式环境里这个建议更为关键,但需要把它升级为“一个事务只更新一个数据源内的一个聚合”。
每次聚合状态变更,都由该聚合的仓储在自己的数据源中完成持久化。其他上下文需要感知这次变更时,通过领域事件进行通知。事件发布和本地事务必须保证原子关系,最可靠的手段是事务性发件箱(Transactional Outbox)模式:在本地业务库里创建一张outbox表,应用层在同一个本地事务里写入业务数据和待发送事件,之后后台进程把outbox表里的记录投递到消息队列,投递成功后再标记已发送。
很多团队会问,这样是不是多了一步?是,但换来的是系统在跨服务场景下不再依赖强一致分布式事务。大多数业务场景里,用户能接受支付回调晚几秒、库存扣减通知延迟几十毫秒,却不能接受支付成功但订单卡死这种情况。用outbox,业务数据落库和事件落库是原子的,事件不会丢,最多重复投递,配合幂等就是最终一致。
3.2 本地消息表与事务性发件箱的实现细节
实现事务性发件箱,有几点细节值得注意:
第一,outbox表最好和业务数据在同一个数据库和事务里写入。想象一下:业务表插入订单,同时插入一条“OrderCreated”事件记录,这两个操作在一个本地事务里提交。外部消费者即使稍后才收到事件,数据也不会缺。
第二,投递进程的扫描频率和批量大小要按业务量调。一般我建议初始参数是每秒扫描一次、每批取100条,投递到消息队列后根据消息队列的ack机制更新outbox状态。如果消息队列出现抖动,outbox积压会直接反映在数据库表行数上,比较容易监控。
第三,事件内容是版本化的。建议在outbox里直接存事件类型、事件版本、聚合根ID、事件ID和JSON负载。消费者拿到事件后,先处理版本兼容,再做幂等校验,最后更新自己的读模型或发起后续命令。
第四,垃圾回收。已投递的记录需要定期清理,否则outbox表会无限膨胀。常见方案是删除N天前且标记为已投递的记录,或者用一张单独的audit表归档。
3.3 Saga编排:跨聚合长流程的“最终一致”之道
有的业务,比如下单后要扣库存、锁定优惠券、创建支付单,牵涉多个上下文。这些操作显然没法用本地事务覆盖,此时我推荐用Saga模式,尤其是Choreography(协作式)或Orchestration(编排式)其中一种。
协作式Saga,是每个聚合在处理完自己的本地事务后发布事件,下一个聚合监听到事件再做自己的动作;比如订单创建后发布“订单已创建”,库存服务扣减后发布“库存已扣减”,最后订单服务收到库存扣减成功再确认订单。这种模式好处是去中心化、扩展性好,坏处是流程不直观,如果事件链路出现循环或丢失排查比较费劲。
编排式Saga则用一个Saga Manager来告诉每个参与方“下一步做什么”,参与者本地提交,Saga Manager负责记录状态机,一旦某一步失败就按序触发补偿操作。好处是流程直观、可控,坏处是Saga Manager可能成为单点,需要自身高可用。
我做过的模拟项目X中,同时用了两种方式:核心下单链路用编排式Saga,因为需要明确的补偿顺序;会员积分累计这种不关键的旁路,用协作式事件驱动,失败了就重试,不影响主流程。
还要强调两点:一是每个Saga步骤必须是独立本地事务,且支持重入;二是补偿操作也必须是业务上合理的反操作,比如“扣减库存”的补偿是“回补库存”,“冻结优惠券”的补偿是“解冻”,而不是简单地“删除记录”。
3.4 不要迷信分布式事务中间件,多数场景可以绕开
不少团队在跨服务写一致性上一上来就想用XA事务或者分布式事务中间件,我的建议是:非必要不用。分布式事务中间件的吞吐量打折、架构复杂度和运维成本都很高,而且依然无法解决网络不可用时的数据一致问题,最终还是得靠业务补偿和重试。
如果你评估后确实需要强一致,我建议先重新审视业务需求:是不是可以把约束放宽成最终一致?是不是可以把写操作收敛到同一个服务里?如果答案是“不能”,再考虑引入分布式事务中间件,并且只让它覆盖核心链路,不要让所有跨库操作都走它。
有一次客户坚持说支付和订单必须强一致,后来详细讨论后发现真实诉求只是“支付成功之后订单不能消失”,而不是“支付和订单必须同时可见”。于是我们用事件驱动+状态校验解决了问题,完全没有引入分布式事务中间件。这种“需求翻译成技术选型”的能力,比单纯堆技术重要得多。
4. 跨上下文协作:领域事件与事件驱动架构的引入
4.1 为什么事件驱动更适合分布式DDD
分布式系统里,跨上下文交互主要有两种方式:命令式和事件式。命令式(比如同步REST/RPC调用)符合直觉,但会形成强耦合:调用方必须知道被调方地址、接口语义、失败策略,且链路越长越脆弱。事件式则让发布方只负责“发生了什么”,不关心谁消费、何时消费,下游服务按自己的节奏处理。
从DDD的角度看,领域事件本身就是领域模型的一部分:订单已创建、库存已扣减、支付已完成,这些都是在业务过程中真实发生的事实。把它放进模型,比把一系列接口调用放在应用层更贴近业务本质。
我用事件驱动重构过一个通知功能。原来用户在订单确认后,系统同步调用短信、邮件、站内信三个服务,任何一个慢都会拖慢下单响应。改造后,订单确认只发布一个“OrderConfirmed”事件,三个通知服务各自异步消费,互不阻塞。下单接口耗时从原来800ms降到150ms,用户体验提升非常明显。
4.2 事件总线的选型与事件结构设计
选消息队列的时候,主要的考量是:是否支持可靠的ack机制,是否能保证至少一次投递(at-least-once),是否有延迟不高。具体选型不展开,关键是团队要能驾驭。事件结构建议遵守几个要点:
- 事件名用过去时,比如“OrderCreated”“InventoryReserved”。
- 事件头包含事件ID、时间戳、来源上下文、事件类型、事件版本。
- 事件体包含聚合ID和变更后的关键数据,尽量是自包含的,方便消费者不查源库就能做后续处理。
- 不要只把变更增量丢给消费者,有些消费者需要完整的上下文快照,事件体里多放几个字段通常是值得的。
事件结构不要频繁变字段。如果确实需要变更,采用事件版本策略,比如同一条业务事件可以发V1和V2两个版本,或消费者同时兼容两种格式。我习惯先在事件体里多预留一些可选字段,宁可冗余,也不要逼着下游紧跟上游改代码。
4.3 幂等处理:这是分布式一致性的最后一道保险
事件驱动最大的隐患,是“至少一次投递”会导致消费端重复执行。如果消费者在收到“库存扣减”事件时没有幂等保护,消息重试可能让库存被扣两次。解决方式我常用的有三类:
- 消费者侧维护已处理事件ID表,每次收到事件先查该表,存在则直接ack。为了性能,事件ID表可以只保留最近7天。
- 用业务键的唯一约束做幂等。比如库存扣减事件里有“订单ID+商品ID”作为业务键,表上加唯一索引,重复插入直接报冲突,捕获后按已处理处理。
- 如果是更新型操作,用状态机校验。比如只有在“待支付”状态下才能执行“支付成功”操作,重复事件会因为状态不匹配被拒绝。
我在实际项目中三种方式都混用,核心原则是:宁可重复消费被安全跳过,也不要在消息丢失后才发现数据对不上。
4.4 事件溯源:引入前想清楚成本和收益
在有DDD背景的分布式系统里,事件溯源(Event Sourcing)经常被讨论。它的核心思路是:不只存当前状态,而是把产生状态变化的全部事件都存下来,当前状态可以看成事件的投影。它对审计、追踪、历史重建有天然优势,也能让事件总线上的消息直接来自事件存储。
但对多数业务系统,我建议谨慎使用。事件溯源的难点不少:事件存储本身也需要维护,传统关系型查询变得麻烦,CQRS侧的读模型需要同步,事件版本演进会随着时间越来越难;如果某个事件在过去有bug,修正它会导致历史的“分支重放”,处理成本很高。
比较适合事件溯源的场景,是强审计需求、状态变化频繁且需要完整历史记录的领域,比如金融账户流水、物流轨迹、医疗记录等。电商的购物车和订单,必要性不算高。我自己的判断标准很简单:业务方是否会基于“历史状态序列”去追溯或做决策?如果不会,就别用事件溯源,事件驱动已经够用。
5. 从单体到分布式改造:九个聚合拆成四个服务的复盘
5.1 改造前:单体应用里的“大聚合”困境
之前有个旧系统,代码约几十万行,数据库是一整张关系网。业务上其实有订单、库存、会员、营销、支付五个上下文,但代码全在一个进程里,所有聚合共享同一个数据库。团队想微服务化,又担心拆坏了。
我们做的事,首先是画出业务蓝图:列出核心事件、命令、聚合。整理后发现系统里至少有九个相对独立的聚合:订单、订单项、支付记录、库存流水、会员账户、积分、优惠券、物流单、营销活动。问题在于:订单聚合里长了支付记录和优惠券引用,库存流水也被订单应用层直接修改,聚合之间严重串味。
5.2 按限界上下文收敛服务边界,重新划定聚合并识别弱依赖
按限界上下文分组后,我们得到四个服务:
- 订单服务:拥有订单、订单项、支付请求(仅作请求记录)聚合。
- 库存服务:拥有库存流水、库存快照聚合。
- 会员服务:拥有会员账户、积分聚合。
- 营销服务:拥有优惠券、营销活动聚合。
在这个过程中,我们明确了两件事:支付渠道的最终状态以支付服务回传为准,所以订单服务里不做支付状态的强一致,而是通过事件同步;营销活动对订单的优惠依赖,通过“订单内记录优惠券快照+营销服务单独做核销”实现,而不是去共享受惠数据表。
原本九个大聚合被重组,每个服务内部的聚合粒度反而变小了。锁粒度下降了,频繁修改超大聚合导致的锁等待和冲突也随之减少。
5.3 拆分步骤与每个阶段的验证思路
改造不是一口气切完,而是分四步走的:
第一步是代码层“上下文隔离”。先在单体代码里给每个上下文建独立模块,强制依赖方向:订单模块不能直接调用库存DAO,只能走已抽象好的接口。这一步不改表结构,只调整代码归属。
第二步是数据库层面的“逻辑隔离”。按上下文把表拆分到不同schema,物理上仍在同一个数据库实例,但应用代码只允许操作自己schema下的表。跨schema查询直接废除,改用领域服务接口实现数据交换。
第三步是“服务化拆分”。每个schema对应的模块单独打包部署,先做双写和回放验证。例如订单服务在写订单库的同时,把历史数据同步到新库,定时对比差异,直到稳定后才切流量。
第四步是“事件驱动补位”。通过事务性发件箱把原来同步调用库存、会员积分、营销核销的代码改成事件异步投递,并在消费者侧加强幂等、补偿和监控。
每一步之后,我们都做一次回归:核心业务链路、数据一致性报告、接口响应时间、错误率。和业务方约定好“最多允许N秒后数据可见”的口径,用最终一致替代原来单体内靠魔改事务弄出来的“伪强一致”。
5.4 改造过程中的几个典型踩坑点
第一,错把数据库schema当成了所有划分依据。不同模块之间偶尔需要关联查询,如果直接禁掉,业务会很痛苦。我后来的经验是:允许有限制的参照查询,例如通过公共ID查一条只读信息,但业务变更必须走接口或事件,这样既保持上下文隔离,又不至于让团队改得想离职。
第二,补偿逻辑当初没设计好。库存扣减的补偿,一开始做成了“扣减记录删除”,结果重试时原扣减记录找不到了,库存回补重复执行,最后账实不符。改成“逆操作+全局唯一业务号”后,每个补偿都对应原始操作,靠业务号做幂等,问题才消失。
第三,低估了事件消费端的排序问题。比如订单先收到支付成功事件,再收到支付状态变化的历史事件,状态机就乱了。解决办法是消费者只接受“比当前状态更新”的事件,或者用源系统的event时间戳做比较,而不是简单按到达顺序处理。
改造复盘下来,最关键的收获不是某个模式,而是“业务边界先对齐、技术方案后补位”的执行顺序,并且每一个拆分步骤都有一致性、幂等、可回滚的明确方案。
6. 组织与流程层面:DDD落地不能只靠架构组
6.1 通用语言不是文档,是跨角色沟通的协议
DDD的“通用语言(Ubiquitous Language)”这个概念,在分布式系统里比在单体里更重要。因为每个服务可能由不同小团队开发,如果订单团队把“下单”叫“创建订单”,库存团队叫“预占”,营销团队叫“锁定”,对外暴露的接口和事件名五花八门,集成成本会成倍上升。
我曾经在一次跨团队需求评审中看到,三个团队对“订单支付成功”这个事实有三种叫法:业务方叫“已支付”,订单团队叫“已支付成功”,支付团队叫“支付完成”。文档倒是没关系,但代码里的事件名、状态枚举、日志关键字一旦不一致,出现问题排查就特别痛苦。
后来我们规定:每个限界上下文维护一份术语表,通用事实的事件名必须统一,跨上下文消息里的状态枚举不能使用不同字符串表示同一含义。评审时先对齐术语,再对齐流程。比较省力的做法是每个上下文把事件名和状态机做成公共枚举包,服务间引用同一份枚举定义,避免关系数据库里存“1、2、3”靠开发脑补含义。
6.2 让每个服务拥有独立演进的能力
分布式DDD和团队拓扑有直接关系。如果一个服务有6个团队都在改,它的限界上下文一定没画对;反之,如果一个团队同时维护着6个上下文但业务节奏不同,服务耦合的历史包袱也会越来越大。
理想情况是“特性团队对应上下文”。一个上下文由一个小团队端到端负责,包括接口设计、领域模型、数据库表、事件定义。这样团队对模型的演进有整体认知,也更容易做好防腐层和通用语言的一致性。实践中有几个“小团队但大上下文”的场景,如果一个上下文太大,可以再按子域拆,但前提是业务耦合度也随子域边界变低。
在管理层面,比较好的做法是建立“上下文契约测试”机制。每个服务对外承诺的API和事件模式,在CI里跑契约测试。另一个团队改字段、改事件名但忘改契约时,测试会直接标红。这样理论上独立演进,实际又不会突然把下游打挂。
6.3 技术评审里我必问的几个DDD问题
我参加技术评审时会特意问几个问题,能很快判断出团队到底是真懂DDD还是在“假DDD”:
- 这个聚合的业务不变量是什么?如果答不上来,聚合八成是从数据表硬包出来的。
- 你的事件是“业务事实”还是“技术通知”?“数据已插入”不是领域事件,“订单已提交”才是。
- 你的服务边界是按什么画的?如果只回答“因为数据表多”或“因为三个团队”,基本可以判定上下文没画好。
- 跨服务数据一致性怎么保证?如果只说“我们不搞跨服务事务”但没有补偿方案、没有幂等策略,那是把风险藏起来了,不是解决了。
- 防腐层在哪儿?如果下游一变更上游直接改DTO,防腐层就是缺失的。
这些问题都不是形式主义,它们能暴露设计是否真正从业务模型出发。分布式系统对DDD的容错度更低——单体里模型烂还能靠调试和重构拯救;跨服务之后,模型烂会让每个调用链都变成高危地带。
6.4 DDD在分布式系统里的“隐藏成本”清单
最后想盘点一下,在分布式系统里引入DDD不只是画几张好图和几个聚合,还包含这些隐藏成本:
- 事件和消息结构的设计、版本管理、消费端的兼容测试。
- 事务性发件箱的运维、重试和监控。
- Saga 状态机的管理界面和可观测性。
- 幂等键的落地规范、唯一键设计。
- 防腐层的持续维护和契约测试。
- 各上下文通用语言的对齐成本。
如果你所在团队连基础的测试规范都还没建立,或者事件队列都没有监控,不建议直接上完整版DDD。先从一个限界上下文试点,把拆分、事件、幂等的管线跑通,再逐步推广。这也是我反复强调“分布式系统里的DDD是系统工程,不是代码风格”的原因。
7. 改造落地时的几点个人体会
做分布式系统的DDD改造,我最大的感觉是“慢就是快”。很多人一上来就画大图,把所有上下文、所有聚合、所有事件一次定稿,然后进入一个月的大重构。这种做法的风险在于:跨服务边界的改动一旦出错,排查成本远高于收益。
如果现在让我给一个务实的建议,我会这样说:先挑一个业务上相对独立、团队可控的上下文,比如营销优惠券或者会员积分,在这个范围内把事件驱动、事务性发件箱、幂等、防腐层全部跑一遍。集团队在这个小范围内把DDD的“分布式版本”玩熟,包括怎么设计事件、怎么处理补偿、怎么监控一致性和失败链路。这个跑通后,再迁移其他上下文时会顺手很多,也更容易说服业务方配合调整流程。
另外一个小技巧:事件和接口不要一开始就追求完美,但幂等和可追溯一定要从一开始就建好。消息丢了可以补偿,接口响应慢可以优化,但如果重复执行把数据搞脏了,恢复工作会让你怀疑人生。把每一笔跨服务操作都带上唯一的业务ID,把每一次状态变更都留下事件记录,这些基础工作真正决定了分布式DDD能不能安全落地。