前阵子代码评审,有位同事交上来一个查询功能的改动。场景不复杂,但代码是用最原始的方式写的:一条 SQL,一个 ResultSet,再手工把每个字段塞进对象,全程下来近千行。会议室安静了几秒,有人小声说,这不就是不用 ORM 硬写 SQL 的典型。
如果目标只是“执行一条查询”,直接写 SQL 永远是最短的路径。但在一个真实系统里,程序员面对的不是一条 SQL,而是几十张表、几百个接口、无数版本迭代。那几天我把“ORM 框架到底解决了什么问题,为什么不直接写 SQL”从头想了一遍。今天这篇文章,想写给刚接触后端、还在纠结要不要引入 ORM 的人,也写给那些在“全自动 ORM”和“手写 SQL”之间反复摇摆的团队。我不打算站队,只想把两个选项背后真实的工作量、风险点和取舍逻辑讲清楚。
1. 先不急着骂ORM:从手写SQL的日常说起
很多刚入行的开发者对 ORM 的第一印象来自框架文档里那句“自动生成 SQL”,听起来像个黑盒。但如果你真的从 JDBC、ODBC 或者裸的数据库驱动开始写过一个完整的查询流程,就会明白 ORM 解决的远不止“自动生成”这四个字。
1.1 一个最简单的查询,实际要写多少行
假设你要按主键查一个用户。用 SQL 写出来只有一句:
sql复制select id, name, age from user where id = ?
但在 Java 的 JDBC 里,为了让这句话跑起来,你需要拿到 Connection,创建 PreparedStatement,绑定参数,执行查询,遍历 ResultSet,把每一列的值手动赋给 User 对象的每个字段,最后还要记得关掉连接。完整代码大概是这样的:
java复制public User findById(Long id) {
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(
"select id, name, age from user where id = ?")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
User user = new User();
user.setId(rs.getLong("id"));
user.setName(rs.getString("name"));
user.setAge(rs.getInt("age"));
return user;
}
}
} catch (SQLException e) {
throw new RuntimeException(e);
}
return null;
}
这才是一个最简单的单表按主键查询。真实系统里还有分页、排序、批量插入、乐观锁、逻辑删除、父子关联查询。你想想,如果每张表都要手写这一整套,代码量会膨胀到什么程度。
我见过不少老项目就是这样。每个实体类对应的 DAO 里,十几个方法的结构几乎一模一样,区别只是 SQL 语句和字段赋值不同。老同事复制粘贴起来很快,但新来的同事改起来就很痛苦:漏一个字段、少一个括号、把 rs.getString 的参数写错,都是编译期根本发现不了的问题,等运行时报错或者数据对不上,排查成本反而更高。
1.2 对象和关系表的“阻抗失配”
这里有个更本质的矛盾,做后端的人称为“阻抗失配”。
关系型数据库的核心模型是二维表,数据以行和列的形式存在,表与表之间通过外键关联。而编程语言里的核心模型是对象:对象有继承、有多态、有嵌套结构,一个用户对象里可以直接包含一个订单列表。
这两种模型天然不是一回事。你从数据库查出一张用户表、一张订单表,想在代码里把它们拼成 user.getOrders() 这样的结构,就必须要写“翻译代码”。ORM 这个名字,Object Relational Mapping,说的就是这层翻译工作。
很多人觉得翻译不难,不就是把行变成对象吗?但问题在于关系模型里的关联关系表达方式,和对象模型里的导航方式差异很大。比如在 Java 里,user.orders 是一个集合属性,遍历它很自然;但数据库里没有“集合”概念,只有订单表里一个 user_id 外键。你要把这个外键关系变成内存里的 List,就要处理一对多、多对一、多对多映射,还要考虑嵌套层级多深、哪些字段要一起查出来。这些逻辑如果用原生代码写,每个关联场景都要单独实现一套,维护成本非常高。
ORM 的价值不在于把 SQL 藏起来,而在于把“行到对象”的翻译和关系导航做成了框架级能力,业务代码只需要用对象思维去写,剩下的事情交给统一机制处理。
1.3 维护成本才是手写SQL最大的敌人
手写 SQL 还有一个容易被低估的问题:表结构一旦变化,改动的涟漪面特别大。
数据库加一个字段、改一个列名、调整一个索引,听起来是 DBA 的事。但在非 ORM 项目里,你要同步修改至少三层代码:SQL 语句本身、ResultSet 的字段读取、对象的属性定义。如果哪个模块漏改,最轻的是查询报错,严重的会把新字段的值读成 null,造成线上数据混乱。
我自己就踩过这种坑。老系统里有个报表查询,后来表里新增了一个字段,我把主查询改了,却漏掉了另一个文件里的统计 SQL。现象是列表数据正常,点开明细一看,新字段永远是空。排查了大半个下午,最后发现是另一段手写 SQL 里字段列表没跟上。
而用 ORM 的话,字段变更通常会集中在“实体类 + 数据库迁移脚本”两层。实体类是字段的单一事实来源,框架生成 SQL 时会自动带上当前映射的所有字段。这不是说 ORM 让你完全不用改代码,而是把改动范围收敛了,漏改的概率会小很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. ORM带来的不只是“自动生成SQL”
如果说映射和翻译是 ORM 最表面的功能,那再往下挖一层,会发现它真正值钱的地方在于帮你管理了数据访问的完整生命周期。这一层看不见摸不着,但如果你从来没理解过,用着用着就会觉得框架“很玄学”,出了问题也不知道去哪里找。
2.1 状态跟踪和脏检查:ORM靠“快照”在做事
很多新手刚用 JPA 或 Hibernate 时会有个疑问:为什么我改了对象的属性,没有调用 update,事务提交后数据库里就变了?
这不是什么魔法,背后是 ORM 的“状态跟踪”机制。Java 的 JPA 把实体对象分成瞬时态、托管态、游离态、删除态,Python 的 SQLAlchemy 也有类似概念。当一个对象通过查询进入 Session 或 EntityManager 的上下文后,框架会保存一份它的原始快照。接下来你修改这个对象的属性,框架在事务提交或调用 flush 的时候,把当前对象和快照做一次比对。只要发现有差异,就自动生成一条 update 语句。
这种“脏检查”机制最大的价值是,你不用在业务代码里逐个记录哪个对象被修改过。比如你从数据库查出一批订单,循环里根据条件改了其中一部分对象的金额,最后统一提交,框架只会更新真正变化的行。要是手写 SQL,你得自己维护一个“待更新集合”,还要小心不漏掉任何一条。
但正因为这个机制是自动的,它也带来了隐患:如果某个实体被莫名其妙地修改了属性,就可能产生非预期的 update。我在项目里见过有人把实体对象传到前端,前端又把整个对象原样传回来做更新,结果对象里被前端塞了一些不该动的字段,导致脏数据写回数据库。处理办法是更新接口用明确的 DTO,别让实体对象一直处于托管态暴露给外部。
2.2 事务边界如果一直靠人肉记,迟早出事故
手写 JDBC 的时候,事务得自己控制:拿到连接后 setAutoCommit(false),业务处理完 commit,出异常回滚,finally 里释放连接。开始写觉得没什么,写多了就会发现,最容易出错的地方恰恰是这些“人人都知道的规范”。
比如一个方法里查了数据、调了外部接口、又更新了两张表。到底什么时候该提交?如果中间发生异常,回滚范围怎么界定?如果某个同事忘了关闭资源,连接池会被慢慢拖垮。这些问题和 SQL 水平无关,纯粹是事务边界管理上的重复劳动。一次大意,线上就可能出现半提交状态,数据对不上账再回头修,比写一百条 SQL 都痛苦。
ORM 配合容器事务管理,把事务边界从“手动 try/catch”变成了“注解或配置声明”。你只需要标记哪个方法是在事务里运行的,框架负责在方法进入时开启事务、正常结束时提交、异常时回滚。更关键的是,它把“连接获取”和“事务生命周期”绑定在一起,不会出现你费劲写完 SQL 却忘了开事务的尴尬。
不过这里我要多说一句:理解事务原理依然很重要。ORM 只是帮你把声明和维护工作简化了,并没有改变事务的本质。那些“一个事务里调远程接口睡三秒”“一个事务里批量跑几万条更新”的经典事故,用了 ORM 一样会发生,只是发生的位置从你的代码挪到了框架回调里,更难察觉。
2.3 乐观锁、逻辑删除与审计字段这类横切逻辑
实际业务里有一些规则是“全表通用”的:逻辑删除的记录查询时要自动过滤,更新时要带上版本号做乐观锁控制,每次 insert/update 要自动填充创建时间和更新时间。
如果这些靠手写 SQL,意味着每一句 SQL 都要记得加 deleted = 0,每一个 update 都要记得 set version = version + 1 where version = ?。一次忘记,可能不会立刻报错,但会在某些角落埋下 bug。
ORM 提供了统一收口这些横切逻辑的能力。逻辑删除可以做成全局过滤器,乐观锁通过 @Version 注解自动处理,审计字段可以在框架的插入/更新监听器里自动填充。业务开发者在写代码时完全不用感知这些规则的存在,规则依然能稳定执行。
我之前接手过一个没有 ORM 的旧系统,逻辑删除真的就是每张表的一个 is_deleted 字段。查询接口里写没写 is_deleted = 0 完全靠个人自觉。审计代码时发现,不少历史 SQL 都没带这个条件。用户看到已删除的数据还在列表里,只能默默在后台代码里一个字段一个字段地补。后来统一改了框架,至少在新增代码里,这类问题不再靠人肉保证了。
3. 直接写SQL派坚持的东西,也有它的硬道理
我不想把 ORM 说得无所不能。直接写 SQL 这件事能一直存在,而且很多资深开发者坚持这么做,背后是有充分理由的。你接触的项目越复杂,越能体会这些理由的重量。
3.1 复杂统计报表里,ORM生成的SQL让人无力吐槽
ORM 的强项是单表 CRUD 和简单的关联查询。一旦进入复杂统计场景,比如多表关联、行转列、按时间窗口聚合、多级子查询,ORM 的表达能力就开始捉襟见肘。
很多 ORM 的查询 API 为了保持“全自动”的特性,会尝试把你用对象方法编写的逻辑翻译成 SQL。但翻译出来的 SQL 往往是很长一段,嵌套一堆子查询,可读性很差。想要手工调整执行计划,又不知道从哪里下手。数据库慢,DBA 把慢查询日志拉出来,你会怀疑这到底是不是自己写的逻辑。
我在做报表需求时遇到过类似情况。用 ORM 的表达方式写了一个跨三张表的统计,功能能跑通,但一查执行计划,发现关联顺序和索引利用都很不理想。后来直接把 SQL 拿到数据库客户端手工优化,索引命中后性能提升了十几倍。这不是 ORM 的 bug,而是它帮你自动生成 SQL 时,不可能比你更了解你的数据分布和索引设计。
复杂报表、多维分析、大批量聚合这类需求,SQL 本身就是表达力最强的语言。硬用 ORM 去“面向对象”地描述统计逻辑,纯粹是自我束缚。
3.2 批量操作面前,ORM的隐性开销被放大了
再举一个自动 ORM 在高性能路径上容易翻车的例子:大批量插入或更新。
ORM 为了做状态跟踪和脏检查,会在对象生命周期里维护额外信息。批量插入一万条数据,如果按默认方式逐条 flush,框架可能会发出上万条 insert 语句,每条都要走一次网络往返、一次事务日志记录。而手写 JDBC 的批量提交,可以用一条批量命令,减少大量网络开销。
你可以类比成搬家:用 ORM 默认方式搬,是一个箱子一个箱子往楼下搬,每个箱子还都要填一张表格记录状态;用手写 SQL 批量操作,是直接叫了一辆卡车,把一整车箱子一次性运走。绝大多数业务场景不需要卡车,但真遇到了性能敏感的大批量任务,你就能体会到 ORM 的“管家式服务”反而成了负担。
所以成熟的 ORM 框架通常都提供了批量模式或原生写入的通道。项目里真有大数据量导入需求时,我一般直接走原生 SQL 或框架提供的 batch 接口,而不是把一个简单的循环交给 ORM 去发挥“自动”能力。
3.3 “用ORM就不用懂SQL”是场危险的误会
这是我最想纠正的一点:ORM 永远不能替代 SQL 能力,反而对 SQL 能力提出了更高要求。
你用 ORM 写一句看起来人畜无害的查询,框架会在背后生成什么样的 SQL,完全取决于你对映射关系的设计、对延迟加载的配置、对查询语法的理解。如果不懂索引,不懂执行计划,不懂连接方式,你甚至不知道框架帮你生成了一条性能极差的 SQL。
比如在 ORM 里设计了一个跨实体的关联查询,框架可能生成了相关子查询,也可能在循环里触发多次额外查询。数据量小的时候没感觉,数据量一上来,数据库连接池被打满、慢查询日志爆表,你打开日志才知道问题出在自己没理解“ORM 背后的 SQL 生成逻辑”。
换句话说,把 SQL 能力丢掉再拥抱 ORM,等于开自动挡车但完全不懂发动机原理。平时代步没问题,一旦上坡爬不动或者山路打滑,你连问题出在前轮还是后轮都判断不了。真正用 ORM 用得好的团队,往往也是一群 SQL 基础很扎实的人。
4. 安全与稳定:ORM真正值回票价的地方
前面讲的都是开发效率和可维护性,这一部分我想专门聊聊安全与稳定。很多人在做技术选型时,把 ORM 的安全收益当成“附带福利”,但真出了事才发现,这个附带福利其实是最该优先考虑的。
4.1 参数化绑定怎么堵上大部分SQL注入风险
SQL 注入的根本原因很简单:代码把外部传入的字符串直接当作 SQL 的一部分拼接进去,导致用户输入可以改变 SQL 语义。
如果不用 ORM,直接写:
java复制String sql = "select * from user where name = '" + name + "'";
这时候用户的输入如果包含特殊字符,整个查询的逻辑就可能被篡改。最典型的场景就是登录接口里的万能密码。防范的办法不是靠写过滤函数,而是所有外部值都必须通过参数化绑定进入 SQL,让数据库把传入值当作数据处理,而不是当作 SQL 指令的一部分。
ORM 在这一点上帮你降低了犯错的概率。它普遍使用预编译语句和参数绑定机制,只要你通过框架提供的查询接口传值,框架就会自动把值作为参数传进去,而不是拼接到语句里。这意味着,大量常见的注入尝试在 ORM 体系里天然失效,因为根本没有“字符串拼接后交给数据库解析”这一步。
但请记住,ORM 不是安全保险箱。它通常也允许开发者写原生 SQL 或动态条件拼接,如果你在这些通道里重新使用了“字符串拼接外部输入”的方式,风险会原样回来。所以在团队规范里,我始终会强调一句:任何来自用户的参数,无论何时都不能拼进 SQL 文本,只能作为绑定参数传进去。
4.2 N+1查询:ORM最容易被骂上热搜的性能坑
ORM 被吐槽最多的性能问题,除了粗放生成 SQL 之外,另一个典型是 N+1 查询。
看这段伪代码:
text复制userList = userService.findAll()
for user in userList:
orders = user.getOrders() // 每个user触发一次额外查询
如果 getOrders() 是延迟加载,那内存里取到的是代理对象,真正访问订单列表时才发起数据库查询。问题是:如果有 1000 个用户,这段代码执行后会产生 1 条查用户列表的 SQL,加上 1000 条查订单的 SQL,共 1001 次查询。
从日志看,数据库连接会被反复打开释放;从响应时间看,接口可能直接超时。这种问题在 ORM 里非常隐蔽,因为你在代码里只写了一层循环,看起来没有任何低效操作。但它又确实是 ORM 关联导航能力的半成品表现。
解决办法也不是不用 ORM,而是要理解延迟加载的行为,并做好预加载。JPA 里可以用 join fetch 或 @EntityGraph,SQLAlchemy 里有 selectinload,MyBatis 有嵌套结果或关联查询。核心思路是把“循环里逐条查”改成“一条 SQL 把关联数据一次性捞出来”。这个优化思路和手写 SQL 时把 N 条查询合并成一条 join 是同一个道理,只是 ORM 让问题出现得更隐蔽,也让优化有了统一入口。
4.3 事务与连接池问题:ORM不是保险箱
ORM 帮你管理连接和事务,但如果你不理解背后的生命周期,一样会踩大坑。
我遇到过一起线上故障。某个接口在一个声明了事务的方法里,先查了数据库,然后调用了一个响应很慢的外部服务,外部服务超时要等好几秒,而在这几秒里,数据库连接一直被事务占着。刚开始流量不大没感觉,后来并发一起来,连接池被打满,所有查询都在排队,系统直接雪崩。
排查下来,问题本质不是 ORM 写错了什么,而是事务边界没有和耗时的外部依赖隔离开。ORM 只是按要求把事务范围扩大到了整个方法,长时间占用数据库连接和锁,完全是业务设计的问题。
所以用 ORM 的时候,我对团队有一条硬性要求:事务里不要做远程调用,分布式事务能不用就不用,普通请求的事务范围要尽可能短。把 ORM 当成工具而不是甩手掌柜,它才能帮你稳定地省心。
5. 混合使用是常态,关键在于怎么切分边界
说了这么多,其实答案已经很明显了:这不是“二选一”的问题,而是在同一个项目里,要分清哪些场景适合 ORM,哪些场景应该直接写 SQL。
5.1 业务开发和分析型需求,策略本来就不该一样
后端项目里的数据访问大体可以分成两类:一类是围绕业务对象的 CRUD,另一类是基于数据的统计分析。
前者非常适合 ORM。用户、订单、商品、配置,这些都是结构清晰、关联明确的对象,用 ORM 开发效率高,代码可读性好,事务、缓存、逻辑删除都有统一机制。后者则恰恰相反——分析型查询通常涉及大量聚合、分组、窗口函数,SQL 本身就是最自然的表达,硬套 ORM 反而会绕。
我自己心里的取舍标准是这样的:
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 常规单表/关联 CRUD | ORM | 开发效率高,字段变更集中 |
| 复杂统计报表 | 原生 SQL 或数据库视图 | 执行计划可控,SQL 可读性强 |
| 大数据量批量导入导出 | 原生批量 SQL / 框架 batch | 减少会话快照与逐条开销 |
| 遗留库结构混乱 | 半自动 ORM 或原生 SQL | 映射成本低,便于逐步治理 |
| 高性能关键路径 | 原生 SQL + 缓存 | 精细控制 SQL 形态 |
这个表不一定适合所有团队,但它背后有个通用原则:越是靠近 OLTP 的核心业务对象管理,越适合让 ORM 接管;越是靠近数据分析的 OLAP 场景,越应该把 SQL 交还给工程师。
5.2 自动ORM也可以留一条原生SQL的后门
别把 ORM 当成一个封闭的黑盒,主流的 ORM 框架基本都提供了“逃生通道”。
JPA 里有 @Query(nativeQuery = true),也可以直接用 EntityManager 执行原生 SQL;MyBatis 从一开始就允许在 XML 里写完整 SQL,本质上是一种“半自动 ORM”;SQLAlchemy 除了 ORM 之外还有 Core 层,支持直接用 text() 写原生语句;Django ORM 也提供 raw() 方法。
这些机制就是用来承接那些“ORM 表达起来很别扭、但直接用 SQL 很清晰”的查询的。正确做法不是禁止原生 SQL,而是给原生 SQL 划定使用范围。我一般会要求:原生 SQL 只能用于只读查询,并且必须在代码评审里单独审查,确认 SQL 走到了正确的索引、没引入额外的安全问题。
比如你用自动 ORM 管理主业务 CRUD,但报表模块里写了几条经过精心优化的原生 SQL。这套组合下来,既享受了 ORM 的开发效率,又保留了 SQL 对复杂查询的控制力。
5.3 团队代码评审里,针对ORM单独盯几件事
引入 ORM 之后,代码评审的关注点会发生变化。除了常规的业务逻辑,我会额外盯这几个地方:
第一条,循环里有没有数据库查询。看到一个 for 循环里出现任何 ORM 查询方法,我都会要求解释清楚,为什么不能提到循环外统一查询。这不是教条,而是为了从源头上防止 N+1。
第二条,有没有对实体对象进行不必要的修改。实体处于持久化上下文时,任何 setter 调用都可能转化为数据库 update。代码评审时我会要求判断:这个字段真的需要更新吗?为什么整个实体被毫无保留地丢给了前端?
第三条,事务方法里有没有远程调用、有没有长时间等待。这类问题通常不会在测试环境暴露,但线上迟早出事。
第四条,查询条件的字段是否都走了索引。ORM 的查询接口同样需要关注索引,你可以打开慢查询日志或框架的 SQL 输出,确认关键查询没有跳过索引。
说实话,ORM 用不好时踩的坑,往往比直接写 SQL 更隐蔽。因为错误不会出现在你眼前那一行代码上,而是出现在你看不到的框架机制里。代码评审盯这些点,本质是在弥补“抽象层带来的不可见性”。
5.4 一条用了很久的经验
我从手写 JDBC 到 Hibernate,再到 MyBatis、SQLAlchemy,每个阶段都踩过不少坑,也走过极端。我的实际体会是:ORM 和手写 SQL 从来不是对立的,它们之间本来就是连续谱。全自动的框架让你少写代码,但要求你理解它背后的状态管理和 SQL 生成逻辑;原生 SQL 让你拥有绝对控制力,但每个查询都要自己承担映射、连接、事务管理的复杂度。
大多数项目最稳妥的姿势,是让 ORM 负责 80% 的常规工作,把 20% 的复杂查询和关键路径留给原生 SQL,同时保证每个开发成员都看得懂框架生成的 SQL,知道去哪查慢查询日志,理解事务边界为什么不能随便扩大。这比争论“要不要用 ORM”有价值得多。
