首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >接口为什么越写越卡?Java Web 性能优化的 7 个关键点(实战踩坑分享版)

接口为什么越写越卡?Java Web 性能优化的 7 个关键点(实战踩坑分享版)

原创
作者头像
用魔法才能打败魔法
发布2025-11-18 16:06:06
发布2025-11-18 16:06:06
370
举报

Java Web 怎么就越跑越慢了?

我不知道你有没有类似的经历:一个接口刚上线的时候飞快,RT 只有 20~30ms,大家都说「稳得很」。结果一个季度过去,业务又多了点逻辑,数据库表又多了点字段,Redis 键多了三倍,接口忽然变成 120ms、180ms、甚至偶尔超时

我们团队前几年就遇到过这种情况,某个用户画像接口,最开始 QPS 500,毫无压力;半年后业务膨胀,峰值 QPS 800 时直接把线程池干满,CPU 从 30% 干到 85%,接口开始出现毛刺一样的 RT 抽风。排查了两天一夜,最后发现是三个问题叠加:

  • JSON 转换太慢(Jackson)
  • DB 查询被一个字段模糊匹配干炸
  • 线程池队列积压

我那时才意识到:接口不是「写多了变慢」,而是「每一次小优化缺失,都会变成系统级灾难」。

下面这些点,基本都是我一次次被线上教做人总结出来的。

“卡住了?到底卡在哪里?”静态看日志永远找不到答案

很多人排查问题时第一步就是看日志,没错,日志重要,但它只能告诉你「结果」,很难告诉你「原因」。

有一次线上报警,说某个下单接口 RT 飙到了 600ms,我们翻了两小时日志,只看到:

代码语言:txt
复制
order created success, cost=612ms

屁用没有。

后来我直接在接口加了最原始、最土但最有效的方式——分段计时:

代码语言:java
复制
long t1 = System.currentTimeMillis();
User user = userService.getUser(uid);
long t2 = System.currentTimeMillis();
Order order = orderService.create(req);
long t3 = System.currentTimeMillis();
logger.info("user={}, userCost={}ms, orderCost={}ms",
        uid, (t2 - t1), (t3 - t2));

一分钟后指标出来了:

  • userService:8ms
  • orderService:560ms

罪魁祸首终于找到了。

所以排查性能问题,有三个绝对不能省的手段:

  • 接口分段打点
  • 线程池队列监控
  • 数据库 slowlog
  • GC 日志

很多人光靠“感觉”排查性能问题,其实是在玩火。

“数据库慢?”其实不是 SQL 慢,是你写的方式慢

数据库慢查询永远是性能问题的前排,但很多时候不是 SQL 写得烂,而是“业务不知不觉把 SQL 弄烂了”。

● 那次被模糊查询坑惨的经历

有一次我们一个“用户搜索”接口突然变慢,从原来的 50ms 飙到 300ms+,CPU 居高不下。

后来查 SQL:

代码语言:sql
复制
SELECT * FROM user WHERE name LIKE '%abc%';

是的,就是这个 %keyword%

最骚的是,这个接口被一个新业务用了,每分钟调用 2000 次,它直接把数据库 I/O 打穿。

错误做法
代码语言:sql
复制
LIKE '%xxx%'
正确做法
  • ES / OpenSearch
  • 拼音索引
  • 前缀匹配而不是全模糊
  • 字段建合适的 BTREE 或 GIN 索引

● 我的 SQL 优化 checklist

  • 是否走索引?EXPLAIN 一下就知道
  • 是否批量?1 次 vs 100 次 I/O 差太多
  • 是否可以缓存?
  • 是否可以异步?
  • 是否存在 N+1 查询?

尤其是 N+1 查询,它比你想得更阴险:

代码语言:java
复制
List<User> users = userMapper.selectAll();
for (User u : users) {
    u.setOrders(orderMapper.selectByUid(u.getId()));
}

这是我见过的最杀性能的写法之一,接口卡顿直接从 40ms → 300ms

“第三方服务慢”比数据库慢更致命(因为你一点能做的都没有)

大多数人都遇到过这种情况:

你自己的接口都优化得飞起,结果第三方服务一顿卡,就把你拖死。

我们之前对接某个支付渠道,他们有时稳定、但一旦波动,RT 可以从 80ms → 2000ms。然后你本地线程池瞬间被打爆。

● 超时配置千万别用默认

很多人不知道 HttpClient 或 OkHttp 默认超时是多少。

比如:

代码语言:java
复制
OkHttpClient client = new OkHttpClient(); // 默认 10 秒超时

十秒?你的接口能撑这么久吗?

● 正确做法

代码语言:java
复制
OkHttpClient client = new OkHttpClient.Builder()
        .connectTimeout(300, TimeUnit.MILLISECONDS)
        .readTimeout(500, TimeUnit.MILLISECONDS)
        .build();

同时搭配重试策略:

  • 重要接口:快速失败 → 重试(最多 2 次)
  • 非关键接口:失败就降级,用缓存或兜底

你救不了第三方服务,但你能救自己的线程池。

JSON 序列化到底有多慢?慢到你怀疑人生

你可能不信,但很多接口的瓶颈根本不是数据库,也不是网络,而是 —— JSON 反序列化

之前我们做一个导出接口,处理 200 条记录,数据库耗时 30ms,结果序列化耗时 80ms。我们当时傻了。

● Jackson 默认配置并不快

尤其是:

  • include 非 null 字段
  • 转换 BigDecimal
  • 转换 LocalDateTime

这些都是性能杀手。

● 几个很实用的优化方式

代码语言:java
复制
ObjectMapper mapper = new ObjectMapper();
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);

更狠一点:

使用 JSON-B、Fastjson2、Kryo 做内部对象序列化(不是对外接口)。

“线程池队列满了”为什么会让接口突然集体变慢?

线程池是线上最容易被忽略、却最能搞出事故的点。

我之前有一次看到线程池队列长度从 0 一路飙到 500,接口 RT 从 30ms → 2000ms

原因是线程池写得太“佛系”:

代码语言:java
复制
new ThreadPoolExecutor(
    10, 50,
    60, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(), // 无限队列!排队排死你
);

● 无限队列 = 慢性自杀

因为线程池不会拒绝请求,而是疯狂排队。

用户感觉就是:怎么接口突然变慢了?

其实你的线程在排队排得要死。

● 更合理的写法

代码语言:java
复制
new ThreadPoolExecutor(
    20,
    50,
    60, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(200),
    new ThreadPoolExecutor.CallerRunsPolicy()
);

至少一旦队列满了,你能在日志里看到拒绝信息,而不是默默变慢。

GC 暂停导致接口忽快忽慢

GC 是性能问题中的“隐形刺客”。

大多数人都只看 RT,不看 GC。

我们那次排查接口抖动,从 20ms 偶尔飙到 400ms。看了两天都没找出来,最后是 GC 日志给了我答案:

代码语言:txt
复制
[GC pause (G1 Evacuation Pause) 92ms]

接口延迟的 92ms 就这么来了。

● 为什么 GC 会导致接口变慢?

因为 Stop-The-World。

哪怕只停 50ms,用户都能感知到。

● 几个真能解决问题的配置

代码语言:txt
复制
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-Xms4g
-Xmx4g

Xms = Xmx 是非常关键的,因为堆大小动态扩容本身也会触发停顿。


对象创建太多?是的,这也能让接口变慢

曾经我见过有人在循环里 new 对象:

代码语言:java
复制
for (...) {
    new BigDecimal("0.00");
    new SimpleDateFormat("yyyy-MM-dd");
}

疯了。

尤其是:

  • BigDecimal 的创建非常贵
  • SimpleDateFormat 线程不安全,所以被 new 了无数次
  • VO/DTO 层疯狂复制对象

有次我们一个接口创建了 18000 个临时对象,Young GC 频率从原来的 每 30 秒一次 → 每 3 秒一次,RT 波动特别明显。

● 解决方式

  • 用 ThreadLocal 缓存可复用对象(如日期对象)
  • 使用 MapStruct 而不是频繁 copy
  • 尽量复用 BigDecimal、Pattern 等对象

限流、降级、缓存这些“老三件”,永远是兜底方案

你优化再多,也不能保证业务永远不爆炸,所以限流、降级、缓存,这些“稳态系统的基石”一定得提前想好。

● 缓存的最大坑:热 key

我们线上曾遇到一个 Redis 热 Key,TPS 4000,多节点 Redis 直接被压死。

后来我们通过以下方式解决:

  • key 热度监控(Redis 的 hotkeys
  • 热 key 分片(如 user:info:123 → user:info:123:A/B/C)
  • 多级缓存(本地缓存 + Redis)

● 限流一定要做“漏桶式”

比如:

代码语言:java
复制
RateLimiter limiter = RateLimiter.create(1000); // 每秒 1000 个请求

否则冲击瞬间接口就被打穿。


来一个真实案例:一个“看起来没问题”的接口为什么突然从 40ms 变成 350ms?

背景:用户权限计算接口

QPS:700

原来的 RT:40ms

突然报警:RT 均值 350ms,最大 1.2s。

● 排查过程

  1. 查看监控:线程池 active = 48/50,队列 300+
  2. 打印分段耗时:
  • DB:8ms
  • Redis:2ms
  • 逻辑:15ms
  • JSON:60ms
  • 再看 GC:Young GC 每 4 秒一次,每次暂停 40ms
  • 最终找到核心原因: 有同事在循环里 new 了 30 个 LocalDateTimeFormatter 对象 导致对象分配暴涨,GC 频率升高,线程池处理不动,队列直接爆。

● 最终优化

  • 把 date formatter 放 ThreadLocal
  • JSON 改成 Fastjson2
  • 线程池队列改成有界 + 拒绝策略
  • 增加本地缓存减少频繁 JSON 转换

最终 RT 回到 50ms 左右

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • Java Web 怎么就越跑越慢了?
  • “卡住了?到底卡在哪里?”静态看日志永远找不到答案
  • “数据库慢?”其实不是 SQL 慢,是你写的方式慢
    • ● 那次被模糊查询坑惨的经历
      • 错误做法
      • 正确做法
    • ● 我的 SQL 优化 checklist
  • “第三方服务慢”比数据库慢更致命(因为你一点能做的都没有)
    • ● 超时配置千万别用默认
    • ● 正确做法
  • JSON 序列化到底有多慢?慢到你怀疑人生
    • ● Jackson 默认配置并不快
    • ● 几个很实用的优化方式
  • “线程池队列满了”为什么会让接口突然集体变慢?
    • ● 无限队列 = 慢性自杀
    • ● 更合理的写法
  • GC 暂停导致接口忽快忽慢
    • ● 为什么 GC 会导致接口变慢?
    • ● 几个真能解决问题的配置
  • 对象创建太多?是的,这也能让接口变慢
    • ● 解决方式
  • 限流、降级、缓存这些“老三件”,永远是兜底方案
    • ● 缓存的最大坑:热 key
    • ● 限流一定要做“漏桶式”
  • 来一个真实案例:一个“看起来没问题”的接口为什么突然从 40ms 变成 350ms?
    • ● 排查过程
    • ● 最终优化
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档