很多系统的性能瓶颈,都在只读查询上。尤其是内容类、报表类、数据展示类的系统,读操作远远多于写操作,查询慢了,整个系统就慢了。
去年我负责的一个项目,就遇到了严重的只读性能问题。一个数据查询接口,高峰期响应时间2秒多,用户经常抱怨卡顿。我花了两周时间,从分析瓶颈到逐步优化,最终把响应时间降到了50毫秒以内,提升了40倍。
今天分享这次性能优化的实战经历,包括怎么分析瓶颈、用了哪些优化手段、每一步的效果如何。希望能给遇到类似问题的朋友一些参考。
一、问题背景
先说说项目背景。这是一个企业级的数据展示平台,主要功能是把业务数据以图表和表格的形式展示给用户。用户可以按时间、地区、部门等维度筛选数据,查看各种统计报表。
系统架构:
- 前端:Vue + ECharts
- 后端:Java + Spring Boot
- 数据库:MySQL 5.7,单库,数据量大概500万行
- 缓存:Redis,但用得不多
问题出在一个核心查询接口上。这个接口要根据用户的筛选条件,从数据库里查询数据,做聚合计算,然后返回给前端。数据量不大,但查询条件复杂,涉及多表关联和分组聚合。
问题表现:
- 平时响应时间500毫秒左右,还能接受
- 高峰期(上午9-11点,下午2-4点)响应时间2秒以上,甚至超时
- 数据库CPU使用率经常到80%以上,慢查询日志里全是这个接口的SQL
- 用户抱怨页面加载慢,图表半天出不来
这个接口是系统的核心,每天被调用几十万次,慢了严重影响用户体验。必须优化。
二、第一步:分析瓶颈
优化的第一步,不是上来就改代码,而是先分析瓶颈在哪里。
1. 看监控数据
先看系统监控,确认瓶颈:
- 应用服务器CPU使用率不高(30%左右),说明不是应用层的问题
- 数据库CPU使用率很高(80%+),说明瓶颈在数据库
- 网络延迟正常,不是网络问题
- Redis命中率不高(只有20%),缓存没发挥作用
初步判断:瓶颈在数据库查询,缓存没用好。
2. 抓慢SQL
开启MySQL的慢查询日志,抓到这个接口的SQL,拿出来分析。
SQL大概长这样(简化后):
SELECT
d.date,
r.name AS region,
SUM(o.amount) AS total_amount,
COUNT(o.id) AS order_count
FROM orders o
JOIN dates d ON o.date_id = d.id
JOIN regions r ON o.region_id = r.id
WHERE
d.date BETWEEN '2022-01-01' AND '2022-01-31'
AND r.id IN (1, 2, 3, 4, 5)
AND o.status = 1
GROUP BY d.date, r.name
ORDER BY d.date;这个SQL涉及3张表关联,有WHERE条件,有GROUP BY,有ORDER BY。看起来不复杂,但数据量一大就慢。
3. EXPLAIN分析执行计划
用EXPLAIN看执行计划:
EXPLAIN SELECT ...发现几个问题:
orders表做了全表扫描(type=ALL),没有用到索引dates和regions表虽然用了索引,但是回表查询- 有临时表(Using temporary)和文件排序(Using filesort)
- rows列显示要扫描100多万行
问题很清楚了:索引没建好,导致全表扫描和临时表排序。
4. 确认数据分布
还需要了解数据分布:
- orders表:500万行,其中status=1的有400万行
- dates表:3650行(10年的日期)
- regions表:20行
- 查询时间范围一般是1个月,涉及大概50万行订单
了解了这些,就可以开始优化了。
三、第二步:SQL和索引优化
最基础也是最有效的优化,就是SQL和索引优化。
1. 建联合索引
原来的orders表只有一个主键索引,没有针对查询条件的索引。根据查询条件,建一个联合索引:
ALTER TABLE orders
ADD INDEX idx_status_date_region (status, date_id, region_id);联合索引的顺序很重要,遵循"等值在前,范围在后"的原则:
- status是等值条件(=1),放最前面
- date_id是范围条件(BETWEEN),放中间
- region_id是IN条件,放最后
建了索引之后,再EXPLAIN,type从ALL变成了range,rows从100多万降到了50万。查询时间从2秒降到了800毫秒。
2. 覆盖索引
虽然用了索引,但还是要回表查询amount字段。可以建一个覆盖索引,把需要的字段都包含进去:
ALTER TABLE orders
ADD INDEX idx_cover (status, date_id, region_id, amount, id);这样查询的时候,只需要扫描索引,不需要回表。Using index出现在Extra列里。
查询时间从800毫秒降到了500毫秒。
3. 优化GROUP BY和ORDER BY
原来的SQL有临时表和文件排序,因为GROUP BY的字段和索引顺序不一致。
可以调整SQL,让GROUP BY的字段和索引顺序一致,或者用SQLBIGRESULT等hint。但更有效的是,把dates和regions表的关联去掉,直接在orders表里冗余字段。
我在orders表加了date和region_name两个冗余字段,写入的时候同步。这样查询就不需要JOIN了:
SELECT
o.date,
o.region_name AS region,
SUM(o.amount) AS total_amount,
COUNT(o.id) AS order_count
FROM orders o
WHERE
o.date BETWEEN '2022-01-01' AND '2022-01-31'
AND o.region_id IN (1, 2, 3, 4, 5)
AND o.status = 1
GROUP BY o.date, o.region_name
ORDER BY o.date;单表查询,索引覆盖,没有JOIN。查询时间从500毫秒降到了200毫秒。
4. 小结
SQL和索引优化,把查询时间从2秒降到了200毫秒,提升了10倍。这是最基础也是性价比最高的优化。
但200毫秒还是不够快,高峰期还是会有压力。需要进一步优化。
四、第三步:引入缓存
只读查询,最有效的优化手段就是缓存。这个接口的数据,不需要实时性很强,延迟几分钟用户也能接受。
1. 设计缓存策略
缓存的key怎么设计?这个接口的查询条件很多(时间范围、地区、部门等),如果每个条件组合都缓存,缓存量太大。
我的策略是:
- 按"时间粒度"缓存:把查询结果按天缓存,比如2022-01-01的数据缓存一份
- 查询的时候,把时间范围拆成每一天,从缓存里取每天的数据,然后在应用层聚合
- 缓存过期时间:当天的数据1小时过期,历史数据永久缓存(或每天凌晨更新)
这样,缓存的key就是日期,数量有限(最多几千个),命中率很高。
2. 实现缓存
用Redis做缓存,value用JSON格式存储每天的统计数据:
// 伪代码
public List<StatData> query(String startDate, String endDate, List<Integer> regions) {
List<StatData> result = new ArrayList<>();
// 按天遍历
for (String date : dateRange(startDate, endDate)) {
String cacheKey = "stat:" + date;
List<StatData> dailyData = redis.get(cacheKey);
if (dailyData == null) {
// 缓存未命中,查数据库
dailyData = queryFromDB(date, regions);
redis.setex(cacheKey, 3600, dailyData); // 1小时过期
}
// 按地区过滤
result.addAll(filterByRegion(dailyData, regions));
}
// 应用层聚合
return aggregate(result);
}3. 缓存预热
光靠被动缓存还不够,第一次查询还是会打到数据库。我加了一个定时任务,每天凌晨把前一天的数据算好,预热到缓存里。
这样,用户查询历史数据的时候,缓存一定命中,不会打到数据库。当天的数据,第一次查询会查库,之后1小时内都走缓存。
4. 缓存效果
引入缓存后:
- 缓存命中率从20%提升到95%
- 接口平均响应时间从200毫秒降到了30毫秒
- 数据库CPU使用率从80%降到了20%
效果非常明显。
5. 缓存的坑
缓存虽好,但也有一些坑要注意:
- 缓存穿透:查询不存在的日期,每次都打数据库。解决:缓存空值,或者用布隆过滤器
- 缓存雪崩:大量缓存同时过期,数据库压力骤增。解决:过期时间加随机值
- 数据一致性:数据更新了,缓存还是旧的。解决:更新数据时删除缓存,或者用消息队列异步更新
- 大key问题:一天的数据量太大,一个key存了几MB。解决:按地区再拆分key
这些坑我都遇到过,一个个解决之后,缓存系统才稳定下来。
五、第四步:读写分离
虽然缓存已经解决了大部分问题,但还有5%的请求会打到数据库(缓存未命中、当天数据过期等)。高峰期这些请求还是会给数据库造成压力。
进一步优化:读写分离。
1. 架构调整
- 主库:负责写操作(数据录入、更新)
- 从库:负责读操作(查询报表)
- 主从同步:MySQL原生的主从复制,延迟大概1-2秒
因为这个系统对数据一致性要求不高,延迟几秒完全可以接受,所以读写分离很合适。
2. 实现方式
用Spring的AbstractRoutingDataSource,根据操作类型动态切换数据源:
- 写操作走主库
- 读操作走从库
- 特殊场景(刚写完立刻读)强制走主库
配置很简单,代码改动也不大。
3. 效果
读写分离后:
- 从库负责所有查询,主库只负责写
- 从库的CPU使用率降到了30%以下
- 即使缓存全部失效,从库也能扛住查询压力
- 主库的性能也提升了,因为不用处理查询了
六、第五步:数据预热和预计算
到这一步,性能已经很好了。但我还想再优化一下,让当天的数据也能走缓存,不用每次过期都查库。
1. 实时预计算
用消息队列,数据写入的时候,发一条消息。消费者收到消息后,实时更新当天的统计数据,写入缓存。
这样,缓存里的当天数据是准实时的,不用等查询的时候再算。查询的时候直接从缓存取,响应时间10毫秒以内。
2. 定时全量校准
实时计算可能有误差(消息丢失、重复消费等),每天凌晨跑一个定时任务,全量计算前一天的数据,覆盖缓存,保证数据准确。
3. 效果
预计算之后:
- 当天数据也走缓存,命中率接近100%
- 接口响应时间稳定在10-30毫秒
- 数据库几乎没有查询压力,CPU使用率长期在10%以下
七、优化效果总结
整个优化过程,从2秒降到30毫秒,提升了约60倍。总结一下每一步的效果:
| 优化步骤 | 响应时间 | 提升倍数 |
|---|---|---|
| 优化前 | 2000ms | 1x |
| SQL+索引优化 | 200ms | 10x |
| 引入缓存 | 30ms | 67x |
| 读写分离 | 25ms | 80x |
| 数据预计算 | 15ms | 133x |
数据库CPU使用率从80%降到10%,用户再也不抱怨卡顿了。
八、性能优化的经验总结
这次优化,我总结了几条经验。
1. 先测量,再优化
不要凭感觉优化,先看监控、抓慢SQL、EXPLAIN分析,找到真正的瓶颈。很多时候,瓶颈不在你以为的地方。
2. 从最基础的开始
SQL和索引优化是最基础的,也是性价比最高的。很多性能问题,建对索引就解决了。不要一上来就搞缓存、搞分布式,先把基础做好。
3. 缓存是只读优化的利器
对于读多写少、对实时性要求不高的场景,缓存是最有效的优化手段。但要注意缓存的坑(穿透、雪崩、一致性)。
4. 读写分离是数据库扩展的必经之路
单库扛不住读压力的时候,读写分离是最简单有效的扩展方式。只要能接受主从延迟,就可以用。
5. 预计算比实时计算快
能提前算好的,就提前算好。用户查询的时候直接取结果,比实时计算快得多。尤其是统计报表类的需求,预计算非常合适。
6. 不要过度优化
优化到一定程度就够了,不要为了极致性能把系统搞复杂。30毫秒和10毫秒,用户感知不到差别,但实现复杂度差很多。够用就行。
九、写在最后
性能优化是一个持续的过程,不是一次就能搞定的。这次优化之后,系统运行了半年,数据量增长了一倍,性能依然很好。
但未来数据量再增长,可能还需要进一步优化,比如分库分表、用OLAP引擎(ClickHouse、Doris)等。到时候再根据实际情况选择方案。
只读类性能优化,核心思路就是:能不查库就不查库(缓存),必须查库就查得快(索引),查库压力大就分开(读写分离),能提前算就提前算(预计算)。
按照这个思路,大部分只读性能问题都能解决。
2022年了,数据量越来越大,性能优化是每个后端工程师的必修课。希望这篇实战分享能帮到你。
如果你也有性能优化的经验或问题,欢迎在评论区交流。
祝大家的系统都能又快又稳。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录