后端架构演进:从单体到云原生,一个快手老架构师的血泪史

曹桂英○
2025-12-13 01:13
阅读 655

作者:某位在快手摸爬滚打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,部署到三台物理机

开发效率确实高。新人第一天就能跑起来,改个接口五分钟上线。但问题也很快暴露:

  1. 部署耦合:哪怕只改了登录逻辑,整个应用都要重新构建、测试、发布。
  2. 资源浪费:用户注册模块其实很轻,但和风控这种 CPU 密集型模块绑在一起,扩容只能整体扩。
  3. 技术栈锁定:想试试新框架?不行,整个项目都得兼容。

最惨的一次是去年 618,风控规则更新导致线程池满,结果连注册都挂了。当时我坐在工位上,看着报警邮件刷屏,真的想砸电脑。


第一步:垂直拆分,别怕“过度设计”

很多人一听到“微服务”就想到 Spring Cloud 全家桶,其实第一步根本不需要那么重。我们的策略是:按业务边界垂直拆分,先解耦,再治理

比如用户中心,明显能拆出:

  • auth-service(认证)
  • profile-service(用户资料)
  • risk-service(风控)

每个服务独立 Git 仓库、独立数据库(甚至可以不同引擎,比如 auth 用 MySQL,profile 用 MongoDB)、独立 CI/CD 流水线。

关键来了:怎么拆而不崩?

我们用了“绞杀者模式”(Strangler Fig Pattern):

  1. 新功能只往新服务写
  2. 老接口通过 API Gateway 路由到新服务(旧逻辑保留)
  3. 逐步迁移存量数据 + 下线老代码

举个例子,用户资料读取接口 /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 在另一个库。我们做了两件事:

  1. 异步同步:用 Canal 监听 MySQL binlog,把关键字段同步到 profile 库(最终一致性)
  2. 冗余设计: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

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