CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
后端入门实战/课时/ORM
RawBlame
课时目录12 节

后端入门实战

  1. 01后端入门概览
  2. 02Git 与托管
  3. 03语言选型
  4. 04PostgreSQL
  5. 05数据库进阶
  6. 06Redis 与缓存
  7. 07REST API
  8. 08认证
  9. 09ORM
  10. 10单元测试
  11. 11集成与功能测试
  12. 12综合练习

ORM

ORM 与 N+1 问题。

30 分钟

ORM

这一课你将能做到

  1. 说清楚 ORM 解决什么问题、付出什么代价,以及「什么时候该绕过它手写 SQL」
  2. 用 Prisma 完成模型定义、迁移、CRUD 和关联查询的完整流程(JPA/MyBatis 对照代码也会给)
  3. 亲眼制造并修复一次 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:

// 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:

// 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 不失控」。

讲清楚要点

  1. 迁移是表结构的版本控制:改 schema → 生成迁移文件 → 提交 Git → 团队每人 migrate dev 同步结构。直接在数据库里手工改表而绕过迁移,迟早让「本地能跑、测试库报错」。生产环境用 migrate deploy(只应用不生成)。
  2. N+1 的发现与解决三步:① 开 SQL 日志(Prisma log:['query']、Hibernate show_sql)看请求数;② 列表接口的 SQL 条数应与数据量无关,随行数线性增长即中招;③ 解法按序选择:ORM 的 include/JOIN FETCH 批量预取 → 需要统计就聚合查询/关联子查询 → 实在不行手写 SQL。注意预取也别无脑全 include——把整张关联表拖回来同样伤。
  3. ORM 的边界:多表复杂报表、写法特殊的锁提示、超大批量写入,用原生 SQL($queryRaw / @Query / XML mapper)+ 参数化。混用不丢人,硬用 ORM 表达才是灾难。
  4. SQL 注入在 ORM 时代依然要懂:Prisma/JPA/MyBatis 的参数化接口都安全,但拼接字符串的地方全是坑——MyBatis 里 ${} 是拼接(危险)、#{} 是参数化(安全);$queryRaw 模板串插变量安全,字符串拼接危险。原则:用户输入永远走参数占位符。
  5. 模型即文档:字段类型、唯一约束、索引都声明在 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/createN+1 或逐条 INSERT 拖垮性能include 预取 / createMany 批量
手改数据库表结构不迁移各环境 schema 漂移,迁移链断裂一律走 migrate,schema 是唯一事实源
MyBatis 用 ${} 接用户输入SQL 注入一律 #{} 参数化
热点列表无脑 include 所有关联数据拖回来一大坨select 按需取列,关联只取要展示的
外键列不建索引关联查询与级联删除全表扫@@index([authorId]) 显式声明
复杂报表硬用 ORM 方法链表达代码比 SQL 还难读$queryRaw / mapper 写原生 SQL

卡点急救

  1. P1001: Can't reach database server:连接串错或 PG 没起。docker start pg-demo,核对 .env 的用户名/密码/端口/库名,库不存在先 CREATE DATABASE ormdb。
  2. 迁移报 P3005(数据库非空但无迁移历史):库里有手工建的表。练习库最省事:DROP DATABASE 重建;正式做法 prisma migrate resolve --baseline 打基线。
  3. 改了 schema 但代码没类型提示:schema 变更后要重新 npx prisma generate(migrate dev 会自动带一次);IDE 重启 TS 服务。
  4. update 报 P2025: Record not found:where 的记录不存在。要「有则改无则略」就先 updateMany(它返回 count 不抛错)。
  5. 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),冲突重试,读多写少时用。

自测三问

  1. 复现 N+1 演示:你的反面教材总共打印了几条 SELECT?修好后是几条?分别是什么形状(贴 SQL 关键行)?
  2. 给 Note 加一个 pinned Boolean 字段,写出完整迁移流程和验证方式。
  3. 场景题:笔记列表页需要展示「每个用户的笔记数」并支持按数量排序——你选 ORM 方法链还是原生 SQL?写出关键代码。

小结一句话

ORM 把样板 SQL 自动化、把表结构变成受版本控制的代码;它的纪律是「schema 走迁移、关联防 N+1、用户输入走参数化」,它的边界是「复杂与性能敏感处手写 SQL」。

延伸

  • Prisma 官方文档
  • Hibernate 性能反模式合集—— Vlad Mihalcea 的博客,JPA 深入必读
  • 下一课:单元测试——没有测试的代码,重构一次心慌一次。
加载本节练习…
← 上一课认证下一课 →单元测试