做后端这些年工具换了一波又一波但 NestJS 和 TypeORM 这对组合我是越用越顺手。NestJS 把依赖注入、模块化、生命周期管得明明白白TypeORM 又帮我把 SQL 世界里最琐碎的关系映射、建表、查询封装成了 TypeScript 的语法两者配合起来确实是写业务系统非常舒服的搭档。今天这篇不是官方文档的复读而是我在真实项目里把 NestJS 和 TypeORM 搭在一起的完整记录。从基础配置、实体设计到 CRUD、事务、性能排查再到项目变大之后的多数据库场景我都会把当时的考虑、踩过的坑、以及现在回头看会怎么选的判断依据写清楚。适合正在用 NestJS 搭建接口服务或中后台项目的朋友尤其是已经跑过一两个小例子、想往复杂业务走的同学。看完你至少能拿到一套可以直接抄作业的落地方案以及几个常规文档里不会写的取舍思路。1. 先想清楚为什么是 TypeORM而不是其他方案1.1 数据库访问层的三种选型思路在 NestJS 里做数据访问主流的无非三条路直接用原生 SQL封装一层数据库驱动选 Prisma 这种自带 schema 和客户端生成机制的现代化 ORM再就是 TypeORM 这种偏传统、以实体和装饰器为中心的 ORM。原生 SQL 最大的优势是可控数据库长啥样你心里门清执行计划、索引、分库分表都骗不了你。但代价也很现实实体和表结构的对应关系得自己维护写接口时一半精力都花在写 DDL 和 resultSet 转换上。项目一多、表一多光靠肌肉记忆迟早翻车。Prisma 我实话说体验也很好schema 文件极其直观类型生成更是省心查询 API 对前端出身的人特别友好。但它有几个场景让我不太舒服动态条件一复杂写起来明显吃力有些查询想手动补一段 SQL绕来绕去还是不如 Query Builder 顺手还有一小部分需要跑存储过程、分析函数的老业务支持度也不如 TypeORM 来得完整。TypeORM 恰好卡在中间。它保留了接近原生的 SQL 控制力有灵活的 Query Builder又可以像 Active Record 一样在实体上直接写数据库逻辑同时它的装饰器模型和 NestJS 的依赖注入、模块系统融合得非常自然。再加上它对 MySQL、PostgreSQL、SQLite、MongoDB 都有覆盖同一个数据模型在不同环境下切换成本很低。如果你的团队本来就用 TypeScriptNestJS 作为框架TypeORM 作为数据层技术栈非常统一。1.2 NestJS 模块体系与 TypeORM 的配合点选型不能只看 ORM 本身的 API还要看它和框架能不能咬合。NestJS 的核心是模块化加依赖注入TypeORM 在这方面有天然优势因为从早期版本开始官方便提供了TypeOrmModule这套桥接模块。TypeOrmModule.forRoot()负责在全局范围内建立一个数据源实例把这个实例作为 IoC 容器里可注入的对象管理起来。接着TypeOrmModule.forFeature([User, Post])会在某个业务模块内部注册对应的 Repository 提供者你在 service 构造函数里写一个InjectRepository(User) private readonly userRepo: RepositoryUser框架会自动注入一个绑定到 User 实体的 Repository。这种写法让 controller、service、repository 三层之间保持着清晰的调用链也便于做单元测试时替换 mock不用每次都在模块里手工创建数据源。在实际用的时候我基本不会让业务代码直接持有 DataSource 去到处查询而是依赖 Repository 的抽象把数据库操作限制在 service 层以内。这样既保住了 TypeORM 的灵活查询能力又不会让项目结构变成一锅粥。2. 环境与连接配置绕不开的第一步2.1 开发版和生产版的数据源配置差异新手最容易犯的错误是直接照着文档把TypeOrmModule.forRoot()里的连接参数写死在代码里synchronize 还开着直接同步到生产库。这类代码在本地跑没问题一上线上就是事故现场。我的习惯是开发环境图省事可以用 forRoot但生产环境一律用 forRootAsync 配合 ConfigService 去读环境变量。这样配置可以被环境隔离也方便在 CI 流程里用不同的环境文件切库。// app.module.ts import { Module } from nestjs/common; import { TypeOrmModule } from nestjs/typeorm; import { ConfigModule, ConfigService } from nestjs/config; import { UserModule } from ./user/user.module; Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), TypeOrmModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService) ({ type: mysql, host: config.getstring(DB_HOST, 127.0.0.1), port: config.getnumber(DB_PORT, 3306), username: config.getstring(DB_USER, root), password: config.getstring(DB_PASSWORD, ), database: config.getstring(DB_NAME, app), autoLoadEntities: true, // 只允许在非生产环境开自动同步 synchronize: config.getstring(NODE_ENV) ! production, timezone: 08:00, charset: utf8mb4, maxQueryExecutionTime: 1000, }), }), UserModule, ], }) export class AppModule {}这里有一个关键点synchronize开关。本地开发时它为 true能省掉手写建表语句的重复劳动但生产环境必须关掉。数据库变更应该走 migration 流程否则表结构出点沟通不及时比上线出 bug 更难排查。2.2 连接池参数别照抄默认值很多人忽略连接池的设置等到并发一上来数据库连接被占满应用整个变慢才开始回头调参数。TypeORM 本身在extra字段里透传给底层驱动比如 MySQL 驱动常见的connectionLimit、waitForConnections、queueLimit值得根据实际流量做一次设置。参数默认值建议说明连接池大小1050-100看应用实例数和数据库允许的连接上限waitForConnectionstruetrue连接耗尽时排队而不是直接报错queueLimit0不限0一般保持默认队列过长时后端会自然降速connectionTimeout10s20s网络抖动场景可以适当放大maxQueryExecutionTime不设1000ms超过时间会打印慢查询日志连接池不是越大越好。每个连接在数据库端都对应线程和内存如果应用开了 10 个实例每个实例又默认 10-20 个连接数据库很快就会被挤爆。我一般先按 POD 实例数乘以单实例连接数的模型去估算压测后再微调。2.3 时区、字符集和实体同步的细节时区这个坑几乎所有用 MySQL 的人都会碰到。TypeORM 的timezone选项不只影响写入还会影响读取时日期字符串的解析。如果你的服务器和数据库不在同一个时区建议明确设置timezone: 08:00避免查出来的时间比预期少 8 个小时。还有一种情况是数据库连接串里本来就带了时间参数但测试环境和本地环境不一致导致同一套代码在不同环境查出来的时间不同。字符集方面我建议直接指定charset: utf8mb4不然用户在昵称、留言里输入 emoji直接触发Incorrect string value报错。刚入行时我在生产环境遇到过线上表建好之后列模式还是 utf8后来改一列一列地 ALTER折腾了半夜。教训就是建库建表时就把字符集定成 utf8mb4别等出事了再补。此外实体的自动加载建议用autoLoadEntities: true它会扫描通过forFeature注册过的实体避免显式维护一长串实体数组。如果你非要显式声明那就小心漏掉新加的实体TypeORM 会给你一个明晃晃的EntityMetadataNotFoundError看起来完全不知道去哪里找原因。3. 实体定义与关系映射从能跑到不乱的关键3.1 字段命名策略和映射规则实体类里的属性名和数据库列名之间TypeORM 默认会做大小写转换但实际项目里我更倾向于保持数据库列名是snake_case、代码字段名是camelCase再用name参数显式声明列名。这样写 SQL 时直观读 ORM 生成的表结构也清楚到底对应哪个物理字段。// user.entity.ts import { Entity, PrimaryGeneratedColumn, Column, OneToMany, ManyToMany, JoinTable } from typeorm; Entity(user) export class User { PrimaryGeneratedColumn() id: number; Column({ type: varchar, length: 128, unique: true, name: email }) email: string; Column({ name: nick_name, type: varchar, length: 64 }) nickName: string; OneToMany(() Post, (post) post.author) posts: Post[]; ManyToMany(() Role, { onDelete: CASCADE, onUpdate: CASCADE }) JoinTable({ name: user_role, joinColumn: { name: user_id, referencedColumnName: id }, inverseJoinColumn: { name: role_id, referencedColumnName: id }, }) roles: Role[]; }这里有个经验不要迷信 TypeORM 内置的SnakeNamingStrategy。它在某些版本、某些关联表上的命名会出乎意料尤其是 joinColumn 名称生成规则一变老项目升级后莫名其妙报错。不如直接在字段上把 name 写清楚虽然代码有点长但自动生成的 SQL 可控性高得多。3.2 一对多、多对多关系的配置细节一对多和多对多关系定义本身简单但有几个坑值得提。oneToMany方向需要对方实体中有对应的ManyToOne否则外键关系建立不起来。JoinTable只在关系的拥有侧写多对多通常意味着你需要在两边都声明关系字段但 join table 只放在一处。cascade和onDelete是两个容易混淆的概念。cascade: true是 ORM 层面的行为比如manager.save(role)时会连带保存关联的用户onDelete: CASCADE是数据库外键层面的行为是在物理删主表时级联删除关联记录。生产环境我更依赖数据库外键约束ORM 层面的 cascade 用得很克制因为稍不留神就会把一张大表的所有数据悄悄带进一次 save 操作里。一旦关系配置错了常见的报错不是立刻崩而是序列化返回 JSON 时出现Converting circular structure to JSON或者干脆栈溢出。User 里有 postsPost 里又有 user未加关系排除时序列化循环引用直接把你整个接口拖垮。习惯了也就知道每个关系字段必须思考好谁序列化、谁排除。3.3 关系加载模式与懒加载的正确姿势TypeORM 的 lazy 关系用起来像是把属性包成 Promise访问时会触发额外的查询。这在低频场景下没有问题但如果你在循环里访问一个 lazy 属性N1 查询会让你数据库压力瞬间飙升。更麻烦的是lazy 属性必须用await才能拿到真实值service 层在同步和异步之间切换容易出错。eager: true也不是省事的万能药。它会在每次查询该实体时自动 join 出关联实体如果关联又有关联很容易拉出一整张巨大的对象图。我现在很少用全局的 eager需要时就在查询方法里显式用relations选项或 Query Builder 的 leftJoinAndSelect 去加载。选择关系加载模式的核心原则是默认只加载当前表按业务需要显式加载关系。这样 SQL 一行能看明白它查了哪些表性能出问题也能很快定位。4. CRUD 落地从 Repository 到 Query Builder4.1 用 Repository 构建基础 CRUD业务模块里注入 Repository 是最直接的方式。TypeORM 0.3 之后findOne的写法有所调整需要把条件放在where里我经常看到旧代码用findOne({ id })在新版本上已经不能用了。// user.service.ts import { Injectable } from nestjs/common; import { InjectRepository } from nestjs/typeorm; import { Repository } from typeorm; import { User } from ./user.entity; Injectable() export class UserService { constructor( InjectRepository(User) private readonly userRepo: RepositoryUser, ) {} async findById(id: number): PromiseUser | null { return this.userRepo.findOne({ where: { id } }); } async findByEmail(email: string): PromiseUser | null { return this.userRepo.findOne({ where: { email } }); } async create(data: PartialUser): PromiseUser { // 使用 create save而不是直接把 DTO 传给 save const entity this.userRepo.create(data); return this.userRepo.save(entity); } async update(id: number, data: PartialUser): PromiseUser | null { await this.userRepo.update({ id }, data); return this.findById(id); } async remove(id: number): Promisevoid { await this.userRepo.delete({ id }); } }这里有一个细节create会把普通对象转换成实体实例属性改动能被 TypeORM 追踪。如果你直接把 DTO 对象传给 save有些版本会把 value 里多出来的字段也当 columns 处理容易和数据库映射不匹配。养成create(data) - save(entity)的习惯能省掉很多奇奇怪怪的问题。4.2 什么时候切到 Query BuilderRepository 的 API 在固定条件下用起来很方便但一旦条件动态变多比如搜索接口里用户可能传 keyword、部门、角色、日期范围用 find 写条件怎么写都像在拼 SQL而且还容易出错。这时我通常直接建一个 Query Builder整个结构化得多。// user.service.ts async searchUsers(query: SearchUserDto) { const qb this.userRepo.createQueryBuilder(u); if (query.keyword) { qb.andWhere((u.nickName LIKE :kw OR u.email LIKE :kw), { kw: %${query.keyword}%, }); } if (query.deptId) { qb.innerJoin(u.departments, d, d.id :deptId, { deptId: query.deptId, }); } if (query.startTime query.endTime) { qb.andWhere(u.createdAt BETWEEN :start AND :end, { start: query.startTime, end: query.endTime, }); } qb.orderBy(u.createdAt, DESC) .skip((query.page - 1) * query.pageSize) .take(query.pageSize); const [items, total] await qb.getManyAndCount(); return { items, total, page: query.page, pageSize: query.pageSize }; }我觉得一个很实用的判断标准是当某个查询开始需要三个以上.where条件或者需要 join 多张表时就立即切到 Query Builder。Repository 负责简单的单表存取Query Builder 负责复杂的检索和聚合不高傲地非要坚持哪一种写法。4.3 分页的两种实现和取舍基础分页就是skip/take思路简单配合 getManyAndCount 能一次拿到列表和总数。但skip在数据量变大以后性能会明显下降因为它在数据库层面其实要扫描并跳过前 N 行。当表超过几百万行我更倾向用 keyset 分页按上一页的最后一条记录的时间戳或 id 作为条件。keyset 的写法是qb.where(u.id :cursor, { cursor: lastId }) .orderBy(u.id, DESC) .take(20);这种写法查询计划稳定在联合索引的配合下基本都能走索引。缺点是前端不能再随意跳页了。所以我的建议是管理后台这种页面级跳转的场景用 skip/take 就能满足C 端信息流这种滚动加载的场景用 keyset。5. 事务处理简单场景和复杂场景的写法5.1 老的 Transaction 写法已经过时了TypeORM 0.3 之后Transaction、TransactionRepository这些装饰器被标记废弃。如果你还在老版本文档里看到这种写法别挣扎了新写法是用 DataSource 提供的事务方法。NestJS 项目里通常把 DataSource 注入到 service然后直接调用。Injectable() export class OrderService { constructor( private readonly dataSource: DataSource, ) {} async createOrderWithItems(dto: CreateOrderDto): PromiseOrder { return this.dataSource.transaction(async (manager) { const order manager.create(Order, { userId: dto.userId, totalAmount: dto.items.reduce((sum, item) sum item.price, 0), }); const savedOrder await manager.save(order); const items dto.items.map((item) manager.create(OrderItem, { orderId: savedOrder.id, skuId: item.skuId, quantity: item.quantity, price: item.price, }), ); await manager.save(items); return savedOrder; }); } }回调里拿到的manager不是普通 Repository它是事务范围内的 EntityManager所有在这个 manager 上执行的操作都会在同一个事务里完成。回调正常 return 就自动提交抛异常就自动回滚不用手动 commit、rollback。5.2 手动 QueryRunner 与隔离级别如果事务跨多个 service 方法或者你需要精确控制什么时候开启、什么时候提交直接用dataSource.transaction就有点不够了。这时我用 QueryRunner它把底层连接直接暴露出来可以精细控制。const runner this.dataSource.createQueryRunner(); await runner.connect(); await runner.startTransaction(READ COMMITTED); try { await runner.manager.save(User, ...); const order await runner.manager.findOne(Order, { where: { id } }); await runner.commitTransaction(); } catch (err) { await runner.rollbackTransaction(); throw err; } finally { await runner.release(); }隔离级别方面MySQL 默认的REPEATABLE READ在大部分场景够用帮助避免不可重复读。高并发扣库存这类场景会使用READ COMMITTED来降低锁压力但会引入幻读问题需要配合版本号或在应用层做好幂等控制。5.3 事务里常见的低级错误事务最容易“看似成功实际失败”的三个场景一是接口里把异常吞了却没有抛出去导致事务不会回滚二是把耗时的网络操作、第三方调用放在事务里面导致持锁时间过长并行时会相互等待三是在事务回调里使用了外部注入的 Repository而没使用 manager 上的方法结果是事务边界形同虚设。我个人的约定是事务内部只做数据库操作和内存计算外部 HTTP 请求一律放事务外面。锁是数据库最贵的资源之一长时间持锁不仅自己慢还会拖累整个表的其他操作。真实的业务系统死锁问题绝大多数不是 SQL 写错而是事务范围切错了。6. 性能与日志数据量上去之前就要养成的习惯6.1 打开慢查询日志TypeORM 有一个参数maxQueryExecutionTime超过设定时间的查询会在日志里输出 warning。生产环境把它设成 1000ms基本能捕获到九成以上的问题查询。日志级别建议用logging: [query, error, warn]但如果生产环境日志量太大只开[error, warn]也行。SQL 日志输出默认格式比较简陋只打印 SQL 语句和参数。想排查慢查询建议直接把 NestJS 的 Logger 接上TypeORM 可以通过logger: advanced-console或自定义 logger 把日志纳入统一格式。这样你在日志系统里就能按 traceId 追溯到某一个请求执行的全部 SQL。6.2 N1 查询的识别与修复N1 查询是 ORM 项目最容易中招的性能杀手。你在列表接口里查了 100 条订单然后循环里访问了每条订单的用户信息结果产生 1 100 次 SQL。发现 N1 的通用做法把 SQL 日志打开看到同一类语句反复出现就是典型特征。修复方式有几种在 find 时用relations: [user]一次加载用 Query Builder 的leftJoinAndSelect显式 join数据量极大时考虑单独批量查询再在内存里做映射举个例子原来这种代码const orders await orderRepo.find(); for (const order of orders) { const user await userRepo.findOneBy({ id: order.userId }); }改成const orders await orderRepo.find({ relations: { user: true }, });就是一次 joinSQL 清晰可见。遇到更复杂的过滤、排序、聚合我仍然建议 Query Builder 加leftJoinAndSelect它生成的 SQL 逻辑非常明确。6.3 联合索引与查询条件顺序TypeORM 实体上加联合索引很简单Index(idx_user_dept_time, [deptId, createdAt])联合索引的生效条件是对应最左前缀。如果查询里只出现 createdAt但索引是 deptId createdAt这个索引大概率用不上。所以设计索引前应该先把业务里最高频的 where 条件列出来按区分度从高到低排区分度低的放最前面避免第一个字段到处重复。我踩过的坑是在一张订单表上建了一个 type status 的联合索引结果线上查询大部分只按 status 过滤索引基本没起作用。后来把 status 放在前面type 放后面很多慢查询直接消失。这类索引优化不复杂但确实需要观察线上查询模式而不是闭门造出一个大而全的索引。7. 多环境与多数据源项目变大之后的必经之路7.1 多数据源注册的两种路径一个微服务里连多个数据库或读写分离、分库是常见需求。TypeORM 支持通过name区分数据源。NestJS 的模块写法可以给每个数据源起一个名字之后在 forFeature 里指定对应的数据源。Module({ imports: [ TypeOrmModule.forRoot({ name: default, type: mysql, host: db-main.example.com, database: main, entities: [dist/**/*.entity.js], }), TypeOrmModule.forRoot({ name: readonly, type: mysql, host: db-replica.example.com, database: main, entities: [], }), ], }) export class AppModule {}业务侧注入对应数据源的 Repository 时用InjectRepository(User, readonly) private readonly userReadRepo: RepositoryUser,注意同一个实体可以注册到多个数据源但别把同一个实体在多个数据源里同时同步否则两个数据源一旦都建表元数据可能冲突。7.2 配置文件和配置中心的隔离多环境我建议所有连接参数都从配置中心或环境变量读取不在代码里出现任何环境相关的内容。用 NestJS 的 ConfigModule 把.env.development、.env.staging、.env.production隔离好部署的时候通过 CI 注入对应文件。一个非常实用的技巧是把数据源配置单独提取成一个函数然后在模块加载时判断当前环境测试环境可以自动切换到 SQLite 内存库跑测试生产又切回 MySQL。这样开发测试两套环境共用一个 service 层代码验证查询逻辑时非常方便。8. 问题排查与实用技巧实录8.1 我实际遇到过的典型问题问题现象可能原因处理方式查出的时间比预期晚 8 小时未显式设置 timezone连接配置加timezone: 08:00写入 emoji 报 Incorrect string value表字符集不是 utf8mb4修改列和表的字符集连接加 charsetEntityMetadataNotFoundError实体未注册或 autoLoadEntities 未开在 forFeature 里注册实体确认扫描路径循环引用导致 JSON 序列化失败User 和 Post 双向关系未排除关系字段加Exclude()或序列化时设置排除策略连接耗尽、应用卡死连接池过小或被并发占满调整 connectionLimit并检查慢查询查询偶尔报 Lock wait timeout exceeded事务内持锁时间过长缩短事务边界把非 DB 操作移出事务某个 Repository 注入到 service 时 undefined模块里没有 import 对应的 forFeature在对应模块中 import TypeOrmModule.forFeature使用 findOne({ id }) 报错TypeORM 0.3 API 变更改成findOne({ where: { id } })8.2 定位问题的一套调试思路排查数据库相关问题我有个固定的步骤先打开 SQL 日志把当前请求执行的全部 SQL 打印出来逐条看是否和预期一致确认 SQL 有没有走正确的索引用 EXPLAIN 手动执行一遍看 type 是否 range/ref/const如果查询很慢先去掉 ORM 层把同一份 SQL 放到数据库客户端里直接跑判断慢在 SQL 本身还是网络延迟加和减关系加载字段对比判断是不是 N1 或加载了多余的关系大多数“TypeORM 又出 bug”的疑案查到最后都是开发没看清实际执行的 SQL 长什么样。让框架把你的 SQL 完完整整暴露出来是最不费劲的排查起点。我还有一个习惯在接口层写一个简单的执行耗时中间件把所有超过 500ms 的请求路径和 SQL 树记录到日志里。这样排查性能问题时不用凭空猜是哪个接口慢日志自己会说话。说实话NestJS 和 TypeORM 这套组合最让我觉得放心的反而不是哪一处的 API 设计而是整个闭环足够稳定模块注入、实体映射、事务控制、查询构建每一层都有清晰的边界。项目规模小的时候感受不明显等到系统跑了半年、接口增加到上百个再回头看每个 service 里规规矩矩的 Repository 和 Query Builder 代码会觉得当初没有图省事乱封装是最正确的决定。最后再分享一个小技巧写实体的时候顺手把字段注释写在Column的comment参数里。TypeORM 生成 DDL 时会带上注释以后维护表结构、排查线上数据这份额外信息能帮你省下大把翻文档的时间。
