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

编译通过了吗
2025-12-15 09:33
阅读 654

上周五晚上11点,公司办公室只剩我一个人。窗外下着小雨,咖啡已经凉透,屏幕上的 docker-compose up 正在疯狂输出日志。这场景简直是我入职这两个月的缩影——白天跟产品经理扯需求边界,晚上偷偷重构老系统。说真的,刚来这家公司时我以为只是个 CRUD 项目维护工,结果没想到两个月就卷进了“微服务改造”这个深水炸弹。

被逼上梁山:为什么我们要拆单体?

我们原来的系统是个典型的 JavaScript 单体应用(Node.js + Express),前后端没分离,数据库是 MySQL,部署方式是直接扔到 ECS 上跑。听起来是不是有点像十年前的架构?但说实话,在业务量不大的时候它跑得还挺稳。

直到去年双11前夕,运营同学突然搞了个“裂变拉新”活动,用户量一夜之间涨了5倍。我们的 API 延迟直接飙到3秒+,数据库连接池爆满,最离谱的是有一次订单服务把整个用户模块拖垮了——因为它们共用同一个进程和数据库连接。

运维大哥当时在群里吼:“你们能不能别在一个进程里干这么多事?!”
测试妹子补刀:“连个独立的测试环境都没有,每次上线我都怕。”
而产品经理只问了一句:“啥时候能好?”

那一刻我意识到:再不拆,迟早被线上事故搞失业。

技术选型:JS 还是 Go?成年人不做选择

作为一个深夜写代码效率更高的“夜猫子程序员”,我对性能特别敏感。老系统用的是 JavaScript(准确说是 TypeScript),开发体验确实爽,类型安全、生态丰富、调试方便。但一到高并发场景,V8 的 GC 停顿和单线程模型就成了瓶颈。

于是我和后端组的老王(一个写了十年 Go 的老炮)开了个小会。他甩给我一句灵魂拷问:“你是要开发快,还是要跑得快?”

最终我们定了个混合方案:

  • 核心交易链路(订单、支付、库存)用 Go 重写,追求极致性能和稳定性
  • 非核心业务(用户资料、通知、活动配置)保留 JavaScript/TypeScript,快速迭代

有人可能会说“技术栈不统一很麻烦”。但现实是:没有银弹。我们不是 Google,没必要为了“统一”而牺牲效率。况且现在 DevOps 工具链这么成熟,多语言部署早就不是问题。

拆分策略:别一上来就“微”到骨子里

很多新人一听说微服务,立马想拆成20个服务,每个服务只干一件事。结果呢?服务间调用复杂度爆炸,链路追踪变成噩梦,本地开发环境搭三天都跑不起来。

我们采取的是渐进式拆分

  1. 先垂直拆分:按业务域切,比如用户中心、商品中心、订单中心
  2. 再水平优化:对高频接口做缓存、异步化、限流
  3. 最后治理:加监控、日志聚合、熔断降级

举个具体例子:原来的 /api/order/create 接口,内部要查用户、查库存、扣库存、生成订单、发消息……全在一个函数里。现在拆成:

  • user-service:提供用户信息(TS)
  • inventory-service:处理库存扣减(Go)
  • order-service:创建订单(Go)
  • notification-service:发短信/邮件(TS)

服务间通信用 gRPC(Go 服务之间)和 REST(TS 服务 or 跨语言调用),消息队列用 RabbitMQ 解耦异步任务。

关键代码片段:服务通信与错误处理

Go 服务间 gRPC 调用(带超时和重试)

// inventory_client.go
func (c *InventoryClient) DeductStock(ctx context.Context, skuID string, qty int) error {
    // 设置超时,防止雪崩
    ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
    defer cancel()

    req := &pb.DeductRequest{
        SkuId: skuID,
        Qty:   int32(qty),
    }

    // 加上简单重试逻辑(生产环境建议用 retryablehttp 或 circuit breaker)
    for i := 0; i < 2; i++ {
        _, err := c.client.DeductStock(ctx, req)
        if err == nil {
            return nil
        }
        log.Printf("DeductStock failed on attempt %d: %v", i+1, err)
        time.Sleep(50 * time.Millisecond)
    }
    return fmt.Errorf("deduct stock failed after retries")
}

JavaScript 服务调用 Go 服务(REST + Circuit Breaker)

// order-service/src/utils/inventory.ts
import axios from 'axios';
import { CircuitBreaker } from 'opossum';

const inventoryAxios = axios.create({
  baseURL: process.env.INVENTORY_SERVICE_URL,
  timeout: 1000, // 1秒超时
});

// 熔断器:失败5次就断开,30秒后尝试恢复
const breaker = new CircuitBreaker(inventoryAxios.post, {
  timeout: 1200,
  errorThresholdPercentage: 50,
  resetTimeout: 30000,
});

export const deductStock = async (skuId: string, qty: number) => {
  try {
    await breaker.fire('/stock/deduct', { skuId, qty });
  } catch (err) {
    if (breaker.halfOpen) {
      console.warn('Circuit breaker is half-open, proceeding with caution');
    }
    throw new Error(`Inventory service unavailable: ${err.message}`);
  }
};

数据库设计:别让“分布式事务”变成你的噩梦

微服务最头疼的不是代码,是数据一致性。我们坚决不用两阶段提交(2PC)——太重,性能差,还容易卡死。

解决方案:Saga 模式 + 补偿事务

比如创建订单流程:

  1. order-service 创建“待支付”订单(状态=PENDING)
  2. 调用 inventory-service 扣库存
  3. 如果扣库存失败 → order-service 主动把订单状态改为 CANCELLED
  4. 如果后续支付失败 → 发送“回滚库存”消息,inventory-service 收到后加回库存

关键点:所有操作必须幂等!

我们在数据库表里加了 request_id 字段,配合唯一索引,确保同一个请求不会重复执行:

-- inventory_stock_changes 表
CREATE TABLE inventory_stock_changes (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  request_id VARCHAR(64) NOT NULL UNIQUE, -- 幂等键
  sku_id VARCHAR(32) NOT NULL,
  delta INT NOT NULL, -- 正数为加,负数为减
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

性能对比:拆完真香?

上线两周后,我们做了压测对比(模拟双11流量):

指标 单体架构 微服务架构
P99 延迟 2850ms 320ms
错误率 8.7% 0.3%
CPU 使用率(峰值) 95% 62%
部署粒度 整个应用 单个服务
故障隔离 无(全挂) 仅影响单服务

最让我开心的是:现在改用户头像功能,再也不用担心把支付系统搞崩了。运维也终于能睡个好觉——因为我们可以单独扩缩容订单服务,而不必把整个应用实例翻倍。

血泪教训:那些没人告诉你的坑

  1. 本地开发环境地狱:微服务多了,本地启动一堆依赖太痛苦。解决方案:用 Docker Compose + Makefile 封装启动命令,或者直接连测试环境(打上 dev 标签)

  2. 日志分散:以前查 bug 只要看一个日志文件,现在要 grep 十几个服务。赶紧上了 ELK(Elasticsearch + Logstash + Kibana),加 trace_id 串联请求链路

  3. 配置管理混乱:每个服务一堆 env 文件?我们迁移到了 Consul + 配置中心,支持动态刷新

  4. 网络抖动放大故障:服务 A 调 B,B 调 C,C 超时 2 秒,A 的超时设 1 秒 → 直接级联失败。务必遵守超时传递原则:下游超时 < 上游超时

  5. TypeScript 和 Go 的 JSON 兼容性:Go 的 omitempty 和 TS 的 optional 字段经常对不上。最后统一约定:所有字段显式定义,null 就是 null,不要省略

最后一点心里话

微服务不是银弹,它解决的是规模化协作高可用性问题,而不是“我的代码不够酷”。如果你的团队只有3个人,业务日活不到1万,老老实实用单体吧——别为了简历好看硬上微服务。

但如果你像我一样,被线上事故逼到凌晨三点还在修 bug,那拆分可能是你唯一的出路。

现在每次看到监控面板上平稳的曲线,我就觉得这两个月熬的夜值了。虽然产品经理还是天天催新需求,但至少我不用再担心改一行代码就让整个公司停摆。

对了,刚收到消息:下周要开始拆支付模块了。Go 写支付,想想就刺激。咖啡续上,今晚继续肝。


P.S. 有朋友问工具链,简单列一下我们用的:

  • 服务注册发现:Consul
  • API 网关:Kong
  • 链路追踪:Jaeger
  • 消息队列:RabbitMQ(后面可能换 Kafka)
  • 容器编排:K8s(测试环境用 Docker Compose)
  • 监控:Prometheus + Grafana

如果你也在经历类似改造,欢迎留言交流——尤其是那些踩过的坑,咱们一起避雷。

评论 0

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