我只修改了一段 Java 代码,就将 API 响应时间从 3 秒缩短到了 80 毫秒

内容分享2小时前发布 人骄
2 1 0

我只修改了一段 Java 代码,就将 API 响应时间从 3 秒缩短到了 80 毫秒

一个可能导致你的 API 崩溃的性能错误(而你甚至都不知道)

目前是凌晨2点47分。我盯着Grafana仪表盘,好像它们会神奇地自行修复似的。

我们的API响应时间一般在3秒左右,有时会到4秒。运气不好的时候,超时时间会达到7秒。

用户怨声载道,销售团队慌了神。我的经理安排了明天早上进行一次“快速同步”会议——这实则是公司委婉地表达“解释为什么我们的产品快要凉了”的意思。

我什么方法都试过了:

  • 添加了 Redis 缓存(略有协助)
  • 优化数据库查询(效果略有提升)
  • 增加服务器资源(AWS 对此很满意,但作用不大)
  • 重写了一些算法(略有改善)

什么方法都不管用。

后来我找到了缘由。一个愚蠢的Java错误害得我们损失惨重。一个改动就把响应时间从3000毫秒降到了80毫秒。

我会向你们详细解释到底是什么问题,由于我敢肯定你们当中有些人目前正在犯同样的错误。

那个逐渐消亡的API

我们用的是一个相当标准的 REST API。Spring Boot,PostgreSQL,没什么特别的。用户管理、身份验证、一些业务逻辑。典型的企业级应用。

导致问题的端点:

@GetMapping("/api/users/{id}/profile")
public ResponseEntity<UserProfileDTO> getUserProfile(@PathVariable Long id) {
    UserProfileDTO profile = userService.getUserProfile(id);
    return ResponseEntity.ok(profile);
}

看起来很无害,对吧?代码简洁,关注点分离得当,遵循最佳实践。

只是返回用户数据需要 3 秒钟。

仅限一位用户。

起初毫无进展的调查

我做了每个开发者都会做的事——开始记录所有信息。

@GetMapping("/api/users/{id}/profile")
public ResponseEntity<UserProfileDTO> getUserProfile(@PathVariable Long id) {
    long startTime = System.currentTimeMillis();
    
    UserProfileDTO profile = userService.getUserProfile(id);
    
    long endTime = System.currentTimeMillis();
    log.info("Profile fetch took: {}ms", endTime - startTime);
    
    return ResponseEntity.ok(profile);
}

日志显示平均耗时 2800 毫秒。因此,问题肯定出在
userService.getUserProfile()……

我们来看看这项服务:

@Service
public class UserService {
    
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private PostRepository postRepository;
    
    @Autowired
    private CommentRepository commentRepository;
    
    @Autowired
    private FriendRepository friendRepository;
    
    public UserProfileDTO getUserProfile(Long userId) {
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new UserNotFoundException(userId));
        
        List<Post> posts = postRepository.findByUserId(userId);
        List<Comment> comments = commentRepository.findByUserId(userId);
        List<Friend> friends = friendRepository.findByUserId(userId);
        
        return UserProfileDTO.builder()
            .user(user)
            .posts(posts)
            .comments(comments)
            .friends(friends)
            .build();
    }
}

这个看起来也没问题。四个简单的查询语句。我逐一检查了:

  • findById(userId)- 15毫秒
  • findByUserId(userId)帖子 – 40毫秒
  • findByUserId(userId)评论 – 35毫秒
  • findByUserId(userId)给朋友 – 20毫秒

总计:约 110 毫秒。不算好,但也不至于 3 秒。

剩下的2700毫秒到底都去哪儿了?

当我意识到自己是个白痴的那一刻

我启用了 Hibernate SQL 日志记录。你知道,这本来应该是你第一要做的事情,但你却总是忽略它,由于日志很烦人。

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE

然后我又一次到达了终点。

我的终端爆炸了。

查询。成百上千条。或许成千上万条。他们不停地滚动。滚动。滚动。

SELECT * FROM users WHERE id = 1
SELECT * FROM posts WHERE user_id = 1
SELECT * FROM comments WHERE post_id = 1
SELECT * FROM users WHERE id = 2  -- wait what
SELECT * FROM comments WHERE post_id = 2
SELECT * FROM users WHERE id = 3
SELECT * FROM posts WHERE user_id = 2
SELECT * FROM comments WHERE post_id = 3
...
(200 more queries)

哦,不。

哦不不不。

N+1 问题。Java/Hibernate 性能杀手中的经典案例。而我却像个十足的业余人士一样,一头扎了进去。

到底什么是N+1问题(以及它为何会毁灭我们)

对于那些还不了解的人(如果你还不了解,那么你即将避免许多痛苦):

当你的代码像这样获取数据时,就会出现 N+1 问题:

  1. 第一个查询:获取主实体(1 个查询)
  2. 然后:对每个相关实体,再进行一次查询(N 次查询)。

所以,如果你获取一个拥有 100 条帖子的用户,你会得到:

  • 1 条用户查询
  • 对每篇文章的数据进行 100 次查询
  • 总计:101 次查询

在我们的例子中,我们获取的是:

  • 1 位用户
  • 他们的帖子(假设有 50 条)
  • 这些帖子下的评论(假设有 200 条)
  • 发表这些评论的用户(另有 150 位)
  • 用户的朋友(假设有 300 位)
  • 那些朋友发的帖子……

你清楚我的意思。结果查询数量激增至数千条。

每次查询耗时5-10毫秒。算算看,我们浪费的3秒钟就这么过去了。

解决一切问题的“一个改变”

解决方法?只需@EntityGraph添加一条注释。就这么简单。

以下是我在代码仓库中所做的更改:

public interface UserRepository extends JpaRepository<User, Long> {
    
    @EntityGraph(attributePaths = {"posts", "comments", "friends"})
    Optional<User> findById(Long id);
}

就这些。

它的作用是:不再逐个延迟加载相关实体(N+1),而是告知 Hibernate 使用 JOIN 在一次查询中获取所有内容。

之前(N+1 次查询):

SELECT * FROM users WHERE id = 1;
SELECT * FROM posts WHERE user_id = 1;
SELECT * FROM posts WHERE user_id = 1;
SELECT * FROM posts WHERE user_id = 1;
-- (repeated 50 times)

(一次查询后):

SELECT u.*, p.*, c.*, f.*
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
LEFT JOIN comments c ON u.id = c.user_id
LEFT JOIN friends f ON u.id = f.user_id
WHERE u.id = 1;

响应时间从3000毫秒降至80毫秒,立竿见影,没有其他任何改动。

我坐在那里大致 5 分钟,不停地刷新端点,观察指标,心想“事情不可能这么简单”。

但实际的确如此。

我应该提及的其他解决方案(但没有使用)

这方法@EntityGraph对我们有效,但并非总是最佳方案。以下是根据您的具体情况提供的其他选择:

1. 在 JPQL 中获取连接

@Query("SELECT u FROM User u " +
       "LEFT JOIN FETCH u.posts " +
       "LEFT JOIN FETCH u.comments " +
       "WHERE u.id = :id")
Optional<User> findByIdWithDetails(@Param("id") Long id);

结果一样,但更明确。我更喜爱 JPQL @EntityGraph,由于它更简洁,但有些人喜爱 JPQL 的控制性。

2. FetchType.EAGER(不要这样做)

@Entity
public class User {
    @OneToMany(fetch = FetchType.EAGER)
    private List<Post> posts;
}

这样做总是会获取相关数据,即使你不需要。这很懒惰(双关语),后来会给你带来麻烦。不要这样做。

3. DTO 预测

@Query("SELECT new com.example.UserProfileDTO(u.id, u.name, p.title) " +
       "FROM User u LEFT JOIN u.posts p WHERE u.id = :id")
UserProfileDTO findUserProfile(@Param("id") Long id);

只获取所需数据。超级适合读取密集型操作。虽然样板代码较多,但如果只需要特定字段,性能会更好。

4. 批量获取

@Entity
public class User {
    @OneToMany
    @BatchSize(size = 10)
    private List<Post> posts;
}

将 N+1 简化为 N/10+1。虽然不如完全解决问题那么好,但如果不能使用 fetch joins,则会有所协助。

为什么这种情况会发生在每个人身上(可能也包括你)?

N+1 问题之所以棘手,是由于:

1. 在开发环境中运行良好。当你用 5 个用户和 10 个帖子进行测试时,即使发出 15 个查询又有什么关系呢?它只需要 50 毫秒。直接发布就行了。

2. 问题逐渐显现。第一个月:100 个用户,没问题。六个月:10,000 个用户,查询量成倍增长,速度开始变慢。等你注意到的时候,危机已经爆发了。

3. 延迟加载是Hibernate 的默认设置。实际上,在大多数情况下,这都是一个明智的默认选择。但这也就意味着,当您知道需要相关数据时,就需要显式地进行优化。

4. 代码看起来很简洁

user.getPosts()

这行看似无害的代码竟然触发了 50 次数据库查询。从表面上看,你根本察觉不到。

如何在生产前发现这个问题

以下是我目前采取的做法(这是我吃过亏才学到的):

1. 始终在开发环境中启用 SQL 日志记录

spring.jpa.show-sql=true

是啊,日志的确 烦人。忍忍吧。总比生产环境着火好。

2. 在测试中使用查询计数器

@Test
public void getUserProfile_shouldNotTriggerNPlusOne() {
    Statistics stats = sessionFactory.getStatistics();
    stats.setStatisticsEnabled(true);
    stats.clear();
    
    userService.getUserProfile(1L);
    
    long queryCount = stats.getQueryExecutionCount();
    assertThat(queryCount).isLessThan(5); // Adjust threshold
}

如果这个测试失败,那么你肯定还有 N+1 这个值。

3. 使用真实数据量进行分析。不要只用 3 个用户进行测试,要用 1000 个用户进行测试。N+1 的影响在小数据聚焦不会显现出来。

4. 使用 Hibernate 统计信息

@Bean
public HibernatePropertiesCustomizer hibernateStatisticsCustomizer() {
    return (properties) -> properties.put("hibernate.generate_statistics", true);
}

然后监控hibernate.statistics生产环境中的指标。您将看到每个端点的查询次数。

无人教授的表演模式

既然说到这里,我就顺便提一下我遇到的其他一些Java性能杀手:

问题:循环中的字符串连接

// 错误的 - 在每次迭代中都会创建一个新的字符串对象
String result = "";
for (String item : items) {
    result += item; // DON'T DO THIS
}

// 正确的
StringBuilder sb = new StringBuilder();
for (String item : items) {
    sb.append(item);
}

问题:资源未关闭

// 错误的
BufferedReader reader = new BufferedReader(new FileReader("file.txt"));
String line = reader.readLine();
// 忘记关闭了——内存泄漏

// 正确的
try (BufferedReader reader = new BufferedReader(new FileReader("file.txt"))) {
    String line = reader.readLine();
} // 自动关闭

问题:使用 ArrayList 进行频繁的插入/删除操作

// 错误的 对开头的许多插入内容不友善
List<String> list = new ArrayList<>();
list.add(0, "item"); // O(n) 操作

// 正确的 对于频繁插入的情况
List<String> list = new LinkedList<>();
list.addFirst("item"); // O(1) 操作

问题:在不需要同步的情况下同步整个方法

// 错误的 - 锁住整个方法
public synchronized void doSomething() {
    // 一些处理
    //其他一些无需加锁的缓慢操作
}

// 正确的 - 只锁需要锁的地方
public void doSomething() {
    // 其他的操作 
    synchronized(this) {
      //只有需要进行锁定的缓慢操作
    }
}

真正的教训在这里

N+1 问题导致我们损失了客户,差点让我丢了工作。而实际上,只需要修复一个注释就能解决问题。

真正的教训不是“使用@EntityGraph”,而是:衡量一切,但不要轻信任何事

你的代码看起来可能完美无瑕,但实际上却是一堆垃圾。测试可能通过,但性能却可能糟糕透顶。你需要不断地进行代码插桩、监控和性能分析。

一些帮了我大忙的工具:

  • Hibernate SQL 日志记录——在开发过程中捕获 N+1 错误
  • Grafana + Prometheus — 可视化响应时间
  • JProfiler / VisualVM — 分析内存和 CPU
  • SQL EXPLAIN — 了解查询性能
  • Spring Actuator — 公开指标端点

不要等到产量激增才采取行动。尽早衡量,频繁衡量。

修复之后发生了什么

我凌晨3点半部署了修复程序。观察仪表盘。响应时间骤降。

第二天早上的“快速同步”变成了我的经理问我“一夜之间发生了什么变化?”我解释了 N+1 问题、解决方法,并展示了相关指标。

他看了我一眼,然后说:“所以我们只差一个注释就失去客户了?”

是的,差不多。

不过,事情是这样的——之后,我审核了我们整个代码库。在其他 7 处发现了 N+1 个问题。我用同样的方法修复了它们。所有 API 的平均响应时间都下降了 60%。

一种模式,多处地点,巨大影响。

您的 API 目前可能运行缓慢。

给你一个挑战:目前就去你的项目中启用 Hibernate SQL 日志记录。访问你最常用的端点。统计一下查询次数。

如果你也是springboot 加jpa项目,并且看到一个简单的 GET 请求有超过 10 个查询,那么肯定存在 N+1 的情况。

在你的经理安排“快速同步”之前把它修好。

你也还在项目中碰到什么噩梦般的经历吗?在评论区分享一下吧。我们都经历过,让我们相互吸取教训吧。


如果这篇内容对你有协助,欢迎点赞 、收藏 ⭐、转发给需要的朋友

我会持续分享:

  • Java 核心与高阶实战
  • AI / Agent / 前沿技术落地
  • 真实项目经验 & 架构思考
  • 企业数字化与产品实践

关注我,一起把“技术”真正用在项目和业务里。

你的每一次支持,都是我持续输出高质量内容的最大动力。

© 版权声明

相关文章

1 条评论

  • 头像
    清晨 投稿者

    [db:评论]

    无记录
    回复
  • 头像
    夏安读书 投稿者
    无记录
    回复
  • 头像
    爱吃橘子的小核桃 投稿者
    无记录
    回复
  • 头像
    阿蛇夜夜有好梦 投稿者
    无记录
    回复
  • 头像
    qiqiiiia 投稿者
    无记录
    回复
  • 头像
    靳默 投稿者
    无记录
    回复
  • 头像
    浮世绘 投稿者
    无记录
    回复
  • 头像
    Luanne-Cheng 投稿者
    无记录
    回复
  • 头像
    陪你看遍世间美景 投稿者
    无记录
    回复