后端架构演进:从单体到云原生——一个想跳槽程序员的血泪复盘
上周五晚上十点半,我坐在工位上盯着屏幕上那个熟悉的 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 个,流量高峰一过又缩回去。运维兄弟第一次没在大促夜加班,感动得差点请我喝瑞幸。
血泪教训:那些没人告诉你的坑
分布式链路追踪必须早做!
刚拆服务时,一个请求跨 5 个服务,出了问题全靠 log grep。后来上了 SkyWalking,Trace ID 串起全链路,排查效率提升 10 倍。记住:没有可观测性,微服务就是灾难现场。数据库拆分要谨慎
我们一开始把订单表和用户表物理分离,结果“查询带用户名的订单列表”这种需求,要么用 JOIN(跨库不行),要么冗余字段。最后妥协方案:用户服务提供 gRPC 接口,订单服务批量调用填充数据。性能虽有损耗,但可接受。配置管理别用手动改
早期用 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