本文目录导读:

在Java应用中解决慢查询问题,通常需要遵循一个从现象到根源的排查思路,而非直接修改代码,慢查询的根源可能是:SQL本身、数据库索引、数据库配置、网络延迟或Java端的处理逻辑。
以下是系统性的解决步骤和案例方案:
第一步:确认慢查询的真实源头
不要凭感觉,先用工具定位。
- 数据库端:开启慢查询日志,例如MySQL:
SET GLOBAL slow_query_log = ON;并设置long_query_time = 1(超过1秒为慢)。 - Java应用端:使用APM工具或JDBC拦截器。
- Druid:配置
DruidFilter输出慢 SQL 日志(slowSqlMillis)。 - Druid/MyBatis:监控
StatFilter中的执行耗时。 - Spring Data JPA:配置
logging.level.org.hibernate.SQL=DEBUG和org.hibernate.type.descriptor.sql.BasicBinder=TRACE。
- Druid:配置
第二步:分析SQL与执行计划
拿到慢SQL后,用 EXPLAIN 分析。
示例:
EXPLAIN SELECT * FROM orders WHERE user_id = 123 AND status = 1;
关键指标:
- type:至少是
range,最好是ref或const。ALL代表全表扫描,需要优化。 - rows:扫描的行数,如果非常大,需要加索引。
- Extra:
Using filesort(文件排序)、Using temporary(使用临时表)都是性能杀手。
第三步:常用解决方案(按原因分类)
缺失索引导致的慢查询(最常见)
案例: 查询某用户最近的订单,执行了5秒。EXPLAIN 显示 type=ALL。
解决方案: 添加复合索引(注意字段顺序:等值条件在前,范围条件在后)。
-- 针对 user_id 和 create_time 建立联合索引 ALTER TABLE orders ADD INDEX idx_user_time (user_id, create_time DESC);
Java代码层面: 在大量数据导入前,可考虑临时禁用部分索引,或使用 hint 强制指定索引。
深度分页导致的慢查询
案例: SELECT * FROM logs ORDER BY id LIMIT 100000, 20,尽管有索引,但MySQL仍需要扫描10万行后丢弃前10万行。
解决方案:
- 方案A(推荐):游标分页。 记录上一页最后一条记录的ID。
-- 上一页最后一条id=99999 SELECT * FROM logs WHERE id > 99999 ORDER BY id LIMIT 20;
- 方案B(次选):覆盖索引 + 子查询。
SELECT * FROM logs WHERE id IN ( SELECT id FROM logs ORDER BY id LIMIT 100000, 20 );
索引失效导致的慢查询
常见原因:
- 对索引列使用函数:
WHERE DATE(create_time) = '2023-01-01'→ 应改为WHERE create_time >= '2023-01-01' AND create_time < '2023-01-02'。 - 隐式类型转换:
user_id是varchar,传了123(int) → 应传'123'。 - /
<>、LIKE '%xxx%':一般不走索引,考虑全文索引或ES。
Java代码审计: 检查 Mapper.xml 或 @Query 中的参数类型是否匹配数据库字段类型。
大量数据返回(网络传输慢)
案例: Java代码 SELECT * FROM huge_table 返回了50万行数据,网络IO成为瓶颈。
解决方案:
- 减少SELECT字段:只查需要的列,避免
SELECT *。 - 分页/流式查询:对于大数据量导出,使用 JDBC流式读取(
setFetchSize(Integer.MIN_VALUE),需注意内存泄漏风险)。 - 批处理:将大查询拆分为多个小查询,分批获取。
数据库连接池/线程池配置不当
案例: 某个业务高峰,几十个请求同时查询同一张表,每个查询耗时200ms,但因为连接池配置太小(如10个),导致请求排队等待,整体耗时被放大到几秒。
解决方案:
- 调大连接池(如HikariCP):
maximumPoolSize=50。 - 设置合理的超时:
connectionTimeout=3000,idleTimeout=600000。
N+1查询(ORM框架常见)
案例: 使用JPA或MyBatis Plus查询所有分类,然后在循环中逐一查询分类下的商品。
Java代码优化:
// 错误做法:for循环内执行多次查询
List<Category> categories = categoryMapper.findAll();
for (Category c : categories) {
c.setProducts(productMapper.findByCategoryId(c.getId())); // 多次SQL
}
// 正确做法:一次性查询所有关联数据
List<Category> categories = categoryMapper.findAll();
List<Integer> categoryIds = categories.stream().map(Category::getId).collect(Collectors.toList());
List<Product> products = productMapper.findByCategoryIds(categoryIds); // IN查询
// 然后在内存中组装
MySQL自身配置问题
案例: 慢查询日志显示 Sorting result或 Creating tmp table 占用大量时间。
解决方案: 调整数据库参数(需要DBA权限)。
- 适当增大
sort_buffer_size(排序缓冲区)。 - 调大
tmp_table_size和max_heap_table_size(临时表内存限制)。 - 优化
innodb_buffer_pool_size(通常设置为物理内存的70%)。
第四步:Java代码层面的性能优化
如果数据库优化后仍然慢,可能是应用层处理逻辑的问题。
- 减少循环中的数据库调用:将多次小查询合并为一次大查询(
IN子句)。 - 缓存热点数据:使用 Redis 缓存高频查询结果(如配置信息、类目标签),设置合适的过期时间(TTL)。
- 异步处理:对于非实时性的统计报表,使用 消息队列(RabbitMQ/ Kafka)异步生成。
- 并行查询:使用
CompletableFuture或ForkJoinPool将一个大查询拆分为多个并行子查询。CompletableFuture<List<Order>> future1 = CompletableFuture.supplyAsync(() -> orderService.queryByTime(t1, t2)); CompletableFuture<List<Order>> future2 = CompletableFuture.supplyAsync(() -> orderService.queryByUser(uid)); List<Order> result = CompletableFuture.allOf(future1, future2).thenApply(v -> combine(future1.join(), future2.join())).get();
- 堆外内存/零拷贝:对于大文件
BLOB/CLOB传输,使用FileChannel.transferTo()避免JVM堆内存拷贝。
一个典型的排查流程(案例)
现象: 用户查询“2023年12月所有会员的已支付订单”的API接口,响应时间8秒。
排查步骤:
- 看慢日志:找到耗时最长的SQL。
- 执行
EXPLAIN:发现type=range,rows=200万,Extra=Using filesort。 - 分析:订单表已有
create_time索引,但status筛选后仍排序慢;且没有覆盖user_id(关联会员表)。 - 解决方案:
- 添加复合索引:
INDEX idx_time_status (create_time, status)解决排序和筛选。 - 将
SELECT *改为SELECT id, status, amount,利用覆盖索引减少回表。 - 代码层面:在业务层添加 Redis缓存,缓存该统计结果,设置TTL=5分钟,如果数据实时性要求不高,缓存能直接解决大部分响应慢的问题。
- 添加复合索引:
- 最终效果:SQL执行时间从5秒降到50ms,加上缓存后,大部分请求直接10ms内存返回。
一句话总结:
先看EXPLAIN加索引,再查代码防循环,最后缓存保命根。 如果数据量千万级以上,考虑分库分表(ShardingSphere)或搜索引擎(Elasticsearch)。