背景与问题
做 Java Web 开发时,接口“偶尔慢一下”和“持续性很慢”是两种完全不同的问题。
我在项目里最常见到的情况是:业务代码看起来不复杂,单机压测也能跑,但一到线上高峰期,接口 RT 就从几十毫秒飙到几秒,甚至把整个应用拖垮。最后排查下来,往往不是某一层单点故障,而是SQL、线程池、连接池、缓存策略几件事叠在一起了。
这篇文章就从一个典型场景出发,带你走一遍完整排查链路:
- 接口响应慢,P95/P99 飙高
- 数据库 CPU 正常,但 SQL 执行时间不稳定
- 应用线程数上涨,Tomcat 工作线程被占满
- MyBatis 查询结果重复访问数据库
- 加了缓存后又出现脏数据或缓存击穿
我们不只讲“原理是什么”,更重点讲怎么定位、怎么止血、怎么改造。
现象复现
假设有一个订单查询接口:
- 根据用户 ID 查询订单列表
- 对每个订单再补充商品信息、物流状态、优惠信息
- 页面访问量高,接口被频繁调用
一个常见的错误写法是:
- 先查订单列表
- 再循环逐条查商品
- 再远程调用物流服务
- 最后做对象组装返回
这时非常容易出现:
- N+1 查询
- 慢 SQL
- 线程池阻塞
- 缓存命中率低
下面先看整个故障传播链。
flowchart TD
A[请求进入接口] --> B[MyBatis查询订单列表]
B --> C{是否存在N+1查询}
C -- 是 --> D[循环查询商品/优惠信息]
C -- 否 --> E[批量查询]
D --> F[数据库连接占用增加]
F --> G[SQL等待与响应抖动]
G --> H[Tomcat线程堆积]
H --> I[接口RT升高]
E --> J[线程池异步补充非关键数据]
J --> K[缓存命中]
K --> L[接口稳定]
定位路径
遇到性能问题,我一般按这个顺序排查,而不是一上来就改代码:
- 先看接口监控
- 平均响应时间
- P95/P99
- QPS、错误率
- 再看应用层
- Tomcat 线程数
- JVM 堆内存
- GC 次数
- 线程池队列堆积
- 再看数据库
- 慢 SQL 日志
- 执行计划
- 锁等待
- 连接数
- 最后看代码结构
- 是否有循环查库
- 是否存在大对象反序列化
- 缓存是否设计合理
如果顺序反了,很容易陷入“猜优化”。
核心原理
1. 接口慢,不一定是 CPU 高
很多同学会先盯着 CPU,但 Java Web 的慢接口,大量情况是等待型耗时:
- 等数据库返回
- 等线程池空闲
- 等连接池连接
- 等远程服务响应
也就是说,接口慢经常不是“算不过来”,而是“等太久”。
2. MyBatis 层最常见的两个瓶颈
慢 SQL
通常表现为:
- 没有命中索引
select *- 条件字段类型不匹配
- 复合索引未正确使用
- 大分页
limit offset
N+1 查询
比如先查 100 条订单,再循环执行 100 次商品查询。
单条 SQL 很快,但叠加起来接口就慢了。
3. 线程池不是越大越好
很多人看到接口慢,第一反应是把线程池调大。这个做法很危险。
因为线程池本身不会消除慢调用,它只是把等待扩散:
- 线程更多
- 数据库连接抢得更凶
- 上下文切换更多
- 最后系统整体更慢
4. 缓存的本质是“减少重复计算和重复 IO”
接口缓存适合:
- 热点读多写少的数据
- 用户基础信息
- 配置项
- 商品静态信息
但不适合:
- 强一致实时数据
- 高度个性化且变化频繁的数据
慢 SQL 排查:先从数据库动手
先给一个典型低效 SQL:
SELECT *
FROM orders
WHERE user_id = #{userId}
ORDER BY create_time DESC;
问题看似不大,但如果表很大,而 user_id, create_time 没有联合索引,查询一多就会抖。
更合理的做法:
CREATE INDEX idx_orders_user_time ON orders(user_id, create_time DESC);
同时避免查全字段:
SELECT id, user_id, total_amount, status, create_time
FROM orders
WHERE user_id = #{userId}
ORDER BY create_time DESC
LIMIT 20;
执行计划一定要看:
EXPLAIN
SELECT id, user_id, total_amount, status, create_time
FROM orders
WHERE user_id = 10001
ORDER BY create_time DESC
LIMIT 20;
重点关注:
type是否为ref/rangekey是否命中预期索引rows扫描行数是否过高Extra是否出现Using filesort
MyBatis 优化思路
1. 避免循环查库
错误示例的思路通常是:
- 查订单列表
- 遍历订单 ID
- 每个订单再查商品信息
正确做法是改成批量查询。
sequenceDiagram
participant C as Client
participant A as Order API
participant M as MyBatis
participant D as DB
C->>A: 查询订单列表
A->>M: select orders by userId
M->>D: 执行订单SQL
D-->>M: 返回订单列表
A->>M: 批量查询商品信息(orderIds)
M->>D: IN 批量SQL
D-->>M: 返回商品信息
A-->>C: 聚合结果返回
2. 只查需要的字段
MyBatis 映射很方便,但也容易偷懒写 select *。
字段一多,不仅数据库 IO 大,网络传输和对象映射也有成本。
3. 合理使用分页
大分页是接口慢的高发点。比如:
SELECT id, user_id, total_amount
FROM orders
ORDER BY id DESC
LIMIT 100000, 20;
这种 SQL 即使有索引,也可能很慢。更好的办法是基于主键游标分页:
SELECT id, user_id, total_amount
FROM orders
WHERE id < #{lastId}
ORDER BY id DESC
LIMIT 20;
实战代码(可运行)
下面给一个简化但可运行的 Spring Boot + MyBatis 示例,演示:
- 订单列表查询
- 批量补充商品信息
- 使用线程池异步补充非核心数据
- 使用缓存减少热点查询
1. Maven 依赖
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>performance-demo</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
</dependencies>
</project>
2. 配置文件
spring:
datasource:
url: jdbc:h2:mem:testdb;MODE=MYSQL;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
mybatis:
mapper-locations: classpath*:mapper/*.xml
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
logging:
level:
root: info
3. 初始化 SQL
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
total_amount DECIMAL(10,2),
status VARCHAR(32),
create_time TIMESTAMP
);
CREATE TABLE product (
id BIGINT PRIMARY KEY,
name VARCHAR(128),
price DECIMAL(10,2)
);
CREATE INDEX idx_orders_user_time ON orders(user_id, create_time);
INSERT INTO product (id, name, price) VALUES
(1, '机械键盘', 299.00),
(2, '显示器', 999.00),
(3, '鼠标', 129.00);
INSERT INTO orders (id, user_id, product_id, total_amount, status, create_time) VALUES
(101, 1001, 1, 299.00, 'PAID', CURRENT_TIMESTAMP()),
(102, 1001, 2, 999.00, 'PAID', CURRENT_TIMESTAMP()),
(103, 1001, 3, 129.00, 'CREATED', CURRENT_TIMESTAMP());
4. 启动类与缓存配置
package com.example.demo;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import java.util.concurrent.TimeUnit;
@SpringBootApplication
@EnableCaching
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager("product");
manager.setCaffeine(Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES));
return manager;
}
}
5. 线程池配置
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
public ExecutorService orderEnrichExecutor() {
return new ThreadPoolExecutor(
4,
8,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
}
这里我特意用了有界队列和
CallerRunsPolicy,因为线上最怕的不是慢,而是无限堆积把自己拖死。
6. 实体类
package com.example.demo.model;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public class Order {
private Long id;
private Long userId;
private Long productId;
private BigDecimal totalAmount;
private String status;
private LocalDateTime createTime;
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 Long getProductId() { return productId; }
public void setProductId(Long productId) { this.productId = productId; }
public BigDecimal getTotalAmount() { return totalAmount; }
public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public LocalDateTime getCreateTime() { return createTime; }
public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; }
}
package com.example.demo.model;
import java.math.BigDecimal;
public class Product {
private Long id;
private String name;
private BigDecimal price;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
}
package com.example.demo.dto;
import java.math.BigDecimal;
public class OrderView {
private Long orderId;
private String status;
private BigDecimal totalAmount;
private String productName;
private String extInfo;
public Long getOrderId() { return orderId; }
public void setOrderId(Long orderId) { this.orderId = orderId; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public BigDecimal getTotalAmount() { return totalAmount; }
public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; }
public String getProductName() { return productName; }
public void setProductName(String productName) { this.productName = productName; }
public String getExtInfo() { return extInfo; }
public void setExtInfo(String extInfo) { this.extInfo = extInfo; }
}
7. Mapper 接口
package com.example.demo.mapper;
import com.example.demo.model.Order;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface OrderMapper {
List<Order> findByUserId(@Param("userId") Long userId);
}
package com.example.demo.mapper;
import com.example.demo.model.Product;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ProductMapper {
List<Product> findByIds(@Param("ids") List<Long> ids);
Product findById(@Param("id") Long id);
}
8. 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="findByUserId" resultType="com.example.demo.model.Order">
SELECT id, user_id, product_id, total_amount, status, create_time
FROM orders
WHERE user_id = #{userId}
ORDER BY create_time DESC
</select>
</mapper>
<?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.ProductMapper">
<select id="findByIds" resultType="com.example.demo.model.Product">
SELECT id, name, price
FROM product
WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
<select id="findById" resultType="com.example.demo.model.Product">
SELECT id, name, price
FROM product
WHERE id = #{id}
</select>
</mapper>
9. Service 实现
package com.example.demo.service;
import com.example.demo.mapper.ProductMapper;
import com.example.demo.model.Product;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
public class ProductService {
private final ProductMapper productMapper;
public ProductService(ProductMapper productMapper) {
this.productMapper = productMapper;
}
@Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")
public Product getById(Long id) {
return productMapper.findById(id);
}
}
package com.example.demo.service;
import com.example.demo.dto.OrderView;
import com.example.demo.mapper.OrderMapper;
import com.example.demo.mapper.ProductMapper;
import com.example.demo.model.Order;
import com.example.demo.model.Product;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.stream.Collectors;
@Service
public class OrderService {
private final OrderMapper orderMapper;
private final ProductMapper productMapper;
private final ExecutorService orderEnrichExecutor;
public OrderService(OrderMapper orderMapper,
ProductMapper productMapper,
ExecutorService orderEnrichExecutor) {
this.orderMapper = orderMapper;
this.productMapper = productMapper;
this.orderEnrichExecutor = orderEnrichExecutor;
}
public List<OrderView> getOrders(Long userId) {
List<Order> orders = orderMapper.findByUserId(userId);
if (orders.isEmpty()) {
return Collections.emptyList();
}
List<Long> productIds = orders.stream()
.map(Order::getProductId)
.distinct()
.collect(Collectors.toList());
Map<Long, Product> productMap = productMapper.findByIds(productIds).stream()
.collect(Collectors.toMap(Product::getId, p -> p));
List<CompletableFuture<OrderView>> futures = orders.stream()
.map(order -> CompletableFuture.supplyAsync(() -> {
OrderView view = new OrderView();
view.setOrderId(order.getId());
view.setStatus(order.getStatus());
view.setTotalAmount(order.getTotalAmount());
Product product = productMap.get(order.getProductId());
view.setProductName(product == null ? "未知商品" : product.getName());
// 模拟非核心扩展信息
view.setExtInfo("ext-" + order.getId());
return view;
}, orderEnrichExecutor))
.collect(Collectors.toList());
return futures.stream().map(CompletableFuture::join).collect(Collectors.toList());
}
}
10. Controller
package com.example.demo.controller;
import com.example.demo.dto.OrderView;
import com.example.demo.service.OrderService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
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("/orders")
public List<OrderView> orders(@RequestParam Long userId) {
return orderService.getOrders(userId);
}
}
启动后访问:
curl "http://localhost:8080/orders?userId=1001"
止血方案:线上先保住服务
真正线上出故障时,不一定有时间慢慢重构。我的经验是,先做止血,再做优化。
可以优先做的止血动作
- 限制接口返回量
- 临时加分页
- 限制时间范围
- 关闭非核心字段组装
- 比如物流轨迹、扩展标签、推荐信息先不返回
- 给热点读加本地缓存
- 先顶住数据库压力
- 线程池设置拒绝策略
- 避免任务无限堆积
- 降级远程依赖
- 某些补充信息超时就返回默认值
stateDiagram-v2
[*] --> 正常
正常 --> 抖动: RT升高
抖动 --> 止血中: 限流/降级/缓存
止血中 --> 稳定: 压力回落
稳定 --> 根因修复: SQL与代码优化
根因修复 --> 正常
常见坑与排查
1. 索引建了,但 SQL 还是慢
这类情况我踩过不止一次,通常原因有:
WHERE条件对索引列做了函数运算- 隐式类型转换导致索引失效
- 联合索引顺序不匹配
- 排序字段没走索引
比如下面这种就很危险:
SELECT id
FROM orders
WHERE DATE(create_time) = '2024-01-01';
更好的写法:
SELECT id
FROM orders
WHERE create_time >= '2024-01-01 00:00:00'
AND create_time < '2024-01-02 00:00:00';
2. MyBatis IN 查询太大
批量查询是好事,但 IN 不是越大越好。
如果一次性塞几千上万 ID:
- SQL 变长
- 执行计划可能变差
- 数据库解析成本上升
建议按批拆分,比如每批 200~500。
3. 线程池把数据库打爆了
异步化不是“免费午餐”。
如果异步任务本身还在查数据库,那么线程池一放大,数据库连接压力也会跟着放大。
排查时重点看:
- 活跃线程数
- 队列长度
- 数据源连接池等待时间
- 数据库连接数
4. 缓存命中率低
常见原因:
- key 设计不稳定
- 过期时间太短
- 查询条件过于分散
- 把强个性化结果也硬缓存
5. 二级缓存误用
MyBatis 自带二级缓存不是不能用,但我不太建议在复杂业务里依赖它做核心性能优化。原因是:
- 命中行为不直观
- 多表关联数据一致性难控
- 分布式场景下更复杂
相比之下,显式使用 Caffeine 或 Redis,行为更清晰。
安全/性能最佳实践
1. SQL 层
- 只查必要字段
- 尽量让条件命中索引
- 避免大分页
- 批量查询替代循环查库
- 慢 SQL 必看执行计划,不靠猜
2. MyBatis 层
- Mapper 语句保持简单直接
- XML 中复杂动态 SQL 要审查边界
- 谨慎使用多层嵌套
resultMap - 对高频接口优先返回轻量 DTO
3. 线程池层
- 线程池必须有界
- 队列必须有界
- 拒绝策略要可控
- 非核心任务才能异步化
- 不要把所有耗时操作都扔进线程池
一个简单经验值:
- 如果任务主要是 IO 等待型,可以适当放大线程数
- 如果任务主要是 CPU 计算型,线程数接近 CPU 核数更稳
4. 缓存层
- 热点数据优先缓存
- 设置合理 TTL
- 防止缓存穿透:空值也可短期缓存
- 防止缓存击穿:热点 key 可加互斥更新
- 防止缓存雪崩:过期时间加随机值
示例:
@Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")
public Product getById(Long id) {
return productMapper.findById(id);
}
如果是 Redis 方案,还要额外关注:
- 序列化体积
- 网络开销
- 热 key
- 集群分片
5. 安全层
性能优化不能牺牲安全性,这点很容易被忽略。
- 不要拼接 SQL,统一使用参数绑定
- 分页参数要做上限校验
- 缓存 key 不要直接暴露敏感信息
- 异步线程中注意上下文隔离,避免串用户数据
- 日志不要打印完整用户隐私字段
比如分页上限:
public int safePageSize(Integer size) {
if (size == null || size <= 0) {
return 20;
}
return Math.min(size, 100);
}
一套更实用的排查清单
当接口慢时,可以按下面这个顺序一项项打勾:
应用层
- 是否只有个别接口慢,还是全站慢
- Tomcat 工作线程是否接近打满
- 是否出现 Full GC 或频繁 Young GC
- 异步线程池是否队列堆积
数据库层
- 是否有慢 SQL 日志
- 慢 SQL 是否命中索引
- 是否存在锁等待
- 连接池是否耗尽
代码层
- 是否存在 N+1 查询
- 是否返回了过多字段
- 是否有大分页
- 是否有重复查询未缓存
缓存层
- 热点 key 命中率如何
- 是否存在缓存穿透/击穿
- TTL 是否合理
- 更新后是否需要主动失效
方案取舍:不要一把梭全上
有些团队一遇到性能问题就想同时上:
- SQL 重写
- Redis
- 线程池扩容
- 消息队列异步化
- 分库分表
听起来很猛,但实操里通常不划算。
建议优先级:
- 先消灭慢 SQL 和 N+1
- 再控制返回体和分页
- 再做缓存
- 最后评估异步化和架构升级
原因很简单:
前两步往往能解决 60%~80% 的问题,而且改动可控、收益直接。
总结
Spring Boot + MyBatis 的接口性能优化,最怕“头痛医头”。
真正有效的方法,通常是一条完整链路:
- 用监控确认是哪里慢
- 用慢 SQL 和执行计划确认数据库是否有问题
- 从 MyBatis 查询结构里找 N+1、字段冗余、大分页
- 用有界线程池处理非核心耗时任务
- 对热点读做可控缓存
- 最后再通过压测验证优化是否真实生效
如果你只记住三条,我建议是:
- 先查 SQL,再改线程池
- 先批量查询,再谈异步并发
- 缓存是放大器,不是万能药
边界条件也很重要:
- 如果业务强一致要求极高,不要激进缓存
- 如果数据库本身已经接近瓶颈,线程池扩容只会雪上加霜
- 如果接口核心瓶颈是大结果集传输,优化重点应放在分页和字段裁剪,而不是盲目并发
性能优化从来不是“某个神奇参数”的胜利,而是把每一层的等待和浪费一点点抠掉。
你只要能稳定地按“定位—止血—修复—验证”这条线走,绝大多数慢接口问题都能被收拾干净。