Java Web 开发中基于 Spring Boot + MyBatis 的接口性能优化实战:从慢 SQL 到线程池调优
做 Java Web 接口时,很多性能问题一开始看起来都像“服务有点慢”,但真正排查下去,往往不是某一个点的问题,而是请求链路上多个环节一起拖后腿:SQL 慢、索引失效、MyBatis 映射不合理、连接池打满、线程池配置失衡,最后表现成接口 RT 飙升、超时、CPU 抖动、数据库负载高。
这篇文章我换一个更贴近线上排障的角度来讲:不是先讲概念,而是从一个“慢接口”的完整优化过程切入,一步步把 Spring Boot + MyBatis 场景里最常见的性能瓶颈串起来。你可以把它当成一份可落地的优化教程。
背景与问题
假设我们有一个订单查询接口:
- 支持按用户 ID 查询订单列表
- 可按状态过滤
- 支持分页
- 返回订单基础信息和金额统计
线上现象通常是这样的:
- 平均响应时间从 80ms 涨到 800ms
- 高峰期接口偶发超时
- 数据库 CPU 偶尔飙高
- 应用线程池堆积,请求排队
很多同学第一反应是“加机器”。这不是不能做,但如果 SQL 写法本身有问题、线程模型又配置得不合理,加机器通常只能延缓问题暴露。
我们先看一张典型链路图。
flowchart LR
A[客户端请求] --> B[Spring MVC Controller]
B --> C[Service]
C --> D[MyBatis Mapper]
D --> E[(MySQL)]
E --> D
D --> C
C --> F[线程池异步补充查询/聚合]
F --> C
C --> B
B --> A
这个链路里任何一层卡住,都会反馈到接口耗时上。
前置知识 / 环境准备
建议你具备这些基础:
- Spring Boot 基本开发经验
- MyBatis XML 或注解开发经验
- MySQL 索引与
EXPLAIN的基本使用 - 对线程池参数有初步认知
示例环境:
- JDK 17
- Spring Boot 3.x
- MyBatis Spring Boot Starter
- MySQL 8.x
- HikariCP
- Maven
核心原理
接口性能优化,建议始终按这条顺序思考:
-
先看数据库
- SQL 是否走索引
- 是否出现全表扫描、回表严重、排序/临时表
- 分页是否合理
-
再看 ORM/数据访问层
- MyBatis 是否查了不必要字段
- 是否有 N+1 查询
- 动态 SQL 是否把索引条件写废了
-
再看连接池与线程池
- 数据库连接数是否耗尽
- 业务线程是否阻塞过多
- 异步任务是否无边界堆积
-
最后看接口设计
- 是否把多个耗时操作串行执行
- 是否存在重复查询、重复计算
- 是否可以做缓存或降级
一句话总结:先消灭慢 SQL,再优化并发模型。这一步顺序非常重要。我踩过的一个坑就是先去调线程池,结果线程更多了,数据库更早被打满,问题反而更严重。
逐步验证清单
实际优化时,我建议你按下面的检查顺序执行:
- 记录接口 P50/P95/P99 响应时间
- 打开 SQL 慢日志
- 对核心 SQL 执行
EXPLAIN ANALYZE - 检查是否存在
select * - 检查分页是否为深分页
- 检查是否出现 N+1 查询
- 查看连接池活跃连接数
- 查看线程池队列积压情况
- 压测前后对比吞吐量和错误率
一个典型慢接口的起点
先看一个不太理想的实现。
Controller
package com.example.demo.controller;
import com.example.demo.dto.OrderQueryRequest;
import com.example.demo.dto.OrderVO;
import com.example.demo.service.OrderService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/api/orders")
public List<OrderVO> list(OrderQueryRequest request) {
return orderService.listOrders(request);
}
}
DTO
package com.example.demo.dto;
public class OrderQueryRequest {
private Long userId;
private Integer status;
private Integer pageNo = 1;
private Integer pageSize = 20;
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public Integer getStatus() {
return status;
}
public void setStatus(Integer status) {
this.status = status;
}
public Integer getPageNo() {
return pageNo;
}
public void setPageNo(Integer pageNo) {
this.pageNo = pageNo;
}
public Integer getPageSize() {
return pageSize;
}
public void setPageSize(Integer pageSize) {
this.pageSize = pageSize;
}
}
实体与返回对象
package com.example.demo.dto;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public class OrderVO {
private Long id;
private Long userId;
private Integer status;
private BigDecimal amount;
private LocalDateTime createTime;
private String summary;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public Integer getStatus() {
return status;
}
public void setStatus(Integer status) {
this.status = status;
}
public BigDecimal getAmount() {
return amount;
}
public void setAmount(BigDecimal amount) {
this.amount = amount;
}
public LocalDateTime getCreateTime() {
return createTime;
}
public void setCreateTime(LocalDateTime createTime) {
this.createTime = createTime;
}
public String getSummary() {
return summary;
}
public void setSummary(String summary) {
this.summary = summary;
}
}
Mapper 接口
package com.example.demo.mapper;
import com.example.demo.dto.OrderVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface OrderMapper {
List<OrderVO> selectOrders(@Param("userId") Long userId,
@Param("status") Integer status,
@Param("offset") int offset,
@Param("pageSize") int pageSize);
String selectOrderSummary(@Param("orderId") Long orderId);
}
Mapper XML:存在问题的版本
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.mapper.OrderMapper">
<select id="selectOrders" resultType="com.example.demo.dto.OrderVO">
select *
from orders
where 1 = 1
<if test="userId != null">
and user_id = #{userId}
</if>
<if test="status != null">
and status = #{status}
</if>
order by create_time desc
limit #{offset}, #{pageSize}
</select>
<select id="selectOrderSummary" resultType="string">
select summary
from orders
where id = #{orderId}
</select>
</mapper>
Service:存在 N+1 查询
package com.example.demo.service;
import com.example.demo.dto.OrderQueryRequest;
import com.example.demo.dto.OrderVO;
import com.example.demo.mapper.OrderMapper;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class OrderService {
private final OrderMapper orderMapper;
public OrderService(OrderMapper orderMapper) {
this.orderMapper = orderMapper;
}
public List<OrderVO> listOrders(OrderQueryRequest request) {
int offset = (request.getPageNo() - 1) * request.getPageSize();
List<OrderVO> list = orderMapper.selectOrders(
request.getUserId(),
request.getStatus(),
offset,
request.getPageSize()
);
for (OrderVO order : list) {
order.setSummary(orderMapper.selectOrderSummary(order.getId()));
}
return list;
}
}
这段代码的典型问题有三个:
select *读取了不必要字段order by create_time desc如果没有合适索引,会非常慢- 列表查出 20 条后,又对每条执行一次 summary 查询,形成 N+1 查询
核心原理:为什么它会慢
1. 慢 SQL 不只是“执行久”,还会拖累全局
一条 SQL 慢,通常会带来连锁反应:
- 数据库连接占用时间变长
- 应用线程阻塞等待结果
- 并发一上来,连接池耗尽
- 请求开始排队
- 线程池积压,接口 RT 继续上升
sequenceDiagram
participant U as User
participant A as App
participant P as ConnectionPool
participant D as MySQL
U->>A: 请求 /api/orders
A->>P: 获取数据库连接
P->>D: 执行慢SQL
D-->>P: 返回结果很慢
P-->>A: 连接长时间占用
Note over A: 业务线程持续阻塞
U->>A: 更多请求进入
A->>P: 申请连接
Note over P: 连接池趋于耗尽
Note over A: 请求排队,RT飙升
2. MyBatis 本身不慢,慢在“你让它怎么查”
MyBatis 是很轻量的,但它不会替你优化 SQL。常见问题包括:
- 动态 SQL 写得太灵活,导致索引失效
- 大字段不区分场景,一次全查
- 批量场景写成循环单查
- 深分页仍然用
limit offset
3. 线程池不是越大越好
线程池参数如果设置过大,会出现:
- 更多线程竞争 CPU
- 更多线程同时打数据库
- 上下文切换增多
- 数据库更容易被打爆
性能优化里最常见的误区之一,就是把线程池调大当成万能药。
实战代码:从慢 SQL 到可运行优化版
下面我们给出一套更合理的实现。
第一步:给 SQL 做“减法”
建表与索引示例
create table orders (
id bigint primary key auto_increment,
user_id bigint not null,
status int not null,
amount decimal(10,2) not null,
summary varchar(255),
create_time datetime not null,
update_time datetime not null
);
create index idx_user_status_ctime on orders(user_id, status, create_time desc);
create index idx_user_ctime on orders(user_id, create_time desc);
这里索引的思路是:
- 常见过滤条件有
user_id - 可选条件有
status - 排序字段是
create_time desc
如果你的查询是“按用户 + 状态 + 时间倒序分页”,那联合索引就要尽量贴近这个访问模式。
优化后的 Mapper XML
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.mapper.OrderMapper">
<resultMap id="OrderVOMap" type="com.example.demo.dto.OrderVO">
<id property="id" column="id"/>
<result property="userId" column="user_id"/>
<result property="status" column="status"/>
<result property="amount" column="amount"/>
<result property="createTime" column="create_time"/>
<result property="summary" column="summary"/>
</resultMap>
<select id="selectOrders" resultMap="OrderVOMap">
select id, user_id, status, amount, create_time, summary
from orders
where user_id = #{userId}
<if test="status != null">
and status = #{status}
</if>
order by create_time desc
limit #{offset}, #{pageSize}
</select>
</mapper>
关键变化:
- 去掉
select * - 直接一次查出
summary,避免 N+1 - 强约束
user_id为必传条件,减少“全表查分页”的风险
真实项目里,如果
summary是大字段,可按场景拆成“列表轻量字段”和“详情完整字段”两套查询。
优化后的 Mapper 接口
package com.example.demo.mapper;
import com.example.demo.dto.OrderVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface OrderMapper {
List<OrderVO> selectOrders(@Param("userId") Long userId,
@Param("status") Integer status,
@Param("offset") int offset,
@Param("pageSize") int pageSize);
}
优化后的 Service
package com.example.demo.service;
import com.example.demo.dto.OrderQueryRequest;
import com.example.demo.dto.OrderVO;
import com.example.demo.mapper.OrderMapper;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import java.util.List;
@Service
public class OrderService {
private final OrderMapper orderMapper;
public OrderService(OrderMapper orderMapper) {
this.orderMapper = orderMapper;
}
public List<OrderVO> listOrders(OrderQueryRequest request) {
Assert.notNull(request.getUserId(), "userId 不能为空");
int pageNo = Math.max(request.getPageNo(), 1);
int pageSize = Math.min(Math.max(request.getPageSize(), 1), 100);
int offset = (pageNo - 1) * pageSize;
return orderMapper.selectOrders(
request.getUserId(),
request.getStatus(),
offset,
pageSize
);
}
}
这里顺手做了两件很有价值的小事:
- 限制
pageSize,防止有人一页查几千条 - 明确
userId必传,避免大范围扫描
第二步:用 EXPLAIN 验证,而不是“感觉变快了”
优化 SQL 之后,一定要验证执行计划。
EXPLAIN ANALYZE
select id, user_id, status, amount, create_time, summary
from orders
where user_id = 1001
and status = 1
order by create_time desc
limit 0, 20;
重点看这些指标:
type是否为ref/range/const,尽量避免ALLkey是否命中预期索引rows扫描行数是否明显偏大- 是否出现
Using filesort - 是否出现
Using temporary
如果 status 可选,而有时只传 user_id,那就要关注不同 SQL 组合下是否都能用上合适索引。必要时可以拆成两个 SQL,而不是强行用一条“全能动态 SQL”。
第三步:处理深分页问题
当 pageNo 很大时,limit offset, size 会越来越慢,因为数据库需要跳过前面很多行。
不推荐的大偏移分页
select id, user_id, status, amount, create_time, summary
from orders
where user_id = 1001
order by create_time desc
limit 100000, 20;
推荐:基于游标或上次最大值翻页
select id, user_id, status, amount, create_time, summary
from orders
where user_id = 1001
and create_time < '2026-12-01 10:00:00'
order by create_time desc
limit 20;
如果你的场景是滚动加载、无限下拉,优先考虑游标分页。后台管理系统要求跳页时,再评估是否接受大页码性能损耗。
第四步:线程池调优,不让异步变成放大器
有些接口会同时查数据库、查远程服务、做聚合。为了降低总耗时,很多人会加异步并行,这个方向本身没问题,但前提是异步任务必须有边界。
线程池配置
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.*;
@Configuration
public class ExecutorConfig {
@Bean("orderQueryExecutor")
public ExecutorService orderQueryExecutor() {
return new ThreadPoolExecutor(
8,
16,
60,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new ThreadFactory() {
private int index = 1;
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "order-query-" + index++);
}
},
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
}
这里我刻意没有把线程数配得很大,原因很简单:
- 如果任务主要是数据库查询,线程太多只会增加 DB 压力
- 有界队列比无界队列更安全
CallerRunsPolicy可以起到反压作用,防止任务无限堆积
异步聚合示例
假设除了订单列表,我们还要查询用户优惠信息和统计金额汇总。
package com.example.demo.service;
import com.example.demo.dto.OrderQueryRequest;
import com.example.demo.dto.OrderVO;
import com.example.demo.mapper.OrderMapper;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import java.math.BigDecimal;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
@Service
public class OrderFacadeService {
private final OrderMapper orderMapper;
private final ExecutorService orderQueryExecutor;
public OrderFacadeService(OrderMapper orderMapper, ExecutorService orderQueryExecutor) {
this.orderMapper = orderMapper;
this.orderQueryExecutor = orderQueryExecutor;
}
public OrderPageResult query(OrderQueryRequest request) {
Assert.notNull(request.getUserId(), "userId 不能为空");
int pageNo = Math.max(request.getPageNo(), 1);
int pageSize = Math.min(Math.max(request.getPageSize(), 1), 100);
int offset = (pageNo - 1) * pageSize;
CompletableFuture<List<OrderVO>> orderFuture = CompletableFuture.supplyAsync(
() -> orderMapper.selectOrders(request.getUserId(), request.getStatus(), offset, pageSize),
orderQueryExecutor
);
CompletableFuture<BigDecimal> totalAmountFuture = orderFuture.thenApply(list ->
list.stream()
.map(OrderVO::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add)
);
List<OrderVO> orders = orderFuture.join();
BigDecimal totalAmount = totalAmountFuture.join();
OrderPageResult result = new OrderPageResult();
result.setOrders(orders);
result.setPageNo(pageNo);
result.setPageSize(pageSize);
result.setTotalAmount(totalAmount);
return result;
}
public static class OrderPageResult {
private List<OrderVO> orders;
private Integer pageNo;
private Integer pageSize;
private BigDecimal totalAmount;
public List<OrderVO> getOrders() {
return orders;
}
public void setOrders(List<OrderVO> orders) {
this.orders = orders;
}
public Integer getPageNo() {
return pageNo;
}
public void setPageNo(Integer pageNo) {
this.pageNo = pageNo;
}
public Integer getPageSize() {
return pageSize;
}
public void setPageSize(Integer pageSize) {
this.pageSize = pageSize;
}
public BigDecimal getTotalAmount() {
return totalAmount;
}
public void setTotalAmount(BigDecimal totalAmount) {
this.totalAmount = totalAmount;
}
}
}
这个例子里,金额汇总直接基于已查出的列表计算,没有再发起额外 SQL,避免了数据库重复开销。
第五步:连接池参数要和数据库承载能力匹配
Spring Boot 默认常用的是 HikariCP。很多性能问题并不是 Hikari 本身,而是参数和数据库能力不匹配。
application.yml 示例
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/demo?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8
username: root
password: root
hikari:
maximum-pool-size: 20
minimum-idle: 5
idle-timeout: 600000
max-lifetime: 1800000
connection-timeout: 3000
validation-timeout: 1000
mybatis:
mapper-locations: classpath*:mapper/*.xml
configuration:
map-underscore-to-camel-case: true
经验上:
maximum-pool-size不是越大越好,要结合数据库最大连接数、SQL 平均耗时、服务实例数一起算- 如果服务有 5 个实例,每个实例连接池 50,那数据库可能要扛 250 个连接
- SQL 已经很慢时,加大连接池只会让数据库同时处理更多慢请求
一张完整优化路径图
flowchart TD
A[接口RT高] --> B{先看慢SQL?}
B -->|是| C[开启慢日志/抓SQL]
C --> D[EXPLAIN ANALYZE]
D --> E{索引命中?}
E -->|否| F[调整索引/重写SQL]
E -->|是| G{是否N+1/多余字段?}
G -->|是| H[减少字段/合并查询]
G -->|否| I{是否深分页?}
I -->|是| J[改游标分页]
I -->|否| K[检查连接池与线程池]
K --> L[限制并发/有界队列/反压]
L --> M[压测验证优化结果]
常见坑与排查
这一部分我尽量讲得“接地气”一点,因为很多问题真的很像。
坑 1:索引建了,但就是不走
常见原因:
- 查询条件类型不一致,比如数据库是
bigint,代码里传字符串 - 对索引列做了函数处理
- 联合索引顺序和查询条件不匹配
- 数据分布导致优化器判断全表扫描更划算
排查方式:
EXPLAIN
select id, user_id, status, amount, create_time
from orders
where user_id = '1001';
如果 user_id 是数值列,这种隐式类型转换就值得警惕。
坑 2:动态 SQL 写得太灵活,最后谁都救不了
例如:
<select id="badQuery" resultType="com.example.demo.dto.OrderVO">
select id, user_id, status, amount, create_time
from orders
where 1 = 1
<if test="keyword != null and keyword != ''">
and summary like concat('%', #{keyword}, '%')
</if>
<if test="userId != null">
and user_id = #{userId}
</if>
order by create_time desc
</select>
这里如果模糊查询经常参与,而且 %keyword% 这种写法无法利用普通索引,就容易拖慢整体查询。
建议:
- 把全文检索需求和普通条件过滤拆开
- 不要让一条 SQL 试图兼容所有查询场景
坑 3:把异步并发当“提速开关”
我见过一种配置,线程池开到几百,结果数据库连接池才 20。后果就是:
- 应用层线程疯狂排队等连接
- CPU 上下文切换增加
- 实际吞吐并没有提升
排查指标建议看:
- 活跃线程数
- 线程池队列长度
- 数据库连接池活跃连接数
- SQL 平均执行时间
- 接口超时比例
坑 4:分页查询返回大对象
比如列表页只展示:
- 订单号
- 状态
- 金额
- 创建时间
但 SQL 却把备注、详情 JSON、扩展字段全带回来。这会浪费:
- 数据库 IO
- 网络传输
- JVM 反序列化和对象创建开销
建议做法:
- 列表 DTO 和详情 DTO 分离
- 列表接口只查必要字段
坑 5:只看平均耗时,不看 P95/P99
平均值常常会骗人。接口性能优化更应该关注:
- P95
- P99
- 超时率
- 错误率
因为线上用户真正感受到的“卡”,往往来自尾延迟。
安全/性能最佳实践
这一节我把最值得长期保留的习惯整理成清单。
1. 参数边界要收紧
pageSize限制上限,例如不超过 100- 对排序字段做白名单校验,避免任意排序注入风险
- 可选条件不要无限扩张
2. SQL 始终显式列出字段
不要依赖 select *。这样做有三个好处:
- 减少无用数据读取
- 避免表结构变更带来兼容问题
- 方便做覆盖索引优化
3. 优先消灭 N+1 查询
如果是:
- 列表 + 详情补查
- 循环单条聚合
- 循环查字典项
都要警惕 N+1。优先考虑:
- 一次 JOIN
- 一次 IN 批量查
- 先查主数据,再批量组装
4. 线程池必须有界
推荐至少做到:
- 有界队列
- 明确拒绝策略
- 根据任务类型分池
- 监控活跃线程和队列长度
5. 连接池与数据库容量要联动设计
建议有这样一个意识:
服务实例数 × 单实例最大连接数 ≤ 数据库可承受连接数的一定比例
不要每个服务都把连接池开到几十上百,最后数据库被“合法配置”打垮。
6. 给关键接口加监控埋点
至少记录:
- 接口总耗时
- SQL 执行次数
- 单次请求命中数据库次数
- 线程池排队时间
- 连接池等待时间
没有数据,优化就只能靠猜。
7. 压测必须模拟真实请求结构
不要只压一个“最简单参数”的 happy path。要覆盖:
- 大页码分页
- 热门用户数据
- 高频状态过滤
- 高并发突发流量
一个更稳妥的优化思路:分层定责
在团队协作里,我很推荐把性能问题按层分开看:
| 层次 | 关注点 | 常见动作 |
|---|---|---|
| Controller | 参数边界、返回体大小 | 限流、校验、裁剪字段 |
| Service | 串并行策略、业务聚合 | 合并查询、异步边界控制 |
| MyBatis | SQL 结构、映射成本 | 去掉 select *、批量化 |
| DB | 索引、执行计划、锁 | 建索引、改分页、拆热点 |
| Infra | 连接池、线程池、监控 | 配置调优、告警、压测 |
这样做的好处是:排查不会乱,责任不会混。
一个最小可运行验证方案
如果你想自己快速验证,可以按下面步骤操作:
- 建表并插入 10 万条订单数据
- 先运行“慢版本”接口
- 用 JMeter 或 wrk 压测 100 并发
- 记录 RT、吞吐量、错误率
- 切换到优化 SQL 的版本
- 再对比线程池与连接池参数前后的差异
重点不要只看“有没有变快”,而要看:
- 数据库 CPU 是否下降
- 活跃连接是否更稳定
- 接口尾延迟是否下降
- 高峰期是否更不容易超时
总结
Spring Boot + MyBatis 的接口性能优化,真正有效的方法不是“盲调参数”,而是沿着调用链逐层收敛问题:
- 先抓慢 SQL
- 用
EXPLAIN ANALYZE验证索引与执行计划 - 消灭
select *和 N+1 查询 - 深分页改成游标分页
- 线程池有界、连接池适配数据库容量
- 用压测和监控验证结果,而不是凭感觉
如果你只记住一句话,我建议记这个:
接口慢,先怀疑 SQL;并发差,先看资源边界;调优前先测,调优后再证。
这套方法的边界也很明确:
- 如果瓶颈是数据库物理资源不足,仅靠代码调优不够
- 如果业务必须做复杂统计,可能需要引入缓存、搜索引擎或离线计算
- 如果是热点数据争用,还要进一步分析锁竞争和事务设计
但对于大多数中级 Java Web 项目来说,把 SQL、MyBatis、连接池、线程池这四层打通理解,已经能解决 70% 以上的接口性能问题。这也是最值得优先投入的优化路径。