Administrator
发布于 2026-06-13 / 1 阅读
0
0

接口性能优化方案-202509120207

接口性能优化方案-202509120207

接口性能优化方案

一,背景

针对接口耗时过长的问题,总结关于接口性能优化的通用方案。

二,接口优化方案总结

1,批处理

在循环插入场景的接口中,可以在批处理执行完成后一次性插入或更新数据库,避免多次 IO。

JPA批处理示例:

//for循环单笔入库

for(TrjavaansDetail detail:transDetailList){

insert(detail);

}

//批处理入库

batchInsert(transDetailList);

@Service

public class BatchService {

@PersistenceContext

private EntityManager entityManager;

//配置文件中每次批量提交的数量

@Value("${spring.jpa.properties.hibernate.jdbc.batch_size}")

private long batchSize;

/**

  • 批量插入
  • *

  • @param list 实体类集合
  • @param 表对应的实体类
  • */

    @Transactional

    public void batchInsert(List list) {

    if (CollectionUtils.isNotEmpty(list)) {

    for (int i = 0; i < list.size(); i++) {

    entityManager.persist(list.get(i));

    if (i % batchSize == 0) {

    entityManager.flush();

    entityManager.clear();

    }

    }

    entityManager.flush();

    entityManager.clear();

    }

    }

    /**

  • 批量更新
  • *

  • @param list 实体类集合
  • @param 表对应的实体类
  • */

    @Transactional

    public void batchUpdate(List list) {

    if (CollectionUtils.isNotEmpty(list)) {

    for (int i = 0; i < list.size(); i++) {

    entityManager.merge(list.get(i));

    if (i % batchSize == 0) {

    entityManager.flush();

    entityManager.clear();

    }

    }

    entityManager.flush();

    entityManager.clear();

    2,异步处理

    针对耗时比较长且不是结果必须的逻辑,我们可以考虑放到异步执行;

    异步的实现方式:线程池,消息队列;

    业务流程示例:

    3,空间换时间

    在适当的业务场景,合理地使用缓存,是可以大大提高接口性能的。

    缓存其实就是一种空间换时间的思想,针对一些频繁使用且不频繁变更的数据,可以提前缓存起

    来,需要时直接查缓存,避免频繁地查询数据库或者重复计算。

    缓存包括:redis缓存、本地缓存、memcached,或者 Map。

    业务流程示例:

    }

    }

    4,预处理(预取思想)

    把未来可能需要的数据获取好,提前放到本地缓存中,需要的时候,直接去取,而不需要实时计

    算,这样就可以大幅度减少接口耗时。

    可能在启动的时候会耗费一定的时间去预取数据,即使后面程序中并没有用到对应的数据。

    场景示例:

    传统方式:

    优化方式:一次性预取进 ArrayList

    List largeList = userRepository.findAll(); // 假设这是懒加载的

    for (int i = 0; i < largeList.size(); i++) {

    process(largeList.get(i));

    }

    // 每次 get(i) 都有开销:

    // 实际需要的数据尚未加载,每次get(i)都有可能触发数据库访问、触发I/O操作

    List cachedList = new ArrayList<>(largeList); // 一次性复制全部数据

    for (User user : cachedList) {

    process(user); // 处理的是纯内存数据

    优化后:

    1. 一次性触发加载

    new ArrayList<>(largeList) 会调用一次 iterator() ,顺序访问所有元素;

    将数据全都加载出来;

    避免了后续每次 get(i) 再次检查、加载或代理。

    2. 数据结构更高效

    原 largeList 可能是 PersistentList 、LinkedList 或其他代理结构;

    新的 cachedList 是纯 ArrayList ,连续内存、访问迅速。

    适用场景:

    (1)关联数据预加载

    如:查询主实体时,提前加载关联的子实体(如订单项、评论列表)。

    (2)热点数据预加载

    如:系统启动或低峰期,主动加载高频访问数据(如商品详情、配置信息)到缓存;

    (3)文件流预读取

    如:处理大文件时预读下一块数据到内存缓冲区。

    .......

    }

    @Query("SELECT o FROM Order o LEFT JOIN FETCH o.items WHERE o.id =

    :id")

    Order findOrderWithItems(@Param("id") Long id);

    @PostConstruct

    public void preloadHotData() {

    List hotProducts = productRepository.findTop100BySales();

    hotProducts.forEach(p ->

    redisTemplate.opsForValue().set("product:" + p.getId(), p));

    }

    try (BufferedInputStream bis = new BufferedInputStream(new

    FileInputStream("large.log"), 8192 * 4)) {

    byte[] buffer = new byte[8192];

    while (bis.read(buffer) != -1) {

    // 处理当前块,缓冲区自动预读下一块

    }

    }

    5,池化思想:预分配与循环使用

    核心是预先创建并管理一组可重用资源,通过循环使用来避免频繁创建和销毁对象的开销;

    (1)线程池

    线程池的作用就是管理线程,避免增加创建线程和销毁线程的资源损耗。

    原理:

    预先创建5个线程放入池中

    任务到来时分配空闲线程执行

    任务完成后线程返回池中待用

    避免频繁创建/销毁线程的开销

    (2)数据库连接池

    // 创建固定大小的线程池(预分配)

    ExecutorService pool = Executors.newFixedThreadPool(5);

    // 提交任务(循环使用线程)

    for (int i = 0; i < 10; i++) {

    pool.execute(() -> {

    System.out.println("任务执行 by " +

    Thread.currentThread().getName());

    });

    }

    pool.shutdown();

    // 配置连接池(预分配)

    HikariConfig config = new HikariConfig();

    config.setJdbcUrl("jdbc:mysql://localhost:3306/mydb");

    config.setUsername("user");

    config.setPassword("password");

    config.setMaximumPoolSize(10); // 最大连接数

    // 获取连接池实例

    HikariDataSource dataSource = new HikariDataSource(config);

    // 使用连接(循环使用)

    try (Connection conn = dataSource.getConnection();

    Statement stmt = conn.createStatement()) {

    ResultSet rs = stmt.executeQuery("SELECT * FROM users");

    // 处理结果...

    }

    优势:

    避免每次SQL操作都建立新连接

    控制总连接数防止数据库过载

    自动回收空闲连接

    (3)对象池(Apache Commons Pool)

    (4) 字符串常量池(JVM内置)

    // 1. 定义对象工厂

    class StringBufferFactory extends

    BasePooledObjectFactory {

    @Override

    public StringBuffer create() {

    return new StringBuffer();

    }


    @Override

    public PooledObject wrap(StringBuffer buffer) {

    return new DefaultPooledObject<>(buffer);

    }

    }

    // 2. 创建对象池(预分配)

    GenericObjectPool pool = new GenericObjectPool<>(

    new StringBufferFactory(),

    new GenericObjectPoolConfig<>() {{

    setMaxTotal(5); // 池中最大对象数

    }}

    );

    // 3. 使用对象(循环使用)

    StringBuffer buf = pool.borrowObject();

    try {

    buf.append("Hello");

    System.out.println(buf.toString());

    } finally {

    pool.returnObject(buf); // 返还池中

    }

    String s1 = "hello"; // 使用常量池

    String s2 = "hello";

    String s3 = new String("hello"); // 新建对象

    池化思想的优势

    1. 降低开销:避免重复创建/销毁资源的成本

    2. 提高响应:资源立即可用,无需等待创建

    3. 可控资源:限制资源总数,防止系统过载

    4. 统一管理:集中处理资源的初始化、校验等逻辑

    池化实现要点

    1. 初始大小:合理设置初始容量

    2. 扩容策略:定义池满时的处理方式

    3. 回收机制:检测并回收无效对象

    4. 并发控制:确保多线程安全访问

    5. 状态追踪:监控池的健康状况

    6,串行改并行

    串行就是,当前执行逻辑必须等上一个执行逻辑结束之后才执行,并行就是两个执行逻辑互不干

    扰,所以并行相对来说就比较节省时间,当然是建立在没有结果参数依赖的前提下。

    (1)使用并行流

    示例:计算一组数字的平方和

    System.out.println(s1 == s2); // true,同一对象

    System out println(s1 == s3); // false

    不同对象

    //串行实现

    public class SquareSum {

    (2)使用CompletableFuture

    示例:多个独立接口或服务的并行调用

    (3)线程池与ExecutorService

    示例:批量执行独立任务(如文件处理、数据库操作)

    public static int calculateSquareSum(List numbers) {

    int sum = 0;

    for (int number : numbers) {

    sum += number * number;

    }

    return sum;

    }

    }

    //并行处理

    public class ParallelSquareSum {

    public static int calculateSquareSum(List numbers) {

    //parallelStream()方法会在内部将任务分配到多个线程中并行执行

    return numbers.parallelStream()

    .mapToInt(number -> number * number)

    .sum();

    }

    }

    //顺序调用

    String s1 = service.testTask1();

    String s2 = service.testTask2();

    String s3 = service.testTask3();

    //并行改写

    CompletableFuture future1 = CompletableFuture.runAsync(() ->

    map.put("s1", service.testTask1()));

    CompletableFuture future2 = CompletableFuture.runAsync(() ->

    map.put("s2", service.testTask2()));

    CompletableFuture future3 = CompletableFuture.runAsync(() ->

    map.put("s3", service.testTask3()));

    CompletableFuture.allOf(future1, future2, future3).join();

    // 顺序执行

    List results = new ArrayList<>();

    for (Task task : tasks) {

    results.add(process(task));

    注意事项:

    1. 线程安全:并行操作需确保共享数据线程安全(如使用ConcurrentHashMap代替HashMa

    p)。

    2. 顺序性:并行流默认无序,需用forEachOrdered 保持顺序。

    3. 性能权衡:小数据量或简单操作可能因线程调度开销导致并行更慢。

    7,索引

    }

    // 并行改写

    ExecutorService executor =

    Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()

    );

    List> futures = new ArrayList<>();

    for (Task task : tasks) {

    futures.add(executor.submit(() -> process(task)));

    }

    List results =

    futures.stream().map(Future::get).collect(Collectors.toList());

    executor.shutdown();

    (1)数据类型隐式转换

    原理:

    当查询条件中的数据类型与索引列定义的数据类型不一致时,MySQL 会进行隐式类型转换,导

    致无法使用索引。

    (2)对索引列使用函数或表达式

    -- 假设 `user_id` 是 VARCHAR 类型且有索引

    SELECT * FROM users WHERE user_id = 10086; -- 隐式转换为字符串,索引失效

    -- >> 保持类型一致

    SELECT * FROM users WHERE user_id = '10086';

    原理:

    对索引列进行函数操作或计算时,索引无法匹配原始值。

    (3)联合索引未遵循最左前缀原则

    原理:

    联合索引 (a, b, c) 只能按 a → a+b → a+b+c 的顺序使用,跳过左侧列会导致索引失

    效。

    (4)使用 OR 连接非索引列

    原理:

    如果 OR 连接的列中有一个无索引,优化器可能选择全表扫描。

    (5)使用 LIKE 以通配符开头

    原理:

    LIKE '%abc' 或 LIKE '%abc%' 无法利用索引的 B+Tree 有序性。

    -- 假设 `create_time` 是 DATETIME 类型且有索引

    SELECT * FROM orders WHERE DATE(create_time) = '2025-01-01'; -- 索引失

    -- >> 避免使用函数

    SELECT * FROM orders

    WHERE create_time >= '2025-01-01 00:00:00'

    AND create_time <= '2025-01-01 23:59:59';

    -- 索引为 (age, name)

    SELECT * FROM users WHERE name = 'Alice'; -- 未使用 age,索引失效

    -- >> 单独为 name 建索引

    ALTER TABLE users ADD INDEX idx_name (name);

    -- >> 或调整查询条件:

    SELECT * FROM users WHERE age = 25 AND name = 'Alice'; -- 使用联合索引

    -- 假设 `age` 有索引,`address` 无索引

    SELECT * FROM users WHERE age = 25 OR address = 'Beijing'; -- 索引失效

    -- 改写为 UNION:

    SELECT * FROM users WHERE age = 25

    UNION

    SELECT * FROM users WHERE address = 'Beijing';

    -- 假设 `title` 有索引

    SELECT * FROM articles WHERE title LIKE '%mysql%'; -- 索引失效

    (6)范围查询后的列无法使用索引

    原理:

    在联合索引中,若某一列使用范围查询(> 、< 、BETWEEN ),其后的列无法使用索引。

    (7)使用 != 或 <> 操作符

    原理:

    非等值查询需要扫描大部分数据,优化器可能放弃索引;

    (8)索引列参与计算

    原理:

    对索引列进行数学运算或逻辑操作会导致索引失效。

    (9)数据量过少或全表扫描更快

    原理:

    优化器可能认为全表扫描比索引扫描更快(如小表或高比例数据命中)。

    -- >> 使用全文索引(FULLTEXT)优化模糊查询:

    ALTER TABLE articles ADD FULLTEXT INDEX ft_title (title);

    SELECT * FROM articles WHERE MATCH(title) AGAINST('mysql');

    -- 索引为 (age, salary)

    SELECT * FROM employees WHERE age > 30 AND salary = 50000; -- salary

    无法使用索引

    -- >> 调整索引顺序(若 salary 查询频率高):

    ALTER TABLE employees ADD INDEX idx_salary_age (salary, age);

    -- 假设 `status` 有索引

    SELECT * FROM orders WHERE status != 'completed'; -- 索引可能失效

    -- >> 改写为范围查询:

    SELECT * FROM orders WHERE status IN ('pending', 'cancelled');

    -- 假设 `price` 有索引

    SELECT * FROM products WHERE price * 0.9 = 100; -- 索引失效

    -- >> 改写为无计算的表达式

    SELECT * FROM products WHERE price = 100 / 0.9;

    -- 表中仅 100 行数据,`age` 有索引

    SELECT * FROM users WHERE age > 10; -- 可能全表扫描

    (10)隐式字符集转换

    原理:

    JOIN 操作中,若两表的字符集或排序规则不同,索引可能失效。

    (11)使用 IS NULL 或 IS NOT NULL 

    原理:

    若索引列允许 NULL 且大部分值为 NULL,优化器可能放弃索引。

    (12)参数化查询的值分布不均

    原理:

    当查询条件的值在表中占比过高时,优化器可能选择全表扫描

    8,避免大事务

    所谓大事务问题,就是运行时间较长的事务,由于事务一致不提交,会导致数据库连接长时

    间被占用,影响到别的请求访问数据库,影响别的接口性能。

    示例:

    -- 表 A 的 `name` 是 utf8mb4,表 B 的 `name` 是 latin1

    SELECT * FROM A JOIN B ON A.name = B.name; -- 索引失效

    -- >> 统一字符集

    ALTER TABLE B MODIFY name VARCHAR(255) CHARACTER SET utf8mb4;

    -- 假设 `address` 有索引且 90% 为 NULL

    SELECT * FROM users WHERE address IS NULL; -- 可能全表扫描

    -- >> 避免在索引列中存储大量 NULL 值,或强制使用索引:

    SELECT * FROM users USE INDEX (idx_address) WHERE address IS NULL;

    -- 假设 `gender` 有索引,但 95% 的值为 'M'

    SELECT * FROM users WHERE gender = 'M'; -- 可能全表扫描

    @Transactional

    public int createUser(User user){

    //保存用户信息

    userDao.save(user);

    passCertDao.updateFlag(user.getPassId());

    //email消息通知

    sendEmailRpc(user.getEmail());

    return user.getUserId();

    }

    事务中嵌套RPC远程调用,即事务嵌套了一些非DB操作。如果这些非DB操作耗时比较大的话,

    可能会出现大事务问题。

    大事务引发的问题主要有:接口超时、死锁、主从延迟等等。因此,为了优化接口,我们要规避

    大事务问题。我们可以通过这些方案来规避大事务:

    RPC远程调用不要放到事务里面

    一些查询相关的操作,尽量放到事务之外

    事务中避免处理太多数据

    9,优化程序架构

    程序结构问题一般出现在多次需求迭代后,代码叠加形成。

    优化程序逻辑、程序代码,是可以节省耗时的。比如,程序创建多不必要的对象、或者程序逻辑

    混乱,多次重复查询数据库、又或者实现逻辑算法不是最高效的,等等。

    复杂的逻辑条件,有时候调整一下顺序,就能让你的程序更加高效。

    业务需求示例:如果用户是会员,第一次登陆时,需要发一条感谢短信。

    假设有5个请求过来,isUserVip判断通过的有3个请求,isFirstLogin通过的只有1个请求。那么以

    上代码,isUserVip执行的次数为5次,isFirstLogin执行的次数也是3次,如下:

    调整isUserVip和isFirstLogin的顺序:

    isFirstLogin执行的次数是5次,isUserVip执行的次数是1次:

    10,深分页问题

    if(isUserVip && isFirstLogin){

    sendSmsMsg();

    }

    if(isFirstLogin && isUserVip ){

    sendSmsMsg();

    }

    深分页问题的本质

    深分页问题是指当用户需要访问数据集靠后的页面(如第1000页)时,传统分页方式(LIMIT off

    set, size)性能急剧下降的现象。其根本原因在于数据库需要先扫描并跳过前面所有记录才能获

    取目标数据。

    示例:

    执行过程:

    1. 数据库需要先扫描前100000条记录

    2. 然后跳过这些记录

    3. 最后返回接下来的10条

    通过标签记录法和延迟关联法来优化深分页问题。

    标签记录法

    基于排序字段的最后一个值作为游标,而不是使用偏移量;

    实现方式:

    延迟关联法

    先通过子查询获取主键,再关联获取完整数据,就是把条件转移到主键索引树,然后减少回表。

    优化思路就是,先通过idx_create_time二级索引树查询到满足条件的主键ID,后续直接走主键索

    引,同时也减少了回表。

    前端限制

    // 获取第10000页数据(第99991-100000条记录)

    Page page = userRepository.findAll(PageRequest.of(10000, 10));

    SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 100000;

    // 获取第一页

    List firstPage = userRepository.findFirstPage(10);

    Long lastId = firstPage.get(firstPage.size()-1).getId();

    // 获取第二页

    List secondPage = userRepository.findNextPage(lastId, 10);

    @Query("SELECT u FROM User u WHERE u.id IN " +

    "(SELECT u2.id FROM User u2 ORDER BY u2.createdTime DESC LIMIT

    :limit OFFSET :offset)")

    List findUsersWithDelayJoin(@Param("offset") int offset,

    @Param("limit") int limit);

    必须显示页码时,可以限制最大页数(如只允许前500页)

    替代方案

    对于真正的大数据分页,考虑Elasticsearch等搜索引擎

    11,SQL优化

    12,避免锁粒度过粗

    锁本质是为了让代码块“串行化”,那么在串行化的代码块中加入定制的逻辑,就可以实现效率上

    的“做一次”或者是正确性上的”避免并发问题“。但是如果加锁的力度过粗,是很影响接口性能

    的。

    不管是synchronized 加锁还是redis 分布式锁,只需要在共享临界资源加锁即可,不涉及

    共享资源的,就不必要加锁。

    示例:ArrayList 因为涉及到多线程操作,所以需要加锁操作

    //不涉及共享资源的慢方法

    private void slowNotShare() {

    try {

    TimeUnit.MILLISECONDS.sleep(100);

    } catch (InterruptedException e) {

    }

    }

    //错误的加锁方法

    public int wrong() {

    long beginTime = System.currentTimeMillis();

    IntStream.rangeClosed(1, 10000).parallel().forEach(i -> {

    //加锁粒度太粗了,slowNotShare其实不涉及共享资源

    synchronized (this) {

    slowNotShare();

    data.add(i);

    }

    });

    log.info("cosume time:{}", System.currentTimeMillis() - beginTime);

    return data.size();

    }

    //正例

    public int right() {

    long beginTime = System.currentTimeMillis();

    IntStream.rangeClosed(1, 10000).parallel().forEach(i -> {

    slowNotShare();//可以不加锁

    //只对List这部分加锁

    synchronized (data) {

    data.add(i);

    }

    });

    log.info("cosume time:{}", System.currentTimeMillis() - beginTime);

    return data.size();

    }


    本文整理自实践经验,如有问题欢迎交流讨论。


    评论