微服务架构设计实战:从单体到分布式

郭勇
2025-12-16 06:13
阅读 339

凌晨 2:17,办公室只剩下我和空调的嗡嗡声。显示器上的日志还在刷,咖啡杯底残留着昨晚泡面的汤——别误会,不是我吃宵夜,是上周五上线微服务拆分后,连着三天排查一个诡异的分布式事务 Bug,根本没时间吃饭。

我是阿哲,在一家电商中台组做服务端开发快两年了。说“中台”其实有点心虚,毕竟我们这所谓的“中台”,两年前还是个 50 万行代码、十几个模块耦合在一起的巨型单体应用,每次发版都像在拆炸弹,产品经理提需求时我都想给他配个防爆头盔。

但今天这篇,不吐苦水,只聊干货。因为就在上个月,我们终于把那个“祖传单体”成功拆成了 8 个微服务,系统稳定性提升了一倍多,P0 级事故从月均 3 次降到 0。虽然过程堪比渡劫,但值了。这篇文章,就是想把这段实战经验开发心得掏出来,给正在或即将踏上微服务之路的兄弟们一点参考——少踩点坑,早点下班。


为什么非拆不可?不是为了时髦

很多人以为搞微服务是为了“跟风”、“显得高大上”。说实话,我们一开始也是被逼的。

去年双11前一个月,老板拍板要上“直播带货”功能,要求两周内上线。可我们的订单、库存、用户、支付全在一个 Spring Boot 应用里,改个库存逻辑,测试得跑全量回归,CI/CD 流水线动不动就红。更离谱的是,某个营销活动接口一并发高,整个服务 CPU 直接飙到 95%,连登录都卡死。

那天晚上,运维大哥在群里甩了个 Grafana 截图,标题就俩字:“救火”。我盯着那条曲线,心里只有一个念头:再这么下去,别说双11,我人都要没了。

于是,技术总监在周会上扔下一句话:“拆!必须拆!年底前微服务化,谁拦着谁走人。”——典型的互联网公司风格,deadline 是第一生产力。


拆之前先想清楚:拆什么?怎么拆?

很多人一上来就喊“DDD!领域驱动!”,结果拆完发现服务边界模糊,调用链乱成蜘蛛网。我们团队吃过这亏。

第一步不是写代码,而是画图。

我们花了整整一周,拉着产品、测试、运维开了四轮对齐会(期间产品经理差点睡着),用 EventStorming 的方式,把核心业务流程过了一遍:用户下单 → 扣库存 → 创建订单 → 支付 → 发货 → 售后。

然后,我们按业务内聚性数据一致性边界划出初步的服务:

  • user-service:用户信息、权限
  • product-service:商品、SKU、类目
  • inventory-service:库存扣减、回滚
  • order-service:订单创建、状态管理
  • payment-service:支付渠道对接
  • notification-service:短信、站内信
  • ……

📌 开发心得:不要按“模块”拆,要按“业务能力”拆。比如“购物车”看似独立,但它强依赖商品和用户,初期我们把它合并进 product-service,避免跨服务事务。

工具方面,我们用了 PlantUML 画服务依赖图,Swagger 定义 API 契约,Postman 建立 Mock Server 让前端先跑起来。这些工具不是炫技,而是保证拆分过程中前后端不脱节的关键。


通信:REST 还是 RPC?我们选了 gRPC

单体时代,内部调用就是方法调用,快得很。拆成微服务后,网络延迟就成了头号敌人。

一开始我们用 Spring Cloud OpenFeign + REST,结果压测时发现,一次下单要跨 4 个服务,总耗时从 80ms 涨到 320ms。测试妹子直接在群里@我:“阿哲,你这接口是骑共享单车去请求的吗?”

痛定思痛,我们切换到 gRPC。理由很简单:

  • Protobuf 序列化体积小、解析快
  • HTTP/2 多路复用,减少连接开销
  • 强类型接口,IDE 自动补全,少写文档

举个例子,order-service 调用 inventory-service 扣库存:

// inventory.proto
service InventoryService {
  rpc DeductStock(DeductRequest) returns (DeductResponse);
}

message DeductRequest {
  string sku_id = 1;
  int32 quantity = 2;
  string order_id = 3; // 用于幂等
}

message DeductResponse {
  bool success = 1;
  string error_code = 2;
}

生成的 Java Client 一行搞定调用:

InventoryServiceGrpc.InventoryServiceBlockingStub stub = 
    InventoryServiceGrpc.newBlockingStub(channel);
DeductResponse resp = stub.deductStock(
    DeductRequest.newBuilder()
        .setSkuId("SKU_123")
        .setQuantity(2)
        .setOrderId("ORDER_456")
        .build()
);

💡 实战经验:gRPC 虽好,但调试不如 REST 方便。我们配套搭了 gRPC-Gateway,对外暴露 REST 接口,内部走 gRPC,兼顾了运维友好性和性能。


数据一致性:Saga 模式救我狗命

微服务最头疼的,莫过于分布式事务

单体时代,@Transactional 一加,万事大吉。现在,扣库存、建订单、发通知分布在三个服务,一个失败全得回滚。XA 协议?太重。TCC?代码复杂度爆炸。

我们最终采用了 Saga 模式 + 补偿机制

以“创建订单”为例:

  1. order-service 创建订单(状态:pending)
  2. 调用 inventory-service 扣库存
    • 成功 → 继续
    • 失败 → 标记订单为 failed,结束
  3. 调用 payment-service 预占支付(如冻结余额)
    • 成功 → 订单状态变为 confirmed
    • 失败 → 触发补偿:调 inventory-service 回滚库存

关键在于:每个正向操作都要有对应的反向补偿操作,且补偿操作必须幂等。

我们用 RocketMQ 事务消息来保证 Saga 步骤的可靠性。伪代码如下:

// order-service
@Transactional
public void createOrder(OrderDTO dto) {
    Order order = saveOrder(dto); // DB 插入,状态 pending
    
    // 发送事务消息,触发扣库存
    rocketMQTemplate.sendMessageInTransaction(
        "ORDER_CREATE_TOPIC",
        MessageBuilder.withPayload(dto).build(),
        order.getId()
    );
}

@RocketMQTransactionListener
public class OrderTransactionListener implements RocketMQLocalTransactionListener {
    @Override
    public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        try {
            inventoryClient.deductStock(...); // 扣库存
            return RocketMQLocalTransactionState.COMMIT;
        } catch (Exception e) {
            return RocketMQLocalTransactionState.ROLLBACK; // 触发补偿
        }
    }

    @Override
    public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
        // 查询本地订单状态,决定是否重试
        return orderService.getStatus(msg.getOrderId()) == "confirmed" 
            ? COMMIT : ROLLBACK;
    }
}

⚠️ 血泪教训:补偿不是万能的!比如支付成功了但通知服务挂了,钱已经扣了,不能“退钱”。所以我们在关键路径上加了人工审核队列,让运营后台能手动干预。


配置与注册:Nacos 真香

早期我们用 Eureka + Config Server,结果配置改了要重启服务。运维大哥怒吼:“你们开发是不是觉得服务器不用电费?”

后来迁到 Nacos,真香。

  • 服务注册发现:秒级上下线
  • 动态配置:改个限流阈值,实时生效
  • 命名空间隔离:dev / test / prod 清晰分开

配置示例(bootstrap.yml):

spring:
  application:
    name: order-service
  cloud:
    nacos:
      discovery:
        server-addr: nacos.prod:8848
      config:
        server-addr: nacos.prod:8848
        file-extension: yaml
        namespace: prod-ns

配合 @RefreshScope,运行时刷新配置:

@RestController
@RefreshScope
public class ConfigController {
    @Value("${app.max-retry:3}")
    private int maxRetry;

    @GetMapping("/config")
    public String getConfig() {
        return "maxRetry=" + maxRetry;
    }
}

运维再也不用半夜打电话让我“赶紧重启一下服务”了——当然,他现在改催我修 Bug 了。


监控与链路追踪:没它不敢上线

微服务一拆,日志分散在 8 台机器上。以前查 Bug 是 grep,现在得拼图。

我们搭了 ELK + SkyWalking 黄金组合:

  • SkyWalking:自动埋点,看调用链、拓扑图、慢接口
  • ELK:集中日志,用 traceId 串起整条链路

效果有多爽?上周五,用户反馈“下单超时”,我在 SkyWalking 里搜订单号,3 秒定位到是 payment-service 调第三方支付网关超时,直接联系支付渠道,省了两小时抓包。

📊 性能对比(拆分前后)

指标 单体架构 微服务架构
平均响应时间(下单) 220ms 95ms
P99 延迟 1.8s 420ms
日发布次数 1~2 次 10+ 次
故障隔离能力 无(全挂) 单服务故障不影响全局

总结:微服务不是银弹,但值得折腾

回过头看,这场拆分就像一场大型手术。过程中我们踩过无数坑:

  • 忘了加熔断,一个服务雪崩拖垮全链路
  • ID 生成器没统一,订单号重复(还好没丢钱)
  • 本地缓存没清理,用户看到旧库存……

但每一次深夜 debug 后的“终于通了”,都让我觉得这行没白干。

微服务的核心不是技术,而是协作方式的升级。它逼你写清晰的接口契约,做完善的监控告警,考虑服务的自治性。这些,恰恰是高质量代码的基石。

现在,我依然经常加班到凌晨。但至少,我不再害怕改一行代码就炸掉整个系统了。而且,听说隔壁组也在准备拆,产品经理最近看我的眼神都带着敬畏——大概以为我会写魔法吧。

共勉。希望你读完这篇,能少熬几个夜,多陪陪家人。

最后一句真心话:别为了微服务而微服务。如果你的系统还没到“改一处崩全局”的地步,老老实实用单体,早点回家吃饭,它不香吗?

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝