☰
ORM两大陷阱:N+1查询与事务错觉全解析
2026/9/30 9:23:40 网站建设 项目流程

做后端这十几年,我在每个项目里都见过一类事故:上线前一切正常,数据量一上来,接口突然变慢,数据还时不时“莫名其妙”被覆盖。拉开 SQL 日志一看,满屏都是形形色色的 select——N+1 和事务错觉,这俩词基本能把八九成的 ORM 事故解释清楚。

这篇文章适合两类人。一类是用 JPA/Hibernate 或 MyBatis-Plus 写过业务,但总在数据访问层翻车的开发;另一类是正准备在团队里立数据访问层规矩的技术负责人。我会先讲 N+1 是怎么一步步把数据库干趴下的,再讲“事务错觉”到底错在哪,最后给一份可以直接抄走的排查清单和评审军规。不绕弯子,直接上实例。

1. ORM 的坑不是偶然:对象世界和关系世界之间有条填不平的鸿沟

1.1 鸿沟的来源:面向对象思维是导航,关系模型是集合

先别急着骂 ORM 垃圾。要理解为什么它一定会有坑,得先看清它到底在干什么。ORM 全称 Object-Relational Mapping,核心是把 Java(或任意 OO 语言)里的对象模型,映射到数据库里的关系模型。问题在于,这两个模型的思考方式根本不是一个世界。

对象世界是“图”:你拿着一个 Post 对象,点一下post.getComments(),就能顺着内存引用把评论捞出来,像导航一样走到哪算哪。对象有身份(同一个引用指向同一个对象)、有继承、有封装。

关系世界是“集合”:数据躺在表里,表与表之间靠外键关联。想拿评论,你得写 JOIN 或者根据post_id再查一次。没有继承、没有对象图,只有行和列。

ORM 为了把这两套世界观拼起来,必须做一堆取舍。关联关系什么时候加载?加载得太狠,一条 SQL 能干的活给你拆成几百条;加载得太轻,等你真去访问关联对象,又发现 Session 已经关了。于是就有了懒加载代理、持久化上下文、一级缓存这些“聪明的默认值”。讽刺的是,坑恰恰就是这些聪明默认值埋下的。

N+1 是懒加载策略的产物,事务错觉是连接和事务管理被过度抽象后的产物。这些不是框架 bug,而是两种模型强行桥接时的结构性代价。

我常用一个类比:ORM 像一本口袋词典。日常简单对话都能翻,但一到俚语、双关、文化梗,翻译质量就崩。N+1 和事务问题,就是数据访问领域的“俚语”。

1.2 我第一次被 N+1 打蒙的那个下午

第一次真正意识到这个问题,是很多年前一个博客列表接口。Post、Author、Comment 三个实体,JPA 一对多默认懒加载。接口逻辑很简单:查 20 篇文章,每篇统计一下评论数,再拼个 DTO 返回。

开发环境数据量小,接口稳定在 80ms 左右,谁也没觉得有问题。上线两个月后,文章表到了十万级,接口直接从 80ms 飙到 2.8 秒。一开始怀疑是 MySQL 索引问题,加了好几个索引纹丝不动。后来把 SQL 日志和 p6spy 参数日志打开,整个人愣住了——一个接口居然向数据库发了 400 多条 SELECT。其中一条是查文章列表,剩下四百多条全是select * from comment where post_id = ?。

那一刻我才明白一个朴素的道理:数据库慢,很多时候不是数据库的问题,是你的应用在疯狂制造 SQL。

2. N+1 不是玄学:一个循环把数据库打到跪下的全过程

2.1 复现现场:一个 findAll,加一个循环,N 条 SQL

先看一个再典型不过的写法。假设有这两个实体:

@Entity @Table(name = "post") public class Post { @Id private Long id; private String title; @OneToMany(mappedBy = "post", fetch = FetchType.LAZY) private List<Comment> comments = new ArrayList<>(); }
@Entity @Table(name = "comment") public class Comment { @Id private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "post_id") private Post post; }

业务代码长这样:

public List<PostVO> listPosts() { List<Post> posts = postRepository.findAll(); for (Post post : posts) { int commentCount = post.getComments().size(); // 这里出事了 // ... 组装 DTO } }

看着人畜无害对吧?post.getComments()只是取一个属性而已。但findAll()先发了一条 SQL 查出全部文章,然后循环里每访问一次getComments(),Hibernate 的懒加载代理发现关联集合还没加载,就对数据库补发一次查询。SQL 日志会是这样:

select id, title from post; select id, content from comment where post_id = 1; select id, content from comment where post_id = 2; select id, content from comment where post_id = 3; -- ... 一共 N 条

N 篇文章就有 N 条评论查询,加最前面那条主查询,所以叫 N+1。如果 N 是 1000,就是 1001 条 SQL。如果实体关联层级再深一些,比如 Post 关联 Comment,Comment 关联 User,你还在循环里继续访问,查询数会从 N+1 变成 N×M+1,呈指数爆炸。

更阴险的变体是:循环不是你自己写的,是序列化库替你写的。比如 Controller 直接把实体对象返回给前端,Jackson 在序列化getComments()的瞬间触发懒加载。你以为自己只写了两行代码,实际上框架帮你执行了一个微型“循环”——这也是为什么我在评审里看到“直接返回 Entity”四个字就会直接打回。

2.2 三种抓 N+1 的手段:SQL 日志、统计计数器、查询探针

N+1 有个特点:数据量小的时候完全没症状,主键查询单条都是毫秒级,你根本感知不到。它必须靠工具抓,不能靠感觉。

第一层手段是开发期看 SQL 日志。Spring Boot 里把这两行打开:

spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true

show-sql只是 Hibernate 内部打印日志,MyBatis 的 mapper 日志也能看到推送的 SQL。但它有个缺点:参数值经常打不出来,看不出是哪一批数据触发的。这时候可以上 p6spy,它能在 JDBC 层拦截真实 SQL 和绑定参数,一眼就能看到where post_id = 1, 2, 3…这种规律性查询。

第二层手段是看 Hibernate 统计指标。SessionFactory 自带 Statistics,可以精确拿到实体加载次数和语句执行次数:

Statistics statistics = sessionFactory.getStatistics(); long before = statistics.getPrepareStatementCount(); // 执行你怀疑的接口逻辑 long after = statistics.getPrepareStatementCount(); System.out.println("本次调用 SQL 总数: " + (after - before));

第三层手段是给测试环境的数据源套一层“查询计数器”。这里我推荐datasource-proxy,在集成测试里包一层,跑完热点接口后直接断言 SQL 条数:

ProxyDataSource proxyDataSource = ProxyDataSourceBuilder .create(realDataSource) .countQuery() .build(); // 执行被测方法 Long queryCount = proxyDataSource.getQueryCount(); assertThat(queryCount).isLessThan(20);

这些手段可以组合用。我的习惯是开发期用 p6spy,CI 里用 proxy 断言,双保险。

2.3 修复方案是分场合的:JOIN FETCH、批量抓取、DTO 投影怎么选

很多人一听到 N+1,第一反应是“那就 JOIN 呗”。但实际修起来远没这么简单,不同场景有不同最优解。我整理了一个选型对照表:

方案原理适用场景主要副作用
JOIN FETCH / @EntityGraph一条 SQL 把主实体和关联一次性 join 出来单个聚合根需要完整加载,且关联层级不多集合 join 会导致主表行数膨胀;不能直接分页
@BatchSize / default_batch_fetch_size懒加载触发时,按 IN 批次抓取关联对象懒加载兜底,尤其适合循环中分散访问不同实体查询总数变少但仍有多次请求,需控制批次大小
DTO 投影 / 手写 SQL只查出 UI 真正用到的字段列表页、报表、对外接口,最推荐放弃部分 ORM 便利,SQL 要自己维护
MyBatis association 的 select 属性二次查询填充关联对象小数据量简单场景本质就是 N+1,数据量大时立刻现形

具体到 Hibernate/JPA,最常用的修法是JOIN FETCH:

@Query("select p from Post p left join fetch p.comments where p.id = :id") Optional<Post> findWithComments(@Param("id") Long id);

或者用@EntityGraph:

@EntityGraph(attributePaths = "comments") @Query("select p from Post p") List<Post> findAll();

但如果你要分页,情况就变了。Hibernate 在“集合 join fetch + 分页”的场景下,会放弃数据库分页,把全量数据加载进内存再做分页。数据量小的时候没事,数据量大了直接 OOM。

提示:如果遇到了“带集合 fetch 的分页”,优先换思路。要么拆两条 SQL(主查询分页,再按主键集合批量查关联),要么直接用default_batch_fetch_size做懒加载兜底,别硬 join。

多关联集合还会带来笛卡尔积:Post 同时有 Comments 和 Tags,你 left join fetch 两个集合,每条 Post 会膨胀成 comments × tags 行。解决方式要么把集合类型改成Set,要么拆成多次查询。记住一个原则:JOIN FETCH 是手段,不是信仰;一个查询能一次拿完最好,拿不完就分步批量拿。

3. 事务错觉:你以为在事务里,其实连接早就不是你的了

如果说 N+1 是性能问题,那“事务错觉”就是正确性问题,更危险。因为它不报错,不报警,只是数据在你看不到的地方悄悄变了。

3.1 事务绑定的是数据库连接,不是 Java 方法

很多同学对事务的理解停留在“方法上加了@Transactional,方法里所有操作就在一个事务里”。这句话对了一半,而且是错得很危险的那一半。

Spring 的@Transactional是通过 AOP 代理实现的。真正发生的事是:外部调用这个方法时,Spring 生成的代理对象先拦截,从连接池拿一个数据库连接,把连接绑定到当前线程,开启事务,然后才执行你的业务代码;方法执行完毕,代理再决定 commit 还是 rollback,最后把连接还给连接池。

也就是说,事务的边界不在你的代码块里,而在代理对象的调用边界 + 数据库连接的归属上。同一个线程、同一个连接,才谈得上同一个事务。一旦出现以下几种情况,你以为的“事务”就不存在了:

  • 方法内部自调用:this.pay()直接在当前对象上调用,绕过了代理;
  • 方法内部新建线程去操作数据库:新线程拿到的是另一个连接;
  • 方法内部手动取的Connection不属于当前事务管理器的连接。

最典型的自调用例子:

@Service public class OrderService { @Transactional public void createOrder(OrderDTO dto) { orderRepository.save(dto.getOrder()); this.pay(dto.getOrderId()); // 自调用,pay 的 @Transactional(REQUIRES_NEW) 不会生效 } @Transactional(propagation = Propagation.REQUIRES_NEW) public void pay(Long orderId) { paymentRepository.updateStatus(orderId, PaymentStatus.PAID); } }

createOrder里调用this.pay(),这里粗暴地绕过了 Spring 代理,所以pay上的REQUIRES_NEW形同虚设,实际上它是在createOrder的事务里执行的。想要它生效,要么把pay挪到另一个 Service 里注入调用,要么注入自身代理:

@Service public class OrderService { @Autowired private OrderService self; // 注入代理 @Transactional public void createOrder(OrderDTO dto) { orderRepository.save(dto.getOrder()); self.pay(dto.getOrderId()); // 走代理,REQUIRES_NEW 才生效 } }

很多线上事故就是这么来的:开发者在事务方法里批量处理数据,中间调了几个“内部方法”,以为每个方法都是独立事务,结果全部挤在同一个事务里;反过来也有,以为大家在一起事务,结果某个内部方法用了REQUIRES_NEW提前提交了。

3.2 传播行为的三个变体:REQUIRED、REQUIRES_NEW、NESTED

事务传播行为是“事务错觉”的重灾区。这里不展开所有传播级别,只讲最常用的三个,讲完你就明白为什么团队里必须白纸黑字约定传播规则。

REQUIRED(默认):如果当前线程已有事务,直接加入;没有就新建。这是最符合直觉的,但要注意一个反直觉的细节——内层方法抛出异常后,即使外层 catch 住了,事务也已经被打上 rollback-only 标记。等到外层方法正常走到结尾,Spring 提交事务时会发现标记,直接抛UnexpectedRollbackException。

@Transactional public void outer() { try { innerService.inner(); // 内层 REQUIRED,加入当前事务 } catch (Exception e) { log.error("忽略内层异常", e); } // 这里正常返回,但事务已标记 rollback-only,提交时会抛 UnexpectedRollbackException }

很多人以为 catch 住就没事了,结果线上频繁报UnexpectedRollbackException,百思不得其解。真相反而是:异常冒泡过一次,事务注定了要回滚。

注意:REQUIRED 传播下,内层异常一旦穿透到事务边界,外层 catch 也救不回来。

REQUIRES_NEW:暂停当前事务,新开一个独立事务。新事务先提交,外层事务后回滚也不会影响它。这个级别非常危险,因为它把“要么都成功,要么都失败”的原子性打破了。只有在“即使主流程失败也必须把操作日志写进库”这类场景才建议用,而且要在方法名上明确标注。

NESTED:利用数据库 savepoint 做部分回滚,内层失败只回滚到保存点,不影响外层。但这对数据库和连接池都有要求,实际项目里用得很少。如果团队里有人提交了这种代码,评审时一定要把真实意图问清楚。

3.3 异常吞掉与回滚规则:为什么我 catch 了事务就不回滚

Spring 事务默认只对RuntimeException和Error回滚,受检异常(Checked Exception)默认不回滚。这是无数数据事故的根源。

再强调一遍:Spring 判断要不要回滚,不是看“有没有异常发生”,而是看异常是否按规则传播出去了。

最经典的错误写法,我见过不止十次:

@Transactional public void transfer(String fromAccount, String toAccount, int amount) { try { accountMapper.decrease(fromAccount, amount); accountMapper.increase(toAccount, amount); } catch (Exception e) { log.error("转账失败", e); // 异常被吞掉了,事务会正常提交 // 结果:扣款成功,入账失败 } }

代码运行不会报错,但数据已经不一致。这种“事务错觉”比任何报错都可怕,因为没人会主动去查。

如果你确实想对受检异常也回滚,必须显式声明:

@Transactional(rollbackFor = Exception.class)

或者干脆业务别依赖默认规则,统一约定:所有事务方法内部不得吞异常,业务失败就抛 RuntimeException 子类。

3.4 长事务、跨线程和 OSIV:事务错觉的三块放大镜

除了边界问题,还有三个场景会放大事务错觉。

第一个是长事务。方法上挂了@Transactional,里面循环调外部 HTTP 接口、发短信、调另一个服务。每个远程调用几百毫秒,事务内的数据库连接就被你白占着几百毫秒甚至几秒。高并发下,连接池很快被耗尽。更隐蔽的是,长事务意味着数据库层面的锁也被长时间持有,一旦涉及行锁、表锁,其他事务全部排队。我的经验是:事务方法内部不允许出现任何远程调用,一个事务只做数据库操作,而且要收着做。

第二个是跨线程。实体对象是在线程 A 的持久化上下文里加载的,你扔到线程 B 里去改。Hibernate 里这对象已经是 detached 状态,修改不会自动同步。如果不小心又用 repository 去 save,行为完全取决于合并逻辑,很可能出现“我改了却没生效”“没改却被更新”的混乱。线程边界经常就是事务边界,跨线程就别再谈同一个事务了。

第三个是 OSIV(Open Session In View)。Spring Boot 早期的版本默认open-in-view=true,这意味着从进入 Controller 到渲染视图结束,Hibernate Session 一直开着,连接一直占着。你可以在页面模板里懒加载访问关联对象,但代价是:HTTP 响应写完了连接才释放,数据库连接被一个 HTTP 请求从头占到尾。很多连接池耗尽的问题都是这么来的。

建议:Spring Boot 里直接spring.jpa.open-in-view=false,把懒加载的机会彻底断掉,逼自己把所有关联在事务/查询层就 DTO 化。刚开始会不习惯,坚持两周,你会感谢这个配置。

4. 懒加载、脏检查、批量插入:另外几口人人踩过的暗井

N+1 和事务错觉是两座大山,但 ORM 的坑远不止这两座。下面这三口井,几乎每个做过 JPA/Hibernate 的人都踩过至少一次。

4.1 懒加载逃逸:一个没有灵魂的代理对象

当你从 repository 里拿到一个实体时,它可能不是一个“完整”的对象,而是一个 Hibernate 代理——除了 id 和已加载的列是真的,关联属性全是空壳。只要 Session 还开着,你访问懒加载属性,它就去数据库查;Session 一关,你再访问,就直接抛LazyInitializationException。

这个坑最喜欢在 Controller 直接返回实体的场景出现。去掉 OSIV 之后,第一次直接返回实体,序列化post.getAuthor().getName()的瞬间,异常就炸了。很多新人会去打开 OSIV 或把fetch改成EAGER,这是饮鸩止渴。

正确做法是:在事务/查询范围内,把所有要用到的关联一次性抓取,然后转换成 DTO 返回。实体是内部实现细节,不该出现在接口契约里。JPA 项目里有条件就用@EntityGraph,没条件就在 service 层把字段一个个复制到 VO。麻烦一点,但换来的是接口稳定。

4.2 脏检查与乐观锁:谁把我的字段悄悄覆盖了

Hibernate 有自动脏检查机制:Session 内的实体在 flush 时,会跟加载时的快照比对,发现有字段变了就自动发 UPDATE。这意味着你明明没有调用save(),Hibernate 照样更新数据库。对于不熟悉这个机制的人来说,会感觉“我没保存它啊,它怎么自己改了”。

比这更严重的是丢失更新。两个用户同时打开同一个商品编辑页:

  • 用户 A 修改价格并保存;
  • 用户 B 修改库存并保存;
  • 因为 B 的实体是刷新前加载的快照,他的 flush 会把整行覆盖(如果没开dynamic-update,UPDATE 会带上所有字段),A 改的价格被悄悄打回原样。

解决方式是用乐观锁@Version:

@Entity public class Product { @Version private Long version; }

每次 UPDATE 都会带where version = ?,版本不一致就更新失败,抛OptimisticLockException。这是关系模型应对“并发修改同一行”的经典手段,ORM 里用@Version实现成本极低,强烈建议所有可被并发修改的实体都加上。

4.3 批量插入的障眼法:saveAll 不等于批量

很多团队以为saveAll()就是批量插入,性能好。实际上在默认配置下,JPA 的saveAll是循环里一条一条persist(),每一条都在边界时刻 flush,产生 N 次数据库往返。数据量一大,插入耗时直奔几十秒。

要真正批量,要做三件事:

spring.jpa.properties.hibernate.jdbc.batch_size=50 spring.jpa.properties.hibernate.order_inserts=true spring.jpa.properties.hibernate.order_updates=true

如果是 MySQL,JDBC URL 上还必须加上rewriteBatchedStatements=true:

jdbc:mysql://localhost:3306/db?rewriteBatchedStatements=true

还得多说一句:如果主键生成策略用的是IDENTITY,Hibernate 为了拿到数据库生成的自增 id,必须在插入后立刻回读主键,批量插入会被直接禁掉。想批量,要么改用 SEQUENCE 或预先分配 id 的生成方式,要么接受单条插入。这是 Hibernate 文档写明的限制,和 MySQL 性能无关。

MyBatis 也有同样的问题。ExecutorType.BATCH能走批处理,但很多人不知道默认 Executor 是 SIMPLE,一条条发。要么改 executor,要么用foreach拼insert ... values ...,注意控制单批条数,一般 1000 条以内比较稳。

5. 把这套经验固化成团队军规:从日志到事务边界

踩坑只是起点,真正有价值的是把踩坑经验变成团队里可执行的约定。下面几条,是我们团队在血泪之后固化下来的规则。

5.1 评审里看到这几种写法直接打回

我在代码评审里有一个“直接打回”清单,看到以下现象,不解释,改完再来:

现象为什么打回
循环里出现repository.findXXX/save极可能 N+1;SQL 次数随数据量线性增长
Controller 直接返回 Entity序列化器会替你触发懒加载,接口契约和表结构耦合
一个几百行的方法挂了@Transactional事务时长不可控,锁和连接占用会被无限放大
try-catch 吞异常然后正常返回事务提交了你以为会回滚的数据
自调用内部方法套REQUIRES_NEW代理没生效,传播级别形同虚设
用saveAll批量导入很可能一条条 insert,性能预期完全错误

列完这条之后,团队里类似问题少了至少一半。人都会犯错,但规则能拦住大部分低级失误。

5.2 让 SQL 在开发期裸奔,在 CI 里数 SQL

开发环境必须把 SQL 日志和参数打出来。我偏爱 p6spy,因为它能打印完整 SQL 和参数,用起来很直观。但记住:日志是给人看的,CI 断言是给机器看的。

推荐在集成测试里给数据源包一层ProxyDataSource,对热点接口做查询次数断言:

ProxyDataSource proxyDataSource = ProxyDataSourceBuilder .create(realDataSource) .countQuery() .build(); // 执行被测接口 long queryCount = proxyDataSource.getQueryCount(); assertThat(queryCount).isLessThan(30);

阈值定多少取决于接口复杂度,但至少要保证:查询次数不随数据量线性增长。这是 N+1 最本质的特征,也是自动化测试最容易抓到的问题。

5.3 事务边界的四条铁律

团队约定比什么都重要。我们内部的事务设计规范,浓缩成四条:

  1. 事务范围 = 一次业务操作的最小数据库变更集合。不该挂事务的查询不要挂;多个独立写操作分散在不同方法时,优先考虑用 TransactionTemplate 精确定边界,而不是在长方法上无脑@Transactional。
  2. 事务内不允许远程调用。不管是 HTTP、RPC、MQ 发送,一律先收集数据,事务提交后再发。
  3. 事务方法内不允许吞异常。要失败就抛出来,由代理统一决定回滚。
  4. 读取路径尽量不进事务。如果需要“同一时间点的完整数据快照”,可以在小型只读事务里完成,设置readOnly = true并尽快返回。

如果遇到自调用又想精确控制事务的场景,直接用TransactionTemplate最干净:

transactionTemplate.executeWithoutResult(status -> { accountMapper.decrease(fromAccount, amount); paymentRecordMapper.insert(record); });

既没有代理绕行问题,边界也比注解更清晰。

5.4 选型时就把这些坑搬上桌

最后聊聊选型。JPA/Hibernate 适合领域模型复杂、CRUD 密集、团队有足够 Hibernate 经验的场景;MyBatis 适合对 SQL 掌控要求高、报表和联表查询多的场景。但别以为 MyBatis 就没有 N+1——它的<association select="...">本质就是 N+1,只是写法比 JPA 隐式触发更显眼一些。

不管选哪个框架,也不管将来是不是把一半查询挪到 Elasticsearch 之类的其他存储上,底层逻辑是一样的:对象图导航式的访问习惯,在任何存储层都会变成一轮一轮的请求浪费。你在 JPA 里因为循环访问关联属性产生 N+1,到了 ES 里同样会因为循环请求产生 N 次网络往返。核心原则永远只有一个——把“一次业务请求对应多少次存储往返”作为核心指标来设计和评审。

最后说点个人体会。带新人时,我最常问的一句话是:“你这段代码最终会让数据库收到几条 SQL?”如果对方答不上来,那不管用 JPA 还是 MyBatis,迟早出事。ORM 完成了对象到表的映射,但它永远没法替你思考数据是怎么被读取和修改的。我现在每写一个数据访问层,都会先让 SQL 日志裸奔一遍,再在接口测试里把查询数焊死。养成这个习惯之后,N+1 和事务错觉就会从“事故”变成“日常审代码时随手指出来的小问题”。

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

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

立即咨询