最近负责了一个叫Ultra的后端系统的性能调优。

这个系统是公司的核心业务系统,用户量大,接口多。但随着业务增长,性能越来越差,平均响应时间到了2秒,高峰期甚至超过5秒。用户抱怨不断,老板要求优化。

我花了两周时间,把平均响应时间从2秒降到了400毫秒,降了80%。本文分享完整的调优过程,包括性能分析、瓶颈定位、优化方案、实施步骤、效果验证,以及踩过的坑。

一、系统背景

先说说Ultra系统的背景。

1. 系统介绍

Ultra是一个内容管理和分发系统,主要功能:

  • 内容的创建、编辑、发布
  • 内容的存储和检索
  • 内容的分发和推送
  • 用户行为统计
  • 权限管理

技术栈:

  • 后端:Java + Spring Boot
  • 数据库:MySQL 8.0
  • 缓存:Redis
  • 消息队列:Kafka
  • 搜索:Elasticsearch
  • 部署:Docker + Kubernetes

2. 性能问题

主要的性能问题:

  • 平均响应时间2秒,高峰期5秒以上
  • 数据库CPU使用率经常超过80%
  • 接口超时频繁,错误率上升
  • 用户抱怨页面加载慢,操作卡顿
  • 高峰期系统不稳定,偶尔宕机

3. 优化目标

我定的优化目标:

  • 平均响应时间 < 500ms
  • P99响应时间 < 2秒
  • 数据库CPU使用率 < 50%
  • 错误率 < 0.1%
  • 系统稳定,高峰期不宕机

二、性能分析

优化之前,先做全面的性能分析,找到瓶颈。

1. 监控数据收集

首先,收集系统的监控数据:

  • 接口响应时间:平均、P95、P99
  • 数据库性能:慢查询、连接数、CPU、IO
  • 缓存命中率:Redis的命中率
  • JVM性能:GC、内存、线程
  • 服务器资源:CPU、内存、网络、磁盘

我们用的监控工具是Prometheus + Grafana,配合APM工具(SkyWalking)做链路追踪。

2. 慢查询分析

打开MySQL的慢查询日志,分析慢查询。

发现的问题:

  • 很多查询没有走索引,全表扫描
  • 有些查询返回了大量不需要的字段
  • 关联查询太多,有的SQL关联了5张表以上
  • 分页查询用了深分页(LIMIT 100000, 20)
  • 没有用覆盖索引,回表查询多

最慢的一个查询,要3秒多,是一个复杂的统计查询。

3. 代码分析

用APM工具分析接口的调用链,发现代码层面的问题:

  • 有些接口在循环里查数据库(N+1问题)
  • 有些计算可以提前做,但每次请求都重新计算
  • 有些接口没有用缓存,每次都查数据库
  • 有些大对象没有及时释放,导致内存占用高
  • 有些同步调用可以改成异步

4. 缓存分析

分析Redis的使用情况:

  • 缓存命中率不高,只有60%左右
  • 有些热点数据没有缓存
  • 缓存的过期时间设置不合理
  • 没有处理缓存穿透和缓存雪崩
  • 有些缓存的数据太大,占用内存多

5. 架构分析

从架构层面分析:

  • 有些服务耦合度高,一个接口调用多个服务
  • 同步调用太多,没有用消息队列解耦
  • 数据库读写没有分离,读压力都在主库
  • 没有做分库分表,单表数据量太大
  • 静态资源没有用CDN

三、瓶颈定位

综合分析后,定位到主要的性能瓶颈:

1. 数据库是最大瓶颈

  • 慢查询多,数据库CPU高
  • 单表数据量大,查询慢
  • 没有读写分离,主库压力大
  • 索引不合理,很多查询没走索引

数据库的问题,贡献了大约50%的响应时间。

2. 缓存使用不当

  • 缓存命中率低
  • 热点数据没缓存
  • 缓存策略不合理

缓存的问题,贡献了大约20%的响应时间。

3. 代码效率低

  • N+1查询
  • 重复计算
  • 同步调用多

代码的问题,贡献了大约20%的响应时间。

4. 架构不合理

  • 服务耦合
  • 没有异步化
  • 没有读写分离

架构的问题,贡献了大约10%的响应时间。

四、优化方案和实施

针对这些瓶颈,制定了优化方案,分步实施。

第一阶段:数据库优化(见效最快)

1. 索引优化

对所有慢查询,分析执行计划,添加合适的索引。

  • 给常用的查询条件加索引
  • 给关联查询的关联字段加索引
  • 给排序和分组字段加索引
  • 删除没用的索引,减少写入开销

实施后,慢查询数量减少了70%,数据库CPU从80%降到了40%。

2. SQL优化

对慢SQL进行优化:

  • 避免SELECT *,只查需要的字段
  • 减少关联表的数量,能拆就拆
  • 深分页改成游标分页
  • 用覆盖索引,避免回表
  • 复杂的统计查询,改成预计算

比如,那个3秒的统计查询,改成每天凌晨预计算,结果存在汇总表里,查询时直接查汇总表,响应时间降到了50ms。

3. 读写分离

配置了MySQL的主从复制,读请求走从库,写请求走主库。

  • 主库:负责写操作
  • 从库:负责读操作
  • 用Sharding-JDBC做读写分离

实施后,主库的压力减轻了很多,读查询的性能也提升了。

4. 分库分表

对于数据量最大的几张表(内容表、行为表),做了分库分表。

  • 按时间分表:每个月一张表
  • 按用户ID分库:分散到多个库
  • 用Sharding-JDBC做分库分表

分库分表后,单表数据量从几千万降到了几百万,查询性能大幅提升。

第二阶段:缓存优化

1. 增加缓存覆盖

对热点数据,增加缓存:

  • 内容详情:缓存到Redis,过期时间1小时
  • 用户信息:缓存到Redis,过期时间30分钟
  • 配置数据:缓存到本地内存(Caffeine),过期时间10分钟
  • 统计数据:缓存到Redis,过期时间5分钟

缓存覆盖率从30%提升到了80%。

2. 优化缓存策略

  • 热点数据永不过期,后台异步更新
  • 缓存过期时间加随机值,避免缓存雪崩
  • 用布隆过滤器防止缓存穿透
  • 缓存空值,防止缓存穿透
  • 用Redis Pipeline减少网络往返

3. 多级缓存

引入多级缓存:

  • 一级缓存:本地内存(Caffeine),速度最快,容量小
  • 二级缓存:Redis,速度快,容量大
  • 三级缓存:数据库,速度慢,容量大

查询时,先查一级,再查二级,最后查数据库。查到后,逐级回填。

多级缓存实施后,缓存命中率从60%提升到了95%。

第三阶段:代码优化

1. 解决N+1查询

对循环里查数据库的代码,改成批量查询:

优化前:

for (Long id : ids) {
    User user = userMapper.selectById(id); // 循环里查数据库
}

优化后:

List<User> users = userMapper.selectBatchIds(ids); // 批量查询
Map<Long, User> userMap = users.stream()
    .collect(Collectors.toMap(User::getId, u -> u));

实施后,很多接口的数据库查询次数从几十次降到了几次。

2. 预计算和缓存计算结果

对重复的计算,提前计算并缓存:

  • 内容的统计数据,发布时计算好,存在冗余字段
  • 用户的等级和权限,登录时计算好,存在Token里
  • 复杂的聚合计算,定时任务预计算

3. 异步化

对非核心流程,改成异步:

  • 内容发布后的推送,用Kafka异步处理
  • 用户行为统计,用Kafka异步上报
  • 邮件和短信通知,异步发送
  • 日志记录,异步写入

异步化后,接口的响应时间明显缩短,因为不用等待非核心流程完成。

4. 对象池和连接池优化

  • 数据库连接池:调整最大连接数,避免连接不够或过多
  • HTTP连接池:用连接池复用连接,减少握手开销
  • 线程池:合理设置核心线程数和最大线程数
  • 对象池:对创建开销大的对象,用对象池复用

第四阶段:架构优化

1. 服务拆分

对耦合度高的服务,进行拆分:

  • 内容服务:负责内容的管理
  • 用户服务:负责用户和权限
  • 统计服务:负责数据统计
  • 推送服务:负责消息推送

拆分后,每个服务可以独立部署和扩展,故障也不会互相影响。

2. 引入消息队列

用Kafka做服务间的异步通信:

  • 内容发布事件,通知统计和推送服务
  • 用户行为事件,通知统计服务
  • 系统通知事件,通知推送服务

消息队列的引入,降低了服务间的耦合,提升了系统的响应速度和稳定性。

3. CDN加速

静态资源(图片、JS、CSS)用CDN加速:

  • 图片上传到对象存储,CDN分发
  • JS、CSS构建后上传到CDN
  • 配置合适的缓存策略

CDN加速后,静态资源的加载速度提升了很多。

五、优化效果

说说优化后的效果。

1. 响应时间

  • 平均响应时间:从2000ms降到400ms,降低80%
  • P95响应时间:从4000ms降到800ms,降低80%
  • P99响应时间:从8000ms降到1500ms,降低81%

2. 数据库性能

  • 慢查询数量:减少了90%
  • 数据库CPU:从80%降到35%
  • 数据库连接数:稳定在合理范围
  • 查询响应时间:平均从500ms降到50ms

3. 缓存性能

  • 缓存命中率:从60%提升到95%
  • Redis CPU:稳定在30%左右
  • 缓存查询响应时间:平均2ms

4. 系统稳定性

  • 错误率:从2%降到0.05%
  • 高峰期不再宕机
  • 系统资源使用率稳定
  • 用户投诉明显减少

六、踩过的坑

说说优化过程中踩过的坑。

坑一:加了索引反而更慢

有一次,给一个查询加了索引,结果查询反而更慢了。

原因是,索引的区分度太低,MySQL优化器选择了这个索引,但扫描的行数比全表扫描还多。

解决:删除区分度低的索引,或者用强制索引(FORCE INDEX)指定更好的索引。

教训: 加索引后,一定要用EXPLAIN验证执行计划,确保索引被正确使用。


坑二:缓存导致数据不一致

引入缓存后,出现了数据不一致的问题。

原因是,更新数据库后,没有及时删除缓存,导致用户读到的是旧数据。

解决:采用"先更新数据库,再删除缓存"的策略,并且用消息队列做最终一致性保证。

教训: 缓存和数据库的一致性,是个经典问题,要设计好更新策略。


坑三:分库分表后,跨库查询困难

分库分表后,有些需要跨库查询的功能,变得很麻烦。

比如,按时间分表后,查询跨多个月的数据,需要查多张表,再合并结果。

解决:

  • 建立汇总表,预计算跨表的统计数据
  • 用Elasticsearch做复杂查询
  • 避免跨库的关联查询,改成冗余字段

教训: 分库分表前,要考虑清楚查询模式,避免分完后查询困难。


坑四:异步化后,数据丢失

改成异步后,出现了数据丢失的问题。

原因是,Kafka消息发送失败,没有重试机制,导致消息丢失。

解决:

  • 消息发送加重试机制
  • 消息消费加幂等处理
  • 消息持久化,确保不丢失
  • 监控消息堆积和消费延迟

教训: 异步化不是简单地把同步改成异步,要考虑消息的可靠性和一致性。


坑五:优化了A接口,B接口变慢了

有一次,优化了一个慢查询,加了索引。结果另一个接口变慢了。

原因是,新的索引影响了MySQL优化器的选择,导致另一个查询选错了索引。

解决:用FORCE INDEX强制指定索引,或者调整索引的顺序。

教训: 数据库优化是系统工程,一个改动可能影响其他查询,要全面测试。

七、性能调优的一般思路

总结一下性能调优的一般思路:

1. 先测量,再优化

不要盲目优化,先用工具测量,找到瓶颈。

  • 监控系统看整体性能
  • APM工具看调用链
  • 慢查询日志看数据库
  • 火焰图看CPU热点

2. 优先优化瓶颈

根据二八定律,80%的性能问题来自20%的地方。

优先优化最大的瓶颈,投入产出比最高。不要在细枝末节上浪费时间。

3. 从易到难,分步实施

优化要分步实施:

  • 第一步:索引优化、SQL优化(见效快,风险小)
  • 第二步:缓存优化(效果明显)
  • 第三步:代码优化(需要改代码)
  • 第四步:架构优化(改动大,风险高)

每一步都要验证效果,确保优化有效,没有引入新问题。

4. 优化后要验证

优化后,一定要验证效果:

  • 对比优化前后的性能数据
  • 做压力测试,确保高峰期稳定
  • 监控错误率,确保没有引入bug
  • 观察一段时间,确认优化效果持续

5. 建立性能监控和告警

优化不是一劳永逸的,业务在增长,数据在增加,性能可能会再次下降。

要建立完善的性能监控和告警:

  • 接口响应时间监控
  • 数据库性能监控
  • 缓存命中率监控
  • 系统资源监控
  • 异常告警,及时发现问题

八、写在最后

这次Ultra系统的性能调优,把响应时间降了80%,效果很明显。

但性能调优不是一次性的工作,而是持续的过程。业务在发展,数据在增长,新的性能问题会不断出现。我们需要建立性能意识,在开发的时候就考虑性能,在运行的时候持续监控和优化。

性能调优的核心,不是用了多高级的技术,而是:找到瓶颈,针对性优化,持续验证。

2022年了,系统越来越复杂,用户对性能的要求越来越高。作为开发者,我们要把性能当成一等公民,从设计到开发到运维,全程关注。

最后,用一句话总结:"性能调优没有银弹,测量、定位、优化、验证,一步一步来。先解决最大的瓶颈,再逐步细化。"

愿你的系统,又快又稳。