Featured image of post 服务拆分:康威定律、DDD 领域建模与生产落地指南

服务拆分:康威定律、DDD 领域建模与生产落地指南

服务拆分:康威定律、DDD 领域建模与生产落地指南

Java Web 微服务系列 · 第 5 篇 · 服务拆分 阅读时长:约 70 分钟 本文写于 2026 年 6 月 配套版本:Spring Cloud 2022.0.x / Spring Boot 3.x / Dubbo 3.2.x 前置阅读:《技术选型:为什么最终选了 Spring Cloud Alibaba + Dubbo 3》(系列第 4 篇) 后续衔接:《Nacos:Java 微服务的服务中心与配置中心怎么选、怎么用》(系列第 6 篇)

引子:38 个微服务大泥球的那一年

2021 年初我接手一个团队的微服务架构,上一任架构师留下的"遗产"是这样的:8 个业务域、38 个 Spring Boot 服务、2 个 PHP 老系统、3 个 Python 脚本、1 个没人敢动的 jQuery 前端。所有服务都通过 Eureka 注册,看起来"很微服务",但剖开看内核是一场灾难:

  • 38 个服务共享同一个 MySQL 集群(16 个分库),改一个字段要同步 12 张表
  • 几乎所有"业务服务"都依赖一个 9000 行的 user-core 服务,这个服务被 27 个上游调用,改一个方法签名要发 27 个版本
  • order-servicepay-serviceinventory-service 三个服务循环依赖:orderpay 算价格、payorder 查订单状态、inventorypay 锁库存、pay 又反过来调 inventory 查库存数
  • 4 个团队抢一个共享代码仓库 common-utils,每个团队都在里面塞自己的工具类,1 万 2 千行代码,谁都不敢删
  • 一次普通的需求评审,需要协调5 个团队、2 周排期才能上线,因为每个服务都有自己的"上线窗口"

更让我崩溃的是 CEO 在季度会上问的问题:"我们已经是微服务了,为什么改个东西比 3 年前的单体还慢?"

我花了 3 个月调研、6 个月重构,才让这个系统从"38 个微服务大泥球"变成"12 个真正自治的服务"。这篇文章把那段经历的方法论 + 工具箱 + 实战案例 + 踩坑教训完整写出来。

本文不是讲 Spring Cloud 怎么配置(那是第 4 篇技术选型),也不是讲 Nacos 怎么部署(那是第 6 篇)。本文讲的是"为什么拆、怎么拆、拆错了怎么救"——也就是"先有边界,再有服务"的整套思维框架。

核心三块:

  1. 康威定律 + Team Topologies:为什么"组织"决定了"架构",以及怎么"反向"利用它
  2. DDD 领域驱动设计:怎么用战术工具(实体/值对象/聚合根/限界上下文)识别真正的业务边界
  3. 5 条工业级拆分原则 + 6 个生产案例 + 10 个踩坑清单:把方法论落到代码、数据库、团队三个层面

一、拆分之前先想清楚:业务边界 = 团队边界 = 康威定律

“Any organization that designs a system… will inevitably produce a design whose structure is a copy of the organization’s communication structure.” —— Melvin Conway, 1967

1.1 康威定律的原始表述

1967 年,计算机科学家 Melvin Conway 在《How Do Committees Invent?》一文中提出一个观察:系统的设计结构,必然反映设计该系统的组织沟通结构。也就是说,如果你的团队有 4 个后端、2 个前端、1 个 DBA,那产出的系统大概率是"前端-后端-DBA 三层架构",而不是按业务域划分的微服务。

康威定律不是技术规律,是社会学规律——它描述的是"人和人怎么沟通"决定了"代码怎么组织"。一个团队 8 个人都在同一个 Slack 频道,他们写出来的代码大概率会在同一个代码仓库、用同一种技术栈、按同一种部署节奏发布。

1.2 推论:4 种系统设计映射组织结构

康威定律有 4 个广为流传的推论(Jim McCarthy):

  1. Communication dictates design:组织沟通结构决定系统设计
  2. Time and space:时间(同步/异步) + 空间(同地/异地) 影响协作效率
  3. There’s never enough time to do it right, but always enough time to do it over:没时间做对,但有时间重做
  4. The design that succeeds is the one that’s actually built:最终交付的设计才是设计

第 1 条是最致命的:你写的不是代码,是你团队沟通结构的镜像。一个 50 人团队按"前端组/后端组/DBA 组/测试组/运维组"分,不管他们多努力按业务域拆微服务,最后产出的还是"前端-后端"双层架构——因为沟通壁垒在团队结构里。

1.3 Inverse Conway Maneuver:先调组织,再调架构

既然康威定律说"组织决定架构",那反过来:先调整组织结构,再让架构跟着变。这就是 Jonny LeRoy 在 2009 年提出的 Inverse Conway Maneuver(反向康威)。

实际操作就 3 步:

  1. 画组织结构图:把当前团队的人员、汇报关系、Slack 频道、代码仓库权限全画出来
  2. 画系统架构图:把当前服务的依赖关系、数据库归属、部署关系全画出来
  3. 对比两张图,看哪里不一致:
    • 团队结构和系统结构一致 → 康威定律在生效,这是好事
    • 团队结构和系统结构不一致 → 架构师在跟康威定律对着干,这往往就是性能瓶颈的根因

我那次接手 38 个微服务大泥球时,画的组织结构图是这样的:

  • 团队 A(订单业务):6 人,负责 order / pay / inventory 三个服务
  • 团队 B(用户业务):4 人,负责 user / auth / address 三个服务
  • 团队 C(运营业务):5 人,负责 marketing / coupon / points 三个服务
  • 团队 D(基础平台):7 人,负责 gateway / config / monitor 等基础设施

但实际系统调用关系是:订单业务的服务有 30% 的调用跑到用户业务,15% 跑到基础平台,5% 跑到运营业务。团队边界和系统边界完全错位——这就是为什么一次改动要协调 5 个团队。

解决思路不是"让架构师硬性约束跨团队调用"(那只是治标),而是让团队边界和系统边界对齐。具体怎么对齐?这就是 1.4 要讲的 Team Topologies。

1.4 Team Topologies:4 种团队类型 + 3 种交互模式

2019 年,Matthew Skelton 和 Manuel Pais 出版了《Team Topologies》,把康威定律落地成可操作的团队设计模式。核心是把团队分成 4 种类型,互相之间用 3 种交互模式协作:

4 种团队类型:

类型职责典型人数例子
Stream-aligned team端到端负责一条业务流(从用户到数据库)5-9订单业务团队、用户中心团队
Enabling team帮助 Stream-aligned 团队提升能力3-5安全赋能团队、可观测性赋能团队
Complicated-subsystem team负责需要深度专业知识的复杂子系统3-7风控算法团队、推荐引擎团队
Platform team提供内部平台(自助服务)给 Stream-aligned 团队用5-12K8s 平台团队、CI/CD 平台团队

3 种交互模式:

模式含义适用场景
Collaboration紧密合作,共担责任短期攻坚、新系统搭建初期
X-as-a-Service一个团队消费另一个团队的服务,弱依赖稳定子系统、跨域调用
Facilitating一个团队帮另一个团队"扫除障碍"能力建设、技术辅导

关键启示:

  • 80% 的团队应该是 Stream-aligned(对应"业务域")
  • Platform team 不是万能的——平台做得太重会变成新的"巨石服务"
  • Complicated-subsystem team 的边界要稳,不能随便拆——风控算法不会因为组织调整而"换语言"

1.5 生产案例:某物流公司从 8 个职能团队 → 4 个业务团队

我 2022 年辅导过的一家物流公司,典型康威定律反例:

原组织(8 个职能团队):

  • Java 组 8 人、DBA 组 3 人、前端组 6 人、测试组 4 人、运维组 5 人、产品组 7 人、UI 组 3 人、数据组 4 人
  • 系统架构:“前端-后端-DBA"三层单体,JSP + Spring MVC + 单库
  • 痛点:改一个"运单状态变更"需求要走 6 个团队、3 周排期

新组织(4 个 Stream-aligned + 2 个支撑):

  • 运单业务团队 7 人(含 1 个后端、1 个前端、1 个测试、1 个产品)—— 端到端负责"运单”
  • 调度业务团队 6 人(同上结构)—— 端到端负责"调度"
  • 司机业务团队 5 人 —— 端到端负责"司机 + 车辆"
  • 财务业务团队 4 人 —— 端到端负责"对账 + 结算"
  • 平台团队 8 人 —— 提供 K8s + 监控 + CI/CD 自助平台
  • 赋能团队 3 人 —— 安全 + 可观测性

结果(6 个月后):

  • 每个 Stream-aligned 团队自己决定技术栈、自己决定发布节奏——运单团队用 Java、调度团队用 Go、司机团队用 Node.js 都可以
  • 部署频率从 1 次/周 → 30 次/天(从 CI/CD 平台拿指标)
  • MTTR(平均恢复时间)从 4 小时 → 15 分钟(因为业务团队对代码全栈负责,排查不需要跨团队)
  • 改一个"运单"需求,从 3 周 → 2 天(一个团队内部闭环)

这个案例的关键不是"用了微服务",而是"先改了组织,后改的架构"。如果只改架构不改组织,38 个微服务大泥球的悲剧会再上演。

1.6 启示:拆分前先问 3 个问题

回到我自己 2021 年的处境,看完 Team Topologies 后,我在拆分前先问了 3 个问题:

  1. 业务边界是什么? —— 我们公司的核心业务流是"下单 → 支付 → 库存 → 物流",沿这条流切业务域
  2. 团队边界应该是什么? —— 按业务域组建 4-5 个 Stream-aligned 团队,而不是按"前端/后端/DBA"
  3. 业务边界 ≠ 团队边界时,谁先动? —— 永远是组织先动。架构师单方面"按业务拆服务"而不调组织,会变成"38 个微服务大泥球"

这一节建立的认知是:服务拆分不是"技术活",是"组织工程"。下一节讲的 DDD,则是"怎么识别业务边界"的方法论工具箱。

二、领域驱动设计:战术工具箱

上一节讲了"为什么拆"(康威定律、组织先行)。这一节讲"按什么拆"——DDD 给的战术工具。 战略设计:识别业务边界(限界上下文) 战术设计:在一个上下文内怎么写代码(实体/值对象/聚合根/领域服务)

Eric Evans 2003 年的《Domain-Driven Design》中文版叫《领域驱动设计》,核心思想:软件的核心是它所服务的领域(domain),技术是手段。DDD 不是一套框架,是一套思维方式 + 一组战术工具,专门解决"复杂业务怎么拆分"这个问题

我把 DDD 工具箱按"先用、后用"分成两层:**战略层(限界上下文)**先识别业务边界,**战术层(实体/值对象/聚合根)**再在一个上下文内写代码。

2.1 战略层:限界上下文(Bounded Context)

限界上下文是 DDD 最重要的概念,没有之一

定义:一个限界上下文 = 一个业务领域 + 一套统一语言 + 一个代码边界。在限界上下文内部,术语含义是确定的(比如"订单"特指电商订单);跨限界上下文时,术语含义可能不同(比如"账户"在用户中心指"用户登录账号",在支付中心指"钱包账户")。

实战:电商系统的限界上下文切分

限界上下文核心语言职责对应微服务
销售上下文订单/购物车/优惠创建订单、计算价格、应用优惠order-service / cart-service / promotion-service
支付上下文交易/退款/钱包收款、退款、对账pay-service / refund-service / wallet-service
库存上下文SKU/库存/预占库存数量管理、预占/释放inventory-service / warehouse-service
物流上下文运单/配送/签收运单创建、配送调度、签收记录logistics-service / dispatch-service
用户上下文用户/地址/等级用户信息、收货地址、会员等级user-service / address-service / member-service
营销上下文优惠券/积分/活动优惠券发放、积分累积、活动配置coupon-service / points-service / campaign-service

关键判断:为什么"订单"和"支付"必须分两个上下文?

因为它们的"核心语言"不同:

  • 订单上下文里"订单" = 一份意图(“我想买 X 商品,共 100 元”)
  • 支付上下文里"交易" = 一笔事实(“我已支付 100 元,支付渠道是支付宝”)

订单和支付通过领域事件(OrderCreated、PaymentCompleted)异步解耦,而不是订单服务直接调支付服务同步收钱。这种"异步 + 事件"的设计,是微服务拆分后避免分布式事务的第一道防线。

2.2 战术层 1:实体(Entity) vs 值对象(Value Object)

实体:有唯一标识(ID)、生命周期可变、状态可变的对象。例:Order(orderId=20240101001)、User(userId=10086)。

值对象:没有唯一标识、不可变、只描述属性的对象。例:Address(地址)由"省/市/区/详细地址"组成,两个完全相同的地址可以互相替换;Money(金额)由"数字 + 货币类型"组成,5 元和 5 元是等价的。

实战对比:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// ❌ 反例:用实体表示地址(过度设计)
@Entity
public class Address {
    @Id
    private Long id;          // 地址有自己的 ID
    private String province;
    private String city;
    // ... getter/setter
}

// ✅ 正例:用值对象表示地址(不可变,按值相等)
@Value
@Builder(toBuilder = true)
public class Address {
    private String province;  // 省
    private String city;      // 市
    private String district;  // 区
    private String detail;    // 详细地址
}
// 值对象的好处:User 修改地址时,直接 setAddress(new Address(...))
// 不用考虑"旧地址对象要不要清空引用"

判断口诀:

  • 有 ID 吗?有 → 实体;没有 → 值对象
  • 可变吗?可变 → 实体;不可变 → 值对象
  • 生命周期独立吗?独立 → 实体;依附于其他对象 → 值对象

2.3 战术层 2:聚合根(Aggregate Root) — DDD 最难也最重要的概念

聚合根 = 一组紧密相关的实体 + 值对象的"管理者",外部访问聚合内部的实体,只能通过聚合根。

经典案例:订单聚合

1
2
3
4
5
6
7
8
Order (聚合根)
├── OrderItem (实体) — 订单项
│   ├── SkuId
│   ├── Quantity
│   └── UnitPrice
├── ShippingAddress (值对象) — 收货地址
├── Buyer (值对象) — 买家信息
└── OrderStatus (枚举) — 订单状态

聚合根的不变式(invariant):

  • 订单必须有 1 个或多个 OrderItem,不能有 0 个
  • 订单的总金额 = 所有 OrderItem 的数量 × 单价 之和(永远一致)
  • 订单创建后,收货地址不能修改(可以修改前 cancel + recreate)
  • 订单状态转换有规则:CREATED → PAID → SHIPPED → COMPLETED,不能跳过

这些规则只在聚合根 Order 内执行,外部服务(支付、物流)不能直接改 OrderItem,只能通过 Order 暴露的方法(order.pay()、order.ship())间接修改。

实战代码(Spring Boot + JPA):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Entity
@Table(name = "t_order")
public class Order {
    @Id
    private String orderId;
    
    // 聚合根内部包含实体和值对象
    @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
    @JoinColumn(name = "order_id")
    private List<OrderItem> items = new ArrayList<>();
    
    @Embedded
    private ShippingAddress shippingAddress;
    
    @Enumerated(EnumType.STRING)
    private OrderStatus status;
    
    // 不变式:通过聚合根的方法保证
    public void addItem(SkuId skuId, Quantity qty, Money unitPrice) {
        if (status != OrderStatus.CREATED) {
            throw new IllegalStateException("只有 CREATED 状态的订单能加商品");
        }
        this.items.add(new OrderItem(skuId, qty, unitPrice));
    }
    
    public Money totalAmount() {
        return items.stream()
            .map(item -> item.getUnitPrice().multiply(item.getQuantity().value()))
            .reduce(Money.ZERO, Money::add);
    }
    
    public void pay() {
        if (status != OrderStatus.CREATED) {
            throw new IllegalStateException("只有 CREATED 状态的订单能支付");
        }
        if (items.isEmpty()) {
            throw new IllegalStateException("空订单不能支付");
        }
        this.status = OrderStatus.PAID;
        // 领域事件:发布 OrderPaid 事件
        registerEvent(new OrderPaidEvent(this.orderId, totalAmount()));
    }
}

聚合根的"事务边界"价值:

一个聚合根 = 一个事务边界。改一个聚合根内的多个实体,在同一个数据库事务里(因为它们物理上在同一张表或同一组表)。跨聚合根的修改,必须用最终一致性(领域事件),不能用分布式事务(2PC/Saga)。

这是拆分微服务后最关键的事务策略——同一个微服务内部的多个表 = 强一致性(本地事务);跨微服务的数据 = 最终一致性(事件 + 对账)。

2.4 战术层 3:领域服务(Domain Service) vs 应用服务(Application Service)

层级职责是否持有状态例子
实体 / 值对象业务逻辑、自身状态变更Order.pay()、User.changePassword()
领域服务跨实体的业务规则、不属于任何实体的逻辑无状态PriceCalculator(算价格,跨 OrderItem + Promotion)
应用服务编排用例、事务边界、外部调用无状态OrderApplicationService.createOrder()
基础设施数据库、消息、缓存、RPCOrderRepository、EventPublisher

实战案例:下单流程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// ✅ 应用服务:编排用例(不写业务规则)
@Service
public class OrderApplicationService {
    @Autowired private OrderRepository orderRepository;
    @Autowired private PriceCalculator priceCalculator;       // 领域服务
    @Autowired private InventoryService inventoryService;    // 外部服务
    @Autowired private EventPublisher eventPublisher;
    
    @Transactional
    public Order createOrder(CreateOrderCommand cmd) {
        // 1. 创建订单(聚合根内部的不变式)
        Order order = new Order(cmd.getBuyer(), cmd.getShippingAddress());
        
        // 2. 调领域服务算价格
        for (OrderItemCommand item : cmd.getItems()) {
            Money unitPrice = priceCalculator.calculate(item.getSkuId(), item.getQuantity());
            order.addItem(item.getSkuId(), item.getQuantity(), unitPrice);
        }
        
        // 3. 预占库存(跨聚合根,走最终一致性)
        inventoryService.reserve(order.getOrderId(), order.getItems());
        
        // 4. 保存
        orderRepository.save(order);
        
        // 5. 发布领域事件
        order.getDomainEvents().forEach(eventPublisher::publish);
        
        return order;
    }
}

判断口诀:

  • 逻辑只涉及一个实体?→ 写在实体
  • 逻辑跨多个实体、但只涉及领域?→ 写在领域服务
  • 逻辑涉及"调用外部系统、事务边界、事件发布"?→ 写在应用服务

2.5 战术层 4:领域事件(Domain Event)

领域事件 = 业务上已经发生的事实。“OrderCreated"是事实,“CreateOrderCommand"是意图(还没发生)。

事件结构:

1
2
3
4
5
6
public class OrderPaidEvent {
    private String orderId;
    private Money amount;
    private Instant paidAt;
    // getter...
}

事件命名:用过去时态(OrderPaidPaymentCompletedInventoryReserved),不要用"动作时态”(CreateOrder 这种是命令不是事件)。

实战消费:支付完成后,触发物流 + 积分 + 通知三个动作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 支付服务发布事件
@EventPublisher
public class PaymentApplicationService {
    public void handlePayment(PaymentCommand cmd) {
        // ... 处理支付 ...
        eventPublisher.publish(new PaymentCompletedEvent(cmd.getOrderId(), cmd.getAmount()));
    }
}

// 物流服务订阅
@EventHandler
public class LogisticsEventHandler {
    public void on(PaymentCompletedEvent event) {
        // 创建运单、调度配送
        shipmentService.createShipment(event.getOrderId());
    }
}

// 积分服务订阅
@EventHandler
public class PointsEventHandler {
    public void on(PaymentCompletedEvent event) {
        // 加积分
        pointsService.addPoints(event.getBuyerId(), event.getAmount());
    }
}

关键设计原则:

  • 事件是不可变的(已经发生的事不能改)
  • 事件订阅是异步的(支付服务不等物流服务完成)
  • 事件要有版本号(v1v2),演化时兼容老版本
  • 事件持久化到消息中间件(Kafka/RocketMQ),消费者崩溃可重放

2.6 上下文映射(Context Map):跨上下文怎么协作

当限界上下文确定后,跨上下文怎么交互?这就是上下文映射要解决的问题。 常见 9 种模式(选最常见的 4 种):

模式含义实战例子
Partnership(伙伴)两个上下文互相依赖、同步演进订单 ↔ 库存(双向紧密配合)
Customer-Supplier(客户-供应商)上游提供服务、下游消费支付服务 → 订单服务(上游)
Anti-Corruption Layer(防腐层)翻译老系统的"烂接口”集成遗留 ERP 时加 ACL
Shared Kernel(共享内核)两个上下文共享一小段代码共享 Money 值对象

ACL(防腐层)实战:对接老 ERP 系统

老 ERP 系统的接口是这样的(典型的"大泥球"对外暴露):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 老 ERP 的返回结构(各种字段混在一起)
public class ErpLegacyResponse {
    private String order_id_erp;     // 下划线命名
    private String user_no;
    private String sku_code;
    private int qty;
    private double price_rmb;        // 混币种
    private String status_code;      // "01"/"02"/"99" 这种魔数
    private String memo;             // 万能字段
    // ... 200 多个字段
}

不写 ACL 的下场:订单服务里全是 if (status.equals("01")) { ... } else if (status.equals("02")) { ... } 这种魔数判断,改一次 ERP 字段全崩。

写 ACL 的实战:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 1. 在订单服务里定义自己的领域模型
public class Order {
    private OrderId orderId;
    private OrderStatus status;  // 枚举:PAID / SHIPPED / COMPLETED
    private Money totalAmount;
    // ...
}

// 2. ACL 把老 ERP 的"魔数"翻译成领域枚举
@Component
public class ErpAntiCorruptionLayer {
    public Order translate(ErpLegacyResponse erp) {
        return new Order(
            new OrderId(erp.getOrder_id_erp()),
            translateStatus(erp.getStatus_code()),
            new Money(BigDecimal.valueOf(erp.getPrice_rmb()), Currency.CNY),
            // ... 字段映射
        );
    }
    
    private OrderStatus translateStatus(String erpCode) {
        return switch (erpCode) {
            case "01" -> OrderStatus.PAID;
            case "02" -> OrderStatus.SHIPPED;
            case "99" -> OrderStatus.COMPLETED;
            default -> throw new IllegalArgumentException("未知 ERP 状态: " + erpCode);
        };
    }
}

// 3. 订单服务的其他代码只看 Order,不接触 ErpLegacyResponse
@Service
public class OrderSyncService {
    @Autowired private ErpAntiCorruptionLayer acl;
    
    public void syncFromErp(String erpOrderId) {
        ErpLegacyResponse erp = erpClient.getOrder(erpOrderId);
        Order order = acl.translate(erp);  // ACL 翻译
        orderRepository.save(order);
        // 业务代码只看 Order
    }
}

ACL 是微服务拆分中最被低估的设计。每当你要"集成老系统"“对接外部第三方"“统一多个数据源"时,先想 ACL——不要让外部的脏数据污染你的领域模型。

2.7 事件风暴(Event Storming):DDD 的工作坊方法

事件风暴是 Alberto Brandolini 2013 年提出的 DDD 工作坊方法,核心是用"事件"驱动团队讨论业务,最终识别出限界上下文、聚合根、领域事件。

15 步流程(简化版,1-2 天):

  1. 邀请业务专家 + 开发 + 测试 + 运维(全栈人员,10 人左右)
  2. 准备一面大白墙 + 橙色便签(领域事件) + 蓝色便签(命令) + 黄色便签(外部系统) + 紫色便签(问题)
  3. 从业务流程的"起点"开始(如"用户下单”),贴第 1 个事件便签:OrderCreated
  4. 依次向后推演:用户支付 → PaymentCompleted,库存预占 → InventoryReserved,发货 → ShipmentDispatched,签收 → ShipmentDelivered
  5. 每个事件前面贴一个命令:用户想下单 → CreateOrder,支付 → PayOrder
  6. 事件之间识别"聚合根”:OrderCreated + OrderPaid + OrderShipped + OrderCompleted 围绕 Order
  7. 聚合根按业务相关性归类 → 限界上下文:Order、Pay、Inventory 三个聚合根 → 销售上下文
  8. 画上下文边界:不同上下文用不同颜色便签区分
  9. 识别上下文映射:Order → Inventory(下游消费)、Order → Pay(下游消费)
  10. 补充遗漏:贴紫色便签写"未解决的问题"“模糊的业务规则”
  11. 优先级排序:最重要的 1-2 个上下文先实施

事件风暴的价值:

  • 业务专家第一次和开发面对面讨论流程,消除了"开发自以为是"和"业务想当然"
  • 可视化产出物就是微服务的拆分依据
  • 一次事件风暴的结果能用 6-12 个月——边界识别后,微服务边界、API 边界、数据库边界都按这个走

我那次 38 个微服务大泥球重构,关键的"销售上下文/支付上下文/库存上下文"划分,就是 2 天事件风暴的产物。没有一个架构师能独自想清楚业务边界,必须业务专家+开发+测试一起推演

三、微服务拆分 5 条工业级原则

上一节讲了 DDD 工具箱(限界上下文、聚合根、领域事件、ACL、事件风暴),这一节讲把这套工具落到"代码 + 数据库 + 团队"上的 5 条工业级原则。 这 5 条不是教条,是"踩过 38 个大泥球"后归纳的"高概率正确"经验。

3.1 原则 1:单一业务职责(One Bounded Context, One Service)

口诀:一个微服务 = 一个限界上下文 = 一组紧密相关的聚合根

反例:把订单、支付、物流、库存放在一个"交易服务"里——这是单体服务的微服务外壳,依然是大泥球。看起来是微服务,实际部署 1 次要协调 4 个业务的发布窗口,本质还是单体。

正例:

  • 订单服务:只管 Order 聚合(订单 + 订单项)
  • 支付服务:只管 Payment 聚合(交易 + 退款)
  • 库存服务:只管 Inventory 聚合(SKU 库存 + 预占)

判断标准:用"如果改这个服务,需要不需要协调其他业务团队"作为标尺

  • 改一个服务,需要另一个业务团队配合 → 拆得不够细
  • 改一个服务,只影响当前业务团队 → 拆得刚好
  • 改一个服务,只影响 1-2 个聚合根 → 可能拆得太细(每个聚合根一个服务是过度拆分)

“拆得太细"的反模式:OrderOrderItemOrderAddress 三个服务(订单项、地址独立成服务)。问题是:OrderItem 离开 Order 没意义,OrderAddress 离开 Order 也没意义,它们的生命周期和 Order 完全绑定,这种"拆"只是把本地事务变成了分布式事务,徒增复杂度。

3.2 原则 2:边界自治(Autonomy) — 数据库隔离是底线

口诀:一个微服务 = 一套独立的数据库(Schema/DB),不共享表、不共享连接池、不跨服务 JOIN。

反例(38 个大泥球的核心病灶):

  • 38 个服务都连同一个 MySQL 集群
  • 订单服务 SELECT * FROM t_user WHERE user_id = ? 跨服务读用户表
  • 支付服务 UPDATE t_inventory SET stock = stock - 1 跨服务改库存
  • 一旦 DBA 把 t_user 改个字段名,38 个服务全挂

正例:

  • 订单库 t_ordert_order_item(订单库独享)
  • 支付库 t_paymentt_refund(支付库独享)
  • 库存库 t_inventoryt_reservation(库存库独享)
  • 跨服务要数据,只能通过 API 或事件,不能直接 SQL 查

实战:订单服务要查用户地址怎么办?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// ❌ 反例:跨库 JOIN
@Query("SELECT o.*, a.detail FROM t_order o JOIN t_user_address a ON o.user_id = a.user_id WHERE o.order_id = ?")
OrderDetail findDetail(String orderId);

// ✅ 正例:通过 OpenFeign 调用用户服务
@FeignClient("user-service")
public interface UserServiceClient {
    @GetMapping("/api/v1/users/{userId}/addresses/{addressId}")
    AddressDTO getAddress(@PathVariable Long userId, @PathVariable Long addressId);
}

@Service
public class OrderQueryService {
    @Autowired private UserServiceClient userClient;
    
    public OrderDetail getDetail(String orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        AddressDTO addr = userClient.getAddress(order.getBuyerId(), order.getAddressId());
        return new OrderDetail(order, addr);
    }
}

为什么"数据库隔离"这么重要?因为它是微服务"可独立演进"的技术底线。共享数据库时,改一个表要协调所有用的服务;隔离后,改库 = 改当前服务,不用协调别人。

3.3 原则 3:独立演进(Independent Evolution) — 接口稳定、内部可改

**口诀:微服务的 API 接口(对外暴露的 OpenAPI/gRPC IDL)**一旦发布,就尽量不改;实现细节(代码、数据库、缓存)可以随时重构。

反例(常见的"服务失控”):

  • 支付服务 V1 接口 POST /api/pay 返回 { "payId": "P001" }
  • 3 个月后产品改需求,支付服务改返回 { "transactionId": "P001" }
  • 订单服务 27 个调用方全挂
  • 支付团队为了不挂调用方,把"transactionId" 改回 “payId” 但内部多加一个"transactionId" 字段(双写)——API 越来越乱,最终没人敢动

正例:版本化 API + 内部实现可改:

  • 支付 V1:POST /api/v1/pay 返回 { "payId": "P001" } —— 永久保留,标记 deprecated
  • 支付 V2:POST /api/v2/pay 返回 { "transactionId": "P001" } —— 新接口
  • 订单服务新代码用 V2,老代码保留 V1(并设 6 个月废弃时间表)
  • 支付服务内部实现可改:V1/V2 共享同一个 PaymentAppService,只是 Controller 层路由不同

“接口稳定"的额外保障:

  • OpenAPI 3.0 规范写接口(代码生成,人写容易漏)
  • 改接口前先在 API 仓库发 RFC,所有调用方在 PR 里 ack
  • 没有 ack 的 PR 不能合 master(流程强制)

“内部可改"的边界:

  • 改一个服务的代码 → 这个服务的 owner 团队自己决定
  • 改数据库表结构 → 同上
  • 改 API 契约 → 必须走 RFC 流程

3.4 原则 4:可观测(Observability) — 拆不开就先建观测

口诀:在拆之前先把"链路追踪 + 统一日志 + 指标监控"建好,否则一拆就崩

我见过很多团队直接拆 38 个服务,但没建观测系统,结果:

  • 一个请求跨 8 个服务,出问题不知道卡在哪
  • 18 个服务都在打 ERROR 日志,但日志格式不统一,grep 不到
  • 数据库慢查询没法定位是哪个服务发的
  • 2 周后 38 个服务重新合并回 1 个单体(因为没人能维护)

可观测的 3 大支柱:

  1. 链路追踪(Tracing):一个请求的全链路调用图,看到每个服务的耗时、错误
    • 工具:Jaeger、Zipkin、SkyWalking
    • 关键字段:traceId(全局唯一)、spanId(单服务内唯一)、parentSpanId(上游)
  2. 统一日志(Logging):结构化日志(JSON 格式),带 traceId 串联
    • 工具:ELK(Elasticsearch + Logstash + Kibana)、Loki
    • 关键字段:traceId、serviceName、level、message、timestamp
  3. 指标监控(Metrics):服务的 QPS、错误率、延迟 P99、JVM/DB 连接池
    • 工具:Prometheus + Grafana
    • 关键指标:RED 方法(Rate 请求数 / Error 错误数 / Duration 延迟)

生产案例:38 个大泥球重构前的"先建观测”:

  • 第一周:全员接入 SkyWalking(零业务代码改动,只加 Java agent)
  • 第二周:把 27 个服务的日志改成 JSON 格式,接 ELK
  • 第三周:Prometheus + Grafana 上线,所有服务的 QPS / 错误率 / 延迟可视化
  • 第四周开始拆服务——这时的可观测是"边拆边看”,不是"先拆再补"

反观测的反模式:

  • 服务 A 用 Logback 写文本日志,服务 B 用 Log4j2 写 JSON 日志,服务 C 直接 System.out.println
  • 每个服务有自己的"日志格式",运维同学想找一条 ERROR,要用 3 种不同的 grep
  • 建观测的第一件事就是统一日志格式(建议 SLF4J + Logback + JSON Encoder)

3.5 原则 5:数据收敛(Data Convergence) — 每个服务管自己的数据

口诀:一个服务 = 一份权威数据源(Source of Truth),其他服务要这个数据,只能"订阅"或"调用"

反例(数据散落是分布式系统的"癌症"):

  • 用户名"张三"在用户表、订单表、支付表、物流表里都有一份
  • 用户改了昵称"张三" → “Zhang San”,只有用户表更新
  • 订单表、物流表、支付表里还是"张三"
  • 用户看到订单详情里"买家:张三",但个人中心显示"Zhang San"——数据不一致

正例:事件驱动的数据同步

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 用户服务:用户改昵称,发布 UserProfileChanged 事件
@EventPublisher
public class UserApplicationService {
    public void changeNickname(UserId userId, String newNickname) {
        User user = userRepository.findById(userId).orElseThrow();
        user.changeNickname(newNickname);
        userRepository.save(user);
        eventPublisher.publish(new UserProfileChangedEvent(userId, newNickname));
    }
}

// 订单服务:订阅 UserProfileChanged,更新自己表里的冗余字段
@EventHandler
public class OrderEventHandler {
    public void on(UserProfileChangedEvent event) {
        // 异步更新订单表里的冗余字段
        orderRepository.updateBuyerNickname(event.getUserId(), event.getNewNickname());
    }
}

“数据收敛"的关键设计:

  1. 唯一权威源:用户的"昵称"只在用户表里是权威,其他表是冗余/快照
  2. 事件传播:权威源变更时,广播事件,下游订阅更新自己的冗余
  3. 最终一致性:事件传播有延迟(秒级/分钟级),下游最终会和权威源一致
  4. 容忍延迟:下游查询要容忍"短暂不一致”——比如订单详情显示"张三"(用户表已变"Zhang San",事件还没到),用户看到的不是最新昵称

为什么不能"实时同步"(同步双写)?

  • 用户改昵称 → 同步调 27 个服务的更新接口
  • 一个服务慢 5 秒,用户就卡 5 秒
  • 一个服务挂了,用户改昵称失败
  • 同步链 = 死链,异步事件 = 活链

数据收敛的 4 个层次(选你需要的):

层次含义适用场景
L1 - 强一致(本地事务)一个聚合根内多表单服务内部(默认)
L2 - 最终一致(领域事件)跨服务冗余数据用户昵称、订单买家快照
L3 - 异步对账(批处理)跨服务数据校验T+1 对账、库存盘点
L4 - 妥协查询(直接跨库)实在没时间做事件同步老系统迁移期临时方案(必须标记技术债)

反模式:用分布式事务(Seata AT 模式)代替事件——所有跨服务都 Seata 一把梭哈,看似简单,实际性能极差 + 耦合极重,任何服务挂都拖垮全局。Seata 在第 14 篇会细讲,这里只说:优先事件 + 最终一致,只在"必须强一致"的核心金融场景用 Seata

四、生产场景 6 个真实案例

前三节讲方法论(康威 + DDD + 5 原则),这一节讲 6 个真实的"拆分"案例。每个案例都给:背景、痛点、解决方案、结果、教训。 案例来源:3 个来自本人亲历(脱敏处理),3 个来自公开技术博客/技术大会。

4.1 案例 1:订单中台拆分 — 从"巨型 Order"到 5 个领域服务

背景:某 B2B 电商公司,2018 年起家的单体系统,所有业务(订单、库存、支付、促销、会员)都在一个 Spring Boot 应用的 com.xx.order 包下,3 年后这个包代码量达到 67 万行,部署时间 40 分钟。

痛点:

  • 改一行订单代码,整个应用重新编译 + 部署
  • 任何一个小问题都要全量回滚,影响所有业务
  • 团队扩张到 25 人后,合并冲突每天 30+ 次
  • QPS 撑不住双 11,只能堆机器(已堆到 80 台)

拆分方案(按 DDD 限界上下文):

拆分后服务聚合根独立数据库团队
order-serviceOrder + OrderItem订单库 8 张表订单团队 7 人
inventory-serviceInventory + Reservation库存库 5 张表库存团队 5 人
pay-servicePayment + Refund支付库 4 张表支付团队 5 人
promotion-serviceCoupon + ActivityRule营销库 6 张表营销团队 4 人
member-serviceMember + Address + Points会员库 7 张表会员团队 4 人

关键技术决策:

  1. 绞杀者模式(Strangler Fig):新服务逐步替代老接口,而不是一次性"big bang"
  2. API 网关做路由:老接口 /api/legacy/order/* 路由到旧代码,新接口 /api/v2/order/* 路由到新服务
  3. 数据双写期 3 个月:老库新库同步写,读用开关切换,逐步放量
  4. 事件总线:用 Kafka 做领域事件总线,5 个服务通过 Topic 解耦

结果(6 个月后):

  • 部署时间从 40 分钟 → 单服务 2 分钟
  • 故障爆炸半径从 80 台机器 → 单服务 2-4 台
  • 团队独立发布,从 1 次/周 → 30 次/天
  • 双 11 QPS 峰值从 8000 → 35000(横向扩展更容易)

教训:

  • 第一刀最难——拆第一个服务(order-service)花了 4 个月,后面拆每个服务 1-2 个月
  • 数据双写期一定要有"开关"——不能"老库写、新库不写",必须有双写开关和读开关,出问题秒级回滚
  • 遗留的"老接口"不要立即删——保留 6-12 个月,等所有调用方迁完再删

4.2 案例 2:用户中心拆分 — 多端数据收敛

背景:某社交 App,用户数据散落在 5 个服务里(账号、资料、关系链、设置、设备),每个服务都有自己的 t_user_xxx 表,改一个"昵称"字段要协调 5 个服务。

痛点:

  • 用户改昵称,5 个服务并发更新,经常 2-3 个成功 2-3 个失败,数据长期不一致
  • “查看用户主页"要调 5 个服务的 API,任何一个慢,主页就慢
  • 新接入业务(如"创作者中心”)要复制一份用户数据,造成"数据散落"恶性循环

拆分方案:

  • 保留一个权威的 user-core 服务(管账号、昵称、头像、状态)
  • 其他服务只冗余需要的字段(如订单服务冗余"昵称"+“头像"用于列表展示)
  • UserProfileChanged 事件传播,下游服务订阅并更新自己表的冗余字段
  • 设置、设备这种"用户私有的配置"下沉到 user-settings 服务,有自己的存储

关键代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// user-core:权威源
@Entity
public class User {
    @Id private Long userId;
    private String nickname;
    private String avatarUrl;
    // ... 只有这几个字段是"权威"
}

@EventPublisher
public void changeNickname(Long userId, String newNickname) {
    User user = userRepository.findById(userId).orElseThrow();
    String oldNickname = user.getNickname();
    user.setNickname(newNickname);
    userRepository.save(user);
    // 发布事件,所有订阅方异步更新冗余字段
    eventPublisher.publish(new UserProfileChangedEvent(userId, oldNickname, newNickname, Instant.now()));
}

// 订单服务:订阅,更新冗余
@EventHandler
@Transactional
public void on(UserProfileChangedEvent event) {
    orderRepository.updateBuyerSnapshot(event.getUserId(), event.getNewNickname());
}

结果:

  • 用户改昵称:从 5 服务并发同步 → 1 服务本地事务 + 事件广播
  • 改昵称 P99 延迟:从 800ms → 80ms
  • 数据最终一致时间:通常 2-5 秒,最坏 30 秒
  • 2 年内 0 起"用户数据不一致"工单

教训:

  • 冗余是"成本"不是"问题”——核心问题不是"有冗余",而是"冗余没人维护"
  • 事件总线要持久化 + 重试 + 死信队列——下游消费失败必须能重放,否则数据永久不一致
  • 对账作业不能少——每天 T+1 跑一次"权威源 vs 冗余"对比,发现不一致人工补

4.3 案例 3:支付链路拆分 — 强弱一致的分层

背景:某支付公司,核心是"用户充值 → 钱包账户 → 提现"链路。原架构是 1 个大的 pay-service,内部 5 个模块:账户、充值、提现、对账、风控。

痛点:

  • 5 个模块耦合,改一个"提现限额"要重新部署整个 pay-service
  • 风控规则改 1 行,必须经过全套回归测试
  • 大促时充值并发高,把对账的 CPU 也吃光了

拆分方案(按"强弱一致分层"原则):

  • account-service:钱包账户(强一致,本地事务)
  • recharge-service:充值(本地事务)
  • withdraw-service:提现(本地事务)
  • risk-service:风控规则(无状态,规则引擎)
  • reconcile-service:对账(批处理,独立部署,资源隔离)

关键设计:

  • account-service 的余额修改用本地事务 + 行锁(强一致,绝不能错)
  • 充值流程:用户 → recharge → 调第三方支付 → 回调 → rechargeRechargeSuccess 事件account 消费事件 + 加余额
  • 加余额是本地事务,但充值和加余额是最终一致——中间失败靠对账兜底

结果:

  • 单服务 QPS 提升 5 倍(资源隔离,不再相互影响)
  • 风控规则改一行代码,30 秒上线(独立部署)
  • 大促期间 0 起"对账延迟"事故(批处理资源隔离)

教训:

  • 不是所有支付场景都要"强一致"——充值链路可以"最终一致 + 对账",但钱包余额必须"强一致"
  • “分而治之"在金融场景特别重要——把"实时链路"和"批处理链路"拆开,避免相互挤兑资源

4.4 案例 4:数据一致性 — 事件总线的 4 道防线

背景:某零售公司,营销活动期间单日订单 50 万,涉及 12 个服务的协同,经常出现"订单已支付,库存没扣"“用户已退款,钱包没加"等不一致问题。

痛点:

  • 事件丢失:Kafka 偶发重平衡,消费者没收到事件
  • 重复消费:网络抖动导致事件重发
  • 顺序错乱:用户退款事件比支付事件先到
  • 状态错位:服务 A 说"已发货”,服务 B 说"待支付”

解决方案:事件总线的 4 道防线

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 1. 持久化 + 至少一次语义(防丢失)
@KafkaListener(topics = "OrderPaidEvent", groupId = "inventory-service")
public void onMessage(OrderPaidEvent event) {
    // 持久化到本地 outbox 表
    outboxRepository.save(new OutboxRecord(event.getEventId(), event, "PENDING"));
    // 业务处理
    inventoryService.reserve(event.getOrderId(), event.getItems());
    // 处理完标记
    outboxRepository.markProcessed(event.getEventId());
}

// 2. 幂等消费(防重复)
@Service
public class InventoryEventHandler {
    public void on(OrderPaidEvent event) {
        // 用 eventId 去重:同样的 eventId 处理过一次就跳过
        if (processedRepository.exists(event.getEventId())) {
            return;
        }
        inventoryService.reserve(event.getOrderId(), event.getItems());
        processedRepository.save(event.getEventId(), Instant.now());
    }
}

// 3. 顺序保证(防错乱)
// Kafka 的 partition key 选 orderId,同一个订单的所有事件进同一个 partition
@Bean
public NewTopic orderPaidTopic() {
    return TopicBuilder.name("OrderPaidEvent")
        .partitions(12)
        .replicas(3)
        .config("min.insync.replicas", "2")
        .build();
}
// 生产时用 orderId 作为 key
kafkaTemplate.send("OrderPaidEvent", orderId, event);

// 4. 死信队列 + 人工兜底(防状态错位)
@KafkaListener(topics = "OrderPaidEvent.DLQ", groupId = "dlq-handler")
public void onDlq(OrderPaidEvent event) {
    alertService.sendAlert("事件处理失败,需人工处理", event);
}

4 道防线的成本与收益:

  • 持久化:性能开销 5%,一致性提升 80%
  • 幂等:代码复杂度 +20%,重复消费问题清零
  • 顺序:Kafka 分区策略调优,顺序性提升到 99.99%
  • 死信队列:人工兜底,极端情况仍有出路

结果:

  • 不一致工单:从月均 30 起 → 0 起
  • 大促后对账:T+1 自动对账,人工补单率 < 0.001%

教训:

  • 不要相信"消息中间件一定可靠"——必须有应用层的 4 道防线
  • 事件 schema 必须有版本号——老消费者跑老 schema,新事件跑新 schema,演化兼容

4.5 案例 5:异构系统融合 — 多语言微服务统一治理

背景:某跨国物流公司,核心系统有 4 种技术栈:Java(订单)、Go(调度)、Python(数据)、C#(遗留 ERP)。“微服务化"时,各团队按擅长选了不同语言,治理难度爆炸。

痛点:

  • 4 种语言的注册中心不一致(Eureka / Consul / Nacos / 自研)
  • 4 种语言的配置中心不一致(Spring Cloud Config / Apollo / 自研)
  • 4 种语言的链路追踪不一致(Sleuth+Zipkin / OpenTelemetry+Jaeger / 自研)
  • 一次故障排错 4 个团队,沟通成本极高

解决方案:统一基础设施 + 各语言 SDK

能力选型JavaGoPythonC#
服务发现NacosNacos-SpringNacos-GoNacos-PythonNacos-C#
配置中心NacosNacos-SpringNacos-GoNacos-Python自研 HTTP 调用 Nacos OpenAPI
链路追踪Jaeger(OTel 标准)OTel JavaOTel GoOTel PythonOTel .NET
日志Loki(统一 JSON 格式)Logback + JSON Encoderzap + JSONlogging + JSONlog4net + JSON
指标PrometheusMicrometerprometheus/client_pythonprometheus-netMicrometer
CI/CDJenkins + ArgoCD统一统一统一统一

关键决策:

  • 基础设施统一,但应用代码不强制统一——Java 用 Spring Boot,Go 用 Gin,Python 用 FastAPI,各团队按擅长
  • 统一标准 > 统一实现——所有语言都用 OpenTelemetry 协议(只要符合协议,实现自选)
  • 抽象出"语言无关"的 API 网关 + Service Mesh——所有外部调用走网关,所有内部调用走 Mesh(Istio/Linkerd)

结果(12 个月后):

  • 4 种语言,1 套治理(只要学会 OTel + Prometheus + Loki,就能排查所有服务)
  • 服务发现延迟统一 < 50ms
  • 跨语言链路追踪:从"4 个工具 + 4 个 UI” → “1 个 Jaeger UI 全部串起来”

教训:

  • 不要因为"统一"而强制"同一技术栈"——业务复杂、多团队异构是常态
  • 统一治理的"协议层"是 OpenTelemetry——它跨语言、跨框架、开源标准,5 年内不会被淘汰
  • 异构系统最怕"每个团队自己造轮子"——基础设施部门必须提供"开箱即用"的 SDK

4.6 案例 6:遗留系统拆解 — 绞杀者模式 + 数据双写

背景:某传统国企,核心 ERP 是 2008 年的 C# .NET Framework 2.0,代码 120 万行,改任何功能都要发布 .NET 包 + 重启 IIS 集群。新业务(移动端、API)想用,但这个老系统完全不能扩展

痛点:

  • 老系统 18 年没人敢动,改一行代码要全量回归(测试 5 个团队、2 周)
  • 移动端想用老系统的"订单查询"功能,但老系统只暴露 COM 组件,跨进程调用慢且不稳定
  • 2018 年后微软停止 .NET Framework 2.0 支持,安全漏洞没人修
  • 但完全替换 ERP 不可能——20 年业务规则沉淀在代码里,没人能说清楚

解决方案:绞杀者模式(Strangler Fig)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
阶段 1(0-3 个月):基础设施
  - 老 ERP 前面架 API Gateway
  - 新建 new-erp-service(Java Spring Boot),先实现"订单查询"一个新功能
  - API Gateway 路由:移动端 → new-erp(新),其他 → 老 ERP

阶段 2(3-12 个月):逐步迁移
  - 每月迁移 1-2 个功能(订单查询 → 订单创建 → 订单退款 ...)
  - 每迁移一个功能,API Gateway 切换路由
  - 老 ERP 代码逐月减少,新服务逐月增加

阶段 3(12-18 个月):数据迁移
  - 老的"t_order"表数据全量同步到 new-erp-service 的新表
  - 双写期 3 个月:同时写老表 + 新表
  - 读用开关切换
  - 3 个月后废弃老表

阶段 4(18-24 个月):老 ERP 下线
  - 路由全部切换到 new-erp
  - 老 .NET 进程灰度下线
  - 服务器资源回收

关键技术点:

  1. API Gateway 是"切换开关"——所有路由都集中管理,切换可秒级
  2. 数据双写期必须"可观测"——每次写老库 + 新库,都要记录"是否双写成功",不一致告警
  3. 业务功能按"用户使用频次"排序迁移——高频优先,低频最后
  4. 老 ERP 的"业务规则"必须文档化——迁移前 2 个月专门做"业务规则考古"

结果(24 个月后):

  • 老的 .NET 系统完全下线,释放 80% 服务器资源
  • 新系统(.NET Core 6 + Spring Boot 混部)支持移动端 + Web + 开放 API
  • 改一个订单功能:从 2 周 → 2 小时
  • 核心业务规则零损失——通过 24 个月的"双写 + 对账"保障

教训:

  • 绞杀者模式是"渐进式重构"的标准答案——不要做"big bang"(一次性重写,风险极高)
  • 数据迁移的"双写期"是必须经历的风险——不要为了"快"跳过
  • 老系统的"业务规则"是最值钱的资产——架构可能过时,业务规则 20 年不变

五、踩坑清单:拆分失败的 10 个真实教训

这一节不讲方法论,讲 10 个我亲眼见过(或亲身踩过)的"拆分失败"教训。每条都附"症状 + 根因 + 解决"。

1. 没做事件风暴就拆服务——症状:微服务拆完后业务逻辑散落,跨服务调用乱成一团;根因:没识别清楚限界上下文;解决:拆之前先 2 天事件风暴。

2. 拆得"过细"——症状:一个聚合根一个服务,本地事务变分布式事务,性能差 + 一致性差;根因:把"DDD 实体"当"微服务边界";解决:一组紧密相关的聚合根 = 一个服务。

3. 共享数据库——症状:DBA 改一个字段,多个服务挂;根因:微服务"逻辑上拆了,物理上没拆";解决:每个服务独立 Schema,跨服务走 API/事件。

4. 同步链路太多——症状:用户改昵称要等 5 个服务响应,3 秒后才返回;根因:用了同步 RPC 而不是异步事件;解决:同步只用于"读自己的数据",其他用事件。

5. 没有可观测就拆——症状:出问题找不到根因,排错 3 天;根因:链路追踪 + 统一日志没建;解决:先建 SkyWalking + ELK + Prometheus,再建微服务。

6. 团队结构没动——症状:微服务是 5 个团队协作,部署时还是 2 周排期;根因:康威定律反着干;解决:组织先行,按业务域组建 Stream-aligned 团队。

7. 分布式事务滥用——症状:Seata AT 模式全链路,一个服务挂全链路卡 30 秒;根因:把"必须强一致"扩展到所有场景;解决:优先事件 + 最终一致,只在金融核心用 Seata。

8. 事件 schema 没版本号——症状:消费方升级后,老事件处理报错;根因:事件结构变了但没兼容老版本;解决:每个事件带 schemaVersion 字段,消费者按版本路由。

9. 没有 API 版本管理——症状:改一个支付接口,订单服务 27 个调用方全挂;根因:接口改动没 RFC 流程;解决:OpenAPI 3.0 + RFC 评审 + 老版本保留 6 个月。

10. 老接口没及时下线——症状:系统里同时跑 V1/V2/V3 接口,代码越积越多;根因:没设废弃时间表;解决:每个老接口设 deprecationDate,到期强制下线。

这 10 条没有一条是"技术难题",都是"流程 + 纪律"问题。微服务拆分的成功,80% 靠组织治理,20% 靠技术

结语:把"拆"换成"治"

回到开头 CEO 的那个问题:“已经是微服务了,为什么改个东西比单体还慢?”

答案是:没有"自治"的拆分,只是把大泥球切成了小泥球。真正的微服务,不是"部署了 N 个 Spring Boot 应用",而是:

  • 业务边界清晰(DDD 限界上下文)
  • 团队边界对齐(Team Topologies)
  • 数据各自收敛(每个服务一份权威源)
  • 失败有兜底(事件 + 对账 + 死信队列)
  • 演进有节奏(绞杀者模式 + 双写期 + 老接口废弃表)

如果只学一招,我建议把"康威定律"贴在显示器上——任何架构决策前,先看组织结构;组织没动,先动组织

这就是我那次从 38 个微服务大泥球到 12 个真正自治服务的全部心法。后续的 Nacos 怎么选(第 6 篇)、网关怎么限流(第 7 篇)、熔断怎么做(第 8 篇),都是这套心法的"组件落地"。先有边界,再有服务;先有组织,再有架构——这是 10 年 Java 微服务实践最值钱的 5 个字。

本系列共 16 篇,本文为第 5 篇 · 查看全部
使用 Hugo 构建
主题 StackJimmy 设计