跳转到内容
123xiao | 无名键客

《Java Web开发中基于Spring Boot与MyBatis的接口性能优化实战:从慢SQL排查到线程池与缓存调优》

字数: 0 阅读时长: 1 分钟

背景与问题

做 Java Web 开发时,接口“偶尔慢一下”和“持续性很慢”是两种完全不同的问题。

我在项目里最常见到的情况是:业务代码看起来不复杂,单机压测也能跑,但一到线上高峰期,接口 RT 就从几十毫秒飙到几秒,甚至把整个应用拖垮。最后排查下来,往往不是某一层单点故障,而是SQL、线程池、连接池、缓存策略几件事叠在一起了。

这篇文章就从一个典型场景出发,带你走一遍完整排查链路:

  • 接口响应慢,P95/P99 飙高
  • 数据库 CPU 正常,但 SQL 执行时间不稳定
  • 应用线程数上涨,Tomcat 工作线程被占满
  • MyBatis 查询结果重复访问数据库
  • 加了缓存后又出现脏数据或缓存击穿

我们不只讲“原理是什么”,更重点讲怎么定位、怎么止血、怎么改造


现象复现

假设有一个订单查询接口:

  • 根据用户 ID 查询订单列表
  • 对每个订单再补充商品信息、物流状态、优惠信息
  • 页面访问量高,接口被频繁调用

一个常见的错误写法是:

  1. 先查订单列表
  2. 再循环逐条查商品
  3. 再远程调用物流服务
  4. 最后做对象组装返回

这时非常容易出现:

  • 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[接口稳定]

定位路径

遇到性能问题,我一般按这个顺序排查,而不是一上来就改代码:

  1. 先看接口监控
    • 平均响应时间
    • P95/P99
    • QPS、错误率
  2. 再看应用层
    • Tomcat 线程数
    • JVM 堆内存
    • GC 次数
    • 线程池队列堆积
  3. 再看数据库
    • 慢 SQL 日志
    • 执行计划
    • 锁等待
    • 连接数
  4. 最后看代码结构
    • 是否有循环查库
    • 是否存在大对象反序列化
    • 缓存是否设计合理

如果顺序反了,很容易陷入“猜优化”。


核心原理

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/range
  • key 是否命中预期索引
  • 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"

止血方案:线上先保住服务

真正线上出故障时,不一定有时间慢慢重构。我的经验是,先做止血,再做优化。

可以优先做的止血动作

  1. 限制接口返回量
    • 临时加分页
    • 限制时间范围
  2. 关闭非核心字段组装
    • 比如物流轨迹、扩展标签、推荐信息先不返回
  3. 给热点读加本地缓存
    • 先顶住数据库压力
  4. 线程池设置拒绝策略
    • 避免任务无限堆积
  5. 降级远程依赖
    • 某些补充信息超时就返回默认值
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
  • 线程池扩容
  • 消息队列异步化
  • 分库分表

听起来很猛,但实操里通常不划算。

建议优先级:

  1. 先消灭慢 SQL 和 N+1
  2. 再控制返回体和分页
  3. 再做缓存
  4. 最后评估异步化和架构升级

原因很简单:
前两步往往能解决 60%~80% 的问题,而且改动可控、收益直接。


总结

Spring Boot + MyBatis 的接口性能优化,最怕“头痛医头”。
真正有效的方法,通常是一条完整链路:

  • 用监控确认是哪里慢
  • 用慢 SQL 和执行计划确认数据库是否有问题
  • 从 MyBatis 查询结构里找 N+1、字段冗余、大分页
  • 用有界线程池处理非核心耗时任务
  • 对热点读做可控缓存
  • 最后再通过压测验证优化是否真实生效

如果你只记住三条,我建议是:

  1. 先查 SQL,再改线程池
  2. 先批量查询,再谈异步并发
  3. 缓存是放大器,不是万能药

边界条件也很重要:

  • 如果业务强一致要求极高,不要激进缓存
  • 如果数据库本身已经接近瓶颈,线程池扩容只会雪上加霜
  • 如果接口核心瓶颈是大结果集传输,优化重点应放在分页和字段裁剪,而不是盲目并发

性能优化从来不是“某个神奇参数”的胜利,而是把每一层的等待和浪费一点点抠掉。
你只要能稳定地按“定位—止血—修复—验证”这条线走,绝大多数慢接口问题都能被收拾干净。


分享到:

上一篇
《AI 智能体落地实战:基于 RAG 与函数调用构建企业知识库问答系统》
下一篇
《Spring Boot 实战:基于 Actuator、Micrometer 与 Prometheus 搭建应用监控告警体系》