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 List<CallRecordVO> getRecentCalls(Long userId, int limit) {// 问题1: 没有分页,limit如果传1000,内存压力巨大List<CallRecordDO> records = callHistoryMapper.selectByUserId(userId, limit);List<CallRecordVO> 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: 全量加载到内存计算List<CallRecordDO> allRecords = callHistoryMapper.selectByUserIdAndYear(userId, 2023);int totalCalls = allRecords.size();long totalDuration = 0;Map<String, 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="NumberInfoDO">SELECT * FROM number_info WHERE number IN<foreach collection="numbers" item="num" open="(" separator="," close=")">#{num}</foreach>
</select>
Service层改为:
public List<CallRecordVO> getRecentCalls(Long userId, int limit) {// 限制limit上限,防止恶意大查询int safeLimit = Math.min(limit, 100);List<CallRecordDO> records = callHistoryMapper.selectByUserId(userId, safeLimit);if (records.isEmpty()) {return Collections.emptyList();}// 批量查归属地List<String> numbers = records.stream().map(CallRecordDO::getCaller).distinct().collect(Collectors.toList());Map<String, 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());// 月度统计如果数据量大,考虑单独查询或缓存List<MonthlyCountDO> monthly = callHistoryMapper.selectMonthlyStats(userId, 2023);Map<String, 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的在线诊断工具。去扒一扒源码,比看十篇博客都有用。
你在项目里踩过这个坑吗?评论区聊聊