JPA懒加载引发N+1查询:从线上事故到根治方案
2026/9/10 21:34:11 网站建设 项目流程

告警平台凌晨一点把电话打到手机上,订单列表接口的 P99 响应时间从 300 毫秒直接飙到 15 秒,监控面板上一片飘红。我打开电脑第一件事不是看代码,而是先把慢 SQL 日志拉出来——直觉告诉我,这种接口突然“龟速”的情况,十有八九是数据访问层出了问题。而事后复盘的结果,果不其然,根子落在 JPA 懒加载上。

这起事故本身不算复杂,但排查过程绕了不少弯子,背后涉及的原理和踩坑点很有代表性。这篇文章就把完整的排查思路、机制拆解和修复方案整理出来,尤其是几个常规文档里不会写清楚的细节。如果你正在用 Spring Data JPA,或者正准备把项目的懒加载策略捋一遍,这篇内容应该能帮你省下不少排查时间。

1. 线上事故:一个“龟速”接口的诞生

先说清楚当时的业务背景,方便后面所有分析能对上号。我们这边是一个订单中台,对外提供订单查询能力,接口逻辑本身不复杂:按条件分页查询订单主表,然后返回订单状态、下单用户昵称、商品标题、支付金额等字段。前端用的是 Vue 3 的管理后台,列表页有滚动加载需求,也就是前端做列表懒加载——这里必须先敲个黑板,前端懒加载和后端 JPA 懒加载完全是两码事,后面我会专门提到这个容易混淆的点。

正常情况下,这个接口分页查 20 条订单,响应时间在 300ms 到 400ms 之间,数据库 CPU 和连接池水位都正常。事故发生时没有任何版本发布,也没有流量突增,就是突然之间接口耗时全线飘红,紧接着超时告警触发,上游调用方陆续反馈订单页面转圈刷不出来。

1.1 事故初现

我先看了基础设施的监控,第一反应是数据库是不是扛不住了。结果有点意外,数据库 CPU 只比平时高了大概 15%,慢查询日志里也没有单条执行时间特别离谱的 SQL。连接池的活跃连接数确实有明显上涨,但远没到打满的程度。再看应用侧,堆内存没有明显压力,Full GC 频率没有异常,CPU 使用率也只是轻微抬升。整体看下来,所有单点指标都算不上“事故级别”,可接口就是慢得离谱。

这个现象本身就很有信息量——说明性能瓶颈大概率不是某一条 SQL 特别慢,而是 SQL 数量变多了,或者应用层在某个环节上出现了大量等待。问题是,从监控面板上能看到的指标维度,不足以直接定位到具体代码,于是只能上链路跟踪逐层看。

1.2 第一轮排查:表象与迷惑

链路跟踪系统里一查,那个订单列表接口的调用链拉出来之后,我愣了一下:接口内部的业务逻辑段耗时占了将近 14 秒,数据库访问段反而每笔都很短。这就有意思了,数据库层面单次交互很快,但整个方法却慢如蜗牛,典型的“温水煮青蛙”数据——大量时间被分散消耗在一次又一次细小操作里,从外部看只能看到总耗时炸了。

接着我做了个最简单的验证:手动在测试环境跑了一遍同样的接口,第一次调用确实慢,但第二次调用竟然明显快很多。这个现象一出,我心里基本有底了——第一次请求要加载大量关联数据,第二次因为有缓存或上下文状态存在而变快,这种“冷热差异”在很多 JPA 懒加载事故里都会出现。数据在逐步逼近真相,但还缺最后一块拼图。

2. 层层递进:从 SQL 到根因的追寻

既然监控层面已经说明问题不在单点资源上,下一步自然就是看真实执行的 SQL。Hibernate 的 SQL 日志一直是定位这类问题最直接的工具,但日常生产环境为了性能不会一直开着,我当时的做法是在出问题的实例上临时动态开启了 DEBUG 级别日志,只跑那个接口,然后收集三到五分钟的日志做分析。

2.1 开启 SQL 日志:第一眼真相

对应的配置很简单,如果你是 Spring Boot 项目,在 application.yml 里加上就行:

logging: level: org.hibernate.SQL: DEBUG org.hibernate.type.descriptor.sql.BasicBinder: TRACE

第二行 TRACE 是用来打印 SQL 绑定参数的,排查时很有用,因为很多慢接口的根因藏在“看似相同、参数不同”的重复 SQL 里。日志刷出来之后,我同时开了终端统计,一个分页接口只查 20 条主记录,结果 Hibernate 整整执行了 400 多条 SQL。每条 SQL 本身都是毫秒级,但乘以 400 再叠上网络往返和应用层循环处理,20 条记录就能跑出十几秒的耗时。

2.2 定位 N+1

这类问题在 JPA 社区有个经典名词:N+1 查询。意思是程序先执行 1 条查询获取主表数据,然后循环里再逐个访问关联属性,每条记录触发 1 条额外查询,总共执行 1 + N 条 SQL。在订单列表这个场景里,N 不是 20,而是远远大于 20——因为每查一次订单,除了查用户,还可能查商品、物流、售后等多个关联对象,叠加以后 SQL 数量自然爆炸。

我顺藤摸瓜找到了对应代码,核心逻辑大概是这样的:

@Service public class OrderQueryService { @Autowired private OrderRepository orderRepository; public List<OrderVO> listOrders(int page, int size) { Page<Order> orderPage = orderRepository.findAll(PageRequest.of(page, size)); List<OrderVO> result = new ArrayList<>(); for (Order order : orderPage.getContent()) { OrderVO vo = new OrderVO(); vo.setId(order.getId()); // 下面两行就是罪魁祸首 vo.setUserName(order.getUser().getNickName()); vo.setGoodsTitle(order.getGoods().getTitle()); vo.setStatus(order.getStatus()); result.add(vo); } return result; } }

这个写法早该被 code review 拦下来,但因为它逻辑“看起来完全正确”,而且本地测试时数据量小、关联对象简单,根本没有暴露性能问题。

2.3 到了最后的根源:懒加载的时机问题

问题浮出水面之后,还要搞清楚一个本质问题:为什么之前没爆,偏偏这次突然爆了?这就要说到 JPA 懒加载的核心机制。

在 Order 实体里,user 和 goods 都被显式配置成了FetchType.LAZY。这个配置本身没问题,但它的意思是:Hibernate 在查询 Order 时不会主动关联查询 User 和 Goods,而是给这些属性生成一个代理对象。这个代理对象只有在被访问的那一刻才会触发 SQL 去数据库里加载真实数据。也就是说,“懒加载”把查询动作从主查询阶段推迟到了业务代码真正使用关联属性的时刻。

问题就出在这个“使用的时刻”。业务代码在遍历订单集合时逐条访问 user 和 goods,懒加载就在循环里被逐个触发,每次触发都要走一遍会话获取、SQL 执行、结果映射的完整流程。数据量小的时候这个开销不在乎,但一旦单次查询的订单数量变大,并发一上来,连接获取等待、SQL 往返时延、结果集映射这些成本瞬间被放大几百倍。事故就这么发生了。

我个人的判断是:问题根源不只是懒加载本身,而是“懒加载的触发点落在了循环体内 + 事务边界之外的时间点”。如果触发点集中在一个数据库会话里,还能通过缓存减少开销;但如果每个实体都在不同时间点、不同事务上下文里触发加载,性能就是要等所有 SQL 都跑完才能继续。

3. JPA 懒加载机制深度拆解

说实话,很多团队对 JPA 懒加载的理解停留在“默认配置”层面,知道有这个选项,但不清楚它在 JVM 里到底是怎么工作的。这里我想把关键机制讲透,因为只有理解了原理,才能知道修复方案为什么有效,也才能避免换个姿势踩坑。

3.1 什么是懒加载,它的核心逻辑

JPA 里的抓取策略分两种:FetchType.EAGER(立即加载)和FetchType.LAZY(懒加载)。EAGER 表示查询主实体时,关联对象会一并查出,通常通过 JOIN 或额外 SELECT 完成;LAZY 则不会立刻查,而是创建一个“代理对象”占位。

这个代理对象是关键。它本质上是一个继承了目标实体类型的字节码增强类,里面记录了目标实体的 ID 和所属的持久化上下文。当业务代码调用代理对象的 getter 方法时,代理会检查目标实体是否已经加载,如果没有,就通过持久化上下文去数据库查询,再把结果塞进代理内部,最后返回真实值。这个过程对业务代码透明,所以很多人写了很久 JPA,也没感觉到语法上有什么特别。

还有一个概念容易混淆——前端 Vue 3 里说的“列表懒加载”,是指在滚动到页面底部时才去请求下一页数据的交互设计,是浏览器端的行为;而后端 JPA 懒加载是数据库访问层的延迟加载策略。两者的目的都是“按需加载”,但技术层次完全不同。如果你在前端项目里搜“懒加载”,搜到的几乎都是图片懒加载、路由懒加载、列表懒加载这些,和 Hibernate 的 LAZY 没有半毛钱关系,排查问题时一定要分清。

3.2 懒加载和事务边界之间的关系

懒加载要触发 SQL,前提是实体仍然关联着一个有效的持久化上下文(在 Hibernate 里叫 Session,在 JPA 规范里叫 EntityManager)。事务提交或会话关闭之后,实体就变成了“游离态”(Detached),此时再去访问懒加载属性,就会抛出著名的LazyInitializationException

很多入门教程都在强调“把 @Transactional 放在 Service 层”,原理就在这里:只有事务没有结束,实体才保持托管状态,懒加载属性才有可能被访问到。如果 Service 方法里只查了 Order 就返回,等 Controller 层或视图层再去拿 user 的昵称,那已经脱离了事务边界,要么抛异常,要么直接报错——这取决于你有没有开启 Open Session in View。

这里我必须重点提醒一下 Spring Boot 的一个大坑:Spring Boot 2.x 版本默认把spring.jpa.open-in-view设为true。这意味着 Hibernate 的 Session 会在整个 HTTP 请求处理期间保持打开,Controller 层和视图层访问懒加载属性时不会抛异常,反而会自动触发 SQL。这个特性早期是为了解决“在视图层访问关联对象”的便利性问题,但它掩盖了大量懒加载滥用,导致开发阶段 debug 完全没察觉,等到换成接口返回 DTO、或者把 open-in-view 关掉时,N+1 问题才集中爆发。刚才这场事故之所以“突然出现”,实际上就是因为前一次上线时,有同事把open-in-view在配置里关掉了,但代码里循环访问关联属性的逻辑一直没动。

3.3 为什么“看起来正常”的代码会翻车

回到代码本身,那段遍历订单、逐个填充 VO 的写法,相信不少人一眼看过去会觉得“很正常”。它确实在语法上没有犯任何错误,但它在架构上犯了两个隐蔽的错:

第一,它把数据加载逻辑和业务装配逻辑耦合在了一起。查询方法应该明确定义“要查哪些数据”,而不是在 VO 装配时按需临场发挥。一旦关联关系变复杂,这种“临场发挥”的代码会散布在十几个方法里,你根本不知道哪条访问路径会触发多少条 SQL。

第二,它依赖了隐式的事务上下文和代理机制。代码能跑,不代表它跑得对。如果你把同一个方法换到一个没有开启事务的测试工具里跑,立刻就是一片LazyInitializationException;而如果生产环境的 open-in-view 开关一变,问题就从“报错”变成了“性能下降”,后者比前者更难察觉、更难排查。

说白了,懒加载不是不能用,而是要用对地方。它的适用场景是“关联对象不一定会被访问,且访问成本可以接受延迟”的情况。对于“列表接口里每个订单都必然显示用户昵称和商品标题”这种场景,懒加载就是纯粹在给性能埋雷。

4. 修复方案:从应急到根治

定位到根因之后,接下来就是选择修复方案。这一节我会把几种常见做法全部列出来,包括它们的代价和适用边界。先说结论:直接改成 EAGER 是风险最大的操作,绝不能靠这个走捷径。

4.1 紧急止血:临时方案的取舍

事故发生时,最急的不是重构,而是让线上接口先恢复。我当时的应急操作有两个:第一,临时把接口的分页大小从 20 降到 10,让单次查询触发的懒加载 SQL 数量减半;第二,在那个接口的查询方法上加了一个粗粒度的本地缓存,把热数据的重复查询挡住。这两个操作能在五分钟内把接口耗时拉回可接受的范围内,但严格说只是“减轻症状”,不是“治病”。

需要特别强调的是,当时绝对不能干的一件事,就是把FetchType.LAZY直接改成FetchType.EAGER。EAGER 看起来简单粗暴,但它会把“按需加载”变成“无条件加载”,影响所有使用到 Order 实体的查询路径。一个实体可能在十几个接口里被查询,你为了修一个接口的性能,会让其他所有接口都背上额外查询开销,甚至因为 JOIN 复杂导致笛卡尔积,拖垮整个应用。这种“按下葫芦浮起瓢”的操作,在线上的代价往往比懒加载 N+1 更大。

4.2 推荐方案 A:实体图

应急过后,我选了第一个正经修复方案——用@EntityGraph明确指定抓取路径。Spring Data JPA 里这是最贴合 JPA 标准的方式,代码改动量小,语义清晰。

public interface OrderRepository extends JpaRepository<Order, Long> { @EntityGraph(attributePaths = {"user", "goods"}) @Query("select o from Order o where o.status = :status") List<Order> findByStatusWithDetail(@Param("status") String status); }

加上@EntityGraph之后,Hibernate 会生成一条带 JOIN 或子查询的 SQL,一次性把 user 和 goods 都查出来,并且把结果缓存到当前的持久化上下文里。之后业务代码再访问order.getUser().getNickName(),走的是上下文缓存,不会再触发额外 SQL,N+1 问题直接消失。

实体图有个点要说明:如果多个关联属性都是 to-one(多对一或一对一),JOIN 的效果很好;但如果关联属性里有 to-many(一对多),单一集合 JOIN 会导致主表记录被重复展开,所以实体图路径里如果带集合属性,最好配合DISTINCT去重,或者改用下面的 join fetch 方案。

4.3 推荐方案 B:JPQL join fetch

第二种做法是显式使用 JPQL 的join fetch语法,它能在查询级别直接指定“这条查询必须加载哪些关联对象”。

@Query("select distinct o from Order o " + "join fetch o.user u " + "join fetch o.goods g " + "where o.status = :status") List<Order> findByStatusWithDetail(@Param("status") String status);

join fetch和普通join的关键区别在于:普通 join 只做关联过滤,不影响抓取策略;而 join fetch 会真正把关联对象查出来并填充到实体中,即使实体上的 FetchType 是 LAZY 也会立即加载。这相当于把“这条查询需要什么数据”的决定权从实体定义下沉到了查询语句,语义更精确。

不过 join fetch 也有两个容易踩的坑。第一个坑是集合关联分页问题:如果 join fetch 一个一对多集合,再配合物理分页(数据库 limit),Hibernate 会在内存中先加载全部关联数据再分页,一旦某个订单有几百条明细,内存直接炸。所以一对多场景要么用 batch size,要么把分页查询和集合抓取拆开。第二个坑是 select distinct 会多做一次内存去重,性能上有一定开销,但为了正确性,通常还是值得的。在我这个案例里,user 和 goods 都是多对一,两个坑都碰不到,所以 join fetch 是最干脆的选择。

4.4 推荐方案 C:DTO 投影

第三个方案是我个人最推荐在对外接口中采用的——DTO 投影查询。它不在查询结果中返回实体,而是直接返回一个只包含所需字段的 DTO 或接口投影。

用 Spring Data JPA 的接口投影可以这样写:

public interface OrderBriefProjection { Long getId(); String getStatus(); String getUserName(); String getGoodsTitle(); }

Repository 里定义方法:

@Query("select new com.example.dto.OrderBriefDTO(o.id, o.status, u.nickName, g.title) " + "from Order o join o.user u join o.goods g " + "where o.status = :status") List<OrderBriefDTO> findOrderBriefList(@Param("status") String status);

DTO 投影的好处是用多少查多少,不加载整个实体,也不生成代理对象,彻底绕开了懒加载是否触发的问题。上面这个查询里,user 和 goods 都是通过 join 关联查询的,没有用到 FetchType 层面的抓取策略,所以无论实体的 fetch 怎么配置都不影响结果。配合构造函数表达式,返回结构还非常稳定,后续想加字段只需改查询方法和 DTO,不会污染实体模型。

坏处也显而易见:如果查询条件复杂、关联层级深、需要复用领域逻辑,DTO 投影的代码量会变大,维护成本上升。所以我的建议是“能投影就投影,尤其是 API 对外输出层”。

4.5 方案对比

为了方便大家选型,我把三种方案放在一起对比:

维度@EntityGraphjoin fetchDTO 投影
修改成本低,注解加 attributePaths中,改 JPQL 和返回类型中高,需要新增 DTO/接口
查询结果返回实体返回实体返回 DTO,不返回实体
N+1 解决情况好,消除一次性 N+1好,消除一次性 N+1彻底,不返回实体
一对多集合场景需注意重复行,可配合 DISTINCT需注意分页内存问题推荐,结构最稳
对上层代码影响无,上层还是操作实体无,上层还是操作实体有,上层要用 DTO 替代实体
适用场景快速修复已有实体查询精准控制单条查询抓取路径对外 API、报表、列表页

在我的实际修复过程中,是“EntityGraph + 尽快补 DTO”组合推进的:先用 EntityGraph 让线上稳定,随后把列表接口改成 DTO 投影,从根本上杜绝再次出现这类问题的可能性。

5. 实战中的坑位与排查技巧

修复本身不算难,难的是排查过程中那些似曾相识的陷阱。这一节我把自己踩过以及在线下带人时经常见到的坑集中整理一下,包括典型代码形态和排查工具配置,希望你不需要在故障现场临时抱佛脚。

5.1 经典 N+1 现象速查

N+1 的形态可以归纳成两类,识别起来各有套路。

一类是多对一(或一对一)场景的 N+1,特征是在循环里访问了实体 A 关联的单个实体 B 的属性。典型代码就是文章开头那段,循环里调用order.getUser().getNickName()。识别技巧:如果你在业务代码里看到“for 循环里面,第一行就是实体 getter 连着点另一个 getter”,那基本可以判定是 N+1 高风险代码。此时应该考虑用 join fetch 或 EntityGraph 预加载。

另一类是一对多场景的 N+1,特征更隐蔽。比如查询部门列表,然后为了统计每个部门的人数,在循环里访问department.getEmployees().size()。这个访问一旦触发懒加载,就会查出该部门所有员工实体,然后只取一个 size。数据量一大,不是慢的问题,而是内存溢出的问题。正确的统计方式应该是按部门分组查询计数,或者用@BatchSize/ EntityGraph 批量抓取。这两类问题的共性,就是“循环内访问未预加载的关联路径”。

5.2 异步线程中的懒加载

线上事故里最常见到的进阶版,是异步线程里发生懒加载。比如订单创建成功后,主线程调用了@Async方法去生成报表或发送通知,异步方法内部要访问订单的 user 信息。此时新开的线程和主线程之间不存在事务关联,也没有继承持久化上下文。如果你在@Async方法里访问懒加载属性,几乎必然抛出LazyInitializationException

解决方案有几个层次。最直接的是在异步方法之外、主线程事务内,先把需要的关联属性提前初始化,可以用Hibernate.initialize(order.getUser())这种强制初始化手段,但要注意它只会加载代理对象对应的那条数据,不解决批量问题。更稳妥的做法是让异步方法自己开启事务、重新查询数据,而不是沿用主线程传进来的实体。核心思想很简单:跨线程跨事务边界,不要直接传托管实体,要传能独立完成加载所需的信息(比如 ID 或 DTO)。

5.3 序列化与懒加载

还有一种场景是“本来已经修完了,上线几天后接口又慢了”,排查下来发现是序列化阶段触发了懒加载。有的接口直接返回实体对象交给 Jackson 或 Fastjson 去序列化,序列化框架在遍历实体字段时会访问所有 getter,包括那些懒加载关联属性的 getter。如果此时请求还没结束、open-in-view 还开着,序列化过程就会把 SQL 一条条发出去,耗时同样很可观。

更麻烦的是双向关联导致的序列化死循环:比如 Order 关联 User,User 里又有一个 List ,序列化 Order 时去访问 User,User 又遍历回 Order,最终抛 StackOverflowError。处理手段通常是:在不需要序列化的字段上加@JsonIgnore,或者在关联处使用@JsonIdentityInfo,让序列化器按照 ID 引用的方式处理循环引用。但我的习惯是外部接口一律不返回实体,DTO 才是正道。

5.4 排查工具与 SQL 日志配置

最后说说排查工具。生产环境不可能每次都手动开 SQL DEBUG 日志,那只是应急手段。我建议在项目里提前做好三层准备:

第一层,开启 Hibernate 的统计信息。在 application.yml 里设置:

spring: jpa: properties: hibernate: generate_statistics: true

这样 Hibernate 会在日志中输出 session 打开次数、查询执行次数、实体加载次数等统计信息。有一次我就是靠entity load count这个数值发现了某个接口加载了数千个实体,从而快速定位到 N+1。

第二层,接入 p6spy 或者类似工具。p6spy 能打印带真实参数值的 SQL,并且可以统计连接占用时间,比原生日志更好用。但要注意生产环境不要全程开启,最好是做成可配置开关,需要排查时动态打开。

第三层,在链路跟踪里埋 SQL 数量指标。这个信息非常容易被忽略——平台的 trace 界面只展示 DB 访问总耗时,你要自己在 SQL 执行事件里加一个计数。当时事故里如果提前把这个指标加进去,看到一次请求里 SQL 数量从 3 变成 400,几分钟就能定位。

6. 从“救火”到“防火”:代码审查与长期预防

经历过这次事故,我把团队内部的 JPA 使用规范重新梳理了一遍。以前大家写代码完全凭个人习惯,现在有了清晰清单,至少能拦住绝大多数低级问题。这里也分享出来,作为团队落地参考。

6.1 代码审查清单

我现在对涉及 JPA 的代码审查,按优先级盯这几个点:

第一,循环体内是否访问了实体关联属性。不管是 for、foreach 还是 stream 里的 map 方法,只要循环体里出现实体.get关联().get字段(),就打回去重新设计查询。

第二,事务边界是否覆盖了所有懒加载访问。如果一个 Service 方法没有 @Transactional,却在方法体内访问了实体关联属性,那大概率会在某些边界条件下爆炸。标准姿势是把查询和关联访问放进同一个事务方法。

第三,实体上的 FetchType 是否合理。一对多的集合属性默认 LAZY 没问题,但如果一个关联属性在绝大多数查询里都会被用到,却还保持着 LAZY,那就该在查询层显式处理,而不是留给上层碰运气。

第四,接口返回的是实体还是 DTO。我的底线是:Controller 层不直接返回实体。这不是洁癖,而是实体一旦被序列化,就会脱离 JPA 的管控范围,什么时候触发 SQL、触发几条 SQL,完全失去控制。

第五,新增关联关系时必须考虑对既有查询的影响。很多人忽略这一点,在实体上加了一个字段,结果所有查询路径都变了。代码审查时要有意识地问:这个新增的关联,会让哪些现有接口发生新的查询或 JOIN?

6.2 监控与告警

防范于未宁,最后必须说说监控。JPA 懒加载问题有个特点:它不会让服务直接挂掉,而是让延迟一点点变差,很多人就算看了监控也觉得“还能接受”,然后一直拖到用户投诉。所以针对这类问题,我建议单独设置告警指标。

首先是慢接口阈值:对每个外部接口,把 P95 和 P99 的耗时设成告警项,一旦连续五分钟超过阈值就触发。其次是数据访问频率:对核心查询接口,统计单次请求的 SQL 执行次数,超过阈值直接告警。这个指标可以结合链路跟踪的埋点或 Hibernate Statistics 来实现。最后是连接池等待时间:如果一个接口瞬间产生几百个 SQL,连接池活跃连接数会快速上升,连接获取等待时间也会变长,这个信号往往比接口耗时暴露得更早。

我在事故之后做的第一件事,就是把“单请求 SQL 数量”这个指标加到了监控看板上。坦白讲,如果这个指标当时就存在,那次事故的定位时间可以从一小时缩短到十分钟以内。事后补监控虽然不能改变过去,但至少下一次再出问题时,不会两眼一抹黑。

最后再分享一个小技巧

这个内容到这里,核心的排查思路、原理分析和修复方案都已经讲完了。最后单独补一个我每次处理完类似事故都会做的小操作:在测试环境写一个集成测试,直接断言查询方法的 SQL 执行次数。

具体做法是用 Hibernate 的SessionFactory拿到统计信息,在测试开始前重置计数,跑完查询和 VO 填充逻辑后,断言 SQL 数量不超过一个合理阈值。比如订单列表接口,我允许的最大 SQL 数是 3 条(主查询 + 用户关联 + 商品关联,如果用 join fetch 甚至只有 1 条)。这个测试放在 CI 里,以后任何人改了查询逻辑导致 N+1 回归,CI 都会直接红掉。个人体会是,这种“把性能问题变成功能断言”的方式,比任何代码审查意见都更有约束力——毕竟代码审查是人的判断,总会漏,但测试是自动化的,不会打盹。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询