ORM
这一课你将能做到
- 说清楚 ORM 解决什么问题、付出什么代价,以及「什么时候该绕过它手写 SQL」
- 用 Prisma 完成模型定义、迁移、CRUD 和关联查询的完整流程(JPA/MyBatis 对照代码也会给)
- 亲眼制造并修复一次 N+1 查询,掌握「发现 + 定位 + 解决」的标准动作
为什么要学
第 4 课你手写 SQL 时体验过痛点:查询结果是一行行数据,要自己映射回对象;拼接 SQL 字符串一不小心就注入漏洞;改了表结构全项目 SQL 要逐个排查。**ORM(对象关系映射)**让你用「操作对象」的方式读写数据库,由它负责翻译成 SQL 并做结果映射。
人话:SQL 是「寄快递要自己打包填单」,ORM 是「快递上门代打包」——日常小件(CRUD)省心省力;但大宗贵重物品(复杂报表、性能敏感查询),你还是想自己打包(手写 SQL),因为只有你清楚里面每一层怎么保护。
本课以 Node 生态的 Prisma 为主线(类型安全、迁移体验好),对照给出 Java 的 JPA(Hibernate) 和 MyBatis 写法——三大流派:全自动映射(Prisma/JPA)与半自动映射(MyBatis:SQL 自己写、映射它来做)。
先建立图像
ORM 在代码与数据库之间做的事:
你的代码 ORM 翻译层 数据库
prisma.note.findMany({ ──▶ SELECT id, title, ──▶ notes 表
where: { done: false }, content, author_id
include: { author: true } FROM notes
}) LEFT JOIN users ...
WHERE done = false
◀── 组装成嵌套对象 ◀── 行数据
[ { id: 1, title: '...', author: { name: 'alice' } } ]
注意方向:ORM 不是「不用学 SQL」,而是把日常 80% 的样板 SQL 自动化;剩下 20% 复杂查询它照样允许你写原生 SQL。你写的 where/include 最终长成什么 SQL,必须能预判——这是用 ORM 的及格线。
跟着做
步骤 1:初始化 Prisma 项目
前置:本机 PostgreSQL 已运行(第 4 课的 Docker 容器即可)。
mkdir bebg-orm && cd bebg-orm && npm init -y
npm install @prisma/client
npm install -D prisma
npx prisma init # 生成 prisma/schema.prisma 和 .env
编辑 .env 里的连接串(对应第 4 课的账号密码):
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/ormdb"
步骤 2:定义模型(schema 即代码)
编辑 prisma/schema.prisma:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
username String @unique
notes Note[] // 一对多:反向引用
createdAt DateTime @default(now()) @map("created_at")
}
model Note {
id Int @id @default(autoincrement())
title String
content String @default("")
done Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId Int @map("author_id")
@@index([authorId]) // 外键列建索引——第 5 课的原则这里落地
@@map("notes") // 表名映射,默认会是 "Note"
}
生成迁移并应用(迁移 = 把 schema 的变更记录成带版本的 SQL 文件):
npx prisma migrate dev --name init
# Prisma 会生成 prisma/migrations/20260101xxxx_init/migration.sql 并执行
打开那个 migration.sql 看一眼——迁移文件就是被版本控制管理的 DDL,这就是「迁移」的含义:表结构变更和代码一样走 review、可回溯(第 2 课的 Git 在这里闭环)。
步骤 3:CRUD 全家桶
// crud-demo.mjs 运行:node crud-demo.mjs
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// 增
const alice = await prisma.user.create({ data: { username: 'alice' } });
const note = await prisma.note.create({
data: { title: 'ORM 学习笔记', content: '今天学会了迁移', authorId: alice.id },
});
console.log('创建:', note);
// 查:条件 + 排序 + 分页
const list = await prisma.note.findMany({
where: { done: false, title: { contains: 'ORM' } },
orderBy: { createdAt: 'desc' },
take: 10, // LIMIT 10
skip: 0,
});
console.log('列表:', list);
// 查单个,找不到返回 null 而不是报错
const one = await prisma.note.findUnique({ where: { id: note.id } });
// 改:先 updateMany 确认存在性,或捕获 P2025
const updated = await prisma.note.update({
where: { id: note.id },
data: { done: true },
});
console.log('更新:', updated);
// 删
await prisma.note.delete({ where: { id: note.id } });
// 原生 SQL 混用:复杂查询的逃生舱
const raw = await prisma.$queryRaw`
SELECT u.username, count(*)::int AS note_count
FROM users u JOIN notes n ON n.author_id = u.id
GROUP BY u.username
`;
console.log('原生 SQL:', raw);
await prisma.$disconnect();
步骤 4:亲手制造 N+1,再修掉它
// n-plus-one-demo.mjs 运行:node n-plus-one-demo.mjs
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient({ log: ['query'] }); // 打印真实 SQL
// 造数据:2 个用户,各 3 条笔记
const users = await prisma.user.findMany();
if (users.length < 2) {
const alice = await prisma.user.create({ data: { username: 'alice' } });
const bob = await prisma.user.create({ data: { username: 'bob' } });
for (const u of [alice, bob]) {
for (let i = 1; i <= 3; i++) {
await prisma.note.create({ data: { title: `${u.username} 的笔记${i}`, authorId: u.id } });
}
}
}
console.log('===== 反面教材:循环查询(N+1)=====');
const all = await prisma.user.findMany(); // 1 次查用户 = "1"
for (const u of all) {
const notes = await prisma.note.findMany({ where: { authorId: u.id } }); // N 次 = "N"
console.log(u.username, '有', notes.length, '条笔记');
}
console.log('===== 正确做法:include 一次取回 =====');
const withNotes = await prisma.user.findMany({ include: { notes: true } }); // 2 次:用户 + 批量关联
for (const u of withNotes) {
console.log(u.username, '有', u.notes.length, '条笔记');
}
await prisma.$disconnect();
运行后对比控制台:反面教材打印 1 + 2 条 SELECT(用户数翻倍就变 1 + N 条);正确做法只有 2 条(users 一条、notes 用 IN (...) 批量取回)。N+1 的本质:列表页对每一条记录再发一次关联查询——页面在第 10 个用户时没问题,第 1000 个用户时数据库先死。
步骤 5:JPA 与 MyBatis 对照
JPA(Java,全自动)——实体类映射表,方法名/注解生成 SQL:
public interface NoteRepository extends JpaRepository<Note, Long> {
// 方法名即查询:自动翻译成 SELECT * FROM note WHERE author_id=? AND done=false
List<Note> findByAuthorIdAndDone(Long authorId, Boolean done);
}
// 关联预取防 N+1:@EntityGraph(attributePaths = "author") 或 JPQL JOIN FETCH
MyBatis(Java,半自动)——SQL 自己写,映射交给框架:
@Mapper
public interface NoteMapper {
@Select("SELECT n.*, u.username FROM notes n " +
"JOIN users u ON u.id = n.author_id " +
"WHERE n.author_id = #{authorId} AND n.done = #{done}")
List<NoteVo> findByAuthor(@Param("authorId") Long authorId, @Param("done") Boolean done);
}
Prisma(Node,全自动)——步骤 3 已见。取舍一句话:全自动写得快、复杂查询容易被框架「绑架」;半自动 SQL 透明可控、样板代码多。大厂 Java 项目 MyBatis 占比高的原因就是「SQL 不失控」。
讲清楚要点
- 迁移是表结构的版本控制:改 schema → 生成迁移文件 → 提交 Git → 团队每人
migrate dev 同步结构。直接在数据库里手工改表而绕过迁移,迟早让「本地能跑、测试库报错」。生产环境用 migrate deploy(只应用不生成)。
- N+1 的发现与解决三步:① 开 SQL 日志(Prisma
log:['query']、Hibernate show_sql)看请求数;② 列表接口的 SQL 条数应与数据量无关,随行数线性增长即中招;③ 解法按序选择:ORM 的 include/JOIN FETCH 批量预取 → 需要统计就聚合查询/关联子查询 → 实在不行手写 SQL。注意预取也别无脑全 include——把整张关联表拖回来同样伤。
- ORM 的边界:多表复杂报表、写法特殊的锁提示、超大批量写入,用原生 SQL(
$queryRaw / @Query / XML mapper)+ 参数化。混用不丢人,硬用 ORM 表达才是灾难。
- SQL 注入在 ORM 时代依然要懂:Prisma/JPA/MyBatis 的参数化接口都安全,但拼接字符串的地方全是坑——MyBatis 里
${} 是拼接(危险)、#{} 是参数化(安全);$queryRaw 模板串插变量安全,字符串拼接危险。原则:用户输入永远走参数占位符。
- 模型即文档:字段类型、唯一约束、索引都声明在 schema 里(
@unique、@@index),团队 review 一份 schema 文件就了解全部数据结构——这也是「schema-first」开发的起点。
你可能会懵的地方
-
ORM 会不会比手写 SQL 慢?
多一层翻译有一点开销,但生成的 SQL 大多是合理的。真正慢的是用法错误:N+1、无脑 include 全关联、把百万行 findMany 拉进内存。先排查用法,再怀疑工具。
-
迁移文件越来越多怎么办?能不能删掉合并?
可以「压平」:删掉全部迁移文件夹,prisma migrate dev --name init 基于当前 schema 重新生成一份基线(团队需同步基线)。开发期随手合并,上线后的历史迁移只增不改。
-
Prisma 算严格意义的 ORM 吗?有人叫它 query builder。
学术上有争议(它有自己的查询引擎),但工程上它做全 ORM 的事:模型、迁移、关联、类型安全。别纠结名词,关注能力。
-
一个实体该不该映射所有字段?
列表页往往只需要 5 列,全量 SELECT 浪费 IO。Prisma 用 select、JPA 用投影(DTO projection)按需取列。大表 + 高频列表接口,这是必做优化。
-
为什么 JPA 里改了实体的字段没调用 save 也更新了?
持久化上下文(一级缓存)的「脏检查」:事务提交时比对快照自动 UPDATE。方便但有魔法成本——脱离事务范围修改无效、批量更新性能差。理解它才不会被「幽灵更新」吓到。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 循环里逐条 findMany/create | N+1 或逐条 INSERT 拖垮性能 | include 预取 / createMany 批量 |
| 手改数据库表结构不迁移 | 各环境 schema 漂移,迁移链断裂 | 一律走 migrate,schema 是唯一事实源 |
MyBatis 用 ${} 接用户输入 | SQL 注入 | 一律 #{} 参数化 |
| 热点列表无脑 include 所有关联 | 数据拖回来一大坨 | select 按需取列,关联只取要展示的 |
| 外键列不建索引 | 关联查询与级联删除全表扫 | @@index([authorId]) 显式声明 |
| 复杂报表硬用 ORM 方法链表达 | 代码比 SQL 还难读 | $queryRaw / mapper 写原生 SQL |
卡点急救
P1001: Can't reach database server:连接串错或 PG 没起。docker start pg-demo,核对 .env 的用户名/密码/端口/库名,库不存在先 CREATE DATABASE ormdb。
- 迁移报
P3005(数据库非空但无迁移历史):库里有手工建的表。练习库最省事:DROP DATABASE 重建;正式做法 prisma migrate resolve --baseline 打基线。
- 改了 schema 但代码没类型提示:schema 变更后要重新
npx prisma generate(migrate dev 会自动带一次);IDE 重启 TS 服务。
- update 报
P2025: Record not found:where 的记录不存在。要「有则改无则略」就先 updateMany(它返回 count 不抛错)。
- N+1 修了还是慢:include 只解决「1+N 条 SQL」,数据量大时慢在传输——加 select 取必要列、分页降 size、或这条链路直接上缓存(第 6 课)。
面试常问
-
什么是 N+1 问题?怎么发现和解决?
查列表 1 条 SQL + 每条记录关联查询 N 条。发现:开 SQL 日志或 APM 看接口 SQL 条数是否随数据量增长。解决:JOIN 一次取回 / 批量 IN 预取(include、EntityGraph、JOIN FETCH)/ 聚合下推到 SQL;同时说明「预取粒度」的取舍。
-
ORM 的优缺点?你什么时候绕开它?
优点:样板 SQL 消除、类型安全、防注入、迁移管理;缺点:复杂查询表达力差、性能黑盒、魔法行为需要学习成本。绕开时机:复杂报表、性能敏感路径、需要数据库方言特性(窗口函数提示、锁语义)。
-
MyBatis 的 #{} 和 ${} 区别?
#{} 预编译参数占位(防注入),${} 字符串拼接(有注入风险)。${} 合理用途只有动态表名/排序字段这类「不能参数化」的位置,且必须白名单校验。
-
讲讲数据库迁移的实践。
变更即代码:迁移文件入 Git、评审后部署;生产只 deploy 不 generate;破坏性变更(删列)分两步走——先停写该列发布,确认无依赖后再删;重要变更前备份。
-
乐观锁和悲观锁在 ORM 里怎么用?
悲观锁:事务内 SELECT ... FOR UPDATE(Prisma $queryRaw、JPA @Lock(PESSIMISTIC_WRITE)),并发冲突高时用;乐观锁:版本号字段,更新时 WHERE version=?(JPA @Version),冲突重试,读多写少时用。
自测三问
- 复现 N+1 演示:你的反面教材总共打印了几条 SELECT?修好后是几条?分别是什么形状(贴 SQL 关键行)?
- 给 Note 加一个
pinned Boolean 字段,写出完整迁移流程和验证方式。
- 场景题:笔记列表页需要展示「每个用户的笔记数」并支持按数量排序——你选 ORM 方法链还是原生 SQL?写出关键代码。
小结一句话
ORM 把样板 SQL 自动化、把表结构变成受版本控制的代码;它的纪律是「schema 走迁移、关联防 N+1、用户输入走参数化」,它的边界是「复杂与性能敏感处手写 SQL」。
延伸