后端架构演进:从单体到云原生 —— 一位老码农的实战经验分享

王平·
2025-06-22 15:00
阅读 603

背景介绍

背景介绍

我在一家中型互联网公司做了近十年的后端开发,最早的时候我们用的是Spring Boot搭建的单体应用,部署在一台物理服务器上。那时候系统规模小,功能不多,代码量大概也就几万行,数据库也没怎么优化,接口响应时间都在毫秒级,一切都显得刚刚好。

但随着业务的发展,用户量、订单量、商品数量都在增长,系统的压力也在不断增加。最初我们尝试横向扩容,加了几台服务器做负载均衡,但发现效果并不理想,因为很多服务模块之间高度耦合,共享数据库,根本无法独立部署和伸缩。

后来,我被安排负责整个技术架构的改造工作,目标是将单体应用逐步拆分为微服务,并最终迁移到Kubernetes(简称K8s)平台实现云原生化。这个过程持续了将近两年,踩了很多坑,也收获了不少经验。今天我想以第一人称的角度,结合真实项目中的场景,和大家分享这次架构升级的全过程。


遇到的问题与挑战

遇到的问题与挑战

1. 单体应用带来的瓶颈

我们的核心系统是一个电商后台服务,包括用户管理、商品管理、订单中心、支付模块、库存管理等多个子系统,都集成在一个Spring Boot工程中:

/src
 ┣ main/
 ┃ ┣ java/
 ┃ ┃ ┣ controller/
 ┃ ┃ ┣ service/
 ┃ ┃ ┣ entity/
 ┃ ┃ ┣ config/
 ┃ ┣ resources/
 ┃ ┃ ┣ application.yml

这种结构的好处是开发简单,本地运行快,部署容易,但它很快暴露出一系列问题:

  • 发布风险大:一个小功能上线都要重新部署整个应用,出问题影响面广。
  • 代码臃肿:各个模块之间没有边界,常常一个类引用另一个模块的服务,甚至直接访问其他表。
  • 扩展性差:想对某个模块单独扩容基本不可能,数据库成了瓶颈。
  • 维护成本高:团队人数增多后,不同人改同一段代码经常冲突。

2. 运维自动化程度低

之前我们使用Shell脚本 + Jenkins手动打包部署。每次上线都需要专人执行命令,一旦出错就得回滚,效率低且容错率差。

3. 没有统一的服务治理能力

服务之间的调用全靠硬编码配置或者内网IP直连,缺乏统一注册发现机制,也没有限流、熔断这些高级能力。


架构改造思路与实施路径

架构改造思路与实施路径

为了解决这些问题,我们决定走一条“先拆分、再微服务、最后上云”的渐进式路线:

第一阶段:单体应用拆分(Monolith to Microservices)

我们选择从业务逻辑相对清晰的几个模块开始拆分:订单中心、支付服务、用户服务、商品服务。

服务拆分策略

我们采取了以下策略进行拆分:

  • 按领域划分:每个服务专注于自己的业务领域,比如订单只处理订单相关的创建、查询、状态变更等。
  • 数据库分离:为每个服务分配独立数据库实例,避免数据层耦合。
  • 对外提供REST API:服务间通过HTTP API通信(后续替换为gRPC),同时保留内部调用SDK(如Feign Client封装)。

拆分遇到的技术难题

  • 服务间的依赖管理:早期服务之间存在大量互相调用,如果不合理设计可能会导致循环依赖。
  • 数据一致性问题:订单服务需要同步用户信息,我们引入了基于Event Driven的设计,借助消息队列(我们用了Kafka)实现异步更新。

举个例子:用户修改手机号时会发布一个UserUpdatedEvent,订单服务消费该事件并更新对应的数据缓存。

@Component
public class UserEventListener {

    private final OrderService orderService;

    public UserEventListener(OrderService orderService) {
        this.orderService = orderService;
    }

    @KafkaListener(topics = "user.updated")
    public void handleUserUpdate(UserDTO userDTO) {
        orderService.updateUserDetails(userDTO.getUserId(), userDTO.getPhone());
    }
}

这段代码虽然看起来简单,但我们初期没考虑到的是消息重复消费的问题,后来加上了幂等控制字段(例如用户ID + eventID 的Redis布隆过滤器)才解决。

接口设计注意事项

我们在定义接口时遵循了以下几个原则:

  • 所有接口必须使用统一格式的返回对象,比如 ResponseEntity<ApiResponse<T>>
  • 错误码标准化,前端可识别处理
  • 分页、排序、过滤等通用参数抽象成PageRequest/SortRequest对象

这为后续服务治理打下了良好的基础。


第二阶段:引入服务治理体系

为了更好地管理服务间通信和监控,我们引入了以下组件:

工具 作用
Spring Cloud Alibaba Nacos 注册中心+配置中心
Sentinel 限流、熔断
OpenFeign + LoadBalancer 服务间通信
Sleuth + Zipkin 分布式链路追踪

其中,我们最受益的其实是 Sentinel + Nacos的组合。它可以动态配置规则,在控制台上实时调整限流策略。

示例:限流配置在Nacos中定义

spring:
  cloud:
    sentinel:
      datasource:
        ds1:
          nacos:
            server-addr: 127.0.0.1:8848
            data-id: order-service-sentinel-rules
            group: DEFAULT_GROUP
            rule-type: flow

然后,我们在Nacos上维护类似如下内容:

[
  {
    "resource": "/api/order/create",
    "count": 100,
    "grade": 1,
    "limitApp": "default"
  }
]

这就表示,针对创建订单接口设置每秒QPS不能超过100,超过后拒绝请求,保护下游系统。


第三阶段:向云原生迁移(Cloud Native with Kubernetes)

前面两个阶段完成后,我们已经完成了微服务的基础建设。这时候,我们决定进一步拥抱云原生理念,将所有服务容器化,并使用Kubernetes进行编排管理。

容器化改造

我们首先将所有服务打包为Docker镜像:

FROM openjdk:8-jdk-alpine
ADD *.jar app.jar
ENTRYPOINT ["java", "-jar", "./app.jar"]

然后编写Deployment和Service YAML文件进行部署:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      containers:
        - name: order-service
          image: registry.example.com/order-service:latest
          ports:
            - containerPort: 8080
          envFrom:
            - configMapRef:
                name: common-config
---
apiVersion: v1
kind: Service
metadata:
  name: order-service
spec:
  selector:
    app: order-service
  ports:
    - protocol: TCP
      port: 8080
      targetPort: 8080

Kubernetes给我们带来的收益

  • 弹性伸缩:根据CPU使用情况自动扩缩Pod
  • 健康检查:Liveness / Readiness Probe保障服务稳定性
  • 滚动更新:支持灰度发布、失败自动回滚

我们还接入了Prometheus + Grafana做监控告警体系,大大提升了可观测性。


开发与运维过程中踩过的坑

开发与运维过程中踩过的坑

坑1:服务注册慢或丢失

一开始我们用Zookeeper做服务注册,后来换成了Nacos。结果在压测环境下发现某些服务节点频繁注册失败,排查后发现是因为心跳检测超时设置过短,导致部分网络波动大的环境出现反复下线。

最终我们增加了重试次数、拉长了心跳间隔,并启用了服务健康检查,才稳定下来。

坑2:日志分散难查找

微服务多了以后,日志变得特别分散。最初我们只是把日志输出到各自Pod的目录里,查问题非常痛苦。后来我们引入ELK(Elasticsearch + Logstash + Kibana)统一收集日志,并设置了索引模板,按服务名、日期分类存储。

坑3:K8s内存溢出却无报错

有一次上线后突然有服务频繁OOM,但K8s并没有报错。排查了很久才发现是Java虚拟机默认堆大小没限制,导致JVM申请超过Pod配额,被K8s Kill掉但日志中没记录。

解决方案是在启动命令中显式指定:

CMD ["java", "-Xms256m", "-Xmx512m", "-jar", "./app.jar"]

并且在K8s资源限制中增加内存约束:

resources:
  requests:
    memory: "512Mi"
    cpu: "200m"
  limits:
    memory: "1Gi"
    cpu: "1"

坑4:服务初始化慢导致探针失败

有些服务启动要加载几十张表元数据,耗时比较长,LivenessProbe 设置太短的话会导致Pod一直 restarting。

解决方案是给健康检查加上合理的初始化延迟:

livenessProbe:
  httpGet:
    path: /actuator/health
    port: 8080
  initialDelaySeconds: 30
  periodSeconds: 15

改造后的效果与收益

经过近两年的努力,我们完成了一次成功的架构升级。系统在多个维度都有明显提升:

维度 原始状态 改造后状态
系统可用性 月故障多次 连续三个月零事故
发布效率 全量部署,需停机 滚动发布,无需停机
故障隔离能力 出问题影响全局 故障限于局部服务
弹性扩容能力 固定服务器数量 根据流量自动扩缩
日志与监控可视性 分散、难以定位 集中式、可视化、告警联动
技术债务清理 模块耦合严重 清晰边界、服务自治

团队协作也更加顺畅。大家各司其职,专注自己负责的服务模块,再也不用担心别人改代码会影响你的服务。


我的一些经验建议

如果你正在考虑或正在进行类似的架构升级,我有几点建议送给你们:

✅ 不要盲目追求“微服务”

微服务不是银弹。如果你的系统还没到一定复杂度,拆得太早反而带来管理和运维负担。先做好模块化设计,等真正需要时再拆也不迟。

✅ 数据库一定要尽早独立

很多人在拆服务的时候忽略了数据库拆分,这是大忌。数据层如果不解耦,服务之间依然会互相牵制,无法做到真正的“微”。

✅ 服务治理要跟上节奏

拆服务后如果没有配套的注册中心、限流、链路追踪等能力,你会发现服务更容易挂、更难排查问题。

✅ 自动化运维必不可少

CI/CD流水线、容器编排、监控告警这些工具链,都是提升研发效能的核心要素。不要想着“手动操作也可以”,越早自动化越好。

✅ 保留过渡方案,别一步到位

我们当初是边写新服务边兼容旧代码,中间用了不少Adapter模式做适配,直到完全替代为止。这样可以降低切换风险,也能随时回滚。


结语:写给后端开发者的一句话

技术演进从来都不是一蹴而就的事。我在这一路上经历了无数版本迭代、无数次深夜调试、也有过迷茫和怀疑。但我始终相信:一个好的架构,应该服务于业务,而不是反过来;它应该是让工程师更高效、让团队协作更顺畅的工具。

希望我的这段经历能帮你在面对技术选型、架构决策时多一份底气,少一点焦虑。如果你也有类似的实践故事,欢迎留言交流~我们一起成长!

—— 来自一线的老码农 🧱

评论 0

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