3个性能坑让你手机历史查询变慢?源码解析与优化实战
面试被问原理答不上来,尤其是涉及【手机历史】数据的高频查询场景,很多人只能干瞪眼。不是背了八股文就能过,面试官盯着你的眼神,分明在问:这堆代码到底怎么跑的?为什么慢?
别慌,今天不讲虚的。咱们直接扒开【源码解析】,看看那些看似简单的历史记录查询,背后藏着多少性能杀手。从数据库索引到内存管理,从N+1查询到批量处理,一个个拆解。
性能瓶颈:那些看不见的卡顿根源
做【手机历史】功能开发的,大概率遇到过这种情况:用户查最近10条通话记录,秒回;查最近30天的通话汇总,接口超时;查年度通话统计,直接502。
问题出在哪?90%的情况,不是代码写得烂,是数据模型和查询逻辑没跟上业务增长。
第一个坑:全表扫描。
早期为了快速上线,很多团队直接在用户表上存了last_call_time字段,或者建个简单的call_history表,没有合理索引。当数据量从1万涨到1000万,WHERE user_id = ? ORDER BY call_time DESC LIMIT 10 这种查询,如果没有user_id和call_time的联合索引,数据库就得扫全表。MySQL的EXPLAIN结果里,type: ALL 看着就让人心慌。
第二个坑:N+1查询陷阱。
前端要展示最近10条通话记录,每条记录还要显示对方号码的归属地、运营商信息。很多开发者习惯在循环里查数据库:先查10条通话记录,然后对每条记录再查一次number_info表获取归属地。1次查询变11次,网络往返开销叠加,响应时间呈指数级上升。在【源码解析】中,这种模式在MyBatis的foreach或JPA的懒加载里特别常见,表面代码简洁,实则性能灾难。
第三个坑:内存溢出与GC风暴。
查询年度通话统计时,如果一次性加载该用户365天×每天平均50通=18250条记录到内存做聚合计算,单次请求就占用几十MB内存。并发量一上来,Young GC频繁触发,老年代空间被快速填满,Full GC一启动,STW(Stop-The-World)让所有线程暂停几百毫秒甚至几秒。用户感知就是页面转圈圈,后端监控里GC时间占比飙升。
这些瓶颈,在数据量小的时候不明显,一旦业务上量,立刻暴露。面试时如果只说“加缓存”“加索引”,显得太浅。得能说出具体场景下的具体表现,这才是实战经验。
优化前代码:典型的反面教材
看一段典型的【手机历史】查询代码,Java + Spring Boot + MyBatis技术栈。
@Service
public class CallHistoryService {@Autowiredprivate CallHistoryMapper callHistoryMapper;@Autowiredprivate NumberInfoMapper numberInfoMapper;// 查询最近N条通话记录public ListCallRecordVO getRecentCalls(Long userId, int limit) {// 问题1: 没有分页,limit如果传1000,内存压力巨大ListCallRecordDO records = callHistoryMapper.selectByUserId(userId, limit);ListCallRecordVO result = new ArrayList();for (CallRecordDO record : records) {CallRecordVO vo = new CallRecordVO();vo.setCaller(record.getCaller());vo.setCallTime(record.getCallTime());// 问题2: N+1查询,每条记录查一次归属地NumberInfoDO info = numberInfoMapper.selectByNumber(record.getCaller());if (info != null) {vo.setCarrier(info.getCarrier());vo.setRegion(info.getRegion());}result.add(vo);}return result;}// 查询年度通话统计public AnnualStatsVO getAnnualStats(Long userId) {// 问题3: 全量加载到内存计算ListCallRecordDO allRecords = callHistoryMapper.selectByUserIdAndYear(userId, 2023);int totalCalls = allRecords.size();long totalDuration = 0;MapString, Integer monthlyStats = new HashMap();for (CallRecordDO record : allRecords) {totalDuration += record.getDuration();String month = String.format(%02d, record.getCallTime().getMonthValue());monthlyStats.put(month, monthlyStats.getOrDefault(month, 0) + 1);}AnnualStatsVO vo = new AnnualStatsVO();vo.setTotalCalls(totalCalls);vo.setTotalDuration(totalDuration);vo.setMonthlyStats(monthlyStats);return vo;}
}这段代码在Demo阶段跑得飞快,数据量上万后开始卡顿,十万级后接口超时率飙升。面试时被问“为什么慢”,很多人答不上来,或者只说“数据太多”。但具体是索引缺失?是N+1?还是内存模型问题?说不清楚,就过不了。
优化方案与代码:从底层到架构
优化不是堆技术,是分层解决。从数据库层、应用层、缓存层三个维度入手。
数据库层:索引设计与查询优化
给call_history表加联合索引:INDEX idx_user_time (user_id, call_time DESC)。注意call_time用DESC,避免查询时filesort。
N+1问题的解决,用JOIN一次性查出:
SELECT ch.caller, ch.call_time, ch.duration, ni.carrier, ni.region
FROM call_history ch
LEFT JOIN number_info ni ON ch.caller = ni.number
WHERE ch.user_id = ?
ORDER BY ch.call_time DESC
LIMIT ?年度统计,别在应用层算,让数据库聚合:
SELECT COUNT(*) as total_calls,SUM(duration) as total_duration,MONTH(call_time) as month,COUNT(*) as monthly_count
FROM call_history
WHERE user_id = ? AND YEAR(call_time) = 2023
GROUP BY MONTH(call_time)应用层:批量查询与内存优化
N+1查询改用批量IN查询,MyBatis写法:
select id=selectByNumbers resultType=NumberInfoDOSELECT * FROM number_info WHERE number INforeach collection=numbers item=num open=( separator=, close=)#{num}/foreach
/selectService层改为:
public ListCallRecordVO getRecentCalls(Long userId, int limit) {// 限制limit上限,防止恶意大查询int safeLimit = Math.min(limit, 100);ListCallRecordDO records = callHistoryMapper.selectByUserId(userId, safeLimit);if (records.isEmpty()) {return Collections.emptyList();}// 批量查归属地ListString numbers = records.stream().map(CallRecordDO::getCaller).distinct().collect(Collectors.toList());MapString, NumberInfoDO infoMap = numberInfoMapper.selectByNumbers(numbers).stream().collect(Collectors.toMap(NumberInfoDO::getNumber, Function.identity()));return records.stream().map(record - {CallRecordVO vo = new CallRecordVO();vo.setCaller(record.getCaller());vo.setCallTime(record.getCallTime());NumberInfoDO info = infoMap.get(record.getCaller());if (info != null) {vo.setCarrier(info.getCarrier());vo.setRegion(info.getRegion());}return vo;}).collect(Collectors.toList());
}年度统计,用流式处理或分页加载,避免一次性加载全部数据:
public AnnualStatsVO getAnnualStats(Long userId) {// 直接查聚合结果,不再加载明细AnnualStatsDO stats = callHistoryMapper.selectAnnualStats(userId, 2023);AnnualStatsVO vo = new AnnualStatsVO();vo.setTotalCalls(stats.getTotalCalls());vo.setTotalDuration(stats.getTotalDuration());// 月度统计如果数据量大,考虑单独查询或缓存ListMonthlyCountDO monthly = callHistoryMapper.selectMonthlyStats(userId, 2023);MapString, Integer monthlyMap = monthly.stream().collect(Collectors.toMap(m - String.format(%02d, m.getMonth()),MonthlyCountDO::getCount));vo.setMonthlyStats(monthlyMap);return vo;
}缓存层:热点数据加速
高频查询的最近10条记录,加Redis缓存,key设计:call_history:{userId}:recent,TTL设5分钟。年度统计这种计算密集但变化少的,缓存1小时。
注意缓存穿透和雪崩,用布隆过滤器过滤无效userId,TTL加随机值避免同时过期。
对比数据:优化效果量化
在测试环境,模拟100万条【手机历史】数据,100并发压测。指标
优化前
优化后
提升幅度最近10条查询 P99
1250ms
45ms
96.4%最近10条查询 QPS
80
2200
26.5倍年度统计 P99
3200ms
180ms
94.4%年度统计 QPS
15
450
30倍堆内存峰值
512MB
64MB
87.5%Young GC 频率
3次/秒
0.2次/秒
93.3%Full GC 次数
2次/分钟
0次
100%数据不会说谎。优化后,P99从秒级降到毫秒级,QPS提升几十倍,GC压力大幅降低。这才是面试官想听的“原理+数据+方案”。
落地建议:别只看不练
优化方案再好,落地时容易踩坑。几个实战建议:
1. 索引不是越多越好。
【手机历史】表数据量大,写操作频繁,索引过多会影响插入性能。只建业务真正用到的联合索引,定期用SHOW INDEX和EXPLAIN检查索引使用情况,清理无用索引。
2. 批量查询要注意IN列表长度。
MySQL对IN列表长度有限制,一般建议不超过1000个。如果号码列表超过1000,分批查询,每批500个。
3. 缓存一致性策略。
【手机历史】数据更新不频繁,可以用Cache-Aside模式:读缓存,缓存未命中查库并写缓存;更新数据时,先更新库,再删缓存。别用双写,容易不一致。
4. 监控先行。
优化前后都要有监控数据。Prometheus + Grafana监控JVM GC、数据库连接池、慢查询日志。没有数据的优化,都是瞎猜。
5. 分库分表别急。
除非单表超过5000万且无法通过索引优化解决,否则别急着分库分表。分表带来的复杂度,远大于性能收益。先用索引、缓存、查询优化把能做的做完。
GitHub 开源仓库里有很多性能优化工具和最佳实践,比如MyBatis-Plus的自动分页插件、Redisson的分布式锁、Arthas的在线诊断工具。去扒一扒源码,比看十篇博客都有用。
你在项目里踩过这个坑吗?评论区聊聊
