常见的性能优化策略
常见的性能优化策略
性能优化之前我们需要对请求经历的各个环节进行分析,排查出可能出现性能瓶颈的地方,定位问题。
一、性能优化自检清单
1. SQL 语句是否存在问题?
核心原则: 避免慢 SQL,减少数据库层面的瓶颈。
常见问题与示例:
- 避免
SELECT *: 只查询需要的字段,减少数据传输量和内存占用。-- 反例 SELECT * FROM orders WHERE user_id = 1001; -- 正例 SELECT order_id, amount, status FROM orders WHERE user_id = 1001; - 避免在 WHERE 子句中对索引列使用函数或运算: 这会导致索引失效,退化为全表扫描。
-- 反例:索引失效 SELECT * FROM users WHERE YEAR(create_time) = 2024; -- 正例:利用索引范围扫描 SELECT * FROM users WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01'; - 避免大表的
JOIN和子查询: 大表关联会生成临时表,占用大量内存和磁盘。-- 反例:多表嵌套子查询 SELECT * FROM orders WHERE user_id IN (SELECT id FROM users WHERE city = 'Beijing'); -- 正例:改用 JOIN 或 EXISTS SELECT o.* FROM orders o JOIN users u ON o.user_id = u.id WHERE u.city = 'Beijing'; - 分页深度优化:
OFFSET越大越慢,可用游标分页替代。-- 反例:深分页性能极差 SELECT * FROM orders ORDER BY id LIMIT 10 OFFSET 1000000; -- 正例:记录上次最大 ID,用游标方式分页 SELECT * FROM orders WHERE id > 1000000 ORDER BY id LIMIT 10; - 批量操作替代逐条操作: 循环单条插入/更新会产生大量网络 IO。
-- 反例 INSERT INTO logs (msg) VALUES ('a'); INSERT INTO logs (msg) VALUES ('b'); -- 正例:批量插入 INSERT INTO logs (msg) VALUES ('a'), ('b'), ('c'); - 使用 EXPLAIN 分析执行计划: 上线前用
EXPLAIN检查 SQL 的扫描方式、是否命中索引、预估行数等。
2. 是否需要升级硬件?
核心原则: 硬件升级是"兜底方案",在软件优化做到位后才考虑。
常见升级场景与示例:
- 内存升级: 数据库 Buffer Pool 太小导致频繁磁盘 IO(如 MySQL 的
innodb_buffer_pool_size建议设为物理内存的 60%-80%)。 - CPU 核心数增加: 计算密集型任务(如图片压缩、视频转码)单机处理不过来,考虑增加核心数或使用更高主频的 CPU。
- 磁盘升级: 机械硬盘换 SSD 可以将随机读写 IOPS 从 ~200 提升到 50000+,对数据库性能提升巨大。
- 网络带宽: 微服务之间调用频繁、数据量大时,万兆网卡或内网专线可以显著降低网络延迟。
3. 系统是否需要缓存?
核心原则: 缓存是用空间换时间,适合读多写少的场景。
常见缓存策略与示例:
- 本地缓存(进程内): 适用于单机高频读取的热数据。
// Guava Cache 示例 LoadingCache<String, User> cache = CacheBuilder.newBuilder() .maximumSize(1000) .expireAfterWrite(10, TimeUnit.MINUTES) .build(key -> userService.queryById(key)); - 分布式缓存(Redis): 适用于多实例共享的缓存,如 Session、验证码、排行榜。
// Spring Redis 示例 @Cacheable(value = "user", key = "#userId", unless = "#result == null") public User getUserById(Long userId) { return userMapper.selectById(userId); } - 多级缓存架构: 本地缓存(L1)+ Redis(L2)+ 数据库(L3),层层拦截,降低数据库压力。
- 缓存穿透防护: 对不存在的 Key 也缓存空值,或使用布隆过滤器。
// 缓存空值防穿透 String cached = redis.get(key); if (cached != null) { return "".equals(cached) ? null : deserialize(cached); } User user = db.query(key); redis.set(key, user == null ? "" : serialize(user), 5, TimeUnit.MINUTES); - 缓存雪崩防护: 给缓存过期时间加上随机偏移量,避免同时失效。
// 基础过期 10 分钟 + 0~60 秒随机偏移 int ttl = 600 + ThreadLocalRandom.current().nextInt(60); redis.set(key, value, ttl, TimeUnit.SECONDS);
4. 系统架构本身是否有问题?
核心原则: 架构决定了系统的性能上限,代码层面优化再多也弥补不了架构缺陷。
常见问题与示例:
- 单体应用拆分为微服务: 将用户、订单、支付等模块独立部署,按需扩容。例如大促时只需扩容订单服务。
- 同步调用改为异步: 非核心链路使用消息队列解耦。
// 反例:下单同步调用 下单 → 生成订单 → 扣减库存 → 发送短信 → 发送邮件 → 更新积分(全部同步,链路过长) // 正例:非核心操作异步化 下单 → 生成订单 → 扣减库存 → 返回成功 ├→ MQ → 发送短信服务 ├→ MQ → 发送邮件服务 └→ MQ → 更新积分服务 - 引入 CDN: 静态资源(JS/CSS/图片)通过 CDN 就近分发,减少源站压力。
- API 网关限流与降级: 使用 Sentinel / Gateway 对接口做限流、熔断、降级,防止雪崩。
- 读写分离: 主库写、从库读,读多写少场景下数据库压力大幅降低。
5. 系统是否存在死锁?
核心原则: 死锁会导致线程阻塞、连接耗尽,系统表现为"假死"。
常见场景与解决方案:
- 经典死锁 — 两个事务以不同顺序加锁:
解决: 所有事务按相同的顺序(如 id 升序)访问资源。-- 事务 A -- 事务 B BEGIN; BEGIN; UPDATE account SET balance = balance - 100 WHERE id = 1; -- 锁 id=1 UPDATE account SET balance = balance + 50 WHERE id = 2; -- 锁 id=2 UPDATE account SET balance = balance + 100 WHERE id = 2; -- 等待 id=2 → 死锁! UPDATE account SET balance = balance - 50 WHERE id = 1; -- 等待 id=1 → 死锁! - 应用层锁顺序不一致: 多线程同时操作两个对象,获取锁的顺序不同。
// 反例:线程 A 锁 o1 再锁 o2,线程 B 锁 o2 再锁 o1 // 正例:统一定义全局锁顺序,例如按对象 hashCode 排序 - 排查工具: 开启 MySQL 的
innodb_print_all_deadlocks将死锁日志输出到错误日志;JVM 使用jstack查看线程阻塞状态。
6. 数据库索引使用是否合理?
核心原则: 索引是数据库性能的第一道防线。
常见问题与示例:
- 缺失索引导致全表扫描:
-- orders 表有百万数据,user_id 没有索引 SELECT * FROM orders WHERE user_id = 1001; -- 全表扫描,耗时 2s+ -- 添加索引后 CREATE INDEX idx_user_id ON orders(user_id); -- 走索引,耗时 <10ms - 联合索引遵循最左前缀原则:
-- 联合索引 (city, age, name) CREATE INDEX idx_city_age_name ON users(city, age, name); -- 能命中索引 SELECT * FROM users WHERE city = 'Beijing'; SELECT * FROM users WHERE city = 'Beijing' AND age = 25; -- 不能命中索引(跳过了 city) SELECT * FROM users WHERE age = 25 AND name = 'Tom'; - 避免索引失效的写法:
-- 左模糊查询会导致索引失效 SELECT * FROM users WHERE name LIKE '%tom'; -- 全表扫描 SELECT * FROM users WHERE name LIKE 'tom%'; -- 可以走索引 -- OR 连接的条件中有非索引列也会导致全表扫描 SELECT * FROM users WHERE id = 1 OR name = 'Tom'; -- 若 name 无索引则全表扫描 - 避免过多索引: 每个索引都会增加写入成本(INSERT/UPDATE/DELETE 要维护索引),建议单表索引不超过 5 个。
- 使用覆盖索引减少回表: 查询字段都包含在索引中时,无需回表读取数据行。
-- 索引 (user_id, amount) SELECT user_id, amount FROM orders WHERE user_id = 1001; -- 覆盖索引,Using index
7. 系统是否存在内存泄漏?
核心原则: Java 有 GC 自动回收,但错误的编码模式仍然会导致对象无法被回收。
常见场景与示例:
- 静态集合类持有对象引用:
// 反例:List 被 static 持有,元素永远不会被 GC public class CacheHolder { private static final List<Object> cache = new ArrayList<>(); public void add(Object obj) { cache.add(obj); // 只进不出,内存持续增长 } } // 正例:使用 WeakReference 或定期清理,或改用 Guava Cache 等有驱逐策略的缓存 - 未关闭的资源:
// 反例:连接未关闭 Connection conn = dataSource.getConnection(); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery("SELECT * FROM users"); // 如果异常发生,conn 和 stmt 不会被关闭 // 正例:try-with-resources 自动关闭 try (Connection conn = dataSource.getConnection(); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery("SELECT * FROM users")) { // 处理结果 } - ThreadLocal 未清理:
// 反例:线程复用(线程池)时 ThreadLocal 值不会自动清除 private static ThreadLocal<User> currentUser = new ThreadLocal<>(); public void handleRequest(User user) { currentUser.set(user); // 处理请求... // 忘记 remove → 线程池复用时持有旧对象 } // 正例:在 finally 中清理 public void handleRequest(User user) { try { currentUser.set(user); // 处理请求... } finally { currentUser.remove(); } } - 排查工具: 使用
jmap -dump导出堆内存快照,用 MAT(Memory Analyzer Tool)或 VisualVM 分析大对象和 GC Root 引用链。
8. 耗时操作是否进行了异步处理?
核心原则: 主链路只保留必须同步完成的操作,其余异步化。
常见场景与示例:
- 邮件/短信通知:
// 反例:同步发送,阻塞主线程 200ms+ public void createOrder(Order order) { orderMapper.insert(order); emailService.send(order); // 耗时 200ms smsService.send(order); // 耗时 150ms } // 正例:异步发送 @Async public void createOrder(Order order) { orderMapper.insert(order); rabbitTemplate.convertAndSend("notification.exchange", "email", order); rabbitTemplate.convertAndSend("notification.exchange", "sms", order); } - 使用 CompletableFuture 编排并行任务:
// 反例:串行调用,总耗时 = 接口A + 接口B + 接口C User user = userService.getUser(userId); // 100ms List<Order> orders = orderService.getOrders(userId); // 200ms Wallet wallet = walletService.getWallet(userId); // 150ms // 正例:并行调用,总耗时 = max(100, 200, 150) = 200ms CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> userService.getUser(userId)); CompletableFuture<List<Order>> orderFuture = CompletableFuture.supplyAsync(() -> orderService.getOrders(userId)); CompletableFuture<Wallet> walletFuture = CompletableFuture.supplyAsync(() -> walletService.getWallet(userId)); CompletableFuture.allOf(userFuture, orderFuture, walletFuture).join(); - 日志异步写入: 使用 Logback 的
AsyncAppender或 Log4j2 的异步 Logger,避免日志 IO 阻塞业务线程。 - 大数据量导出异步化: CSV/Excel 导出涉及百万行数据时,提交异步任务,完成后通知用户下载。
二、补充优化策略
9. JVM 参数调优
核心原则: 合理的 JVM 配置可以让 GC 更高效,减少 STW(Stop-The-World)停顿。
关键配置与示例:
# 堆内存设置
-Xms4g -Xmx4g # 初始和最大堆大小设为相同,避免动态扩缩带来的开销
-Xmn2g # 新生代大小,一般设为堆的 1/3 ~ 1/2
-XX:MetaspaceSize=256m # 元空间初始大小
-XX:MaxMetaspaceSize=512m # 元空间最大值
# GC 策略选择
-XX:+UseG1GC # 推荐 G1 收集器(JDK 9+ 默认)
-XX:MaxGCPauseMillis=200 # 目标最大 GC 停顿时间 200ms
-XX:G1HeapRegionSize=8m # G1 分区大小
# GC 日志(排查必备)
-Xlog:gc*:file=gc.log:time,uptime,level,tags
排查手段:
jstat -gcutil <pid> 1000实时观察各内存区占用和 GC 频率。- GC 日志分析工具:GCEasy、GCViewer。
- 频繁 Full GC 通常意味着内存泄漏或堆太小。
10. 连接池配置优化
核心原则: 连接池大小不是越大越好,过大会导致数据库端资源争抢。
配置示例(HikariCP,Spring Boot 默认):
spring:
datasource:
hikari:
maximum-pool-size: 20 # 最大连接数,建议公式:CPU核心数 * 2 + 磁盘数
minimum-idle: 5 # 最小空闲连接
connection-timeout: 3000 # 获取连接超时 3s
idle-timeout: 600000 # 空闲连接最大存活 10min
max-lifetime: 1800000 # 连接最大存活 30min(需小于数据库的 wait_timeout)
leak-detection-threshold: 60000 # 连接泄漏检测阈值 60s
同样适用于 Redis 连接池、HTTP 连接池(如 OkHttp、Apache HttpClient)。
11. 序列化与压缩优化
核心原则: 微服务间大量数据传输时,序列化效率和数据大小直接影响性能。
常见策略:
- 序列化框架选择:
- JSON(Jackson/Gson):可读性好,性能一般,适合对外 API。
- Protobuf / Kryo / Hessian:二进制序列化,体积小、速度快,适合内部 RPC。
- 开启 GZIP 压缩:
# Spring Boot 响应压缩 server: compression: enabled: true mime-types: application/json,application/xml,text/html min-response-size: 1024 # 大于 1KB 才压缩 - 数据库大字段按需查询: TEXT/BLOB 字段避免随主查询一起加载,延迟到需要时再查。
12. 线程池参数调优
核心原则: 避免使用 Executors 创建无界线程池,合理配置核心参数。
配置示例:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10, // 核心线程数(CPU密集型: CPU核心数+1,IO密集型: CPU核心数*2)
20, // 最大线程数
60L, TimeUnit.SECONDS, // 非核心线程空闲存活时间
new LinkedBlockingQueue<>(500), // 有界队列,防止 OOM
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:调用方线程执行,起到限流作用
);
排查手段: 暴露线程池监控指标(活跃线程数、队列大小、完成任务数)到 Prometheus + Grafana。
13. 接口幂等性与重试机制
核心原则: 网络不稳定时,合理的重试机制可以提升系统可靠性,但必须保证幂等性。
常见方案:
- Token 机制: 请求前获取唯一 Token,服务端消费 Token 后标记已处理。
- 数据库唯一索引: 利用唯一约束防止重复插入。
CREATE UNIQUE INDEX uk_order_no ON orders(order_no); - Redis SETNX 实现分布式锁:
Boolean acquired = redisTemplate.opsForValue() .setIfAbsent("lock:order:" + orderNo, "1", 10, TimeUnit.SECONDS); if (!acquired) { throw new DuplicateRequestException("请勿重复提交"); } try { // 处理业务 } finally { redisTemplate.delete("lock:order:" + orderNo); }
14. 数据预加载与懒加载
核心原则: 根据业务场景选择合适的加载策略,平衡启动速度和运行时性能。
- 预加载(Eager Loading): 系统启动时提前加载热点数据到缓存。
@PostConstruct public void init() { List<Config> configs = configMapper.selectAll(); configs.forEach(c -> localCache.put(c.getKey(), c.getValue())); } - 懒加载(Lazy Loading): 首屏只加载必要数据,其余数据滚动或点击时按需加载。
首屏请求: GET /api/home → 只返回 banner、推荐列表(10条) 滚动加载: GET /api/home/products?page=2 → 用户下滑时再请求下一页
三、性能优化优先级法则
SQL优化 / JVM调优 / 数据库与中间件参数调优
> 硬件升级(内存、CPU、SSD)
> 业务逻辑优化 / 缓存策略
> 读写分离 / 集群 / 负载均衡
> 分库分表(最后手段,成本最高)
核心理念: 先做低成本高收益的软件优化,最后再考虑高成本的架构级改造。80% 的性能问题都可以通过 SQL 优化和合理缓存解决。

浙公网安备 33010602011771号