ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ORM vs 直接写SQL:解耦、性能与场景选型深度解析

ORM vs 直接写SQL:解耦、性能与场景选型深度解析 1. 一个老生长谈但总被问懵的问题ORM到底好在哪这几年不管是带新人还是在技术群里总能看到类似的争论MyBatis 就是封装了 JDBC我直接写 SQL 不香吗Hibernate 这么重复杂查询根本写不明白还谈什么效率说实话我在刚工作的头两年也是坚定的原生 SQL 派觉得 ORM 框架不过是给不会写 SQL 的人准备的拐杖。直到我接手了一个遗留系统。那个项目的数据访问层选择了最纯粹的方式——几千行 JDBC 代码每个方法里都是Connection、PreparedStatement、ResultSet三件套SQL 常量散落在各个 Service 里。表面上看一切尽在掌控跑得也挺快。但真正动手改需求的时候痛苦就来了加一个字段要翻五六个地方改一个表结构要同步修改十几个查询最要命的是团队里三个人写出来的查询风格完全不一样代码评审基本靠猜。那时候我才意识到ORM 框架解决的不是能不能查数据的问题而是数据访问这件事怎么在一个团队里长期维护的问题。这篇文章不打算站在道德高地上说教而是结合我这些年实际用 MyBatis 和 JPA 的经验把为什么不直接写 SQL这个问题拆开揉碎讲清楚 ORM 到底解决了哪些实际问题、它的边界在哪里、以及什么时候你真的应该抛开它。先说结论ORM 的核心价值不在帮你写 SQL而在三层解耦——应用代码与数据库方言解耦、对象模型与表结构解耦、开发效率与重复劳动解耦。直接写 SQL 本身不是错错的是在那些 ORM 明显占优的场景里还要坚持手写或者反过来在不该用 ORM 的场景里硬套。所以我们不妨从头开始把这件事真正想明白。2. ORM 解决的三类真实的脏活2.1 重复到让人麻木的 JDBC 样板代码先看一段最原始的 JDBC 查询代码这段代码我太熟悉了因为写过太多次public User findById(Long id) { Connection conn null; PreparedStatement ps null; ResultSet rs null; try { conn dataSource.getConnection(); ps conn.prepareStatement(SELECT id, name, email, created_at FROM user WHERE id ?); ps.setLong(1, id); rs ps.executeQuery(); if (rs.next()) { User user new User(); user.setId(rs.getLong(id)); user.setName(rs.getString(name)); user.setEmail(rs.getString(email)); user.setCreatedAt(rs.getTimestamp(created_at)); return user; } return null; } catch (SQLException e) { throw new RuntimeException(e); } finally { // 关闭 rs、ps、conn每个都要判空 closeQuietly(rs); closeQuietly(ps); closeQuietly(conn); } }这段代码看起来不长但它背后藏着三个问题。第一每个查询方法都要重复写一套 try-catch-finally 的资源管理逻辑十张表就是十份复制粘贴二十个查询就是二十份人又不是复印机复制多了总会在某个 finally 块里漏掉closeQuietly(rs)连接池的连接就这么被泄漏掉了。第二结果集到对象的映射是纯手工活这里还只是单表一旦涉及多表关联你就要在一个方法里反复rs.getXxx()然后把它塞进嵌套对象里代码六七层深出错概率极高。第三异常处理基本都是粗暴的throw new RuntimeException翻译一下就是出错了但我不想管这对业务代码的容错性是一种伤害。MyBatis 把这些事收走了。你只需要写一个接口方法加一个 SQL 映射框架负责创建连接、执行语句、解析结果集、映射成对象最后帮你把资源关干净。写这篇文章前我特意数过一个项目里的数据访问代码行数直接用 MyBatis 的部分比纯 JDBC 的原来版本少了一多半。省下来的不是时间是犯错的面积。2.2 数据库方言差异这道隐形的墙直接写 SQL 的另一个隐性成本是对数据库特性的隐性依赖。很多项目在开发环境用 MySQL测试环境用 H2生产环境用 PostgreSQL三个库的 SQL 语法细节并不完全一致。分页语法就是一个很典型的例子-- MySQL 的分页 SELECT * FROM user LIMIT ?, ?; -- PostgreSQL 的分页 SELECT * FROM user OFFSET ? LIMIT ?; -- SQL Server 的分页 SELECT * FROM user ORDER BY id OFFSET ? ROWS FETCH NEXT ? ROWS ONLY;如果你在代码里写死了 MySQL 的LIMIT语法将来切换数据库时就不是改配置的事了而是要扫遍全项目的 SQL 逐一修改。而 MyBatis 的分页插件、Hibernate 的方言机制就是为了消灭这种差异存在的。程序里写的是第几页、每页几条底层到底是被翻译成LIMIT还是OFFSET ... FETCH由框架根据配置的方言自动决定。我见过不少团队的数据库迁移惨案——平时跑得好好的 SQL一换数据库就大面积报错。说实话这种问题与其归咎于某个人粗心不如说是架构层面没有给换数据库这件事预留任何缓冲。ORM 相当于在应用层和数据库之间垫了一层方言转译层平时你可能感觉不到它的存在但当你真的需要动底层数据库的时候这层缓冲的救命程度堪比备胎。2.3 SQL 注入——一个我都写了这么多年 SQL 怎么会犯的坑直接写 SQL 容易出安全问题这一点很多人第一反应是不可能我怎么会犯这种低级错误。但现实是SQL 注入往往不是不会而是疏忽。最经典的低级写法长这样String sql SELECT * FROM user WHERE name name ;当name的值是 OR 11时这条查询就变成了SELECT * FROM user WHERE name OR 11所有用户数据一览无余。稍微有点经验的开发会改用PreparedStatement的占位符但参数化这件事需要靠自觉没有人保证团队里每个人都记得。而 ORM 框架在架构层面就把参数化变成了默认动作——MyBatis 的#{}底层就是预编译加参数绑定MyBatis-Plus 的条件构造器更是在 Java 层面拼参数根本不给你拼 SQL 字符串的机会。当然了这里要诚实一点MyBatis 的${}照样能被注入Hibernate 的 HQL 如果写法不规范也能出问题。ORM 降低的是犯错的概率而不是彻底消灭犯错的可能。所以我才一直强调选 ORM 是对团队平均水准的兜底而不是对高手发挥的限制。3. 两个主流 ORM 的思路差异MyBatis 和 JPA 到底在争什么3.1 MyBatis 的哲学SQL 还是要写但只写有意义的那部分先看 MyBatis 的核心思路。它不试图替你生成 SQL而是帮你把数据库操作和对象映射这两件事做了极致的隔离。你要写 SQL但只需要写真正有业务含义的那部分剩下的连接管理、参数绑定、结果集映射全部交给框架。以一个简单的分页条件查询为例用 MyBatis 的方式是这样的select idpageByCondition resultTypecom.example.User SELECT id, name, email, created_at FROM user WHERE name LIKE CONCAT(%, #{keyword}, %) if teststatus ! null AND status #{status} /if ORDER BY created_at DESC /select对应的 Mapper 接口只需要声明ListUser pageByCondition(Param(keyword) String keyword, Param(status) Integer status)代码层面没有任何关于Connection、PreparedStatement的痕迹。你说 MyBatis 是半自动 ORM也好SQL Mapper 框架也罢它的好处在于复杂查询的可读性和可控性极高SQL 写得明明白白性能瓶颈一眼就能看出来DBA 来了也不用猜你干了什么。国内很多团队选 MyBatis 不是因为 Hibernate 不好而是因为业务系统中复杂报表、多表联动查询实在太多Hibernate 的自动化和黑盒特性反而成了负担。MyBatis 把掌控感交给了程序员代价是你仍然需要维护 XML 或注解里的 SQL 语句以及可能被if标签写出来的动态 SQL 搞得有点乱。但至少你知道 SQL 长什么样出了性能问题可以直接粘到数据库客户端里执行这就是它的底气。3.2 JPA/Hibernate 的哲学把 Java 对象当成一等公民JPA以及它的具体实现 Hibernate走上的是另一条路你不是在操作数据库你是在操作一个实体对象持久化只是它的附件行为。save()方法调用完之后框架去比对当前对象和数据库里原来那条记录的差异把变化的部分同步成 UPDATE 语句这也就是所谓的脏检查机制。Hibernate 的自动化和便捷性确实惊人。你定义一个Entity注解的类把字段用Column注解标注好既不需要写建表 SQL——用ddl-auto配置可以直接生成表结构——也不需要写大多数 CRUD 的 SQL。代码里大量出现的是这种风格Repository public interface UserRepository extends JpaRepositoryUser, Long { ListUser findByNameContaining(String keyword); OptionalUser findByEmail(String email); }findByNameContaining这个名字一出来框架就自动生成一条WHERE name LIKE ?的查询属性路径解析、排序、分页全是内置能力。对于实体关系复杂、领域模型稳定、以 CRUD 为主的管理类系统JPA 的开发效率明显高于 MyBatis。但代价就是性能调优和复杂 SQL 的执行路径不那么透明一旦出现慢查询你得能理解 Hibernate 生成的 SQL 为什么长成那样否则排查起来确实头疼。MyBatis 和 JPA 不是谁替代谁的关系它们是两个方向的代表。MyBatis 更接近数据访问层的工具JPA 更接近领域模型层的框架。前者适合对 SQL 掌控欲强、查询复杂的团队后者适合模型稳定、开发速度优先的团队。很多人纠结学哪个我的意见是两个都值得掌握因为它们代表了两种完全不同的数据访问思维。3.3 国内生态下的特殊存在MyBatis-Plus说到这就不能不提 MyBatis-Plus。国内很多 Spring Boot 项目比如若依框架这种基于 RuoYi 的快速开发脚手架默认集成的就是这个。它做的事情简单概括就是MyBatis 的能力 单表 CRUD 的自动生成。// 不需要写任何 SQL连 XML 都省了 userMapper.selectList(new LambdaQueryWrapperUser() .like(User::getName, 张) .eq(User::getStatus, 1) .orderByDesc(User::getCreatedAt));LambdaQueryWrapper用 Java 方法引用代替了字符串属性名编译期就能发现拼写错误这比 MyBatis 原生TableField写字符串又要安全一截。但要注意MyBatis-Plus 的自动化只覆盖单表操作多表关联还是得靠手写 SQL 或者 XML否则性能会很难看。我的建议是单表操作用 Wrapper 构造器多表联动老老实实写 XML两种方式配合使用既有效率也有掌控。4. 为什么不直接写 SQL这个主张需要加一个但是4.1 ORM 黑盒背后的性能陷阱N1 查询决定用 ORM 并不等于从此高枕无忧。ORM 最大的性能陷阱就是N1 查询问题这个概念所有用 JPA/Hibernate 的团队都应该刻在脑子里。场景很简单查询班级列表然后遍历每个班级去查它下面的学生。如果 ORM 默认的关联加载策略是用到才查即懒加载你可能会写出类似这样的代码ListClazz classes clazzRepository.findAll(); for (Clazz clazz : classes) { System.out.println(clazz.getStudents().size()); // 触发每个班级的学生查询 }第一行执行了 1 条查询循环里每个班级又额外执行 1 条3 个班级就是 134 条 SQL。如果班级有一千个打开日志看到的就是1001 条 SQL 刷屏。单独看每条 SQL 都很快但网络往返加在一起就成了秒级响应变十秒级响应的元凶。解决办法也很明确要么用EntityGraph或者 Hibernate 的join fetch在一条 SQL 里把关联数据查出来要么用 MyBatis 的collection标签做嵌套结果映射要么干脆在查询后手动批量补查。关键是要有这个意识ORM 帮你映射对象关系是有成本的这个成本需要你自己去排查和控制。4.2 复杂报表和深度聚合不是不能写而是没必要硬写ORM 的强项是对象-关系映射但报表、统计分析这类面向结果集而非面向对象的场景恰恰是它的弱项。比如要统计每个分类下近三十天的订单金额趋势SQL 里光聚合函数、时间分组、多表 left join 就好几层SELECT category_id, DATE_FORMAT(created_at, %Y-%m-%d) AS day, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM order WHERE created_at DATE_SUB(NOW(), INTERVAL 30 DAY) GROUP BY category_id, DATE_FORMAT(created_at, %Y-%m-%d) ORDER BY category_id, day;这种查询在 JPA 里也能写但你得用Query配合原生 SQL或者使用CriteriaBuilder、Specification拼动态查询——写起来相当费劲而结果映射到 DTO 的麻烦程度完全不亚于手写 JDBC。MyBatis 相对好一些毕竟 SQL 本身就摆在明面上但映射一个复杂的嵌套聚合结果集同样要有一些 XML 功底。我自己的经验是业务操作走 ORM查询报表走向专门的路由。如果是低频执行的复杂统计直接用原生 SQL、甚至让 DBA 写视图都行如果是高频明细查询那就用 MyBatis 手写 SQL 做精细优化。这不算回归老路而是把工具用在对的地方。ORM 是来帮你干脏活的不是来帮你背所有锅的。4.3 事务边界与锁语义ORM 的封装容易让你忽略底层直接写 SQL 的人对数据库的行级锁悲观锁乐观锁有天然的敬畏感。而 ORM 把这些概念封装得过于友好之后反而容易让人在事务边界上掉以轻心。比如Transactional在方法入口摊开一个大事务里面调了三次外部接口或者做了一堆耗时操作数据库连接就这么被占着性能自然好不了。这里没有银弹唯一的建议就是定期审视接口的性能和事务开销。ORM 框架再好也不会替你决定这段逻辑是否需要事务悲观锁能不能接受这些问题。它们只是让你的操作变得简单但设计复杂度一点都没有消失只是从代码层面转移到了设计和排查层面。所以长时间运行的定时任务、批量数据导入这类场景我会干脆绕开 ORM直接用 JDBC Batch 或者专门的工具来做既快又稳还不用被框架的缓存和懒加载坑到。5. 实践总结什么场景我坚决用 ORM什么场景我直接写 SQL5.1 选型参考一张表说清楚把前面分析的内容整理一下方便你做技术选型的时候直接对照。这张表不涉及谁优谁劣而是尽量客观地描述不同场景下的适用性场景特点纯 JDBCMyBatis / MyBatis-PlusJPA / Hibernate单表 CRUD 占比极高不推荐样板代码太多推荐MP 的 Wrapper 效率很高推荐Repository 直接继承方法复杂多表联查、报表聚合可选但维护成本高强项SQL 透明可控不推荐写法繁琐且难调优团队人员流动大、水平不一不推荐容易写法混乱推荐SQL 直观且规范性强推荐前提是团队熟悉 JPA 规范需要长期演进的大领域模型极不推荐加字段要改多处一般模型约束较弱强项实体关系表达能力强大量数据批处理、ETL推荐JDBC Batch 性能最好一般勉强能用不推荐内存对象和脏检查开销大说实话我之前看到很多团队直接把 MyBatis-Plus 当万金油什么查询都先用 Wrapper 顶上去顶不住了才想着手写 SQL。这样也能用但是长期下来 Mapper 接口里全是一串 LambdaQueryWrapper 串糖葫芦可读性会急速下降。我的建议是CRUD 用自动化复杂查询用手写两者结合而不是互相替代。5.2 不直接写 SQL的三个真正理由第一降低基础设施认知负担。不是所有人都能熟练处理连接池、事务边界和结果集映射ORM 让新人在更短的时间内进入业务开发状态。第二减少与数据库的显式耦合。方言机制里藏着的数据库可替换性是不写特定 SQL换来的灵活。第三把人力投入到业务和模型上。框架做得越多你写业务规则和领域逻辑的时间就越多。这三点综合来看就是为什么不直接写 SQL的完整答案。5.3 分享一个实际优化经验我去年做过一个订单系统的优化统计接口原来完全走 JPA问题就是典型的 N1、懒加载失效和不可控的子查询。后来我把整个统计模块的数据访问层改成 MyBatis报表 SQL 全手写关联查询用resultMap做嵌套映射性能从平均 1.8 秒降到了 350 毫秒左右。另一个有趣的地方是改完之后代码量并没有增多反而因为 SQL 意图明确后续接手的同事改起来更顺畅了。这次优化的结论很简单ORM 不是不能用而是你要分清它在哪个范围内是好帮手出了那个范围就是绊脚石。回到为什么不直接写 SQL这个问题——我的回答是因为数据访问层真正需要的是可控的自动化和合理的抽象而不是在全手写和全自动这两个极端里二选一。成熟的开发者不应该有框架洁癖也不应该有原生 SQL 崇拜看清它们各自的适用边界把每一条查询派到它最合适的位置上数据访问这件事才不会成为项目的瓶颈。
返回列表