文章目录
- 前言
- 问题分析
- 使用TypeORM的优雅处理方案
- 使用Prisma的优雅处理方案
- 比较与建议
- 思考🤔:
- 思考1
- 思考2
- 什么是 N+1 问题?(通俗解释)
- 上面两种方案是如何避免 N+1 的?
- 1. TypeORM 的方式
- 2. Prisma 的方式
- 对比总结表
- 额外几种常见的 N+1 写法(要警惕)
- 总结一句话
前言
问题分析
问题是:在NestJS框架中使用TypeORM或Prisma作为ORM时,如何处理数据库表之间的关联关系。具体场景是“英语分类表”(Category)和“英语句子表”(Sentence),其中Sentence表通过categoryId关联Category表。现在需要在查询Sentence数据时,自动返回关联的Category名称等字段,而不只是ID。目标是优雅处理,避免手动查询或冗余代码。
这是一个典型的ORM关系查询问题。我们可以利用ORM的内置特性(如关系加载)来实现“自动”包含关联数据。以下分别针对TypeORM和Prisma提供方案。假设你的数据库是关系型(如PostgreSQL或MySQL),实体已定义好。
使用TypeORM的优雅处理方案
TypeORM支持实体间的关系定义(如@ManyToOne),并通过relations或eager loading来自动加载关联数据。在NestJS中,这通常在Service层实现。
实体定义:
- Category实体(src/entities/category.entity.ts):
import{Entity,PrimaryGeneratedColumn,Column,OneToMany}from'typeorm';import{Sentence}from'./sentence.entity';@Entity()exportclassCategory{@PrimaryGeneratedColumn()id:number;@Column()name:string;// 分类名称@OneToMany(()=>Sentence,(sentence)=>sentence.category)sentences:Sentence[];// 可选,反向关系} - Sentence实体(src/entities/sentence.entity.ts):
import{Entity,PrimaryGeneratedColumn,Column,ManyToOne}from'typeorm';import{Category}from'./category.entity';@Entity()exportclassSentence{@PrimaryGeneratedColumn()id:number;@Column()sentence:string;@ManyToOne(()=>Category,(category)=>category.sentences)category:Category;// 关联Category实体(非ID)}
注意:这里使用
@ManyToOne定义关系,category字段是实体类型,而非简单ID。这允许TypeORM自动处理join。- Category实体(src/entities/category.entity.ts):
在Service中查询(src/sentences/sentences.service.ts):
- 使用
find方法时指定relations选项,自动加载category:import{Injectable}from'@nestjs/common';import{InjectRepository}from'@nestjs/typeorm';import{Repository}from'typeorm';import{Sentence}from'./entities/sentence.entity';@Injectable()exportclassSentencesService{constructor(@InjectRepository(Sentence)privatesentencesRepository:Repository<Sentence>,){}asyncfindAll():Promise<Sentence[]>{returnthis.sentencesRepository.find({relations:['category'],// 自动加载category,包括name等字段});}asyncfindOne(id:number):Promise<Sentence>{returnthis.sentencesRepository.findOne({where:{id},relations:['category'],// 只加载需要的关联});}}
- 使用
在Controller中返回(src/sentences/sentences.controller.ts):
- 直接返回Service的结果,响应会自动包含category.name:
import{Controller,Get,Param}from'@nestjs/common';import{SentencesService}from'./sentences.service';@Controller('sentences')exportclassSentencesController{constructor(privatereadonlysentencesService:SentencesService){}@Get()findAll(){returnthis.sentencesService.findAll();}@Get(':id')findOne(@Param('id')id:string){returnthis.sentencesService.findOne(+id);}}
- 直接返回Service的结果,响应会自动包含category.name:
优雅点:
- 如果总是需要category,可以在实体中设置
eager: true(@ManyToOne(…, { eager: true })),这样find时无需指定relations,但小心性能(适合小表)。 - 使用QueryBuilder自定义查询:
this.sentencesRepository.createQueryBuilder('sentence').leftJoinAndSelect('sentence.category', 'category').getMany();– 这更灵活,支持select特定字段(如只选category.name)。 - 避免N+1问题:使用relations或join加载,而不是循环手动查询。
- DTO转换:如果需要自定义响应格式,用class-transformer将实体转为DTO,只暴露必要字段。
- 如果总是需要category,可以在实体中设置
使用Prisma的优雅处理方案
Prisma的Schema定义更声明式,使用include选项自动加载关系,性能更好(默认lazy loading)。
Prisma Schema定义(prisma/schema.prisma):
model Category { id Int @id @default(autoincrement()) name String // 分类名称 sentences Sentence[] // 反向关系 } model Sentence { id Int @id @default(autoincrement()) sentence String categoryId Int category Category @relation(fields: [categoryId], references: [id]) // 关联 }- 生成迁移和client:
npx prisma generate和npx prisma migrate dev。
- 生成迁移和client:
在Service中查询(src/sentences/sentences.service.ts):
- 使用
findMany或findUnique时指定include:import{Injectable}from'@nestjs/common';import{PrismaService}from'../prisma/prisma.service';// 假设你有PrismaService@Injectable()exportclassSentencesService{constructor(privateprisma:PrismaService){}asyncfindAll(){returnthis.prisma.sentence.findMany({include:{category:true,// 自动包含category的所有字段,如name},});}asyncfindOne(id:number){returnthis.prisma.sentence.findUnique({where:{id},include:{category:true,},});}}
- 使用
在Controller中返回:
- 同TypeORM,直接返回Service结果,响应会嵌套category.name。
优雅点:
- 自定义select:
include: { category: { select: { name: true } } }– 只返回name,避免多余数据。 - Prisma的类型安全:自动生成TypeScript类型,确保编译时检查。
- 批量查询:Prisma优化了N+1问题,使用DataLoader内部机制。
- 如果需要复杂过滤,用
where结合include。
- 自定义select:
比较与建议
- TypeORM vs Prisma:
- TypeORM更灵活(支持自定义SQL),但配置稍繁琐。适合已有实体模型的项目。
- Prisma更现代、类型安全,查询语法简洁。推荐新项目使用Prisma,因为它内置优化且易维护。
- 通用优雅实践(NestJS中):
- 使用DTO(class-validator + class-transformer)转换响应,避免暴露内部实体。
- 错误处理:添加try-catch或全局interceptor。
- 性能:对于大表,用分页(skip/take in TypeORM, take/skip in Prisma)和缓存(Redis)。
- 测试:用Jest写单元测试,模拟ORM查询。
思考🤔:
思考1
选择 LEFT JOIN 是因为:
✅ 保证句子数据不丢失
✅ 容错性好(分类删除不影响句子显示)
✅ 便于发现数据问题
✅ 符合"以句子为主"的业务逻辑
思考2
N+1 查询问题是 ORM(对象关系映射)中最常见、也最容易被忽略的性能问题之一,尤其在使用 TypeORM、Prisma、Hibernate、Entity Framework 等工具时特别常见。
什么是 N+1 问题?(通俗解释)
假设你有下面这样的场景:
你想查 100 条英语句子(Sentence),并且希望每条句子都带上它的分类名称(Category.name)。
最“天真”的写法(会产生 N+1 问题)大概长这样:
// 伪代码 - 非常危险的写法constsentences=awaitsentenceRepository.find();// 查 100 条句子 → 1 次查询for(constsentenceofsentences){// 每条句子都去查一次 categoryconstcategory=awaitcategoryRepository.findOne({where:{id:sentence.categoryId}});sentence.categoryName=category.name;}实际执行的 SQL 次数:
1 次查询 → 找出 100 条句子 + 100 次查询 → 每条句子单独查一次 category = 总共 101 次数据库查询这就是著名的N+1 问题:
- 1 = 查主表(Sentence)的查询
- N = 主表有多少条记录,就额外发多少次查询去查关联表(Category)
当数据量变大(比如 1000 条、1 万条),性能会急剧下降,甚至几秒钟都出不来结果。
上面两种方案是如何避免 N+1 的?
1. TypeORM 的方式
// 推荐写法awaitsentenceRepository.find({relations:['category']});或
.createQueryBuilder('sentence').leftJoinAndSelect('sentence.category','category').getMany();实际产生的 SQL(大致):
SELECTsentence.*,category.*FROMsentenceLEFTJOINcategoryONsentence.categoryId=category.id只发 1~2 次查询(通常只有 1 次),就把所有需要的 category 数据一起查出来了。
这就是预先加载(eager loading / join fetching)的方式,通过一次 JOIN 操作就把关联数据全部取回,避免了循环里重复查询。
2. Prisma 的方式
awaitprisma.sentence.findMany({include:{category:true}});Prisma 内部实际也会生成类似 JOIN 的查询(大多数情况下是一次查询):
SELECT"Sentence".*,"Category".*FROM"Sentence"LEFTJOIN"Category"ON"Sentence"."categoryId"="Category"."id"结果:同样只用 1 次查询就把所有 category 数据带回来了。
对比总结表
| 方式 | 查询次数 | 是否有 N+1 | 性能(数据量大时) | 推荐场景 |
|---|---|---|---|---|
| 循环里单独查 category | 1 + N | 有 | 极差 | 千万不要用 |
| TypeORM relations / join | 通常 1 次 | 无 | 很好 | 推荐 |
| Prisma include | 通常 1 次 | 无 | 很好 | 强烈推荐 |
| 实体上设置 eager: true | 通常 1 次 | 无 | 好(但全局) | 小表、总是需要的情况 |
| 手动写 JOIN SQL | 1 次 | 无 | 最好 | 极致性能要求时 |
额外几种常见的 N+1 写法(要警惕)
// 危险写法 1 - 隐形的 N+1sentences.forEach(s=>console.log(s.category.name));// 如果 category 没加载,就会触发懒加载// 危险写法 2 - 嵌套循环for(constsentenceofsentences){for(consttagofsentence.tags){// 如果 tags 没预加载// ...}}总结一句话
N+1 问题本质是:本来可以用一次 JOIN 解决的事情,被你拆成了 N+1 次单独查询。
TypeORM 的relations/leftJoinAndSelect和 Prisma 的include都是通过一次 JOIN把关联数据提前加载回来,从而把查询次数从1+N降到1(或极少数情况 2),这就是它们能优雅避免 N+1 的核心原因。