后端架构演进:从单体到云原生,一个快手老架构师的血泪史
作者:某位在快手摸爬滚打6年的后端架构师,现远程办公中,左手Java右手K8s,偶尔被产品经理凌晨三点@问“能不能加个功能”,目前正一边撸猫一边写这篇文。
去年双11前两周,我们组差点集体提桶跑路。
事情是这样的:我负责的老系统——一个用 Spring Boot 单体架构写的用户中心,在大促流量冲进来之后,CPU 直接飙到 98%,接口响应时间从 50ms 涨到 3s+。运维兄弟在群里疯狂 @ 我:“哥,再这样下去 Pod 就要 OOM Kill 了!” 而我盯着 Grafana 面板,手心冒汗,心里默念:这破单体架构,真该重构了。
其实这事早有预兆。三年前这个系统刚上线时,代码才 5w 行,数据库就两张表,跑得飞快。但随着业务膨胀(产品经理:我们要支持多租户、要灰度发布、要 AB Test、还要实时风控……),代码量滚雪球一样涨到 80w+,模块之间耦合得像一团意大利面。每次改个小需求,都得小心翼翼,生怕牵一发而动全身。
更要命的是,现在想跳槽面试?不聊微服务、不谈云原生,你都不好意思说自己是 Java 后端。上周有个前同事来问我:“你们快手现在招人,面试题里是不是必考‘单体怎么拆成微服务’?” 我苦笑:何止是面试题,这根本就是我们每天在干的活儿。
所以今天这篇文,不讲虚的,就掏心窝子聊聊我们是怎么把一个“祖传单体”一步步演进成云原生架构的。全是实战踩坑经验,面试也能直接套用。
初期:单体架构的“甜蜜陷阱”
先说清楚,单体不是原罪。很多团队一开始用单体完全没问题,尤其是 MVP 阶段。我们最早的用户中心就是典型 Spring Boot 单体:
- 所有逻辑在一个 Git 仓库
- 一个
application.yml管所有配置 - MySQL 主从 + Redis 缓存
- Jenkins 打包成一个 fat jar,部署到三台物理机
开发效率确实高。新人第一天就能跑起来,改个接口五分钟上线。但问题也很快暴露:
- 部署耦合:哪怕只改了登录逻辑,整个应用都要重新构建、测试、发布。
- 资源浪费:用户注册模块其实很轻,但和风控这种 CPU 密集型模块绑在一起,扩容只能整体扩。
- 技术栈锁定:想试试新框架?不行,整个项目都得兼容。
最惨的一次是去年 618,风控规则更新导致线程池满,结果连注册都挂了。当时我坐在工位上,看着报警邮件刷屏,真的想砸电脑。
第一步:垂直拆分,别怕“过度设计”
很多人一听到“微服务”就想到 Spring Cloud 全家桶,其实第一步根本不需要那么重。我们的策略是:按业务边界垂直拆分,先解耦,再治理。
比如用户中心,明显能拆出:
auth-service(认证)profile-service(用户资料)risk-service(风控)
每个服务独立 Git 仓库、独立数据库(甚至可以不同引擎,比如 auth 用 MySQL,profile 用 MongoDB)、独立 CI/CD 流水线。
关键来了:怎么拆而不崩?
我们用了“绞杀者模式”(Strangler Fig Pattern):
- 新功能只往新服务写
- 老接口通过 API Gateway 路由到新服务(旧逻辑保留)
- 逐步迁移存量数据 + 下线老代码
举个例子,用户资料读取接口 /api/profile/{uid},最初在单体里。拆分后:
// 新 profile-service 的 controller
@GetMapping("/v2/profile/{uid}")
public ProfileDTO getProfile(@PathVariable String uid) {
// 从独立的 user_profile 表查
return profileService.query(uid);
}
而 API Gateway(我们用的 Kong)配置路由规则:
routes:
- name: profile-v2
paths: ["/api/profile"]
service: profile-service
旧接口依然存在,但流量慢慢切到 v2。这种渐进式迁移,老板看了都说稳。
中期:引入服务治理,否则微服务变“分布式单体”
拆完服务你以为就完了?Too young.
很快我们发现新问题:
- 服务 A 调 B,B 调 C,链路超时没人知道
- 风控服务挂了,注册服务跟着雪崩
- 本地调试要同时启动 5 个服务,IDE 内存爆掉
这时候必须上服务治理。我们选型时对比过 Dubbo 和 Spring Cloud,最终用了 Spring Cloud Alibaba(Nacos + Sentinel + Seata),原因很简单:生态成熟,社区活跃,而且阿里系组件在快手内部有长期使用经验。
重点说两个配置:
1. 服务注册与发现(Nacos)
# bootstrap.yml
spring:
application:
name: profile-service
cloud:
nacos:
discovery:
server-addr: nacos.prod.kuaishou.com:8848
2. 熔断限流(Sentinel)
@SentinelResource(
value = "getProfile",
blockHandler = "handleGetProfileBlock"
)
public ProfileDTO getProfile(String uid) {
return profileDao.selectByUid(uid);
}
// 降级逻辑
public ProfileDTO handleGetProfileBlock(String uid, BlockException ex) {
log.warn("Profile service blocked, returning empty", ex);
return ProfileDTO.EMPTY;
}
配上 Sentinel Dashboard,实时看到 QPS、RT、异常数,还能动态调整规则。再也不用求着运维改 Nginx 限流了。
终局:拥抱云原生,让运维不再背锅
如果说微服务解决了“开发耦合”,那云原生解决的就是“运维噩梦”。
以前每次大促,运维兄弟都要手动扩缩容,半夜爬起来调 HPA 阈值。现在?全交给 Kubernetes。
我们的部署单元从 “fat jar on VM” 变成了 Docker + Helm + K8s:
- 每个服务打包成 Docker 镜像
- 用 Helm Chart 管理 K8s 资源(Deployment, Service, Ingress)
- 自动扩缩容基于 CPU + 自定义指标(比如消息队列积压)
一个典型的 values.yaml:
replicaCount: 3
resources:
limits:
cpu: 1000m
memory: 1Gi
requests:
cpu: 500m
memory: 512Mi
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 20
targetCPUUtilizationPercentage: 70
效果有多猛? 上周五大促预演,流量突增 5 倍,K8s 在 2 分钟内自动扩容到 18 个 Pod,CPU 稳稳压在 65% 以下。运维兄弟终于能在周五晚上准时下班了(感动哭)。
数据库怎么办?别忘了“状态”最难搞
很多人只关注无状态服务的拆分,却忽略了数据库才是真正的耦合源头。
我们拆服务时,坚持一个原则:每个微服务独占数据库,禁止跨库 join。
但历史数据怎么办?比如用户 ID 在 auth 库,profile 在另一个库。我们做了两件事:
- 异步同步:用 Canal 监听 MySQL binlog,把关键字段同步到 profile 库(最终一致性)
- 冗余设计:profile 表里存
uid+username(虽然反范式,但避免跨服务调用)
接口设计也变了味儿:
// 以前:一个接口返回完整用户信息(含认证状态)
public UserFullInfo getUserFull(String uid);
// 现在:只返回本服务数据,前端自己聚合
public ProfileDTO getProfile(String uid); // 只含资料
// 认证状态由前端另调 /auth/status?uid=xxx
牺牲一点便利性,换来系统弹性,这买卖值。
性能优化:云原生不是银弹,Java 还得精调
上了 K8s 就万事大吉?别天真了。
我们遇到一个经典问题:Pod 启动慢。Java 应用冷启动要 40s+,K8s 健康检查超时直接 kill,陷入重启循环。
解决方案:
- 用 GraalVM Native Image 编译(启动 <1s,但内存稍高)
- 或者保守点:优化 JVM 参数 + 延长 readinessProbe 初始延迟
readinessProbe:
initialDelaySeconds: 60 # 给足冷启动时间
periodSeconds: 10
还有 GC 问题。K8s 容器内存限制 1Gi,但 JVM 默认堆可能超限被 OOM Kill。必须显式设置:
# Dockerfile
ENV JAVA_OPTS="-Xmx768m -Xms768m -XX:+UseG1GC"
记住:在容器里跑 Java,JVM 参数不是可选项,是保命符。
面试怎么聊?附赠几道高频题
现在回看这段演进,其实每一步都是被业务逼出来的。但如果你正在求职,这些经验可以直接变成面试加分项。
最近帮团队面试,我常问这几题:
| 面试题 | 考察点 |
|---|---|
| “单体拆微服务,你怎么确定服务边界?” | 领域驱动设计(DDD)理解 |
| “服务拆了,数据一致性怎么保证?” | 分布式事务方案(Saga/TCC/本地消息表) |
| “K8s 上 Java 应用 OOM,怎么排查?” | 容器内存模型 vs JVM 内存模型 |
| “API Gateway 和 Service Mesh 有什么区别?” | 架构演进趋势理解 |
我的建议:别死背答案,讲清楚你踩过的坑。比如:“我们一开始用 Seata AT 模式,结果发现性能扛不住,后来改成本地消息表+定时补偿,虽然复杂但稳。”
最后说点人话
从单体到云原生,不是技术炫技,而是业务规模倒逼下的必然选择。我们花了一年半才走完这条路,中间熬过无数个通宵,也被线上事故教育过。
但回头看,值得。
现在的系统,新同学入职三天就能独立开发一个服务;大促期间我能安心睡觉;连产品经理都学会说“这个需求要不要考虑下服务边界?”(虽然下一秒又说“能不能明天上线”)。
如果你也在经历类似痛苦,别慌。架构演进没有银弹,只有不断试错、小步快跑。记住:你不是一个人在战斗,全世界的 Java 后端都在和单体相爱相杀。
对了,文末彩蛋:我们团队还在招人,要求不高,会写 Java、懂点 K8s、能接受偶尔加班就行(狗头保命)。简历砸过来,面试题我亲自出——保证不问“Redis 为什么快”,只聊真实场景。
(完)
P.S. 写完这篇,我家猫把键盘当床睡了。果然,程序员最好的伴侣,一个是 Git,一个是猫。

评论 0