从单体巨石到云原生微服务我在快手这两年的架构演进血泪史

神奇的创造者
2026-06-08 04:27
阅读 6410

说实话,来快手不知不觉已经6年了。从刚毕业时的懵懂小白,到现在带着兄弟们从0到1扛过几个核心系统的架构演进,中间踩的坑、背的锅,估计能写好几本厚书了。我平时主力机是一台顶配的MacBook Pro,写代码、跑Docker全靠它,Windows对我来说基本就是个摆设,偶尔用来测测IE兼容或者连一下内网那些上古时代的老旧堡垒机。

来现在这个业务组快2年了,对云原生和K8s算是玩得比较溜。最近组里新来了几个校招生,天天围着我问微服务到底该怎么拆,看着他们清澈又愚蠢的眼神,仿佛看到了当年的自己。刚好周末不用去公司加班,就想着把这两年带团队做微服务架构演进的踩坑经验整理一下。

顺便提一嘴,最近我在啃几本外网的云原生原版书籍,遇到一些生僻的专业词汇或者长篇大论的架构理论时,我会丢给讯飞星火大模型去帮我做总结和翻译。你还别说,这玩意儿理解上下文的能力挺强,帮我节省了不少查字典和翻论坛的时间,效率直接拉满,算是我最近挖到的一个提效小神器。

好了,闲话少叙,咱们直接切入正题,聊聊这两年我是怎么把组里那个“祖传”单体应用一步步拆成云原生微服务的。

那个让人头秃的单体巨石

两年前刚接手这个组的时候,我面对的是一坨名副其实的“代码屎山”。整个业务线就一个巨大的Spring Boot单体应用,代码里充斥着各种if-else和长达几千行的“上帝类”。每次打包打出来的Jar包好几百兆,启动一次慢得像蜗牛。

最让我崩溃的是去年双11前夕的那次线上事故。当时流量刚起来,某个非核心的边缘模块(好像是搞什么签到积分计算的)因为一个慢SQL,直接把数据库连接池给打满了。好家伙,这一下不要紧,连带着核心的交易下单链路也全部超时挂掉。当时看着监控大盘上一片刺眼的红色,听着钉钉群里运维和产品经理的疯狂@,我当时真的想砸电脑。

痛定思痛,这种“牵一发而动全身”、扩容只能整体扩容导致成本极高的单体架构,必须得重构了。

拆微服务的“血泪”踩坑记

重构方案汇报通过后,我们开始了轰轰烈烈的微服务拆分。但理想很丰满,现实很骨感,中间踩的坑简直数不胜数。

坑一:为了拆而拆,粒度细到令人发指

刚开始拆的时候,我犯了个新手常犯的错误:恨不得把每个业务模块都拆成独立的微服务。结果呢?一个简单的前端页面渲染,后端需要聚合用户信息、商品信息、营销信息,内部RPC调用套了七八层。

原本单体应用里一个本地方法调用只要几毫秒,拆完之后,一个接口的RT(响应时间)直接飙到了三四百毫秒。网关层一看超时率直线上升,差点把我祭天。

教训:微服务绝对不是越细越好!后来我带着兄弟们重新用DDD(领域驱动设计)的思想梳理了一遍业务边界,把强相关的聚合根合并,把一些只需要简单CRUD的模块做成内部公共组件而不是独立服务。最终将服务数量从最初的20多个精简到了合理的8个,RT也降回了50ms以内。

坑二:分布式事务的痛,谁用谁知道

以前在单体里,一个@Transactional注解就能搞定的事情,拆分成订单服务和库存服务后,跨库了,本地事务直接失效。

一开始我们天真地上了2PC(两阶段提交),结果在压测的时候发现性能惨不忍睹,数据库锁竞争严重,吞吐量直接腰斩。后来果断弃用,改成了基于RocketMQ的最终一致性方案。

但这里又踩了个坑:MQ消费端的幂等性没做好。有一次网络抖动,MQ重试了一条消息,结果用户的库存被多扣了一次。产品经理拿着客诉单来找我的时候,我尴尬得能用脚趾抠出个三室一厅。

解决:后来我们在数据库层面设计了防重表,结合Redis的分布式锁,在消费端做了严格的幂等校验。每次消费前先查防重表,利用数据库的唯一索引来兜底。虽然代码写得啰嗦了点,但总算把这块硬骨头啃下来了。

坑三:K8s运维的毒打与JVM OOM

我对K8s比较熟,所以顺理成章地把所有服务都容器化部署到了K8s集群上。但刚上K8s的第一个星期,我就被Pod频繁OOM(Out Of Memory)重启给教做人了。

一开始我以为是代码有内存泄漏,用Arthas看了半天dump文件,啥也没看出来。后来仔细一查K8s的Events,才发现是JVM参数没适配容器限制。

老版本的JVM在识别内存时,会去读取宿主机的物理内存,而不是读取K8s分配给Pod的Cgroups限制内存。比如Pod限制了2G内存,但宿主机有64G,JVM就会按照64G来分配堆内存,结果稍微一跑就超出了2G的限制,被K8s无情地Kill掉。

解决:在启动脚本里加上了-XX:+UseContainerSupport参数(JDK 8u191之后默认开启,但老镜像里没生效),并且显式配置了-XX:MaxRAMPercentage=75.0。看着Pod终于稳如老狗不再重启,那一刻,开心。

干货时间:核心配置与代码

光说不练假把式,下面分享一些我们生产环境在用的核心配置和代码片段。

K8s Deployment 核心配置

这是我们在K8s里部署Java服务的标准模板,重点看JVM参数和探针配置:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 3
  template:
    spec:
      containers:
      - name: order-service
        image: registry.kuaishou.com/order-service:v1.2.3
        resources:
          requests:
            memory: "2Gi"
            cpu: "1"
          limits:
            memory: "2Gi"
            cpu: "2"
        env:
        - name: JAVA_OPTS
          # 核心:开启容器支持,限制堆内存比例,使用G1垃圾回收器
          value: "-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -XX:+UseG1GC -XX:MaxGCPauseMillis=200"
        # 存活探针:判断Pod是否活着,失败则重启
        livenessProbe:
          httpGet:
            path: /actuator/health/liveness
            port: 8080
          initialDelaySeconds: 60
          periodSeconds: 10
        # 就绪探针:判断Pod是否准备好接收流量,失败则从Service端点剔除
        readinessProbe:
          httpGet:
            path: /actuator/health/readiness
            port: 8080
          initialDelaySeconds: 30
          periodSeconds: 5

RocketMQ 事务消息消费端幂等代码

针对前面提到的分布式事务坑,这是我们在消费端做的幂等处理核心逻辑:

@Component
public class InventoryMessageListener implements MessageListenerConcurrently {

    @Autowired
    private InventoryMapper inventoryMapper;
    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Override
    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
        for (MessageExt msg : msgs) {
            String msgId = msg.getMsgId();
            String lockKey = "mq:idempotent:" + msgId;
            
            // 1. 利用Redis分布式锁防并发
            Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.MINUTES);
            if (Boolean.FALSE.equals(locked)) {
                log.warn("消息正在处理中, msgId: {}", msgId);
                return ConsumeConcurrentlyStatus.RECONSUME_LATER;
            }

            try {
                // 2. 查询数据库防重表,利用唯一索引兜底
                int count = inventoryMapper.checkIdempotent(msgId);
                if (count > 0) {
                    log.info("消息已消费, 直接跳过, msgId: {}", msgId);
                    return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
                }

                // 3. 执行核心业务逻辑(扣减库存)
                processInventoryDeduction(msg);

                // 4. 记录防重表
                inventoryMapper.insertIdempotentRecord(msgId);
                
            } catch (Exception e) {
                log.error("消费消息异常, msgId: {}", msgId, e);
                // 发生异常,释放锁,让MQ重试
                redisTemplate.delete(lockKey);
                return ConsumeConcurrentlyStatus.RECONSUME_LATER;
            }
        }
        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
    }
}

数据库与接口设计的血泪考虑

微服务拆分后,数据库和接口设计也得跟着变,这里也分享两个我们踩过的坑。

分库分表后的全局ID生成: 订单库拆分后,不能再依赖MySQL的自增主键了。我们一开始用UUID,结果发现B+树索引在插入UUID时会导致严重的页分裂,性能极差。后来改成了基于雪花算法(Snowflake)的改进版,结合了WorkerId自动注册到Zookeeper的逻辑,保证了全局唯一且趋势递增,插入性能直接提升了30%。

内部RPC接口的版本控制: 微服务之间通过Dubbo进行RPC调用。这里有个大坑:老接口一旦发布,就永远不敢删了。因为你可能根本不知道哪个边缘的下游服务还在调用它。 我们现在的规范是:接口升级必须加版本号(比如OrderServiceV2),老接口标记为@Deprecated,并在代码里加上监控埋点。如果连续30天老接口调用量为0,才敢在下一个大版本里物理删除。这虽然导致代码里多了一些冗余,但比起线上事故,这点冗余算个屁。

改造前后的指标对比

为了让大家更直观地看到改造效果,我拉了一下去年双11和今年618的核心指标对比:

指标维度 单体架构时期 (去年双11) 微服务云原生架构 (今年618) 提升/优化效果
核心接口平均RT 350ms 45ms 响应速度提升近8倍
单机QPS上限 800 2500 吞吐量提升3倍以上
发版频率 每周1次(且经常回滚) 每天多次(平滑发布) 交付效率大幅提升
资源利用率 15% (为了峰值冗余) 65% (K8s HPA弹性伸缩) 服务器成本节省约40%
故障隔离性 一挂全挂 非核心模块故障不影响主链路 系统可用性达到99.99%

总结与心得

回头看这两年的架构演进,我最大的感触就是:架构是演进而来的,不是坐在办公室里设计出来的。

不要盲目追求业界最新的技术栈,什么Service Mesh、什么Serverless,适合团队当前业务阶段和人员技术储备的,才是最好的。我们当初如果一上来就搞Service Mesh,估计现在还在填 Istio 的坑,根本没精力去梳理业务逻辑。

微服务改造是一场持久战,它不仅仅是技术上的重构,更是对团队协作、运维体系、甚至产品迭代流程的一次全面升级。在这个过程中,多和运维兄弟喝喝酒,多给产品经理讲讲技术边界,能帮你少背很多锅。

好了,今天就聊到这。马上又要到周五的晚高峰了,希望今晚的线上监控一片绿,千万别再有奇葩的Bug来打扰我周末打英雄联盟了。各位码农兄弟姐妹们,祝大家永无Bug,早日财富自由!

评论 0

最热最新
暂无评论
神奇的创造者Lv.1
0
影响力
0
文章
0
粉丝