从单体巨石到云原生微服务我在快手这两年的架构演进血泪史
说实话,来快手不知不觉已经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