从零到上线:一次性能优化的实战记录

前端小茶馆
2025-06-22 13:09
阅读 203

大家好,我是阿凯,一名在一线互联网公司深耕5年的后端工程师。这五年来,我参与过多个大型系统的架构演进、技术攻坚与性能优化工作。今天,想和大家分享一个我在实际项目中经历的技术探索与实践优化的真实故事。

这个故事发生在去年我们团队主导的一个推荐系统重构项目上。当时系统已经运行了一年多,随着业务量的不断增长,我们遇到了一系列性能瓶颈和稳定性问题,最终通过一系列技术手段完成了从“能用”到“好用”的跃迁。

希望我的经历,能够给你在做性能优化时一些启发,少走些弯路。


背景介绍:推荐服务承载的压力

背景介绍:推荐服务承载的压力

我们的推荐系统是一个典型的 CTR(点击率)预估系统,用于为 App 主页 Feed 流提供个性化内容排序支持。核心模块包括:

  • 用户行为特征实时计算
  • 离线画像更新
  • 实时召回 & 排序模型调用
  • 服务编排与接口聚合

该系统每天承接数百万级请求,响应时间要求控制在 100ms 以内(P99),同时 QPS 峰值达到了惊人的 3000。

起初,我们使用的是 Spring Boot 搭建的服务,依赖 Redis 作为用户状态缓存,Elasticsearch 用于召回数据存储,整体服务部署在 Kubernetes 集群中。

但随着时间推移,逐渐暴露出几个严重的问题:

  • 响应延迟高且不稳定
  • CPU 利用率奇高
  • 部分节点频繁出现 Full GC 导致雪崩效应
  • 线上报警频发

这些问题严重影响了用户体验和业务指标,尤其在大促期间,系统几度濒临崩溃边缘。


问题定位:找到性能瓶颈点

问题定位:找到性能瓶颈点

面对问题,我们决定采用科学的排查方式逐步分析:

第一步:监控数据 + 日志抽样

我们接入了 Prometheus + Grafana 监控体系,并对关键链路进行了埋点日志记录。发现几个明显异常点:

  • 单个请求中对 Redis 的查询次数平均达到 12 次/次
  • 多处代码存在 同步阻塞式调用,尤其是特征拼装阶段
  • JVM GC 时间波动较大,最高峰时 GC 停顿可达 1.2s
  • Elasticsearch 查询返回数据偏大,导致序列化耗时长

我们画出了请求调用链的时间分布图(如下示意):

[入口] --> 特征准备(40%) --> 模型调用(35%) --> 数据组装(25%)

可见,“特征准备”环节成为了整个链路上最拖后腿的部分。

第二步:代码 Review

进一步查看代码,发现大量重复调用:

public UserFeature getUserFeature(Long userId) {
    String key = "user:" + userId;
    return redis.get(key); // 同步调用
}

每个特征字段都需要单独查 Redis,还都是同步阻塞操作!更糟的是,这些方法被分布在多个不同的组件中,无法统一管理。

除此之外,我们还在模型调用层做了很多无谓的包装逻辑,包括 JSON 序列化、参数转换、空值处理等,这部分也在无意中增加了 CPU 开销。


技术方案设计:优化策略落地

技术方案设计:优化策略落地

在明确问题根源之后,我们制定了以下优化方案:

优化方向 具体措施 收益预期
减少 Redis 调用次数 使用 Pipeline + 批量读取 降低 RT
异步化处理 特征加载异步化,使用 CompletableFuture 提升并发能力
模型调用优化 使用 gRPC + 缓存热数据 降低 IO 和 CPU 开销
GC 配置调整 新生代扩容 + G1 回收器 减少 Full GC 频率

实施细节一:特征获取优化

我们首先重构了特征获取模块,引入 Redis 批量 Pipeline:

public Map<String, Feature> batchGetUserFeatures(List<Long> uids) {
    List<String> keys = uids.stream().map(uid -> "user:" + uid).toList();
    
    try (Jedis jedis = pool.getResource()) {
        Pipeline pipelined = jedis.pipelined();
        
        keys.forEach(pipelined::get);
        pipelined.sync();  // 执行
        
        List<Response<byte[]>> responses = new ArrayList<>();
        pipelined.getResponseHandler().getResponse(responses);
        
        // 将结果映射回对象...
    }
}

这一步将原来的 N 次串行调用变为 1 次批量调用,显著降低了网络往返时间。

随后我们将整个特征构建过程改为基于 CompletableFuture 的组合式编程:

CompletableFuture<UserFeature> userF = featureService.getUserFeature(uid);
CompletableFuture<BehaviorData> behaviorF = featureService.getBehaviorFeature(uid);
CompletableFuture<Void> allDone = CompletableFuture.allOf(userF, behaviorF);

allDone.thenApply(v -> {
    // 组合两个特征进行后续处理
});

这样一来,主线程不再被阻塞等待,可以继续处理其他请求,提升了吞吐能力。

实施细节二:模型调用性能优化

模型服务我们采用 TensorFlow Serving 搭建,对外暴露 gRPC 接口。最初我们使用的是 HTTP + JSON 方式的通信,后来改为 gRPC 并使用 ProtoBuf 进行数据传输后,整体 RT 下降了 30%。

不仅如此,我们还实现了“预热 + 局部缓存”机制,在高频访问下命中本地 LRU Cache:

LoadingCache<Key, Result> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(30, TimeUnit.SECONDS)
    .build(this::loadModelResult);

public Result predict(Key key) {
    return cache.get(key);
}

这种方式有效减少了与模型服务器之间的交互频率,缓解了整体压力。

实施细节三:JVM 配置调整

针对 Full GC 频繁问题,我们进行了 JVM 参数调优:

  • 堆内存从 -Xmx4g 扩容至 -Xmx8g
  • 新生代大小由默认比例调整为 -XX:NewRatio=2
  • 使用 G1GC 替代 CMS
  • 设置合适的 MaxMetaspaceSize 防止元空间溢出

最终效果非常明显:Full GC 次数从每小时十几次减少到几乎每月才触发一次,GC 停顿时间也下降到了毫秒级别。


效果总结:上线后的收益变化

经过约两个月的持续优化迭代,最终版本上线后取得了非常亮眼的成果:

指标 优化前 优化后 提升幅度
请求平均 RT 145ms 67ms 53%↓
CPU 使用率 80%+ 50%~60% 明显改善
JVM GC 停顿 最高达 1.2s < 50ms 极大幅度改善
QPS 支撑上限 1800 3200+ 提升近一倍
错误率 > 0.5% < 0.1% 大幅降低

最重要的是:系统终于能扛住节假日流量冲击了!而且线上报警频率下降了 90%,运维同学纷纷表示“终于睡得着觉了”。


我的几点建议:性能优化中的经验教训

从事这几年系统开发,我积累了不少性能优化的经验,这里分享几点我觉得特别实用的建议:

✅ 1. 性能优化要从监控开始,不要靠猜测

不要凭感觉说“这里应该慢”,而是要有数据支撑。推荐至少搭建基础的 APM 工具链(如 SkyWalking、Pinpoint 或自研埋点),才能精准定位瓶颈。

✅ 2. 写代码要考虑未来扩展性,别只看眼前

比如像我们最初的代码结构,每个小功能都单独封装调用 Redis,看起来“模块清晰”,实则是反模式。如果一开始就抽象成统一的数据源访问层,或许早就避免了后期的痛苦重构。

✅ 3. 不要忽视异步编程的力量

现代 Java 已经提供了非常强大的异步编程框架(如 CompletableFuture、Reactor),合理使用能让并发能力大幅提升,尤其是在 I/O 密集型场景中。

✅ 4. 技术选型也要讲究“适度”

比如我们当初选择 Redis 而不是本地内存缓存,是因为需要跨服务共享用户状态,但如果只是局部可丢弃状态,其实完全可以用软引用 + Guava Cache 实现更快的访问速度。


小结一下

这次推荐系统的技术优化之旅让我深刻认识到:

性能优化从来不是“加钱买机器”这么简单,而是一场工程能力与系统思维的综合体现。

它考验着我们对底层原理的理解(比如 JVM、Redis)、对编码风格的掌控、以及对架构设计的长期思考。

如果你也正面临类似的挑战,不妨先沉下心来做两件事:

  1. 把你的系统跑一遍压测,看看哪里是瓶颈?
  2. 写一段最小可复现的性能测试代码,真实测量每个环节的表现。

然后再根据具体情况去调整方案。记住:没有银弹,只有因地制宜。


结语:愿你我在码海中越游越远

最后,感谢你愿意花时间读完这篇有点长的文章。作为一名开发者,我们总是在解决问题中成长,在实践中提升。

性能优化不是终点,而是一种思维方式的锤炼。希望这篇文章能成为你在技术道路上的一点微光,哪怕只照亮一小段旅程,我也倍感欣慰。

如果你有任何类似经历或疑问,欢迎留言交流。我们一起在技术这条路上,走得更远一点。


作者简介:阿凯,Java 工程师 / 高性能系统优化践行者,热爱开源与实战结合。目前主要研究分布式系统、服务治理与云原生。

评论 0

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