DDD九种上下文映射模式:选型信号、代码落地与避坑
上次评审会,一个同事在白板上画了十四个方框,框里写着订单、库存、结算、风控、账务……画完他停住了,说线画不下去了。这个场面我见过太多次。限界上下文画出来只是第一步,真正贵的是框与框之间那根线——它代表两个模型怎么对接、两个团队怎么协作、谁向谁妥协、谁为谁改代码。这根线的正式名字叫上下文映射模式,DDD经典里一共归纳了九种:伙伴关系、共享内核、客户-供应商、遵奉者、防腐层、开放主机服务、发布语言、各行其道、大泥球。
这篇东西不打算复述书本定义。我想聊的是:这九种模式在真实项目里各自长什么样,选型时到底该看哪几个信号,代码层面怎么落地,以及最容易踩的那几个坑。适合已经画过上下文图、但对着那堆连线不知道该标什么的新手,也适合做过一两个 DDD 项目、想回头把映射关系重新梳理一遍的老手。文中所有案例都是脱敏后的虚构代称,你按自己的业务替换就行。
1. 先说清楚:上下文映射到底在解决什么问题
1.1 一张图里最贵的不是框,是线
画限界上下文的时候,大家通常很兴奋,因为划分边界有一种"整理房间"的快感。但真实成本几乎全部发生在边界上。两个上下文一旦需要交换信息,就同时产生了三种耦合:模型耦合(同一个词在两边含义不同)、技术耦合(调用协议、数据格式、发布节奏)、组织耦合(哪个团队有决定权、出问题谁背)。
这三种耦合里,最容易被忽略的是第三种。我见过一个系统,订单上下文和履约上下文在代码上只用了一个 HTTP 接口,技术耦合极低,但两个团队为了"订单状态到底有几个"这个字段吵了三个月,最后靠一个副总裁拍板。这在映射模式的语言里,就是客户-供应商关系没被明确承认,双方都以为自己有定价权。
所以映射模式的第一价值不是技术选型,而是把隐性的权力关系显性化。你在图上写下"客户-供应商",就等于承认了一件事:下游有需求,上游有决定权,双方需要定期谈判。这句话写出来,很多扯皮当场就少一半。
1.2 九种模式的本质是协作关系的编码
我习惯把这九种模式按"耦合强度"和"沟通成本"两个维度去理解,而不是死记定义。左侧是强协作,右侧是彻底断开,中间是一条渐变的带子。
强协作一端是伙伴关系和共享内核,两个上下文必须一起变、一起发版,失败的代价共同承担。中间地带是客户-供应商和遵奉者,一方有话语权,另一方接受或谈判。再往右是防腐层,下游主动隔离,把自己的模型保护起来。最右端是各行其道,两边干脆不通信,各做各的。
另外三种——开放主机服务、发布语言、大泥球——严格说不是同一个维度的分类:前两者是"对外暴露的方式",大泥球是"现状描述"。这也是很多人第一次读 DDD 会困惑的地方:为什么一个描述坏味道的词会跟其他模式并列。答案很简单,映射图首先是现状图,不是理想图。承认某块是个大泥球,比假装它是客户-供应商要诚实得多。
1.3 九种模式速查表
下面这张表是我自己项目里用的版本,比教材多加了"典型误用"一列,因为踩过的坑比正确用法更有记忆点。
| 模式 | 关系方向 | 耦合强度 | 沟通成本 | 适用场景 | 典型误用 |
|---|---|---|---|---|---|
| 伙伴关系 | 双向对等 | 极高 | 极高 | 两块业务必须同步演进,团队在同一层楼 | 把普通上下游硬说成伙伴,导致发版永远等对方 |
| 共享内核 | 双向对等 | 高 | 高 | 一小块模型确实两边都需要,且变化不频繁 | 内核越滚越大,最后变成公共大泥球 |
| 客户-供应商 | 上游→下游 | 中 | 中 | 上游有能力响应下游需求,双方有排期机制 | 只挂名不谈判,下游需求永远排在末尾 |
| 遵奉者 | 上游→下游 | 中 | 低 | 上游是外部系统或强势团队,改造不动 | 该谈的时候放弃谈判,长期被别人的模型污染 |
| 防腐层 | 上游→下游 | 低 | 低 | 上游模型质量差或语义差异大,必须隔离 | 只做协议转换不做语义翻译,最后成了薄薄一层包装 |
| 开放主机服务 | 一对多 | 中 | 中 | 一个上下文要服务多个下游 | 接口无版本策略,一次改动全线崩 |
| 发布语言 | 一对多 | 中 | 中 | 多方共享一套数据契约 | 契约没有治理,字段只增不减,两年后没人敢删 |
| 各行其道 | 无 | 无 | 无 | 重复功能成本低于集成成本 | 该集成的硬拆,导致数据两套、对账永远不平 |
| 大泥球 | 现状描述 | 极高 | 极高 | 遗留系统,短期无法重构 | 把大泥球当终点,不做围栏,任其扩散 |
这张表我会建议你在评审时直接摊开,让每个画出来的连线都对号入座。对不上号的线,通常说明你还没想清楚这段关系。
2. 对等关系:伙伴关系与共享内核
2.1 伙伴关系:一起失败,也一起成功
伙伴关系是最重的一种映射。它的定义里有个关键条件经常被漏掉:两个上下文如果有一方失败,另一方在业务上也会失败。注意是业务上失败,不是技术上失败。接口挂了导致对方超时,那叫技术依赖,不叫伙伴关系。
我碰到过一个典型的真实伙伴关系:促销活动和优惠券核销。活动配置改了,券的可用范围必须同步改;券的核销规则变了,活动的成本预估模型也要跟着改。两边团队每周有固定的对齐会,发版窗口绑定在一起,一方延期另一方主动等。这才叫伙伴。
判断是不是伙伴关系,我给三个自检问题:
- 两边能不能独立发版?如果一方的发版必须等另一方,基本是伙伴。
- 出线上问题时,责任能不能干净切开?如果每次都要两边一起排查,基本是伙伴。
- 业务侧的负责人是不是同一个人?如果是,那大概率会被迫成为伙伴。
伙伴关系的代价非常直接:发版频率被拉到最慢的那一方。我见过一个团队为了维持伙伴关系,硬生生把一天三次发版降到一周一次,最后业务方受不了,主动拆成了客户-供应商。所以我的经验是,能不选伙伴就不选,选了就要有明确的退出条件——比如"这块模型稳定后,下游转为防腐层隔离"。
注意:伙伴关系必须写进团队的工作协议里,包括发版节奏、变更通知提前量、联合回归的责任划分。只写在架构文档里、不落到流程上的伙伴关系,三个月内一定退化成互相甩锅。
2.2 共享内核:共管一块地,代价是开会
共享内核是两边共同拥有的一小块模型,加上对应的代码。经典定义里有一句很重要的话:共享内核应该小。多大的小?我的经验值是,共享内核的代码量不超过任一使用方代码量的 10%,参与的上下文不超过三个。超过这个数,它就开始具备"公共大泥球"的全部特征。
共享内核里放什么,是个技术活。我通常只放三类东西:
- 稳定的值对象:金额、数量、单位、时间区间这类,语义明确、几乎不变。
- 通用标识符:比如全局统一的业务单号规则。
- 极少量的领域事件契约:注意是契约,不是实现。
不放什么同样重要。业务流程、状态机、校验规则、聚合根,一律不进共享内核。我见过有人把整个领域模型塞进共享内核,理由是"大家都需要用",结果半年后没人敢动里面任何一行代码,因为不知道会影响到谁。
2.3 共享内核的落地边界与代码组织
工程上,共享内核必须是独立构件,而不是某个上下文里的一个包被别人引用。这个区别很关键:独立构件有版本号、有发布流程、有变更记录;被引用的包只有"谁最后提交谁说了算"。
一个典型的结构长这样:
shared-kernel/ ├── pom.xml (或 build.gradle,独立版本号) ├── src/main/java/.../money/ │ ├── Money.java │ ├── Currency.java │ └── RoundingRule.java ├── src/main/java/.../identity/ │ └── BusinessNo.java └── CHANGELOG.md (每次变更必须记录影响面)配套的治理规则我建议至少定三条:
- 变更必须双人评审,两个使用方的代表都要签字。
- 版本语义化,破坏性变更走大版本,且提前一个迭代通知。
- 季度瘦身,每季度检查一次内核里的类,连续两个季度没有被任何一方使用的,直接删。
第三条最容易被跳过,但它恰恰是防止内核膨胀的唯一有效手段。我现在的做法是在 CHANGELOG 里给每个类标上"最后使用时间",审查时按时间倒序看,没人用的就提议删除。
实操心得:共享内核真正的成本不在写代码,在于协调。如果两个团队不在同一个时区、或者没有固定的联合评审机制,宁可各自复制一份值对象,也别搞共享内核。复制的维护成本是可预期的,协调成本是不可预期的。
3. 上下游不对称:客户-供应商与遵奉者
3.1 客户-供应商:能谈判的关系
客户-供应商是现实项目里出现频率最高的一种映射。核心特征只有一条:下游有需求,上游有义务评估并排期。注意是评估并排期,不是必须满足。这条界线很重要,很多团队把客户-供应商理解成"下游提什么上游做什么",那叫外包,不叫客户-供应商。
落地时我会要求两件事写进协议:
第一,需求入口唯一。下游不能直接找上游的某个开发提需求,必须走统一的需求池。否则谁的嗓门大谁的需求先做,最后变成人治。
第二,拒绝要有理由和替代方案。上游可以说不做,但必须说明为什么、以及下游可以怎么绕。这条看起来软,实际非常管用,它把"我不做"变成了"我们讨论一下怎么做"。
技术层面,客户-供应商通常配合两种实现:同步接口调用,或者异步事件订阅。我的判断标准是下游对上游的变更是否必须实时感知。如果要实时,走同步;如果允许延迟(哪怕只有几秒),优先异步。异步的好处是上游不需要为下游的可用性负责,这在组织上省掉大量扯皮。
3.2 遵奉者:放弃谈判,换取成本
遵奉者的意思很直白:下游完全接受上游的模型,不做翻译,不提需求,上游怎么定义下游怎么用。听起来很憋屈,但它在两种场景下是理性选择。
第一种,上游是外部系统,你根本没有谈判权。比如物流轨迹、支付结果这类由外部提供的数据,你能做的就是照单全收。这种时候强行做防腐层,成本可能高于收益——如果对方的模型稳定、语义清晰、变更极少,那遵奉反而是最经济的。
第二种,上游是内部强势团队,且模型质量还不错。虽然听着不爽,但比起自己另起一套模型再做映射,直接沿用常常更省事。我做过一个项目,财务侧的科目模型非常成熟,业务侧原本想自建一套,评估后发现自建要处理三十多个维度,最后选择遵奉,省了将近两个月。
遵奉者的风险在于被动污染。上游的模型里如果混进了它自己的业务概念,你照单全收就等于把这些概念引进自己的上下文。判断标准是:上游的模型里,有没有跟你的业务无关的概念。如果有,遵奉就是危险的,应该升级成防腐层。
3.3 什么时候该从遵奉者升级成客户-供应商
我给一个相对具体的触发条件清单,满足任意两条就该考虑升级:
| 触发信号 | 说明 |
|---|---|
| 上游模型里出现无关概念 | 说明语义已经开始错位 |
| 下游为适配上游写了超过 3 个转换方法 | 转换逻辑在积累,早晚失控 |
| 上游变更频率超过每两周一次 | 遵奉的维护成本会快速上升 |
| 下游业务开始出现"因为上游不支持所以做不了" | 说明业务被技术绑住了 |
| 上游开始要求下游配合改造 | 关系已经不对称到需要重新定义 |
升级的路径不是一步到位,通常是:先加一层轻量防腐层,把转换逻辑收拢;再建立需求沟通渠道,转成客户-供应商;等下游模型足够成熟,再考虑彻底隔离。这个渐进过程比一次性重构稳得多。
4. 防腐层:最常被挂在嘴上、最少被真正实现
4.1 防腐层到底防的是什么
防腐层的名字容易误导,很多人以为它是防技术故障的,其实它防的是语义污染。上游把"订单状态"分成七种,你的上下文只需要三种,如果你直接把七种状态引进自己的模型,那你的领域模型就开始为别人的业务复杂度买单。防腐层的作用就是在边界上完成这次裁剪和翻译。
我判断要不要加防腐层,用两个问题:
- 上游模型里,有多少概念是我这边根本不该知道的?
- 上游模型变更时,我这边需要改几个文件?
第一个问题的答案超过两个概念,或者第二个问题的答案超过三个文件,就该加。反之,如果上游模型和你的模型高度重合、变更极少,加防腐层纯属给自己找活干。
4.2 三层结构:适配器、翻译器、门面
一个完整的防腐层,我通常拆成三层,职责严格分开:
适配器层负责通信,处理协议、序列化、超时、重试、熔断。这一层不允许出现任何业务判断。
翻译器层负责语义映射,把上游的数据结构转成本地的领域对象。这是防腐层真正的心脏,也是最容易被写歪的地方。
门面层对外提供本地语义的接口,让本地代码感觉不到外部系统的存在。本地上下文只依赖门面接口,不依赖任何上游的类。
三层分开的好处是,上游换协议时只改适配器,上游改语义时只改翻译器,本地业务逻辑完全不受影响。我见过太多项目把三层揉成一个类,五百行代码里混着 HTTP 调用、字段映射和业务判断,改一次心惊一次。
4.3 代码骨架与关键参数
下面是一个翻译器的骨架,用 Java 示意,重点是几个容易漏的处理点:
public class ExternalOrderTranslator { // 1. 枚举映射:必须有兜底分支 public OrderStatus toLocalStatus(String externalStatus) { if (externalStatus == null) { return OrderStatus.UNKNOWN; } switch (externalStatus.trim().toUpperCase()) { case "CREATED": case "SUBMITTED": return OrderStatus.PENDING; case "PAID": case "SETTLED": return OrderStatus.CONFIRMED; case "CANCELED": case "CLOSED": return OrderStatus.TERMINATED; default: // 关键:未知状态不能抛异常中断主流程 log.warn("unknown external status: {}", externalStatus); return OrderStatus.UNKNOWN; } } // 2. 金额换算:单位、精度、舍入规则都要显式写出 public Money toLocalAmount(long amountInCents, String currencyCode) { Currency currency = Currency.ofNullable(currencyCode) .orElseThrow(() -> new TranslationException("unsupported currency: " + currencyCode)); // 上游用分,本地用元,保留两位,银行家舍入 BigDecimal value = BigDecimal.valueOf(amountInCents) .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_EVEN); return Money.of(value, currency); } // 3. 时间换算:明确时区,禁止依赖服务器默认时区 public Instant toLocalTime(String externalTime) { return OffsetDateTime .parse(externalTime, DateTimeFormatter.ISO_OFFSET_DATE_TIME) .toInstant(); } }这段代码里有三个点值得单独说。
枚举兜底不能省。上游新增一个状态值时,如果你直接抛异常,整个主流程会挂掉。返回 UNKNOWN 加上告警,让业务先跑通,再回头补映射,这是我在生产环境里反复验证过的更稳做法。
金额换算必须显式写精度和舍入规则。默认的 HALF_UP 在财务场景下会出问题,很多团队是在对账差一分钱的时候才发现。上游用分、本地用元这种单位差异,是防腐层里最常见也最隐蔽的 bug 来源。
时间必须带时区。依赖服务器默认时区,在跨机房部署时会出现八小时偏差,而且只在部分机器上出现,排查起来极其痛苦。
4.4 成本控制:别让防腐层变成第二个系统
防腐层最大的风险是长成一个小型系统。我见过一个防腐层,代码量比它保护的本地上下文还大,里面有自己的缓存、自己的重试队列、自己的配置中心。这时候它已经不是防腐层了,是一个新的限界上下文。
控制成本有几个具体手段:
缓存要有明确的上限和 TTL。我一般按"上游数据的变更频率"来定 TTL,而不是按调用量。变更频率一天一次的数据,TTL 设十分钟都算保守;变更频率随时可能变的数据,压根不该缓存。粗略估算是:日调用量乘以缓存命中率乘以单次调用耗时,算出省下的时间,再对比数据不一致带来的业务风险。如果风险不可接受,就别缓存。
重试必须有次数上限和退避。三次、指数退避、带抖动,这是我认为的合理默认值。无限重试在上下游同时抖动时会造成雪崩。
翻译逻辑要有单元测试。这是唯一能防止翻译器腐化的手段。我通常要求每个映射方法至少覆盖:正常值、空值、未知值、边界值(比如金额为零、时间为空)。
注意:防腐层不应该吞掉所有异常。网络超时、上游返回业务错误、数据格式非法,这三种情况本地应该有完全不同的处理策略。全裹成一个大异常往上抛,等于把上游的问题原样转嫁给本地业务。
5. 对外契约:开放主机服务与发布语言
5.1 开放主机服务:一套接口服务多方
开放主机服务的核心是收敛。一个上下文如果要被三个以上的下游使用,就不该为每个下游单独定制接口。所有下游共用一套公开的协议,上游只需维护这一套。
这套做法的收益很直接:下游从三个变成十个,上游的维护成本基本不变。代价是这套公开协议必须具备足够的通用性,不能迁就某一个下游的特殊需求。特殊需求怎么处理?我的做法是让下游自己做适配,上游只提供基础能力。这条界线如果守不住,公开协议会被慢慢侵蚀成一个万能接口。
判断要不要做开放主机服务,看下游数量。一个下游,直接点对点更简单;两个下游,可以开始考虑;三个以上,基本必须做。中间地带要看下游的稳定性——如果下游都是短期项目,那也没必要投入。
5.2 发布语言:多方共享的数据契约
发布语言是开放主机服务的天然搭档。有了统一接口,就必须有统一的数据格式和语义定义。它不是简单的 JSON 结构,而是一套有文档、有版本、有治理的契约。
我倾向用 IDL 而不是手写 JSON 来定义发布语言,原因是 IDL 能强制约束字段类型和必选性,还能自动生成多方客户端代码。一个示意的契约:
syntax = "proto3"; package fulfillment.contract.v2; // 发布语言:履约状态对外契约 message FulfillmentSnapshot { string fulfillment_no = 1; // 全局业务单号,唯一 string status = 2; // 取值受枚举约束 int64 amount_in_cents = 3; // 统一用分,避免浮点 string currency = 4; // ISO 4217 三字母代码 string updated_at = 5; // RFC 3339,必须带时区 repeated FulfillmentLine lines = 6; } enum FulfillmentStatus { FULFILLMENT_STATUS_UNSPECIFIED = 0; FULFILLMENT_STATUS_CREATED = 1; FULFILLMENT_STATUS_SHIPPED = 2; FULFILLMENT_STATUS_COMPLETED = 3; FULFILLMENT_STATUS_CANCELED = 4; }几个细节值得强调。枚举第一个值必须是 UNSPECIFIED 且等于 0,这是 protobuf 的硬要求,也是处理"字段缺失"的标准姿势。金额用整数分而不是浮点,这条几乎没有例外。时间必须写明格式和时区要求,光写"时间戳"三个字,下游能给你三种不同实现。
5.3 契约治理:比写契约更难的部分
发布语言真正的难点在治理。我总结了几条硬规则:
字段只增不删,废弃走标记。想删字段,先标deprecated = true,观察使用情况,至少两个大版本后再真删。我见过有人直接删字段,导致一个下游服务凌晨报警。
语义变更等同于破坏性变更。字段名不变、类型不变,但含义从"下单时间"变成"支付时间",这种改动比删字段更危险,因为它不会报错,只会让下游静默算错。
每个字段必须有负责人。这条听起来重,但非常有用。字段出问题时能立刻找到人,比在群里 @ 全体成员高效得多。
定期做使用情况盘点。半年一次,把所有字段的使用方列出来,没人用的标记废弃。不做这件事,契约会在三年内膨胀到没人敢碰。
5.4 版本演进策略
版本号怎么升,是个经常吵架的话题。我的实践是:同一大版本内保持向后兼容,破坏性变更开新大版本,且新旧并行至少一个季度。
并行期内,上游要同时维护两套接口,成本不低。所以破坏性变更的频率必须控制,理想情况下一到两年一次。如果发现半年就要升一次大版本,说明前期的契约设计有问题,该回头审视字段规划了。
6. 断开与共存:各行其道与大泥球
6.1 各行其道:主动选择不集成
各行其道经常被当成"偷懒"的同义词,其实它是一种正经的架构决策。核心判断是:集成的成本高于重复实现的成本。
举个具体场景。两个上下文都需要"地址解析"能力——把一段文本解析成省市区街道。如果做成共享服务,要考虑接口设计、可用性保障、版本管理、故障隔离,加起来可能是两个人月。而各自接一个现成的解析库,各半天。这种情况下,各行其道是绝对正确的选择。
但各行其道有个必须接受的后果:数据会有两份,且可能不一致。所以选择这个模式前,要先回答一个问题:这两份数据不一致,业务上能不能接受?地址解析结果不一致,用户看到的收货地址可能略有差异,通常能接受;账户余额不一致,绝对不能接受。
我的经验是,各行其道适合三类东西:工具型能力(解析、格式化、校验)、低频配置数据、以及对一致性要求不高的展示型数据。涉及资金、库存、权益的,一律不要各行其道。
6.2 大泥球:承认现状,然后围栏
大泥球在九种模式里地位特殊,它不是你应该追求的目标,而是你必须承认的现状。几乎每个有点年头的系统里都有一块这样的地方:模型混乱、边界模糊、谁都不敢动。
把它标成泥球,价值在于两点。第一,它让团队停止自欺欺人——不再假装那块是"客户-供应商",不再为它画漂亮的边界。第二,它明确了策略:不是重构它,而是围住它。
围栏的具体做法是:所有对外交互都通过一层薄薄的适配器,适配器里不做业务判断,只做协议转换。新功能一律不在泥球里加,能绕就绕。这样泥球会慢慢停止生长,等业务压力小了再考虑拆分。
我做过一个项目,泥球那块代码有八年历史,没人敢动。最后我们的策略就是完全不碰,新需求全部在外面做,需要泥球的数据就走只读接口。两年下来,泥球的代码一行没改,但业务照样迭代,这就是围栏的价值。
6.3 从泥球里往外爬的顺序
如果确实要拆,顺序很重要。我的建议是先拆读,再拆写;先拆边缘,再拆核心。
先拆读,是因为读操作通常无副作用,拆错了影响可控。先拆边缘,是因为边缘模块和核心的耦合最少,容易切干净。反过来先动核心写操作,风险极高,一旦出问题就是数据层面的问题。
具体步骤我一般这么排:
- 梳理泥球里的数据表,按业务概念分组,找出耦合最松的一组。
- 为这组数据建独立的读模型,双写或同步,先保证数据可见。
- 把相关读接口切到新模型,观察一段时间,确认数据一致。
- 再把写操作逐步迁移,每次只迁一个聚合。
- 老代码标记废弃但不删,保留回滚能力至少一个季度。
这个过程会很慢,一个中等规模的泥球拆完通常要半年到一年。急不得,急了一定出事。
7. 实操:把九种模式落到画布、代码和排期上
7.1 画映射图的五个步骤
我把画映射图拆成固定动作,避免每次评审都从零开始:
第一步,先画现状,不画理想。团队容易在图上画"应该是什么样",结果图很漂亮,跟代码对不上。我的要求是,每条线都要能指出对应的代码位置——哪个接口、哪个 MQ topic、哪个共享包。
第二步,标方向。每条线必须有箭头。没有方向的线说明关系还没想清楚。方向标完,很多问题会自动浮现:比如两个箭头互相指,那大概率是伙伴关系或者双向依赖。
第三步,标模式。对每条线打上九种模式之一。打不上的,先标"待定",单独列出来讨论。
第四步,标团队。每个上下文写上负责团队。如果两个上下文是同一个团队负责,很多协作成本其实不存在,模式可以选得更激进。
第五步,标变化频率。每个上下文标注最近半年的平均变更频率。变化快的和变化慢的之间,通常不适合做共享内核。
这五步走完,图上的信息密度会高很多,评审时讨论的焦点也会从"边界画得对不对"转移到"关系定义得合不合理",这是更有价值的讨论。
7.2 模式和团队组织要对得上
有个规律我观察了很多次:映射模式和团队组织不匹配时,模式一定会退化成更松的那一种。
比如你设计了一个共享内核,但两个团队分属不同事业部、考核指标不同、连周会都不在一起开,那这个共享内核撑不过三个月,最后会变成两份各自维护的复制品。这不是人的问题,是结构的问题。
反过来,如果两个上下文本来就是同一个团队维护,你非要搞一个重防腐层,那也是浪费——同一个人写的代码,语义不会差到哪里去,直接调用反而更简单。
所以我在做映射设计时,会先把团队结构画出来,再画上下文,最后才定模式。顺序反了,方案再漂亮也落不了地。
7.3 一个完整的落地案例
虚构一个场景说明。某交易类系统有三个上下文:下单、履约、结算。现状是下单直接调用履约和结算的接口,字段互相引用,改一处要动三处。
诊断阶段,我们发现三个问题:履约的模型里混入了下单的概念(比如"下单渠道"这个字段),结算的接口被下单和履约同时使用但两家需求不同,下单对履约的调用没有超时控制。
定模式。下单和履约:业务上强相关,履约失败下单就白做,判定为伙伴关系,但设定退出条件——履约模型稳定后转为客户-供应商。下单和结算:结算需要下单的数据,但下单不需要结算的实时反馈,判定为客户-供应商 + 防腐层,下单作为上游提供开放主机服务,结算侧加防腐层做语义转换。履约和结算:都从下单取数据,各自独立,判定为各行其道,通过订阅同一份领域事件解耦。
落地顺序。先做最痛的:给下单对履约的调用加超时和熔断,这一步只改配置,一天完成。再做收益最大的:把结算侧的转换逻辑收拢成防腐层,两周完成,之后结算侧再改字段不用动下单。最后做最难的:把下单的对外接口整理成开放主机服务加发布语言,一个月完成,配套契约定版。
结果。三个月后,下单的对外依赖从 11 个接口收敛到 1 套契约加 3 个事件,变更影响面从平均 4 个服务降到 1.5 个。这个数字是我们实际统计的,方法很简单:每次改动记录涉及的服务数,取平均。
7.4 演进节奏怎么定
映射关系不是一次定终身。我建议每季度做一次映射复盘,重点看三个指标:
- 跨上下文变更次数:一条线上的变更越频繁,说明耦合越重,可能该升级模式。
- 翻译层代码增长量:防腐层的翻译代码持续增长,说明上游模型在漂移,该重新谈判。
- 契约废弃字段数:发布语言里标记废弃但迟迟不删的字段,说明治理没跟上。
这三个指标不需要复杂工具,从代码仓库和接口文档里就能统计出来。关键是坚持做,做完要有结论——是保持、升级还是降级。
8. 常见问题与排查实录
8.1 常见误用速查表
| 现象 | 大概率原因 | 处理方向 |
|---|---|---|
| 两个上下文改一处要动三处 | 该做防腐层没做,语义直接穿透 | 先收拢转换逻辑,再评估是否升级关系 |
| 发版永远要等另一个团队 | 伙伴关系被滥用 | 设定退出条件,或者拆掉共享部分 |
| 共享内核越来越大,没人敢改 | 缺少瘦身机制和双人评审 | 建立季度清理和变更影响面记录 |
| 下游抱怨上游不响应需求 | 客户-供应商只挂名没机制 | 建需求池,明确评估和拒绝流程 |
| 接口字段只增不减,文档没人看 | 发布语言缺治理 | 引入废弃标记和使用情况盘点 |
| 上下游同时抖动导致雪崩 | 重试和超时配置缺失 | 限次数、指数退避、加抖动、加熔断 |
| 两个上下文数据对不上 | 各行其道用在了强一致场景 | 重新评估一致性要求,改为单一数据源 |
| 新功能不敢加,怕影响老逻辑 | 大泥球没围栏 | 外围新建,泥球只读,停止往里加逻辑 |
8.2 几个具体的排查过程
问题一:翻译层偶发数据错乱。
现象是结算侧偶尔出现金额差一分钱。排查发现两个原因叠加:上游用分、本地用元,换算时用了默认的 HALF_UP;另外部分历史数据上游传的是浮点字符串,解析时精度丢失。解决办法是换算统一用 BigDecimal 并显式指定 HALF_EVEN,同时对历史数据做一次性修正脚本。这类问题靠日志很难发现,最后是拿上下游的对账文件逐条比对才定位到的。
问题二:契约升级导致下游静默失效。
上游在发布语言里把某个字段的含义从"创建时间"改成了"更新时间",字段名和类型都没变。下游依赖这个字段做超时判断,结果所有订单都被判定为刚创建,超时逻辑完全失效。这个问题三天后才被发现。教训是语义变更必须走破坏性变更流程,同时下游对关键字段要做合理性校验。
问题三:防腐层缓存导致数据滞后。
给上游数据加了十分钟缓存,平时没问题,大促时上游状态更新密集,下游看到的全是十分钟前的状态,导致重复发货预警。后来改成按状态类型分别设 TTL:终态数据缓存一小时,中间态不缓存。这个调整很有效,缓存命中率从 78% 降到 61%,但线上问题归零,这个交换完全值得。
8.3 几条踩坑心得
别在项目初期就追求模式完备。我见过团队在第一个迭代就画了九种模式,结果三个月后一半的线都变了。映射关系是演进的产物,初期只需要标出最痛的几条,剩下的跑起来再说。
模式名称要写进代码注释和文档标题。这不只是形式主义。当你在接口文档里写下"本接口属于开放主机服务,破坏性变更走 v3",下游的重视程度会明显不同。名字本身就是一种约束。
跨团队的关系,一定要有书面协议。哪怕只有半页纸,写清楚发版节奏、变更通知提前量、故障响应时限。口头约定在人员变动后等于不存在。
定期检查那条最粗的线。一个系统里通常有一条依赖最重、影响最大的关系,它决定了整个系统的演进速度。我习惯每个季度专门花半天看这条线,问三个问题:它是不是还必要?能不能降级?降级要付什么代价?
防腐层不是越厚越好。有些团队一听防腐层就往里堆缓存、队列、限流、监控,最后它比本地系统还复杂。我的原则是只做必要的翻译和隔离,其他能力复用现有的基础设施。
上游不变更不代表关系稳定。有些客户-供应商看起来很平静,其实是下游已经放弃了提需求,自己在下面做各种绕过。这种"沉默的下游"是最危险的信号,通常在你改动上游时会集中爆发。定期主动问一句"最近有没有需求被卡住",能提前发现问题。
图上没有线的地方,也要看。两个上下文之间完全没有交互,可能是各行其道,也可能是遗漏。我习惯对每一对上下文都问一遍"它们真的不需要交换任何信息吗",答案有时候会让人意外。