接口性能优化方案-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;
/**
*
*/
@Transactional
public
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();
}
}
/**
*
*/
@Transactional
public
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
for (int i = 0; i < largeList.size(); i++) {
process(largeList.get(i));
}
// 每次 get(i) 都有开销:
// 实际需要的数据尚未加载,每次get(i)都有可能触发数据库访问、触发I/O操作
List
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.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
return new DefaultPooledObject<>(buffer);
}
}
// 2. 创建对象池(预分配)
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
int sum = 0;
for (int number : numbers) {
sum += number * number;
}
return sum;
}
}
//并行处理
public class ParallelSquareSum {
public static int calculateSquareSum(List
//parallelStream()方法会在内部将任务分配到多个线程中并行执行
return numbers.parallelStream()
.mapToInt(number -> number * number)
.sum();
}
}
//顺序调用
String s1 = service.testTask1();
String s2 = service.testTask2();
String s3 = service.testTask3();
//并行改写
CompletableFuture
map.put("s1", service.testTask1()));
CompletableFuture
map.put("s2", service.testTask2()));
CompletableFuture
map.put("s3", service.testTask3()));
CompletableFuture.allOf(future1, future2, future3).join();
// 顺序执行
List
for (Task task : tasks) {
results.add(process(task));
注意事项:
1. 线程安全:并行操作需确保共享数据线程安全(如使用ConcurrentHashMap代替HashMa
p)。
2. 顺序性:并行流默认无序,需用forEachOrdered 保持顺序。
3. 性能权衡:小数据量或简单操作可能因线程调度开销导致并行更慢。
7,索引
}
// 并行改写
ExecutorService executor =
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()
);
List
for (Task task : tasks) {
futures.add(executor.submit(() -> process(task)));
}
List
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
SELECT * FROM users ORDER BY id LIMIT 10 OFFSET 100000;
// 获取第一页
List
Long lastId = firstPage.get(firstPage.size()-1).getId();
// 获取第二页
List
@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
@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();
}
本文整理自实践经验,如有问题欢迎交流讨论。