176|微服务拆分原则:业务边界、团队边界、数据边界和演进式拆分

微服务与服务治理约 17 分钟阅读9,379 字符
知识库文档 178 / 251

阅读时间:约 40-50 分钟 | 难度:中高级 | 适用:Java 技术专家 / 架构师面试


为什么微服务拆分比"拆"本身更难?

如果我问你"怎么把单体拆成微服务",你大概率会回答"按业务拆"。这个答案没错,但只对了一半。真正的问题在于:拆完之后,你发现系统比以前更复杂了——分布式事务满天飞、一个需求要改 5 个服务、联调环境永远跑不起来、线上问题定位要靠猜。

这不是微服务的问题,而是拆分原则没搞对。今天我们不谈"微服务好不好",只谈怎么拆才不会后悔。


1. 业务边界:DDD 限界上下文驱动的拆分

1.1 从"功能模块"到"业务能力"

单体时代,我们习惯按"功能模块"组织代码:com.xxx.order、com.xxx.payment、com.xxx.inventory。这看起来没问题,但致命的缺陷是——模块边界是技术视角的,不是业务视角的。

微服务拆分的正确姿势是:一个服务 = 一个业务能力。什么是业务能力?就是"这个业务能独立创造什么价值"。

以电商为例:

业务能力 核心价值 是否独立服务
商品管理 商家上架商品、管理 SKU ✅ 商品服务
订单管理 用户下单、订单状态流转 ✅ 订单服务
支付 收付款、退款 ✅ 支付服务
库存管理 库存扣减、预占、释放 ✅ 库存服务
物流追踪 发货、物流状态 ✅ 物流服务
用户认证 登录、注册、权限 ✅ 用户服务

但这里有一个常见的坑:很多人会把"订单搜索"也拆成一个独立服务,理由是"搜索是独立能力"。错——订单搜索是订单业务的子能力,不是独立的业务能力。判断标准:如果这个"能力"不能独立对外提供价值,就不应该拆成独立服务。

1.2 DDD 限界上下文:拆分的理论锚点

领域驱动设计(DDD)中的**限界上下文(Bounded Context)**是微服务拆分的天然理论锚点。一个限界上下文 = 一个微服务,这几乎是行业共识。

但很多团队在做 DDD 时犯了一个错:把领域模型当成了数据模型。限界上下文的核心不是"数据归谁",而是"语言边界"——同一个词在不同上下文中有不同含义。

举个例子:

code
// 订单上下文中的"商品"
class Product {
    Long productId;
    String name;
    Money price;        // 关心价格
    String imageUrl;    // 关心图片
}

// 库存上下文中的"商品"
class Product {
    Long productId;
    String skuCode;
    Integer quantity;   // 关心数量
    String warehouseId; // 关心仓库
}

同一个"商品"概念,在不同上下文中是完全不同的模型。如果你把这两个模型放在同一个服务里,你迟早会写出一个"万能"的 Product 类,包含 100 个字段,然后每次改一个字段都要担心影响其他业务。

拆分原则 1:按限界上下文拆分,一个上下文一个服务。上下文之间通过领域事件通信,而不是直接调 API。

文章配图
文章配图


2. 团队边界:康威定律与逆康威定律

2.1 康威定律说了什么

"任何组织在设计系统时,都会设计出与组织沟通结构一致的架构。" —— Melvin Conway, 1967

翻译成人话:如果你的团队是按前端/后端/数据库分的,你永远做不出真正的微服务。

康威定律在微服务场景下的推论:

  • 一个微服务应该由一个团队端到端负责。这个团队要能独立完成需求分析、设计、开发、测试、部署、运维。
  • 如果两个团队共享一个服务,这个服务就会变成"公共厕所"——没人愿意认真维护,出了问题互相推诿。
  • 跨团队协作的成本决定了服务拆分的粒度。如果两个服务需要频繁协同变更,考虑合并。

2.2 逆康威定律:用架构反推组织

很多公司做微服务改造的路径是:先定架构 → 再调组织。但逆康威定律告诉我们:也可以先调组织,让组织结构反推架构演进。

具体做法:

  1. 识别业务域的独立团队:把负责订单的人、负责支付的人、负责商品的人物理上分成独立团队
  2. 给每个团队分配独立的代码仓库、CI/CD 流水线、数据库
  3. 让团队在独立环境中自然演进出自己的服务边界

Netflix 的实践就是典型的逆康威定律:他们先组建了独立的产品团队,每个团队负责一个"可独立部署的单元",架构自然就变成了微服务。

2.3 两个披萨原则

Amazon 的 Bezos 提出"两个披萨原则":一个团队如果两个披萨不够吃,就太大了。对应到微服务:

  • 一个团队负责 1-3 个微服务比较合理
  • 如果一个团队要维护 10+ 个微服务,说明拆分过细
  • 如果一个服务需要 3 个团队共同维护,说明拆分粒度不对

拆分原则 2:服务边界要对齐团队边界。一个团队端到端拥有一个服务,团队的认知负荷决定了服务的大小。

文章配图
文章配图


3. 数据边界:每个服务拥有自己的数据

3.1 共享数据库是微服务的原罪

这是微服务拆分中最容易被忽视、但后果最严重的问题。

如果你拆了代码但没拆数据库,你做的不是微服务,是分布式单体。

分布式单体的症状:

  • 订单服务可以直接查支付服务的表,绕过业务逻辑
  • 修改一个表结构,不知道哪些服务会受影响
  • 想做数据库迁移,要协调 5 个团队同时上线
  • 数据库连接池被多个服务抢光,谁也查不出是谁干的

正确做法:每个微服务拥有自己的数据库(或 Schema),其他服务只能通过 API 访问数据。

code
// ❌ 错误:订单服务直接查支付表
@Repository
public class OrderDao {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    public PaymentInfo getPayment(Long orderId) {
        // 直接跨库查支付服务的表
        return jdbcTemplate.queryForObject(
            "SELECT * FROM payment_db.t_payment WHERE order_id = ?",
            PaymentInfo.class, orderId);
    }
}

// ✅ 正确:订单服务通过 API 获取支付信息
@Service
public class OrderService {
    @Autowired
    private PaymentClient paymentClient;  // Feign/Dubbo 调用

    public OrderDetail getOrderDetail(Long orderId) {
        Order order = orderDao.findById(orderId);
        // 通过 API 获取,而不是直接查表
        PaymentInfo payment = paymentClient.getPaymentByOrderId(orderId);
        return OrderDetail.of(order, payment);
    }
}

3.2 数据一致性:从 ACID 到 BASE

拆了数据库之后,最大的挑战是分布式事务。单体的 @Transactional 一包,要么全成功要么全失败。微服务里,你没法这样做。

这就是为什么需要从 ACID(原子性、一致性、隔离性、持久性)转向 BASE(基本可用、软状态、最终一致性)。

常见的最终一致性方案:

方案一:本地消息表 + 定时任务

java
// 订单服务:创建订单 + 写入本地消息表(同一个本地事务)
@Transactional
public void createOrder(CreateOrderRequest req) {
    // 1. 保存订单
    orderDao.save(order);
    // 2. 写入本地消息表(同一个数据库,ACID 保证)
    eventPublisher.publish("order.created", order);
    // 3. 定时任务扫描消息表,发送到 MQ
}

// 定时任务:扫描未发送消息
@Scheduled(fixedDelay = 1000)
public void sendPendingMessages() {
    List<EventMessage> messages = eventDao.findPendingMessages();
    for (EventMessage msg : messages) {
        try {
            mqProducer.send(msg);
            eventDao.markAsSent(msg.getId());
        } catch (Exception e) {
            log.error("发送失败,等待重试", e);
        }
    }
}

方案二:事务消息(RocketMQ 实现)

java
// 使用 RocketMQ 事务消息实现分布式事务
public void createOrder(Order order) {
    // 1. 发送半消息
    TransactionSendResult result = rocketMQTemplate.sendMessageInTransaction(
        "order-topic",
        MessageBuilder.withPayload(order).build(),
        order
    );
}

// 2. 执行本地事务
@RocketMQTransactionListener
public class OrderTransactionListener implements RocketMQLocalTransactionListener {
    @Override
    public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        try {
            orderDao.save((Order) arg);
            return RocketMQLocalTransactionState.COMMIT;  // 本地事务成功,提交消息
        } catch (Exception e) {
            return RocketMQLocalTransactionState.ROLLBACK; // 本地事务失败,回滚消息
        }
    }

    // 3. 回查(MQ 未收到确认时)
    @Override
    public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
        Order order = (Order) msg.getPayload();
        if (orderDao.existsById(order.getId())) {
            return RocketMQLocalTransactionState.COMMIT;
        }
        return RocketMQLocalTransactionState.ROLLBACK;
    }
}

方案三:Saga 模式

Saga 把一个大事务拆成多个小事务,每个小事务有对应的补偿操作:

code
正向流程:创建订单 → 扣减库存 → 扣款
补偿流程:退款 → 恢复库存 → 取消订单
java
// Saga 编排示例
public class CreateOrderSaga {
    public void execute(CreateOrderRequest req) {
        SagaContext ctx = new SagaContext();
        try {
            // Step 1: 创建订单
            ctx.orderId = orderService.createOrder(req);
            // Step 2: 扣减库存
            inventoryService.deduct(req.products);
            // Step 3: 扣款
            paymentService.charge(req.paymentInfo);
        } catch (Exception e) {
            // 补偿:反向执行
            if (ctx.orderId != null) {
                paymentService.refund(ctx.paymentId);         // 退款
                inventoryService.restore(req.products);       // 恢复库存
                orderService.cancel(ctx.orderId);             // 取消订单
            }
        }
    }
}

3.3 数据拆分策略

拆分数据库时有几种策略:

策略 做法 适用场景
按 Schema 拆分 不同服务用同一个数据库实例的不同 Schema 中小规模,减少运维成本
按实例拆分 每个服务拥有独立的数据库实例 大规模,需要独立扩展
按表拆分 把共享表归属到某个服务,其他服务通过 API 访问 过渡阶段

拆分原则 3:每个服务拥有自己的数据存储。服务间数据共享只能通过 API,不允许绕过 API 直接访问数据库。选择合适的一致性方案(本地消息表 / 事务消息 / Saga),放弃强一致性,拥抱最终一致性。

文章配图
文章配图


4. 演进式拆分:别想一步到位

4.1 "大爆炸"拆分的灾难

很多团队拿着 DDD 画完限界上下文后,就开始做"大爆炸"拆分:所有服务同时开发、同时上线。结果:

  • 上线当天发现 20 个服务有 15 个没起来
  • 链路追踪工具还没配好,不知道哪个服务调用失败了
  • 回滚要回滚 20 个服务,操作比单体还复杂
  • 运维团队原地爆炸

正确做法是演进式拆分。 关于演进式架构的核心原则,引用 ThoughtWorks 的理念:

"演进式架构支持跨多个维度的增量式、有指导的变更。"

4.2 绞杀者模式(Strangler Fig Pattern)

绞杀者模式的核心思想:不是"拆"单体,而是让单体被新服务"替代"。

code
Stage 1: 单体应用
  ┌──────────────────────────────────┐
  │  Monolith (Order + Pay + Prod)  │
  └──────────────────────────────────┘

Stage 2: 路由层引入,新服务逐步建设
  ┌──────────────┐    ┌──────────┐
  │   路由层      │───▶│ 单体     │
  │ (API Gateway) │    └──────────┘
  │              │    ┌──────────┐
  │              │───▶│ 支付服务  │  ← 新拆出来的
  └──────────────┘    └──────────┘

Stage 3: 持续提取,单体逐步萎缩
  ┌──────────────┐    ┌──────────┐
  │   路由层      │    │ 单体     │  ← 越来越小
  │              │    └──────────┘
  │              │    ┌──────────┐
  │              │───▶│ 支付服务  │
  │              │    └──────────┘
  │              │    ┌──────────┐
  │              │───▶│ 订单服务  │  ← 新拆出来的
  └──────────────┘    └──────────┘

Stage 4: 单体完全被替代
  ┌──────────────┐    ┌──────────┐
  │   路由层      │───▶│ 支付服务  │
  │              │    └──────────┘
  │              │    ┌──────────┐
  │              │───▶│ 订单服务  │
  │              │    └──────────┘
  │              │    ┌──────────┐
  │              │───▶│ 商品服务  │
  └──────────────┘    └──────────┘

4.3 抽象分支模式(Branch by Abstraction)

在代码层面,抽象分支模式允许你逐步替换实现:

java
// Step 1: 定义抽象接口
public interface InventoryService {
    boolean deduct(Long productId, int quantity);
    void restore(Long productId, int quantity);
}

// Step 2: 单体中的实现(直接操作本地数据库)
@Primary
@Service("localInventoryService")
public class LocalInventoryService implements InventoryService {
    @Override
    public boolean deduct(Long productId, int quantity) {
        // 直接操作本地数据库
        return inventoryDao.deduct(productId, quantity) > 0;
    }
}

// Step 3: 远程实现(调用微服务)
@Service("remoteInventoryService")
public class RemoteInventoryService implements InventoryService {
    @Autowired
    private InventoryClient inventoryClient;

    @Override
    public boolean deduct(Long productId, int quantity) {
        // 调用远程微服务
        return inventoryClient.deduct(productId, quantity);
    }
}

// Step 4: 通过配置开关切换
// application.yml
// feature.toggles.inventory-remote: false  → 先关掉
// feature.toggles.inventory-remote: true   → 灰度验证通过后打开

4.4 拆分优先级:从"最容易拆"到"最难拆"

不要一上来就拆最核心的订单系统。拆分的优先级应该是:

第一梯队(优先拆):

  • 无状态服务(如短信、邮件通知)
  • 读多写少的服务(如商品详情)
  • 独立业务能力(如用户认证)

第二梯队(中等难度):

  • 有状态但数据关系简单的服务(如购物车)
  • 与核心链路耦合度低的服务

第三梯队(最后拆):

  • 核心交易链路(下单、支付)
  • 强一致性要求的服务
  • 数据关系复杂的服务

拆分原则 4:不要一口气拆完。用绞杀者模式逐步替换单体功能,从边缘到核心,每个阶段可独立验证和回滚。

文章配图
文章配图


5. 拆分粒度的"金发姑娘原则"

5.1 拆分过细的代价

微服务不是越细越好。拆分过细会带来:

java
// 一个下单流程跨 7 个服务
public class OrderFlow {
    void placeOrder() {
        userService.validate();         // 1. 用户校验
        productService.checkStock();    // 2. 库存检查
        couponService.verifyCoupon();   // 3. 优惠券校验
        priceService.calculatePrice();  // 4. 价格计算
        orderService.createOrder();     // 5. 创建订单
        paymentService.pay();           // 6. 支付
        notificationService.notify();   // 7. 通知
    }
}

每个 RPC 调用都是潜在的故障点和延迟源。假设每个服务调用有 99.9% 可用性,7 个服务串行调用的整体可用性 = 99.9%^7 ≈ 99.3%,看上去还行。但如果每个服务内部还有下级调用,级联下去可用性会急剧下降。

5.2 拆分过粗的代价

反之,拆分过粗(一个服务包含了太多业务边界)的问题:

  • 部署粒度太大,改一行代码要部署整个服务
  • 无法独立扩展瓶颈模块
  • 团队协作冲突频繁
  • 技术栈锁定,无法为不同业务选择最优技术方案

5.3 判断粒度是否合适的几个指标

指标 太细的信号 太粗的信号 合适的信号
团队人数 一个团队维护 10+ 服务 3+ 团队改同一个服务 一个团队维护 1-3 个服务
变更频率 每次改需求都要改多个服务 改一个功能要动一大片代码 大部分需求只改一个服务
数据依赖 接口调用链路过长,查一个数据要调 3 层 服务内部有多套数据模型混在一起 一个服务处理一个聚合的数据
部署独立性 部署一个服务,必须同时部署其他服务 部署一个服务,需要协调所有团队 每个服务可以独立部署

拆分原则 5:服务粒度既不能太大也不能太小。以"一个需求变更通常只影响一个服务"为标准,以"一个团队能完整维护"为边界。

文章配图
文章配图


6. 拆分后的服务通信设计

6.1 同步 vs 异步的选择

拆分完成后,服务间通信的设计是关键:

维度 同步调用(RPC/HTTP) 异步消息(MQ)
耦合度 高(调用方依赖被调用方可用) 低(生产者不关心消费者)
延迟 低(实时响应) 高(异步处理)
一致性 强一致性(调用方立即知道结果) 最终一致性
适用场景 查询、实时操作 状态变更通知、异步处理

核心原则:查询用同步,命令用异步。

java
// 查询:同步调用(需要即时结果)
@GetMapping("/order/{id}")
public OrderDetail getOrder(@PathVariable Long id) {
    Order order = orderService.getById(id);
    // 用户需要看到完整的订单详情,这是实时查询
    UserInfo user = userClient.getUser(order.getUserId());  // 同步
    return OrderDetail.of(order, user);
}

// 命令:异步事件(不需要即时结果)
@PostMapping("/order")
public void createOrder(@RequestBody CreateOrderRequest req) {
    orderService.create(req);
    // 订单创建后,通知其他系统——不需要等待结果
    eventBus.publish(new OrderCreatedEvent(order));  // 异步
    // 库存扣减、积分发放、通知发送等下游服务异步消费
}

6.2 API 网关:流量入口的统一管控

微服务架构中,API 网关不是可选项,而是必需品:

code
移动端 ──┐
Web端  ──┼──▶ API Gateway ──▶ 订单服务
第三方 ──┘       │            ──▶ 支付服务
                 │            ──▶ 商品服务
                 ├── 认证鉴权
                 ├── 限流熔断
                 ├── 日志监控
                 ├── 协议转换
                 └── 路由转发

网关的核心职责:认证、路由、限流、日志、协议转换——但不应该包含业务逻辑。

6.3 服务发现与负载均衡

java
// Spring Cloud + Nacos 服务发现
@Bean
@LoadBalanced  // 开启客户端负载均衡
public RestTemplate restTemplate() {
    return new RestTemplate();
}

// 使用服务名调用,由 Nacos 解析到具体实例
@Autowired
private RestTemplate restTemplate;

public Order callOrderService() {
    // "order-service" 是服务名,不是 IP
    return restTemplate.getForObject(
        "http://order-service/api/orders/123",
        Order.class);
}

文章配图
文章配图


7. 实战案例:一个电商平台的拆分路径

7.1 背景

某电商平台原单体应用:

  • 代码量:50 万行 Java
  • 数据库:200 张表,单实例 MySQL
  • 部署:500 个接口,每周全量发布一次
  • 问题:发布周期长、故障影响面大、团队协作冲突

7.2 拆分路径

第一阶段(第 1-2 个月):边缘服务拆分

拆出:用户认证服务、消息通知服务、文件存储服务

code
原因:无状态、独立业务、不影响核心交易链路
收益:验证了 CI/CD 流程、服务化基础设施、团队获得微服务经验

第二阶段(第 3-4 个月):读服务拆分

拆出:商品查询服务、订单查询服务

code
原因:读多写少、数据可通过 CDC 同步、不影响写入链路
技术方案:Canal 监听 MySQL binlog → 同步到 ES → 查询服务读取 ES

第三阶段(第 5-7 个月):核心链路拆分

拆出:订单服务、支付服务、库存服务

code
原因:核心链路,需要最谨慎
策略:绞杀者模式,先在灰度环境验证,逐步切流
关键:TCC 分布式事务解决下单、支付、库存的一致性

第四阶段(第 8-10 个月):收尾

移除单体中已废弃的代码,完成全量切换。

7.3 拆分过程中的关键数字

指标 拆分前 拆分后
服务数 1 12
单服务代码量 50 万行 平均 4 万行
发布频率 每周 1 次 每天 10+ 次
故障影响面 全局 单服务
数据库 1 个实例 8 个实例
团队数 3 个团队改同一个仓库 8 个团队各有独立仓库

文章配图
文章配图


8. 拆分自检清单

在做微服务拆分时,问自己以下问题:

业务边界:

  • [ ] 这个服务的业务能力是否独立?能否独立对外提供价值?
  • [ ] 是否进行了限界上下文分析?同一个词在不同上下文中的含义是否一致?
  • [ ] 服务间的数据依赖是否通过 API 解耦了?

团队边界:

  • [ ] 一个团队是否能端到端负责这个服务?
  • [ ] 跨团队协调成本是否在可接受范围内?
  • [ ] 团队的认知负荷是否合理(一个团队维护 1-3 个服务)?

数据边界:

  • [ ] 每个服务是否拥有自己的数据库(或 Schema)?
  • [ ] 是否禁止了跨服务的数据库直接访问?
  • [ ] 选定了合适的数据一致性方案(本地消息表 / 事务消息 / Saga)?

演进策略:

  • [ ] 是否采用了渐进式拆分而不是"大爆炸"?
  • [ ] 是否从边缘服务开始拆分?
  • [ ] 每个阶段是否有独立的回滚方案?

粒度:

  • [ ] 一个需求变更是否通常只影响一个服务?
  • [ ] 服务是否可以独立部署和扩展?
  • [ ] 服务间调用链路是否在 3 层以内?

面试官视角:高频追问

以下问题来自真实面试场景,建议在理解全文后尝试回答:

Q1:你说"按业务能力拆分",那"用户积分"应不应该拆成独立服务?

Q2:如果两个服务共享一张配置表(比如行政区划表),怎么处理?做数据冗余还是做个独立的配置服务?

Q3:你们公司用了 Saga 模式吗?如果补偿操作本身也失败了怎么办?

Q4:康威定律说"架构由组织决定",那如果组织暂时无法调整(比如团队划分本身就混乱),微服务架构还能做吗?

Q5:拆分过程中,旧的单体还在运行,新的服务也在上线,数据库怎么迁移?具体说说你经历过的数据迁移方案。

Q6:拆分后你发现一个下单流程要跨 7 个服务,每个服务 p99 延迟 50ms,用户感知延迟是多少?怎么优化?


本文是 Java 技术专家与架构师面试 250 个高频考点系列的第 176 篇,下一篇将探讨"服务注册与发现:Nacos、Eureka 与 Consul 的原理与选型"。


文末提示:本文属于"12 微服务与服务治理"分类,面试中常与"分布式事务(161-175)"和"架构设计(191-213)"结合提问。


面试官追问:参考答案与深度解析

以下回答不是标准答案,而是面试中展现深度思考的参考框架。真正优秀的回答,需要结合你自己的项目经验。


Q1:你说"按业务能力拆分",那"用户积分"应不应该拆成独立服务?

核心判断维度:积分是否具备独立的业务价值。

如果积分只是营销活动中的一个附属功能(比如"下单送积分、积分抵现"),它不应该拆成独立服务——积分规则和订单、营销紧密耦合,拆出去反而增加了分布式复杂度。

但如果积分已经发展成一个独立的业务体系——有积分商城、积分兑换、积分过期策略、积分风控、积分等级体系——那它就是一个独立的业务能力,应该拆。

面试中展现深度的回答:

从三个维度判断:

  1. 数据独立性:积分是否有自己的核心数据模型?积分的余额、流水、过期时间是否独立于订单和用户?如果有,拆。
  2. 团队独立性:是否有专门的团队(或至少专门的人)负责积分业务?如果有,拆。
  3. 变更独立性:积分规则的变更(比如修改过期策略、新增积分来源)是否经常独立于其他业务?如果是,拆。

反例:一个创业公司,积分只是拉新活动中的一个小功能,总共就两个接口(发积分、查积分),拆成独立服务纯属过度设计。

关键金句:"微服务拆分的粒度不是由技术决定的,而是由业务成熟度和团队规模决定的。Don't split based on what you think you'll need, split based on what you've already proven you need."


Q2:如果两个服务共享一张配置表(比如行政区划表),怎么处理?做数据冗余还是做个独立的配置服务?

这个问题考察的是"共享数据"在微服务中的处理策略,没有标准答案,关键看场景。

方案一:独立配置服务(推荐大多数场景)

code
订单服务 ──▶ 配置服务(行政区划)◀── 物流服务
                │
                └── 数据库:config_db.t_district
  • 优点:单一数据源,修改一处全局生效
  • 缺点:增加了一个服务依赖,配置服务挂了影响面大
  • 适用:数据有权威来源、需要保证一致性、数据变更频繁

方案二:数据冗余(读多写少场景)

code
订单服务 ── 本地表:order_db.t_district(通过 CDC 同步)
物流服务 ── 本地表:logistics_db.t_district(通过 CDC 同步)
  • 优点:无运行时依赖,性能最好
  • 缺点:数据不一致窗口,需要同步机制
  • 适用:数据几乎不变(行政区划确实很少变)、查询频繁、对一致性不敏感

方案三:代码枚举(数据极少且固定)

如果行政区划数据量小且基本不变(比如中国省份就 34 个),直接在代码里用枚举或配置文件,比任何数据库方案都简单。

面试中展现深度的回答:

"我经历过一个案例:早期我们做了独立的配置服务,结果发现行政区划表一年才改一次,但配置服务本身因为各种原因挂了三次。后来我们改成用 Canal 监听配置库的 binlog,同步到各服务的本地缓存,配置服务挂了也不影响查询。对于这种读多写极少的数据,'冗余 + 最终一致'比'集中 + 实时查询'更可靠。"


Q3:你们公司用了 Saga 模式吗?如果补偿操作本身也失败了怎么办?

这是一个经典的分布式事务难题,面试官想听的是你对"最终一致性兜底"的理解。

补偿失败的处理策略:

第一层:自动重试(解决瞬时故障)

java
// 补偿操作自带重试
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 2000))
public void refundCompensation(Long paymentId) {
    paymentService.refund(paymentId);
}

第二层:持久化 + 定时任务(解决持久故障)

java
// 补偿失败后写入补偿任务表
@Recover
public void recoverRefund(Exception e, Long paymentId) {
    compensationTaskDao.save(new CompensationTask(
        "refund", paymentId, "PENDING"
    ));
}

// 定时任务扫描未完成的补偿
@Scheduled(fixedDelay = 60000)
public void processPendingCompensations() {
    List<CompensationTask> tasks = compensationTaskDao.findPending();
    for (CompensationTask task : tasks) {
        try {
            executeCompensation(task);
            compensationTaskDao.markAsCompleted(task.getId());
        } catch (Exception e) {
            compensationTaskDao.incrementRetryCount(task.getId());
            if (task.getRetryCount() >= MAX_RETRY) {
                // 第三层:人工介入
                alertService.sendAlert("补偿失败,需人工处理", task);
            }
        }
    }
}

第三层:人工介入(终极兜底)

设置告警阈值,超过自动重试上限后发送钉钉/企微通知,运营/开发手动处理。关键是要有对账机制——定期比对各系统的数据,发现不一致自动告警。

面试中展现深度的回答:

"Saga 不是银弹,我见过很多团队用它但最后都加了一层人工兜底。关键不是'补偿失败怎么办',而是'你怎么知道补偿失败了'。我们做了两件事:

  1. 每个 Sagas 步骤都有业务流水号,补偿操作记录到补偿表,定时对账
  2. 补偿任务表有重试次数和最大重试上限,超过上限自动升级为工单

另外,能不用 Saga 就尽量不用。很多场景下,本地消息表 + 幂等消费比 Saga 简单得多。Saga 的真正价值在于长事务(比如跨多个服务、执行时间超过 10 秒的流程),短事务用本地消息表就够了。"


Q4:康威定律说"架构由组织决定",那如果组织暂时无法调整(比如团队划分本身就混乱),微服务架构还能做吗?

直接回答:可以做,但必须接受"架构会反映组织的混乱"这个事实。

现实策略:

策略一:先从单体内部做模块化(推荐)

组织混乱时,不要急着拆服务。先在单体内部用 DDD 划分限界上下文,用包结构、模块化来隔离业务边界。这不需要组织调整,但为未来拆分做好了准备。

code
com.example
├── order          // 订单模块(包级别隔离)
│   ├── domain
│   ├── application
│   └── infrastructure
├── payment        // 支付模块(包级别隔离)
│   ├── domain
│   ├── application
│   └── infrastructure
└── shared         // 共享内核

等组织调整到位后,把 order 包拆成独立服务,几乎不需要改代码结构。

策略二:用"技术债"换"业务敏捷"

如果必须在组织混乱时拆分,那就接受一个事实:服务边界会跟组织一样混乱。你的策略是:

  1. 先拆出最独立的服务(通知、文件、认证)
  2. 标记哪些服务边界是"临时不正确"的(文档化技术债)
  3. 等组织调整后重新划分服务边界

策略三:反向使用康威定律

如果组织混乱是因为"没有人知道谁该负责什么",那就先定义服务边界,再根据服务边界来重组团队。这其实就是逆康威定律——让架构反推组织。

面试中展现深度的回答:

"我辅导过的一个团队,当时 30 个人在一个仓库里开发,组织上按前端/后端/DBA 划分。我们没急着拆服务,而是先做了三件事:

  1. 在代码里按业务域划分了 6 个 Maven 模块
  2. 每个模块指定了 owner(虽然这些人还在同一个团队里)
  3. 定了一个规则:跨模块的改动必须找 owner review

就这三步,不需要任何组织调整,代码的耦合度就大幅下降了。三个月后,组织调整水到渠成。核心逻辑:先让代码结构对齐你想要的架构,再让组织对齐代码结构,最后才是服务拆分。"


Q5:拆分过程中,旧的单体还在运行,新的服务也在上线,数据库怎么迁移?具体说说你经历过的数据迁移方案。

这是面试中最能体现工程经验的问题。没有实战过的人通常会卡壳。

标准方案:双写 + 灰度 + 对账

阶段一:影子模式(第 0-2 周)

java
// 新服务上线,但不接流量,只做数据同步验证
@PostMapping("/order")
public void createOrder(@RequestBody Order order) {
    // 1. 写单体数据库(正常流程,不变)
    monolithDb.save(order);
    
    // 2. 异步写新服务数据库(影子模式)
    newOrderClient.createOrderShadow(order);
    
    // 3. 对账:比较两个数据库的数据是否一致
    reconciliationService.compare(order.getId());
}

阶段二:双写阶段(第 3-4 周)

java
// 新服务开始接流量,但单体仍然全量写入
@PostMapping("/order")
public void createOrder(@RequestBody Order order) {
    // 同时写两个数据库
    monolithDb.save(order);       // 旧库
    newOrderClient.createOrder(order);  // 新库
    
    // 如果新库写入失败,记录到补偿表
    if (!newWriteSuccess) {
        compensationTaskDao.save(order);
    }
}

阶段三:灰度切流(第 5-6 周)

java
// 根据用户 ID 哈希,逐步将读流量切到新服务
public Order getOrder(Long orderId) {
    Long userId = getUserIdByOrderId(orderId);
    if (userId % 100 < grayPercent) {
        return newOrderClient.getOrder(orderId);  // 新服务
    }
    return monolithDb.getOrder(orderId);  // 旧服务
}

阶段四:全量切换 + 废弃旧表(第 7-8 周)

灰度 100% 后,再观察 1-2 周,确认无问题后下线旧表。

物理数据迁移(一次性):

对于历史数据,不要边拆边迁,而是:

  1. 找一个低峰期(比如凌晨 3 点)
  2. 用工具(如 DataX、Canal、自研脚本)批量迁移
  3. 迁移期间开启双写,确保不丢数据
  4. 迁移完成后运行对账脚本

面试中展现深度的回答:

"我经历过一次 MySQL 到 MySQL 的拆分迁移,200 张表拆到 8 个独立实例。最大的坑不是技术,而是数据不一致的回滚预案。

我们做了三件事来保证安全:

  1. 双写期间,所有写入操作都记录到一张 **migration_log** 表,记录了旧库 ID、新库 ID、操作时间、操作类型。如果新库有问题,可以根据这张表回滚
  2. 对账脚本每小时跑一次,对比新旧两边的数据,发现不一致自动告警
  3. 灰度不是只按百分比,而是按业务线。先灰度一个非核心业务线(比如内部管理系统),跑一周,再灰度一个 C 端小业务线,最后才灰度核心交易链路

另外,很多人忽略了一个点:数据库迁移不只是数据,还有定时任务、存储过程、视图。这些也要一并迁移或改造。"


Q6:拆分后你发现一个下单流程要跨 7 个服务,每个服务 p99 延迟 50ms,用户感知延迟是多少?怎么优化?

延迟计算:

如果 7 个服务是串行调用,用户感知延迟 ≈ 7 × 50ms = 350ms。但如果考虑长尾效应(p99 叠加),实际 p99 延迟远超 350ms。

数学上:每个服务 p99=50ms,串行 7 个的 p99:

  • 乐观情况(独立同分布):≈ 50ms × 7 = 350ms(但尾延迟叠加后会更差)
  • 实际工程中:由于网络抖动、GC 停顿、线程池排队等因素,7 个服务串行的 p99 很可能在 500ms-800ms

优化策略(按优先级排):

策略一:并行化(最直接)

java
// 串行:350ms
UserInfo user = userClient.getUser(userId);           // 50ms
Product product = productClient.getProduct(productId); // 50ms
Inventory inv = inventoryClient.checkStock(productId); // 50ms
Coupon coupon = couponClient.verifyCoupon(couponId);   // 50ms
Price price = priceClient.calculatePrice(items);        // 50ms
// ... 总共 350ms+

// 并行化:≈ 50ms(取最慢的那个)
CompletableFuture<UserInfo> userFuture = 
    CompletableFuture.supplyAsync(() -> userClient.getUser(userId));
CompletableFuture<Product> productFuture = 
    CompletableFuture.supplyAsync(() -> productClient.getProduct(productId));
CompletableFuture<Inventory> invFuture = 
    CompletableFuture.supplyAsync(() -> inventoryClient.checkStock(productId));
CompletableFuture<Coupon> couponFuture = 
    CompletableFuture.supplyAsync(() -> couponClient.verifyCoupon(couponId));

CompletableFuture.allOf(userFuture, productFuture, invFuture, couponFuture).join();

策略二:数据冗余/缓存

如果商品信息在订单流程中频繁出现,直接在订单服务里冗余一份商品快照,下单时不需要远程调商品服务。

java
// 订单服务本地冗余商品信息
public class OrderService {
    public void createOrder(CreateOrderRequest req) {
        // 不需要调商品服务,直接用发布时的商品快照
        ProductSnapshot snapshot = productSnapshotCache.get(req.getProductId());
        // 如果缓存未命中,降级调商品服务,但这是少数情况
    }
}

策略三:异步化(非关键路径)

下单流程中,不是所有步骤都需要同步完成。比如:

  • 发送通知 → 异步
  • 发优惠券 → 异步
  • 更新用户画像 → 异步

策略四:合并请求(减少调用次数)

java
// 不要这样:每个商品调一次库存服务
for (CartItem item : cart.getItems()) {
    inventoryClient.checkStock(item.getProductId());  // N 次调用
}

// 应该这样:一次批量查询
inventoryClient.batchCheckStock(cart.getItemIds());  // 1 次调用

面试中展现深度的回答:

"从 350ms 优化到 50ms 的关键一定是并行化。但并行化之后,你的瓶颈就变成了线程池。

我遇到过一个真实案例:并行化后,p99 从 350ms 降到了 80ms,但 QPS 上来后发现线程池满了,大量请求排队。原因是我们用了 ForkJoinPool.commonPool(),它被所有 CompletableFuture 共享,当请求量上去后,线程池成了新的瓶颈。

解决方案是为下单流程分配独立的线程池,并根据 Little 定律计算线程数:线程数 = 目标 QPS × 平均响应时间。对于 1000 QPS、平均响应 50ms,需要约 50 个线程。

另外,如果并行化之后还不能满足延迟要求,就要考虑数据冗余。把商品信息、用户信息在下单时冗余一份到订单里,这是用空间换时间,但要注意数据一致性问题——商品价格变了,订单里的快照还是旧价格。这需要业务上接受'订单记录的是下单时的价格'这个语义。"


面试建议:以上问题在面试中通常会以"连环追问"的形式出现——面试官会根据你的回答不断深入追问。建议你不仅记住答案,更要理解每个答案背后的权衡逻辑。好的架构师不是说"这个方案最好",而是说"在这个场景下,这个方案比那个方案更好,因为……"。


本文是 Java 技术专家与架构师面试 250 个高频考点系列的第 176 篇。