在 Java 服务端开发中,我们追求代码简洁、逻辑清晰、架构优雅。
但有时候,过度追求“写得漂亮”,反而埋下了性能地雷。
日志一行拼接、循环一次查询、分页翻到第千页、事务包裹 RPC 调用、Stream 链式炫技……这些看似无害的操作,在高并发场景下可能瞬间引爆 CPU、打满数据库、耗尽连接池,甚至引发全站雪崩。
本文结合多年线上故障复盘经验,揭示 五个最常被忽视却危害极大的性能陷阱,并提供可立即落地的避坑方案、监控手段与工程规范建议。
1. 日志不是“只写不看”,而是“写了就执行”
🚨 陷阱代码
log.info("用户操作:" + JSON.toJSONString(userRequest));
很多开发者误以为:“只要日志级别关掉,这行就不会执行。”
错!Java 是 eager evaluation(及早求值)语言——方法参数必须先计算完,才能传入 log.info()。即使日志最终没输出,JSON.toJSONString() 和字符串拼接早已消耗了 CPU 和内存。
💥 后果
- 大对象序列化导致 CPU 飙升;
- 临时字符串堆积触发频繁 Young GC;
- 若在高频接口或循环中使用,可能直接 OOM。
✅ 正确姿势
永远用 SLF4J 的占位符语法:
log.info("用户操作:{}", userRequest);
SLF4J 会在确认日志级别匹配后,才调用 userRequest.toString()。
若需自定义格式(如 JSON),务必加判断:
if (log.isInfoEnabled()) {
log.info("用户操作:{}", JSON.toJSONString(userRequest));
}
🔍 如何发现?
- Arthas 火焰图:若 com.alibaba.fastjson.JSON.toJSONString 占比异常高,警惕日志拼接;
- CI/CD 防御:在 SonarQube 中配置规则,禁止日志中出现 + 拼接或toString() 显式调用。
📌 工程建议:将日志规范写入团队《编码守则》,Code Review 时重点检查。
2. N+1 查询:数据库的“温水煮青蛙”
🚨 陷阱场景
List<Order> orders = orderService.list();
for (Order order : orders) {
User user = userService.getById(order.getUserId()); // 每次查库!
}
表面看只是“多查几次”,实则:
- 1 次主查询 + N 次子查询;
- 数据库连接池迅速耗尽;
- ORM 级联加载还可能创建海量临时对象,撑爆堆内存。
✅ 正确姿势
批量查询 + 内存组装(推荐):
Set<Long> userIds = orders.stream().map(Order::getUserId).collect(Collectors.toSet());
Map<Long, User> userMap = userService.batchGet(userIds)
.stream().collect(Collectors.toMap(User::getId, u -> u));
orders.forEach(o -> o.setUser(userMap.get(o.getUserId())));
或使用 JOIN 一次性拉取(适用于关联数据量可控)。
🔍 如何发现?
- MyBatis 日志:开启 SQL 打印,观察是否出现大量重复单条查询;
- SkyWalking / Pinpoint:追踪一个请求的 DB 调用次数,若远大于预期(如 1000+),极可能是 N+1;
- 慢 SQL 监控:虽然单条 SQL 不慢,但高频小查询会压垮 DB IO。
📌 工程建议:在 DAO 层禁止返回裸 List,强制要求封装为“带缓存/批量能力”的服务接口。
3. 深分页与索引失效:慢查询的“合法外衣”
🚨 陷阱代码
SELECT * FROM orders ORDER BY id LIMIT 1000000, 10;
或
// 字段是 VARCHAR(20),但传了 Integer
orderMapper.selectByPhone(13800138000);
前者让 MySQL 扫描 100 万 + 10 行再丢弃前 100 万;后者因类型不匹配导致索引失效,触发全表扫描。
✅ 正确姿势
改用游标分页(Seek Method):
SELECT * FROM orders
WHERE id > {last_seen_id}
ORDER BY id
LIMIT 10;
前提:排序字段有唯一索引(如主键)。
- 确保参数类型与 DB 字段一致:使用 MyBatis 的 @Param 或明确指定 JDBC Type。
- 覆盖索引优化:尽量让 SELECT 字段全部包含在索引中,避免回表。
🔍 如何发现?
- 慢查询日志(slow_query_log):Rows_examined 远大于 Rows_sent 是典型信号;
- EXPLAIN 分析:关注 type=ALL(全表扫描)或 Extra=Using filesort;
- APM 工具告警:对 P99 > 1s 的 SQL 自动告警。
📌 工程建议:前端禁止无限制翻页,后端对 offset > 10000 的请求直接拒绝或降级。
4. 大事务:连接池的“钉子户”
🚨 陷阱代码
@Transactional
public void processOrder(Order order) {
validate(order); // 业务校验
callPaymentService(); // 调第三方支付(可能超时)
saveToDatabase(order); // DB 操作
sendSms(); // 发短信
}
事务开启即占用 DB 连接,直到提交才释放。若中间调用外部服务耗时 2 秒,那么这条连接就被“锁住” 2 秒。高并发下,连接池迅速枯竭。
✅ 正确姿势
缩小事务边界,只包裹核心 DB 操作:
// 非事务操作
validate(order);
User user = callUserInfo(); // 可提前获取
// 事务仅包含 DB 写入
transactionTemplate.execute(status -> {
orderDao.insert(order);
inventoryDao.decrease(itemId, count);
});
// 事务外发消息
mqProducer.send(orderCreatedEvent);
###🔍 如何发现?
- 连接池监控(如 HikariCP 的 JMX 指标):activeConnections 持续接近 maximumPoolSize;
- 链路追踪:事务跨度(Span)过长,且包含非 DB 操作;
- 数据库 wait event:大量 Waiting for connection。
📌 工程建议:禁止在 @Transactional 方法中调用 RPC、MQ、文件 IO 或 sleep。
5. Stream API 滥用:优雅的代价
🚨 陷阱一:自动装箱黑洞
List<Integer> numbers = ...; // 百万级
int sum = numbers.stream()
.filter(n -> n > 0)
.map(n -> n * 2)
.reduce(0, Integer::sum);
每一步都在 int 和 Integer 间转换,产生百万级临时对象。
✅ 修正:使用原始类型流
int sum = numbers.stream()
.mapToInt(Integer::intValue)
.filter(n -> n > 0)
.map(n -> n * 2)
.sum();
🚨 陷阱二:ParallelStream 雪崩
userIds.parallelStream().map(id -> paymentRpc.call(id))...
parallelStream() 使用全局 ForkJoinPool.commonPool()。一旦某个 RPC 阻塞,整个 JVM 的并行任务都会卡死。
✅ 修正:用 CompletableFuture + 自定义线程池
ExecutorService ioPool = Executors.newFixedThreadPool(10);
List<CompletableFuture<Void>> futures = userIds.stream()
.map(id -> CompletableFuture.runAsync(() -> paymentRpc.call(id), ioPool))
.collect(Collectors.toList());
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
🚨 陷阱三:链式炫技,难以维护
把复杂逻辑塞进一条 Stream 链,看似“函数式”,实则:
调试困难(NPE 堆栈指向 lambda 表达式);
副作用隐蔽(如在 map 中修改对象状态);
新人阅读成本极高。
✅ 修正:拆分为清晰步骤,或回归 for 循环。
📌 工程建议:团队约定——Stream 仅用于简单过滤/映射,复杂逻辑必须拆方法。
结语:性能是设计出来的,不是压测出来的
这五大陷阱之所以危险,是因为它们:
- 平时无害:低流量下一切正常;
- 爆发突然:大促或爬虫一来,系统瞬间崩溃;
- 定位困难:CPU 高?GC 频繁?DB 慢?表面现象掩盖真实根因。
真正的高性能系统,不靠“事后救火”,而靠: - 防御性编码习惯;
- 可观测性基础设施(日志、指标、链路);
- 团队规范与自动化卡点(CI/CD、Code Review)。
记住:优雅的前提是正确,简洁的前提是安全。
写每一行代码时,多问一句:“如果并发 10000,它还会好吗?”
评论区