这标题其实就是我刚入行那会儿经常追问老同事的一句话。当时总觉得ORM层层封装、行为黑盒,查个数据还得琢磨框架到底生成了什么鬼SQL,远不如自己写两行来得踏实。直到后来把一个五十多张表的老系统从裸JDBC重构成Spring Boot + MyBatis-Plus,才明白一个道理:项目里大量“直接写SQL”的代码,其实是在用最低效的方式做重复劳动,而ORM框架解决的根本不是“执行SQL”这件事,而是“数据代码的工程化”这件事。这篇文章我就把这里的门道掰开了讲,既有技术选型对比,也有我自己踩过的慢SQL、N+1、连接池之类的坑,希望能帮正在纠结“框架还是原生SQL”的朋友少走点弯路。
先说清楚这篇文章适合谁看。如果你刚接触Spring Boot、若依这类框架,对Mapper、Entity、BaseMapper.selectList()这些概念半懂不懂,不知道封装底层的SQL要怎么写,那你大概率能用这篇文章搭起完整认知框架。如果你已经写了两三年SQL,正处在看ORM很不顺眼的阶段,那第二部分和第四部分的选型思路、场景取舍应该能对上你的胃口。如果你是架构选型负责人,第六部分的排查速查表和常见坑可以直接当团队培训材料用。
1. 先从真实业务视角回答“为什么不直接写SQL”
1.1 一段让我彻底改观的线上事故
先讲个真事。早几年我接手过一个对账系统,账务明细、订单、退款、渠道流水好几张表之间关联特别密,之前几任开发都是裸JDBC风格,所有查询全写在Service里,拼SQL全靠字符串拼接。表面上看着很“可控”,每个SQL都五脏俱全。但有一次需求要新增一个“渠道类型”字段,用来区分线上和线下订单。按常理,只要改表结构、加个实体字段、改改涉及的对账查询就行。
问题是,退款明细和渠道流水两张表里压根没有这个字段,而旧SQL里到处是LEFT JOIN嵌套LEFT JOIN,结果集映射全靠手写ResultSet循环,改漏一个地方就需要在几十个查询里追一次。联调当天还不明显,跑批到凌晨两点,对账平不了。爬起来查了半天,发现是新增字段后某段SQL查询只带出了主单的渠道类型,子单关联查询漏了条件,导致两条子单落到错误的分组里。那一夜之后我就把ORM的正经价值彻底想明白了:当业务规则长在代码里而不是仅仅长在SQL里时,“直接写SQL”的维护成本远比你想象的高。
这件事给我两个教训。第一,手写SQL的本事必须练,那是排查问题的底子。第二,一旦业务复杂度和表数量上来,“直接写SQL”会让字段变更变成一场灾难,因为你很难在几十上百条SQL里快速抓住“哪条忘了加新条件”。
1.2 你真正应该避免的不是SQL,而是重复的机械映射
后来我反思,当时对ORM的反感其实停留在一种误解上:以为ORM是在“取代SQL”。实际上Hibernate、MyBatis这类框架从来不是要替代SQL,而是要把ResultSet与Java对象之间的双向映射、事务边界管理、连接获取与释放、脏数据检查等这些机械操作接过去。
有一次我带新人重构模块,让他统计一个月内的支付单。他二话不说写了三十多行JDBC:getConnection()、prepareStatement()、executeQuery()、rs.getLong("order_no")、手动if (rs.next())判断,再手动拼成对象。我说你要真有耐心可以继续这么写,但过两个月表加个字段,你要把所有rs.getXXX()的地方翻出来改一遍。他抬头看了看我,突然懂了——ORM收走的是这些毫无创造性但极容易出错的样板代码,而不是收走你对数据关系的理解能力。
一个特别贴切的类比是:直接写SQL好比你现在下楼买菜,路线自己定,速度自己控,灵活性很高。ORM则像是叫了个靠谱的代驾,你只要说清楚目的地(查询条件)和偏好(分页、排序、关联范围),代驾自己会选路线。但代驾选的路不一定是最短最快的,所以你仍然需要知道“大概怎么走”——这就是为什么懂SQL原理的人用ORM才顺手,而完全不懂SQL的人用ORM会掉进N+1查询和全表扫描的坑里。
1.3 数据库可替换性带来的架构自由度
还有一个不太好量化但影响很大的优势:数据库可替换性。早期项目一旦深挖进LIMIT、TOP、NVL这类方言,换数据库几乎等于重写数据层。ORM虽然做不到100%屏蔽数据库差异,但至少分页、主键生成、基本类型映射这类最常见的差异,框架已经帮你抹平了。
我自己经历过的项目里就有一次活生生的案例。原来跑在MySQL上的系统,因为客户内网要求换到PostgreSQL,数据层代码基本没动,只调整了方言配置、略微改了两处原生SQL的分页写法,其余业务代码原封不动。要是当初几十个查询全部手写方言级SQL,那周期就不是两三周能搞定的了。
2. 主流ORM框架技术路线与选型思路
2.1 全自动派:JPA/Hibernate 系
全自动ORM的代表是Hibernate,以及建立在它之上的Spring Data JPA。这类框架的特点是:你定义好实体类与表映射关系后,框架通过持久化上下文自动管理对象状态。当你调用save()、find()时,框架会自动生成SQL,并且在事务提交前自动执行脏检查,把改动过的字段同步到数据库。
优点很明显:日常CRUD开发效率极高,代码写起来几乎跟操作普通Java集合一样自然。缺点也明显:当查询复杂到一定程度,自动生成的SQL可能不如你手写的优化,尤其多表关联、子查询、窗口函数场景下,生成了你真的看不懂的执行计划。团队里如果有人不了解底层懒加载机制,还特别容易触发经典的LazyInitializationException。
用我们一段真实经验说,JPA系并不适合“报表密集、查询需求变化极快”的项目,却非常适合“领域模型复杂、事务性操作多、对象关系密集”的系统。比如电商订单模型,订单、明细、收货地址、支付单、物流单,这些对象之间天然存在明显的聚合关系,用JPA管理级联操作会非常顺手。
2.2 半自动派:MyBatis/MyBatis-Plus
MyBatis走的是另一条路:SQL还是要你自己写,但参数映射、结果映射、动态SQL、连接管理交给框架。这种“半自动”的设计很符合国内大多数Java项目的实际口味,因为它保留了SQL的透明性,又消灭了大量样板代码。MyBatis-Plus则在MyBatis基础上加了BaseMapper、条件构造器、内置分页插件,让单表CRUD都不用写SQL了。
我在实际项目里用MyBatis-Plus最深的感受是:它给了团队一个很清晰的底线。简单CRUD走框架内置方法,复杂查询写XML里的SQL,两条路随时可以混用,完全没有什么心理负担。若依框架(RuoYi)这类国内脚手架默认配的也是MyBatis-Plus,原因也很朴素——上手快、看得懂SQL、团队成员无论水平高低都不容易跑偏。
2.3 技术选型对照表
| 维度 | JPA/Hibernate | MyBatis/MyBatis-Plus | 直接JDBC |
|---|---|---|---|
| 开发效率(单表CRUD) | 极高 | 高 | 低 |
| 复杂SQL掌控力 | 弱,需要JPQL或原生SQL兜底 | 强,XML里都能写 | 最强 |
| 对象状态管理 | 自动脏检查、级联操作 | 基本没有,偏向手写 | 完全没有 |
| 学习曲线 | 较陡,缓存、懒加载概念多 | 平缓,核心就是Mapper | 平缓但繁琐 |
| 数据库可替换性 | 最好 | 中等,SQL方言还需注意 | 最差 |
| 适合场景 | 领域模型复杂、事务操作密集 | 互联网业务、快速迭代、报表 | 极少,一般仅局部使用 |
选型没有绝对正确答案。我更愿意把JPA理解为“对象思维”,把MyBatis理解为“SQL思维”。团队背景决定选型,如果团队整体SQL功底扎实、业务又以查询为主,MyBatis系通常更稳。如果团队领域驱动设计做得深、希望对象模型直接指导持久化设计,那就认真学好JPA的缓存和懒加载机制,别半吊子上车。
3. 把ORMs的“基础设施”细节吃透再上手
3.1 连接池与事务边界:别把框架当保险箱
很多人以为用了ORM,连接管理就完全不用管了。这是最危险的误解。ORM只是封装了连接的获取和释放,底层仍然依赖连接池。如果你用的是默认配置,连接池参数不合理,或者事务边界设计得乱七八糟,性能照样崩。
举一个我排查过的问题。某个挂牌系统上线后运行稳定,但每到整点批量任务一跑,前端操作就变得卡顿。查看监控发现不是慢SQL导致的,而是数据库连接池被打满了。原因在于代码里有个@Transactional注解挂在一个耗时很长的Service方法上,方法内部还进行远程HTTP调用,事务迟迟不提交,连接一直被占用。这其实和用什么框架无关,但ORM让“事务看起来太容易”这一点,反而让人容易放松警惕。
我现在的习惯是三条铁律:
@Transactional只加在Service层需要原子性的方法上,绝不往Controller层加。- 事务方法内不进行远程调用、文件上传、消息发送这类耗时操作。
- 连接池参数初始值宁可给保守一些,也要留出监控报警空间。
3.2 实体设计与表结构之间的“翻译”
实体设计是ORM项目中最需要投入精力的地方。一个经验不足的团队很容易把实体类和数据库表做成“一个Java类对一个表”的僵化映射。实际上,更合理的方式是先梳理聚合边界:哪些对象是一起读写的,哪些只是查询展示用的,哪些字段变更频率高,哪些字段属于低频历史数据。
举个具体例子。我们有个订单表,字段一度膨胀到四十多个,因为运营不停往里面塞新属性。后来我把高频交易字段留在主表,把扩展属性拆成单独的JSON字段或扩展子表,再用ORM的@OneToOne、@Transient等机制组织好读取路径。这样实体类不会变成一个无从下手的上帝类,SQL执行的宽度也瘦身不少。
实体设计还有个容易被忽略的点:懒加载的使用原则。MyBatis-Plus里默认不搞懒加载那套,查出来是什么就是什么,反而是JPA里默认ToMany懒加载。使用JPA时,如果你明确知道某个接口列表不需要引用明细数据,就坚决别去碰getOrderItems()这类方法,否则框架会在你毫不知情的情况下为每行数据发一条明细查询SQL。这个“看起来没事”的背后,往往就是N+1的温床。
3.3 SQL日志与慢查询定位:给ORM装上仪表盘
既然ORM是帮你生成SQL的,那么你就必须有能力看到SQL,否则等于蒙眼开车。JPA系在配置里打开spring.jpa.show-sql=true配合format_sql=true,MyBatis系可以在mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl打开控制台SQL打印。生产环境不建议直接打日志,但至少保证在预发环境能随时开启。
更关键的是,线上数据库的慢查询日志永远比应用日志优先。我一般会同时做两件事:把long_query_time设成1秒,持续观察慢SQL;再用EXPLAIN分析每一条被ORM自动生成的复杂SQL。尤其是MyBatis-Plus的QueryWrapper,它生成的多表关联SQL有时会让你皱眉——不是不能用,而是你必须掌握随时把它拿到Navicat里执行一遍并看执行计划的习惯。
4. 那些年踩过的N+1、分页和慢SQL的坑
4.1 N+1问题:框架只走一条SQL,实际发了一百条
N+1可以算ORM世界里名气最大的坑。典型场景是这样的:你查一个班级列表,框架发了一条SQL查出50个班级。然后在页面模板里遍历班级,取每个班的班主任信息,由于这段访问是懒加载,框架又发了50条SQL查询班主任。加起来一共51条,这叫N+1(1条主查询+N条关联查询)。
我在一个老项目里见过最夸张的情况是一张列表页面加载了三千多条SQL。当事同事特别委屈,说自己只写了一行clazz.getTeacher().getName()。这正说明问题不出在SQL本身,而出在对ORM懒加载机制的理解。解决思路一般有三条路:
- 批量抓取:JPA里用
@BatchSize或@EntityGraph,把关联查询改成一次IN查询,合并成少数几条SQL。 - 查询时指定抓取:MyBatis-Plus里直接用
select注解写清楚要查询的字段,或者手动JOIN好再映射,避免循环里触发懒加载。 - 转换DTO:先查询出必要字段,用
Map批量匹配关联数据,而不是在循环里逐条取数。
一定要记住,访问数据库的次数往往比单条SQL是否优化更影响性能。一次网络往返可能是0.5毫秒,但三千次往返可能就是几十倍差距。压测时如果碰到吞吐量上不去,建议先看应用日志里统计SQL次数,而不要一上来就死磕单条语句。
4.2 分页查询:不同框架的不同应对法
分页是最常见也最容易出错的场景。MyBatis-Plus自带分页插件,用起来非常舒服,但它底层还是通过拦截器改写SQL实现LIMIT。问题是,当你分页的数据量特别大时,比如几十万行,LIMIT 500000,20这种深度分页扫描大量行再丢弃,效率极低。
我自己优化过一个案例:用户操作日志表年增量几百万行,后台管理页面点第9000页时,数据库CPU飙到80%。后来改成基于游标的分页,也就是前端把上一页最后一条记录的ID和创建时间传回来,SQL改成WHERE id < ? ORDER BY id DESC LIMIT 20,这样数据库只扫描需要的范围,性能立刻提升两个数量级。你要明白,分页优化的核心从来不是ORM,而是分页策略本身。ORM只是帮你把策略写得更省力而已。
JPA分页则要注意,Pageable对象虽然便捷,但某些场景下框架会先发一条count查询再查数据。如果你的列表接口数据量本身就很大且不需要展示总条数,可以考虑用Pageable.unpaged()或者直接返回Slice,省掉count查询,响应时间能有明显改善。
4.3 慢SQL优化:先看执行计划,而不是先改代码
很多朋友碰到慢SQL第一反应是换成手写SQL,觉得“一定是ORM生成了垃圾SQL”。实际上我见过的案例里,至少有一半问题不在SQL文本本身,而在索引失效或查询条件设计上。包括隐式类型转换,比如字段是varchar,参数传了Integer,MySQL会放弃索引;还有函数包裹索引列,比如DATE(create_time) = ?,同样导致索引失效。
我处理过一个线上单据查询,页面输入起止日期后列表很慢。EXPLAIN一看,查询确实走了主索引,但过滤之后仍然要回表几百万行。最后把查询从“一个人在列表里直接大范围搜”调整为“先进入最近一个月的默认范围,再用创建时间和状态联合索引过滤”,查询时间从三秒降到一百毫秒以下。
所以正确姿势是:查询慢,开EXPLAIN看type、key、rows三个字段。type若出现ALL说明全表扫描,key是NULL说明索引完全没走。这些信息和是否用了ORM关系不大,纯粹是SQL基本功的问题。
4.4 确实该写原生SQL的几个场景
我也想说句公道话,有些场景你就应该绕开ORM的自动生成机制,直接写原生SQL。我归纳出五个高频场景:
- 复杂报表统计:带多层子查询、窗口函数、临时表关联。用
QueryWrapper硬拼条件纯属自虐。 - 大批量更新:比如按条件批量更新几十万行。走ORM逐条Loop更新会产生大量事务和网络往返,真不如一条原生SQL。
- 复杂动态条件:动态拼SQL条件本身需要极高的可读性时,写在MyBatis XML里的
<where>、<foreach>比Java代码里层层构造清晰得多。 - 特殊数据库方言功能:MySQL的
JSON_EXTRACT、PostgreSQL的ARRAY_AGG等能力,ORM封装不完全,不如直接写。 - 查询列不固定:比如报表模块需要根据用户勾选动态选择查询列,这种场景自动映射反而拖后腿。
使用MyBatis-Plus时,我习惯把简单CRUD和复杂查询分文件管理:简单场景用LambdaQueryWrapper,复杂场景一律进XML,并且用@Select注解直接标注原生SQL时记得加上@Options控制超时时间。每个团队都该有自己的“原生SQL白名单”,而不是一棍子打死。
5. 安全防线:SQL注入在ORM时代的残余风险
5.1 参数绑定的原理决定了95%的安全
ORM能有效防住SQL注入,原理并不玄乎:预编译或参数绑定。当你写WHERE name = ?时,参数是作为数据传给数据库的,数据库不会把它当SQL指令解析。JDBC的PreparedStatement就是干这个的,MyBatis的#{}、JPA的?1本质上都走这个路子。
我见过很多系统用了ORM之后觉得SQL注入问题自动解决了,结果漏出个洞。典型场景是排序字段:用户传入sortField,开发图省事直接拼进ORDER BY,因为MyBatis的#{}无法用在列名或关键字位置,只能字符串拼接。这就是典型的残余注入点——ORM防的是值注入,但它没法替你判断列名合法性。
5.2 MyBatis中的动态SQL安全边界
MyBatis里${}和#{}的区别是安全问题的分水岭。#{}生成预编译占位符?,安全;${}是纯字符串替换,危险。我看过不少项目代码,把表名、排序列、甚至部分查询条件都用${}拼接,完全把参数绑定优势扔了。
安全写法其实并不复杂:
- 排序字段用白名单映射,比如Java里维护一个
Map,把前端传的createTime映射成真实列名create_time,查不到就直接抛参数异常。 - 动态表名如果确实无法避免,必须对传入值做正则校验,仅允许字母数字下划线。
- 所有模糊查询统一用
like #{keyword},不要拼%进去,而是传参时拼好。
这条边界想清楚了,项目的安全底线就比较扎实了。
6. 高频问题排查与经验速查
6.1 一张表看懂常见ORM问题
| 症状 | 大概率原因 | 快速处理建议 |
|---|---|---|
| 列表接口越来越慢,SQL数量巨大 | N+1查询 | 检查懒加载触发位置,改用批量抓取或DTO转换 |
| 分页深度靠后时响应极慢 | 深度分页LIMIT offset过大 | 改用游标/键集分页 |
| 更新数据后查询不到 | 事务未提交或缓存未清 | 先确认事务边界,JPA考虑@Modifying(clearAutomatically=true) |
| 并发更新丢失 | 乐观锁缺少@Version字段,或MyBatis-Plus未配置@Version | 添加版本号字段,用乐观锁插件 |
| 一次保存几百条数据很慢 | 逐条插入产生大量往返 | 批量插入,MyBatis-Plus用insertBatchSomeColumn或XMLforeach |
| 查询条件没走索引 | 隐式类型转换、条件上函数包装 | 改写SQL或调整参数类型,用EXPLAIN验证 |
| 实体字段变更后数据错乱 | 映射关系漏改 | 数据库迁移脚本与实体字段保持一致,集成测试覆盖全字段 |
6.2 排查流程建议
当线上查询出问题,我建议按这个顺序排查,别上来就骂框架:先看应用监控,统计这个接口到底发了几次SQL(P6SPY、Druid Filter都可以)。如果次数多,优先怀疑N+1。如果次数不多但单条耗时高,把SQL捞出来,去数据库中EXPLAIN看执行计划。如果执行计划没问题,再查是不是连接池等待或锁等待。如果数据库本身很快但应用响应慢,这时候才需要怀疑结果集映射、网络序列化等ORM层面的开销。
6.3 我自己的经验:怎么正确练“直接写SQL”的基本功
最后分享一点技术之外的看法。网上总有人拿“你会不会手写SQL”和“用不用ORM”对立起来,其实这是两码事。越是依赖ORM的团队,越要求成员能读懂SQL、会优化SQL、能识别执行计划里的问题。因为你把SQL生成权交给了框架,你就必须有审计框架的能力。
我给团队的建议是:新人入职前几周先让他们手写JDBC,完成一个极简的CRUD,体会ResultSet映射的繁琐。然后引入MyBatis-Plus,让他们感受框架带来的效率提升。等他们掌握了#{}与${}的区别、理解了缓存概念之后,再让他们回头用JPA写一个聚合根模型。这套流程走下来,绝大多数人不会再纠结“为什么不直接写SQL”,而是会问“这里的SQL应该交给ORM生成,还是自己掌控”。
我个人在实际操作中体会最深的一点是,ORM的价值是全流程协同作战,不是单点性能竞赛。它让字段变更的传导成本变低,让数据库切换时的改造范围变小,让新成员编写数据访问代码的安全下限变高。但代价是你必须持续关注SQL日志、执行计划和连接池状态。从这个角度看,ORM和手写SQL根本不是替代关系——手写SQL是本建设能力,ORM是规模化交付手段,两者都要有,才算完整的工程素养。
如果你还在纠结怎么回答“为什么用ORM”这个问题,下次再有人问你的时候,可以反问他一句:你手上五十张表,每个字段变更你都要手工同步到三十条SQL的ResultSet里,你确定自己愿意这么长期维护吗?技术选型从来不是一道“谁更快”的判断题,而是一道“谁更能压住长期成本”的综合题。