后端架构演进:从单体到云原生——一个想跳槽程序员的血泪复盘

堆内存管理员
2025-12-15 23:39
阅读 552

上周五晚上十点半,我坐在工位上盯着屏幕上那个熟悉的 OutOfMemoryError: Java heap space,手边的咖啡早就凉透。产品经理刚在群里@我:“明天上线前一定要搞定这个性能瓶颈啊,双11大促就靠你们了!” 我叹了口气,默默关掉又一个卡死的 VSCode 窗口——插件装太多确实有点吃内存,但比起我们那个快两年没重构的单体应用,这根本不算事儿。

是的,我在这家公司待快两年了。说长不长,说短不短,刚好够把一个项目从“能跑就行”写成“谁动谁死”。最近开始频繁刷 GitHub Trending、看 JD 上的“云原生”、“微服务治理”关键词,心里那点跳槽的小心思越来越藏不住。不是对公司有意见(虽然运维兄弟总说我们的部署脚本是他职业生涯最大噩梦),纯粹就是……代码人生不能一直 CRUD 到老吧?

于是,借着这次双11大促的压力,我主动跟 TL 提议:要不咱们试试把核心模块拆出来,往云原生方向挪一挪?没想到他眼睛一亮:“正好下周架构组有个 POC,你牵头搞一下?” ——好家伙,这锅接得猝不及防,但转念一想,简历上能写“主导系统云原生改造”,跳槽时底气都足三分。


起点:那个又大又臭的单体应用

先说说我们原来的架构吧。典型的 Spring Boot 单体应用,Java 写的,数据库用 MySQL,前端通过 Nginx 反向代理打进来。听起来很标准?问题在于——整个业务逻辑全塞在一个 Git 仓库里,代码量超过 30 万行。每次改个小功能,都得 pull 整个 repo,本地跑起来 VSCode 直接风扇起飞。

更离谱的是部署方式:Jenkins 打个 fat jar,扔到三台物理机上,靠 shell 脚本启停。有一次测试同学改了个配置文件路径,结果线上直接 502,因为没人记得哪台机器上的配置是最新的。运维小哥当时真的想砸电脑,而我只能默默递上我的珍藏版“Java 内存调优手册”安慰他。

资源浪费也是肉眼可见。比如订单模块和用户中心其实完全独立,但每次扩容都得整应用一起扩。双11期间为了扛住流量,硬是开了 20 台 8C16G 的机器,结果监控一看:CPU 平均负载不到 30%,大部分资源都在给“僵尸代码”陪葬。


第一步:微服务化——拆!必须拆!

既然要动刀子,那就得狠一点。我和后端组两个兄弟拉了个小群,取名“拆弹专家”。第一步就是按业务域拆服务:

  • 用户服务(User Service)
  • 商品服务(Product Service)
  • 订单服务(Order Service)
  • 支付服务(Payment Service)

每个服务独立 Git 仓库,独立数据库(后面会说坑),独立 CI/CD 流水线。我们用了 Spring Cloud Alibaba 套件(Nacos + Sentinel + Dubbo),主要是团队对阿里系技术栈比较熟,而且文档多(关键时刻能救命)。

关键代码示例:服务注册与发现

// bootstrap.yml - 每个服务都要配
spring:
  application:
    name: order-service  # 服务名,Nacos 里靠这个找你
  cloud:
    nacos:
      discovery:
        server-addr: nacos-headless:8848  # Kubernetes 里用 headless service

拆的过程中最头疼的是数据一致性。比如创建订单要扣库存、生成流水,原来在一个事务里搞定,现在跨服务了咋办?我们试过分布式事务框架 Seata,结果发现性能损耗太大(TPS 直接掉一半)。最后妥协方案:用可靠消息 + 最终一致性

伪代码示意:

// OrderService 创建订单
@Transactional
public void createOrder(Order order) {
    // 1. 本地保存订单(状态为 PENDING)
    orderRepository.save(order);
    
    // 2. 发送“扣减库存”消息到 RocketMQ
    rocketMQTemplate.syncSend("inventory-deduct-topic", 
        new InventoryDeductEvent(order.getProductId(), order.getQuantity()));
    
    // 3. 订单状态改为 PROCESSING(后续由消息消费者更新最终状态)
}

InventoryService 收到消息后尝试扣库存,成功就回发“订单确认”消息,失败则重试或人工介入。虽然做不到强一致,但业务上能接受,毕竟用户看到“下单成功”后,几秒内显示“支付成功”也正常。


第二步:容器化——Docker 是底线

微服务拆完,部署还是手动 copy jar 包?达咩!我们决定上 Docker。每个服务打成镜像,Dockerfile 写得极其克制:

# 使用官方 OpenJDK 17 Slim 镜像,小!快!稳!
FROM eclipse-temurin:17-jre-alpine

# 设置时区
RUN apk add --no-cache tzdata && \
    cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
    echo "Asia/Shanghai" > /etc/timezone

# 复制 jar 包(注意:用 build.gradle 生成的 shadowJar)
COPY build/libs/order-service-*.jar app.jar

# 启动命令(加 JVM 参数很重要!)
ENTRYPOINT ["java", "-XX:+UseG1GC", "-Xms512m", "-Xmx512m", "-jar", "app.jar"]

为什么强调 -Xmx512m?因为我们吃过亏!一开始没设堆内存上限,Kubernetes 分配 1G 内存给 Pod,结果 JVM 默认占了 90%,OOM Killer 直接把进程干掉。运维兄弟在半夜告警群里咆哮:“又是你们 Java 服务吃内存!” ——那一刻我真想把 GC 日志贴他脸上。


第三步:云原生落地——拥抱 Kubernetes

有了容器,下一步自然是上 K8s。我们用的是公司私有云的 Kubernetes 集群(别问,问就是“自研平台,文档只有 README.md”)。YAML 配置写了又删、删了又写,终于搞定了基本部署:

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 3
  template:
    spec:
      containers:
      - name: order-service
        image: registry.company.com/order-service:v1.2.0
        resources:
          requests:
            memory: "512Mi"
            cpu: "500m"
          limits:
            memory: "1Gi"
            cpu: "1000m"
        env:
        - name: SPRING_PROFILES_ACTIVE
          value: "prod"
---
# service.yaml(Headless 用于 Nacos 注册)
apiType: v1
kind: Service
metadata:
  name: order-service-headless
spec:
  clusterIP: None  # Headless Service
  ports:
  - port: 8080
  selector:
    app: order-service

这里有个坑:Nacos 在 K8s 里注册服务要用 Headless Service!否则客户端拿到的是 ClusterIP,连不上具体 Pod。这个问题卡了我们两天,最后在 GitHub 一个 issue 的评论区角落里找到答案——所以说,GitHub 不仅是代码托管,更是程序员的救命稻草。


资源优化:钱不是大风刮来的

上云之后,老板最关心什么?当然是成本。我们对比了改造前后的资源使用:

指标 单体架构 (20台) 云原生架构 (弹性伸缩)
总 CPU 核数 160 平均 40 (峰值 80)
总内存 320GB 平均 80GB (峰值 160GB)
月度成本估算 ¥80,000 ¥35,000
部署时间 30分钟+ < 5分钟

弹性伸缩是关键!我们给订单服务配了 HPA(Horizontal Pod Autoscaler),基于 CPU 和自定义指标(比如队列长度):

# hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: order-service-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: order-service
  minReplicas: 2
  maxReplicas: 20
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  - type: Pods
    pods:
      metric:
        name: queue_length  # 自定义指标,需 Prometheus Adapter
      target:
        type: AverageValue
        averageValue: "10"

双11当晚,订单服务自动从 3 个实例扩到 18 个,流量高峰一过又缩回去。运维兄弟第一次没在大促夜加班,感动得差点请我喝瑞幸。


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

  1. 分布式链路追踪必须早做
    刚拆服务时,一个请求跨 5 个服务,出了问题全靠 log grep。后来上了 SkyWalking,Trace ID 串起全链路,排查效率提升 10 倍。记住:没有可观测性,微服务就是灾难现场

  2. 数据库拆分要谨慎
    我们一开始把订单表和用户表物理分离,结果“查询带用户名的订单列表”这种需求,要么用 JOIN(跨库不行),要么冗余字段。最后妥协方案:用户服务提供 gRPC 接口,订单服务批量调用填充数据。性能虽有损耗,但可接受。

  3. 配置管理别用手动改
    早期用 Spring Cloud Config,改个配置要重启服务。现在全切到 Nacos 配置中心,动态刷新:

    @RefreshScope
    @RestController
    public class ConfigController {
        @Value("${feature.new-checkout:false}")
        private boolean newCheckoutEnabled;
    }
    

    产品经理临时要关掉新功能?改个开关,秒级生效,再也不用求运维半夜上线。


写在最后:代码人生,不止于 CRUD

折腾了三个月,系统终于稳稳跑在云原生架构上。上周复盘会上,TL 拍着我肩膀说:“干得不错,明年晋升材料我给你重点写这块。” 虽然嘴上说着“都是兄弟们给力”,但心里还是有点小得意——毕竟,这可能是我跳槽前最后一份能写进简历的大项目了。

回头看这段历程,从单体到云原生,表面是技术升级,本质是对资源、效率、稳定性的重新思考。Java 还是那个 Java,但写代码的人,得学会在更大的画布上作画。

至于跳不跳槽?暂时不急了。至少现在,我能理直气壮地在 GitHub 个人简介里写:“Experienced in cloud-native backend architecture”。万一哪天真走了,这段“拆弹”经历,绝对是我代码人生里最硬的筹码。

(P.S. 如果你也正在经历架构转型,欢迎留言交流。要是你们公司招人,且愿意让我继续折腾云原生……咳咳,懂的都懂 😉)

评论 0

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