对于 CRUD 而言,ORM 是正确的默认选择;而当你的查询不再像对象访问、开始像一份报表时,它就成了错误的工具。
你大概经历过那个转折时刻:一个在预发布环境表现良好的列表接口,在生产环境要花四秒钟才返回,而查询日志里塞满了没人手写过的、几乎一模一样的 SELECT。窗口函数、CTE、多表连接聚合以及特定数据库厂商的操作符,正是 ORM 生成的 SQL 变得低效甚至无法表达的地方,也正是下沉到原生 SQL 能够物有所值的地方。本文将精确划出这条界线:对象关系映射在哪些场景是正确的默认选择,在哪些场景会悄然成为瓶颈,以及如何在不放弃注入安全性的前提下越过它。
关键要点
- 对于约 80% 的简单 CRUD 场景,ORM 是正确的默认选择:它减少样板代码、自动参数化输入,并保持数据库无关性。
- 原生 SQL 本质上并不比 ORM 更快。它的优势具体体现在:当 ORM 生成的查询成为瓶颈时,在热点路径、批量操作或 N+1 模式中。
- N+1 问题是 ORM 悄然变成错误工具的最常见方式;先用预加载(eager loading)修复它,只有当预加载后的查询形态依然不对时,才动用原生 SQL。
- 离开 ORM 并不意味着移除 ORM。通过它自身的逃生舱下沉到原生 SQL:Django 的
connection.cursor()或Manager.raw()、SQLAlchemy 的text()、Prisma 的 TypedSQL。 - 当你编写原生 SQL 时,注入安全性就落到了你自己头上,因此始终要通过占位符传递用户输入(psycopg 中的
%s,Postgres/SQLx 中的$1),绝不要将其拼接进查询字符串。
原生 SQL、查询构建器、ORM:一条抽象光谱
“ORM 对比原生 SQL”从来就不是一个二选一的命题。数据访问是一条从完全掌控到完全便利的光谱,中间还有一个大多数对比文章都跳过的层级。在光谱的一端,原生 SQL 让你使用数据库的原生语言,没有任何翻译层。在另一端,像 Django ORM、ActiveRecord、Hibernate、Prisma、Sequelize 和 SQLAlchemy 这样的 ORM 将数据行映射为对象,并替你生成 SQL。介于两者之间的就是查询构建器。
查询构建器将查询模式形式化为可链式调用的方法,同时保持与它所输出的 SQL 高度贴近。大多数 ORM 也提供了直接把原生字符串交给数据库的途径,而这会绕过它们常规查询方法为你所做的转义处理,重新打开 SQL 注入 的大门。查询构建器则是另一种工具:它以编程方式组合 SQL,而不假装自己是对象访问。Knex 是一个活跃维护的 JavaScript 查询构建器,根据其变更日志,自 2026 年 6 月起处于 3.3.0 版本线;在 JVM 平台上,jOOQ 是一个类型安全的 SQL DSL,当前处于 3.21 版本线,其开源版面向 JDK 21。两者都不是 ORM,并且都保持了参数化能力的完整,这正是关键所在。当 ORM 的抽象与你相互掣肘时,在动手写 SQL 之前,查询构建器这一层往往是恰当的降级选择。
Discover how at OpenReplay.com.
ORM 何时是错误的工具?
切换的信号并非某种感觉,而是相当具体的。当你遇到以下五种模式之一时,就该越过 ORM:
- 分析型和报表型查询。 窗口函数、递归 CTE、
GROUP BY ... HAVING汇总以及多表连接报表,正是生成的 SQL 变得低效或根本无法表达的地方。ORM 是为对象访问优化的,而不是为 OLAP 形态的输出优化的。 - 热点路径与批量操作。 在高流量接口或批量
UPDATE/INSERT中,逐行的save()调用和额外的往返开销会不断累积。一条基于集合的语句就能替代数百次 ORM 写操作。 - N+1 查询陷阱。 下文将详细讨论:这是最常见的 ORM 性能问题。
- 数据库特有功能。 Postgres 的 JSONB 操作符(如
@>和->>)、基于tsvector/tsquery的全文搜索、LATERAL连接以及 PostGIS 地理空间函数,都是许多 ORM 无法完整或地道地表达的功能。部分 ORM 提供了辅助工具(如 Django 的contrib.postgres),但覆盖面并不完整。 - 不透明的”魔法”行为。 当你无法看到或调优 ORM 生成的 SQL 时,调试和性能优化就变成了猜谜。这正是对象关系阻抗失配以实际成本的形式显现出来,而且它还带有安全隐患:大多数 ORM 提供的原生查询方法都游离于自身的转义机制之外,因此把某个值插值进去就会让你暴露在风险中。
N+1 查询问题:命名与修复
N+1 问题是 ORM 悄然变成错误工具的最常见方式:懒加载会为每一行触发一次查询,于是一个 100 项的列表就悄无声息地变成了 101 次往返。这段循环看起来人畜无害:
# One query for authors, then one MORE per author for their books
for author in Author.objects.all():
print(author.name, author.books.count())
解决办法是预加载,而不是原生 SQL。Django 的 select_related 和 prefetch_related 会把这些往返压缩成一次 JOIN 或一次 IN 查询:
# Two queries total, regardless of author count
authors = Author.objects.prefetch_related("books")
先用预加载修复 N+1,只有当预加载后的查询形态依然不对时才动用原生 SQL,例如当你需要为每位作者计算一个窗口聚合值、而 ORM 只会把它表达为又一次往返查询时。低效的 ORM 查询很少会在你的代码中自我暴露;它们表现为缓慢的 API 响应和缓慢的页面加载。像 OpenReplay 这样的会话回放工具会在会话时间线中展示出缓慢的网络请求,把你指向那个后端查询亟需关注的接口:它定位的是症状的位置,而非查询本身。关于更深层的权衡,请参阅 OpenReplay 的防范 SQL 注入指南。
编写原生 SQL 时你放弃了什么?
当你编写原生 SQL 时,你就接手了 ORM 曾默默为你做的那一件事:注入安全。始终通过参数占位符传递用户输入,绝不将其拼接进查询字符串。Django 的执行原生 SQL 查询指南阐明了其机制:cursor.execute() 接收 %s 占位符以及一个独立的值列表,驱动会在传入过程中对每个值进行转义,因此它永远不会成为语句文本的一部分。
# Safe: %s is the psycopg/DB-API placeholder, not string formatting
from django.db import connection
with connection.cursor() as cursor:
cursor.execute("SELECT * FROM book WHERE author = %s", [user_input])
rows = cursor.fetchall()
占位符要保持”裸露”状态:在 SQL 字符串中给 %s 加引号会彻底丢弃这层保护。Rust 的 SQLx 的占位符取决于数据库,因此 Postgres 中是 $1,而 MySQL、MariaDB 和 SQLite 中是 ?。除了注入风险之外,你还要承担更多样板代码、与单一 SQL 方言更紧的耦合,以及手动将结果行映射回对象的工作。
放弃 ORM 并不意味着必须放弃安全网。编译期校验的工具保留了这张网:SQLx(0.9)会在应用运行前根据 schema 校验查询,而其官方文档明确声明它不是 ORM;jOOQ(3.21)在 JVM 上做的是同样的事。查询构建器则处于两者之间。“ORM 对比原生 SQL”是一个伪二元命题:真正的坐标轴是每一条具体查询该配得上多少抽象。
关于 ORM 与原生 SQL 的务实结论
对约 80% 的简单 CRUD 使用 ORM,并通过它自身的逃生舱,为那些确实值得的特定查询下沉到原生 SQL。离开 ORM 并不意味着移除 ORM。Django 记录了三条路径:RawSQL 用于将参数化片段嵌入 ORM 查询,Manager.raw() 用于执行仍返回模型实例的原生查询,connection.cursor() 则用于完全绕过模型层。SQLAlchemy 提供 text();Prisma 提供了 TypedSQL(目前为预览功能),以及用于非类型化访问的 $queryRaw。
整个决策可以浓缩为一张简表:
| 场景 | 应选用 |
|---|---|
| CRUD、表单、标准关联关系 | ORM |
| 跨方言可移植性很重要 | ORM 或查询构建器 |
| 多表连接报表、窗口函数、CTE | 原生 SQL |
| 热点接口或批量写入 | 原生 SQL |
| 列表视图中的 N+1 | 先用预加载,必要时再用原生 SQL |
| ORM 无法表达的厂商特性 | 原生 SQL |
原生 SQL 不是一次重写;它是针对少数几条查询的定向逃生舱——在这些查询中,生成的 SQL 正是瓶颈所在。把 ORM 保留为你的默认选择,对慢接口做性能剖析,然后仅在查询计划证明确有必要的地方换上手写的参数化 SQL,除此之外一概不动。
常见问题
原生 SQL 真的比 ORM 更快吗?
并非本质上如此。一条写得好的 ORM 查询和一条写得好的原生查询会命中同一个查询规划器,因此原生 SQL 并不会自动更快。原生 SQL 的优势具体体现在 ORM 生成的查询成为瓶颈时——额外的往返、N+1 模式、宽泛无界的 SELECT,或者在热点路径上用基于集合的语句替代逐行写入。速度优势来自于修复糟糕的生成式 SQL,而非来自原生 SQL 本身。
查询构建器和 ORM 有什么区别?
查询构建器通过可链式调用的方法以编程方式组合 SQL,同时保持与它所输出的 SQL 高度贴近;它不会将数据行映射为对象。ORM 则把数据库行映射为语言对象,并完全隐藏 SQL。Knex 是面向 JavaScript 的查询构建器,jOOQ 是面向 JVM 的类型安全 SQL DSL——两者都不是 ORM。它们都保持了参数化能力的完整,因此你放弃了对象映射这层抽象,却没有失去注入安全性。
如何在编写原生 SQL 时避免应用暴露于 SQL 注入?
将每一个用户提供的值都通过参数占位符传递,绝不把输入拼接进查询字符串。在使用 psycopg 的 Django 中占位符是 %s,数据库驱动会自动转义参数;Rust 的 SQLx 的占位符取决于数据库,因此 PostgreSQL 中是 $1,而 MySQL、MariaDB 和 SQLite 中是 ?。不要在 SQL 字符串中给占位符加引号。像 SQLx 这样进行编译期校验的工具会在应用运行前根据 schema 校验查询,提供了额外一层安全保障。
我能在不移除 ORM 的前提下,在 ORM 内部使用原生 SQL 吗?
可以。每个主流 ORM 都提供了逃生舱,让你在把 ORM 保留为默认选择的同时执行原生 SQL。Django 提供 connection.cursor() 用于直接执行、Manager.raw() 用于返回模型实例,以及 RawSQL 用于在 ORM 查询中嵌入参数化片段;SQLAlchemy 提供 text();Prisma 提供作为预览功能的 TypedSQL 以及用于非类型化访问的 $queryRaw。标准 CRUD 用 ORM,只有在生成的 SQL 确实成为瓶颈的特定查询上,才穿透 ORM 使用原生 SQL。