ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

NestJS + TypeORM 生产环境实践:事务、迁移与性能调优

NestJS + TypeORM 生产环境实践:事务、迁移与性能调优 NestJS TypeORM 这对组合我前后用了差不多三年。起初只是照着官方文档把 User 实体和数据库连起来跑通了就以为完事了直到接触订单、库存这类业务事务、迁移、多数据源一个个撞上来才把“能跑”和“会用”之间的差距彻底补齐。这篇文章不打算重复官方文档那些示例而是把我在 NestJS 里使用 TypeORM 时沉淀下来的关键经验一次性讲清楚——包括为什么这么选型、连接配置里哪些参数最容易被忽略、实体关系映射怎么设计不踩坑、数据访问层怎么分工、事务和并发控制怎么落地以及上线前的迁移和性能调优。无论你是刚在 NestJS 项目里接入 TypeORM 的新手还是已经写了一阵子但总感觉哪里不对劲的开发者都可以从里面找到对应的答案。1. 为什么是 NestJS 配 TypeORM方案选型背后的真实思考1.1 NestJS 的官方倾向只是起点NestJS 官方文档的数据库章节默认放的就是 TypeORM 示例。很多人因此觉得 TypeORM 是 NestJS 的“钦定”ORM跟着文档走肯定没错。这个判断对了一半NestJS 确实对 TypeORM 有很好的内置支持官方维护了nestjs/typeorm这个包提供了TypeOrmModule.forRoot()和TypeOrmModule.forFeature()这类开箱即用的动态模块。但“官方展示过”并不等于“你的项目就该用”更重要的是弄清楚 TypeORM 的设计思路和 NestJS 的契合点在哪儿。TypeORM 最核心的特征是“装饰器驱动”。实体类用Entity、Column、PrimaryGeneratedColumn这些装饰器定义Repository 和 QueryBuilder 又通过InjectRepository注入到 Service 中。这套写法和 NestJS 的依赖注入、模块化体系几乎是同构的你在模块里注册了什么 Provider就可以在构造函数里注入什么依赖。整个调用链非常直白没有额外的代码生成步骤也不用像某些 ORM 那样维护一份独立的 schema 文件。对中小团队来说少一个周边工具链就少一层维护成本。但这里要泼一盆冷水TypeORM 的易用性有点“先甜后苦”。基础 CRUD 写起来非常舒服可一旦出现复杂关联查询、事务嵌套、多数据源切换它的不少默认行为和版本差异会让新人发懵。我见过不少项目跑到一半因为升级了 TypeORM 0.3.x 导致EntityRepository失效整个数据访问层全部要改写法。所以选型阶段一定要先搞清楚 TypeORM 的能力边界而不是等代码量起来之后再被动调整。1.2 TypeORM 与 Prisma、Mongoose 的边界我把 TypeORM 和同期最常被拿来对比的 Prisma 放在一起讨论。两者的思路差异很大。Prisma 是“schema 优先”你用 Prisma Schema 定义模型再用 CLI 生成客户端代码类型安全性极强迁移工具也很成熟特别适合 schema 需要集中审核和团队统一管理的场景。但 Prisma 的短板在复杂查询它更擅长声明式的findMany和include遇到动态条件特别多、需要精细控制 SQL 的情况时往往要写$queryRaw这种从“ORM 层”掉回“SQL 层”的断裂感体验并不好。TypeORM 的优势正好补上这块。它的 QueryBuilder 可以让你在 TypeScript 里一步步拼 SQLleftJoin、where、groupBy、having每一步都是类型安全的字符串范式配合getManyAndCount()、getRawMany()这类方法复杂报表也能在 ORM 内部完成不必频繁切换到裸 SQL。再加上实体类就是普通的 TS 类可以搭配 class-validator 做 DTO 校验也可以放在 monorepo 里被前端共享数据结构的“单一事实来源”来得更自然。至于 Mongoose它面向的是 MongoDB根本不在 SQL 数据库的选型范围内。如果你的项目已经确定使用 MySQL、PostgreSQL 这类关系型数据库那对手就只有 TypeORM 和 Prisma。我的建议是团队对 SQL 熟悉、业务查询灵活多变、希望实体和数据库结构紧耦合的选 TypeORM团队规模大、schema 变更频繁、更看重迁移工具和类型推导的考虑 Prisma。没有绝对的好坏只有是否适合自己的业务形态。1.3 实体类在前后端复用中的边界选择 TypeORM 还有个额外的好处实体类可以作为前后端共享类型的依据。在 monorepo 项目中我会把实体定义放在共享包中前端拿到类型定义后接口返回的数据结构就和后端实体天然对齐。这种做法的前提是控制好暴露面——不能把 TypeORM 的数据库实体直接作为 API 响应体返回。否则一旦实体加了Column({ select: false })的敏感字段或者出现递归关系引用前端会莫名其妙拿到一堆不该有的数据甚至序列化爆栈。正确的姿势是实体与 DTO 分层实体负责数据库映射DTO 负责接口契约。借助class-transformer的Exclude和Expose把实体转换成对外 DTO 时进行字段裁剪。TypeORM 实体类可以直接复用在 service 层的类型推导上但对外接口始终走 DTO 层。这个边界如果守住了实体类带来的前后端一致性就是实打实的收益守不住就会成为泄漏内部结构的坑。2. 从连接配置到模块注册别只抄官方文档2.1 forRootAsync 的依赖注入细节NestJS 中接入 TypeORM初始化连接的方式主要有同步和异步两种。开发环境我会直接写forRoot({ type: mysql, ... })但生产环境几乎一定会用forRootAsync因为数据库密码、连接地址这些配置需要从环境变量或配置中心读取。问题也最容易出在这里。这里有一个典型的报错场景// app.module.ts imports: [ ConfigModule.forRoot({ isGlobal: true }), TypeOrmModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService) ({ type: mysql, host: config.get(DB_HOST), port: parseInt(config.get(DB_PORT), 10), username: config.get(DB_USER), password: config.get(DB_PASS), database: config.get(DB_NAME), entities: [], synchronize: false, }), }), ]有人照抄类似的写法却忘了ConfigModule.forRoot({ isGlobal: true })必须在模块顶层声明。如果你的ConfigModule没有设置为 global而TypeOrmModule.forRootAsync所在的模块又没有在imports里引入ConfigModule运行时就会报 “Nest cant resolve dependencies of the TypeOrmCoreModule” 之类的错误。这个错非常隐蔽因为它不是 No.1 的数据库连接报错而是依赖注入失败。排查方式也比较老套检查ConfigModule作用域要么isGlobal: true要么在imports里显式引入。useFactory支持返回 Promise这点很多人忽略了。如果你的数据库密码存在 KMS 或云密钥管理服务里需要在启动阶段异步获取直接在工厂函数里await即可useFactory: async (config: ConfigService) { const secret await fetchDbSecret(config.get(SECRET_ID)); return { type: postgres, password: secret, ... }; }这比在启动脚本里先拉密钥再注入环境变量要干净得多。2.2 连接池、超时与连接重试参数官方文档通常不会详细展开连接参数但实际运行中连接池才是最容易让系统“半死不活”的地方。TypeORM 底层使用驱动自带的连接池比如 MySQL 驱动是mysql2PostgreSQL 驱动是pg。你可以通过extra字段透传驱动层的连接池配置TypeOrmModule.forRootAsync({ useFactory: () ({ type: mysql, host: localhost, port: 3306, username: root, password: 123456, database: test, extra: { connectionLimit: 10, waitForConnections: true, queueLimit: 0, connectTimeout: 10000, }, }), })在这组配置里connectionLimit决定了连接池最多能同时创建多少个连接。很多人默认不改它结果并发一高就出现“TimeoutError: queryrunner timeout. Cant create new connection within 10s”。这个错误表面是超时本质是连接池被打满。waitForConnections为 true 时请求会排队等待空闲连接为 false 时则直接抛错。queueLimit表示排队的最大长度0 表示不限制生产环境建议设一个合理值避免请求无限堆积拖垮进程。另外还有一组容易混淆的参数retryAttempts和retryDelay。它们控制的是 NestJS 在应用启动时连接数据库失败后的重试次数和间隔。默认retryAttempts是 10retryDelay是 3 秒。在 Docker Compose 环境里如果数据库容器比应用容器启动慢这个机制能帮你避免启动即崩溃。但它只解决“启动阶段暂时性连接失败”的问题一旦应用已经启动数据库中途重启重试机制不会自动帮你重建连接池这时候就要靠应用层的健康检查和数据库驱动的自动重连策略了。2.3 多数据源与实体归属单数据源的项目这一小节可以直接跳过但只要碰到读写分离或者多库聚合就绕不开。TypeORM 支持在同一个 NestJS 应用里注册多个连接只要给forRoot传入不同的name即可TypeOrmModule.forRoot({ name: default, type: mysql, host: primary-host, database: main, entities: [User, Order], }), TypeOrmModule.forRoot({ name: readReplica, type: mysql, host: replica-host, database: main, entities: [User, Order], }),但你很快就发现同一个实体在两个连接里同时注册会导致一些奇怪的行为某些查询走了主库某些查询走了从库迁移工具也会迷惑。我的经验是读写分离场景不要简单地把实体注册到两个连接而是让从库连接只承担查询职责主库连接负责实体同步和写操作。具体做法是默认连接负责写从库连接在entities数组中不注册任何实体查询时通过dataSource.getRepository(Entity)切换。还有autoLoadEntities这个配置。开启后TypeOrmModule.forFeature([User])会自动把User实体加载进连接。这个设计非常贴心因为它省去了在entities数组里手动维护路径的麻烦。但要注意autoLoadEntities只对通过forFeature注册的实体生效。如果某个实体从来没有被forFeature主动引入而是期望通过通配符路径加载那autoLoadEntities就不起作用了。生产环境我建议统一走forFeature加autoLoadEntities: true的组合避免dist/**/*.entity.js这类通配符在 Windows 和 Linux 上路径分隔符不一致的问题。3. 实体与关系映射装饰器背后容易翻车的小细节3.1 主键策略的选择实体定义的第一件事就是选主键。TypeORM 里最常见的两种PrimaryGeneratedColumn()生成自增数字主键PrimaryGeneratedColumn(uuid)生成 UUID 字符串主键。自增主键的性能最好B 树索引插入有序不会产生页分裂但对外暴露了业务量别人能从 ID 推断你的订单量。UUID 主键防猜测能力强但它是 36 位字符串存储和索引都更大插入时随机分布可能导致页分裂频繁。实际项目中我倾向于用自增 bigint 作为数据库主键同时给业务表加一个独立的业务编号列比如订单号带前缀和日期并在这个业务编号上建唯一索引。这样既享受了自增主键的性能又避免向外部暴露内部 ID。如果你确实需要分布式环境下的全局唯一 ID也不建议自己写雪花算法——直接用PrimaryColumn()配合应用层生成的 ID 即可TypeORM 不关心你传什么值只要保证唯一性。3.2 关系装饰器的循环引用与加载策略实体关系是 TypeORM 最容易出问题的地方也是“文档看完觉得会了一写就报错”的高发区。一个经典的坑是双向关系。比如 User 和 Post你写了OneToMany(() Post, post post.user)和ManyToOne(() User, user user.posts)然后在查询时relations: [posts, posts.user, posts.user.posts]一旦数据里存在多层关联序列化时就会无限递归最终报 “Maximum call stack size exceeded”。遇到这种情况先想清楚你的查询到底需要加载到哪一层。大部分场景下加载两级关系已经足够三级以上的关系基本都是设计问题。还有一个建议如果业务上只需要从文章找到作者不需要从作者找到文章列表那就只写ManyToOne这一端不要写反向的OneToMany。单向关系可以减少维护点也天然避免了循环引用。TypeORM 里的懒加载lazy: true在 NestJS 的序列化拦截器里很容易踩坑。懒加载的字段类型是Promise如果序列化器或者日志中间件无意中访问了该字段TypeORM 会触发额外的 SQL 查询造成意想不到的 N1 问题。要在 NestJS 里控制加载粒度建议使用relations数组按需加载而不是依赖懒加载。这样每一条查询该带哪些关联都是明牌出问题也好排查。3.3 时间列与软删除CreateDateColumn()和UpdateDateColumn()是 TypeORM 提供的时间自动填充装饰器创建记录时自动写入当前时间更新时自动刷新。用起来很爽但有一个隐患数据库时区。如果你的服务器和数据库不在同一个时区或者数据库时区配置不正确这两个字段会存成 UTC 时间而应用读取时可能存在 8 小时偏差。解决方式是在连接配置里明确设置timezone比如对 MySQL 用timezone: Z强制 UTC 存储业务展示层再做时区换算。最好不要指望应用服务器和数据库服务器天然一致显式指定永远比隐式猜测可靠。软删除又是另一个容易忽视的点。只要在实体上加一列DeleteDateColumn() deletedAt?: Date | null;TypeORM 就会自动开启软删除模式。调用delete()或remove()时它不会真正删除记录而是写入删除时间查询时会自动加上deleted_at IS NULL条件。听起来很人性化但副作用也很明显你所有基于count()、findAndCount()的统计都会自动排除已删除数据。如果有一天老板问你“今年的注册用户总数为什么少了”你的第一反应往往不是去看软删除逻辑而是去查业务代码。这属于“默认行为不透明”的典型例子需要在团队规范里明确说明哪些统计接口应该包含已删除数据哪些不应该。4. Repository、QueryBuilder 与自定义 Repository数据访问层的分工4.1 基础 CRUD 交给 Repository复杂查询交给 QueryBuilderTypeORM 支持 Data Mapper 和 Active Record 两种模式。NestJS 的官方示例和大多数生产项目走的是 Data Mapper 模式实体类只做映射数据访问通过 Repository 完成。这种模式和 NestJS 的 Service 层配合得很自然职责边界清晰。在具体使用时我的分工标准很简单单一实体的基础 CRUD用 Repository 自带的方法涉及多表关联、条件组合特别多、需要分组统计的查询一律用 QueryBuilder。举个例子userRepository.find({ where: { status: active }, relations: [profile] })这种写法没问题但如果你开始往where里塞数组、嵌套对象在order里写复杂表达式这段代码很快就会变得难读且难以调优。而同样的逻辑用 QueryBuilderconst userQb this.userRepository .createQueryBuilder(u) .leftJoinAndSelect(u.profile, p) .where(u.status :status, { status: active }) .andWhere(p.score :minScore, { minScore: 100 }) .orderBy(u.createdAt, DESC) .limit(20); const users await userQb.getMany();这段 SQL 最终长什么样基本一眼就能看出来。SQL 熟练的团队成员维护起来毫无压力遇到性能问题也能直接对应到索引设计。我见过很多团队在 Repository 的find参数里堆条件堆到后来 TypeORM 生成的 SQL 和预期完全不一致排查半天才发现是where对象解析顺序的问题。所以复杂查询及时切换到 QueryBuilder是省时间的第一步。4.2 自定义 Repository 的版本变化很多人希望把复杂查询封装在自定义 Repository 里让 Service 层保持干净。这个诉求很合理但不同版本的 TypeORM 写法差别很大网上搜教程时经常看到两种完全不同的答案原因就在这里。TypeORM 0.2.x 时代的写法是这样的EntityRepository(User) export class UserRepository extends RepositoryUser { async findByEmail(email: string) { return this.createQueryBuilder(user) .where(user.email :email, { email }) .getOne(); } }然后在模块里注册Module({ imports: [TypeOrmModule.forFeature([User, UserRepository])], controllers: [UserController], providers: [UserService], }) export class UserModule {}但 TypeORM 0.3.x 开始EntityRepository被废弃了。官方推荐的做法是通过DataSource扩展export const UserRepositoryProvider { provide: USER_REPOSITORY, useFactory: (dataSource: DataSource) dataSource.getRepository(User).extend({ async findByEmail(email: string) { return this.createQueryBuilder(user) .where(user.email :email, { email }) .getOne(); }, }), inject: [DataSource], };NestJS 的nestjs/typeorm也随之更新。如果你在升级依赖后遇到 “Repository not found” 的报错几乎可以断定是EntityRepository的代码还在生效。这个坑的直接原因是版本不匹配。我给你的建议是新项目直接上 0.3.x 的新写法老项目升级前先全局搜索EntityRepository把它全部换成基于DataSource.extend的 Provider 写法再升级依赖。不要在升级过程中混用两套风格否则排查问题时要同时考虑两套逻辑非常痛苦。4.3 一个分页封装的通用模板分页查询是数据访问层最常用的功能。TypeORM 提供了skip和take两个方法对应 SQL 的OFFSET和LIMIT用起来很简单async paginate(query: PaginationQuery) { const { page 1, pageSize 20 } query; const qb this.postRepository .createQueryBuilder(post) .skip((page - 1) * pageSize) .take(pageSize) .orderBy(post.createdAt, DESC); const [items, total] await qb.getManyAndCount(); return { items, total, page, pageSize }; }getManyAndCount()会同时执行一条数据查询和一条 count 查询避免手写两个方法。这个模板足够应付 90% 的简单分页场景。但要提醒一句当page特别大时OFFSET的性能会急剧下降因为数据库必须跳过前面 N 行才能返回结果。对于深度分页更合理的方式是游标分页用上一页最后一条记录的createdAt作为条件没有跳过的成本也不受数据删除影响。游标分页实施起来略复杂需要在查询参数里传入游标值适合数据量大且需要稳定分页结果的场景。普通后台管理系统用skip/take完全够用不必过度设计。5. 事务与并发控制从订单扣库存聊到乐观锁5.1 QueryRunner 手动事务的推荐写法TypeORM 里操作事务的官方方式有好几种老文档里还有Transaction装饰器但现在已经不推荐了。我自己的项目里统一使用 QueryRunner 手动控制事务因为它最直观也不依赖装饰器的魔法行为。基本套路如下async createOrder(userId: number, productId: number, quantity: number) { const queryRunner this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { await queryRunner.manager.getRepository(User).findOne({ where: { id: userId }, lock: { mode: pessimistic_write }, }); await queryRunner.manager.getRepository(Product).decrement( { id: productId }, { stock: quantity }, ); await queryRunner.manager.save(Order, { userId, productId, quantity, }); await queryRunner.commitTransaction(); } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } }注意finally里的release()这一步很多人会漏掉。不释放 QueryRunner 的话连接池会被慢慢耗尽项目跑几天后突然出现连接超时日志里却看不到明显异常。如果你在代码里搜索createQueryRunner每个调用的结束点都应该有release()或者destroy()。这是一个可以写成团队规范的要求。5.2 事务内调用 Service 方法会脱离上下文事务最常见的翻车点是在启动事务后查询更新操作没有走queryRunner.manager而是走了注入的 Repository。Repository 默认使用连接池中的普通连接事务连接是另一条独立连接。结果就是你的“事务”里有一部分操作根本不在同一个事务里中途出错时该回滚的没回滚该提交的没提交。这个问题在跨 Service 调用时尤其隐蔽。假设你有一个OrderService.createOrder事务里调用了InventoryService.deductStock而InventoryService内部用的是自己的注入 Repository。当订单创建失败触发回滚时库存已经扣减且不会被回滚。这种 bug 在生产上会导致严重的库存数据不一致。我用的解决思路是让 Service 方法支持透传EntityManagerasync deductStock(productId: number, quantity: number, manager?: EntityManager) { const repo manager ? manager.getRepository(Product) : this.productRepository; const product await repo.findOne({ where: { id: productId } }); ... }在事务方法里调用时手动把queryRunner.manager传进去在非事务场景调用时缺省走默认 Repository。这种模式虽然没有依赖注入那么优雅但事务边界非常清晰代码审查的人一眼就能看出哪些方法“能参与事务”哪些方法“只处理单库单表”。等团队变大了还可以进一步把事务相关的编排逻辑抽到独立的TransactionService中避免到处散落createQueryRunner的调用。5.3 乐观锁和悲观锁落地并发扣库存、秒杀这类场景事务和锁总是放在一起讨论。悲观锁适合并发冲突严重、冲突时重试成本高的场景。TypeORM 里用 QueryBuilder 加锁await this.productRepository .createQueryBuilder(product) .setLock(pessimistic_write) .where(product.id :id, { id: productId }) .getOne();MySQL 和 PostgreSQL 会生成SELECT ... FOR UPDATE把选中的行锁住直到事务提交或回滚。代价是并发性能下降持有锁期间其他事务都要等待。如果业务里并发冲突不多大多数请求都能直接成功我更推荐乐观锁。TypeORM 的乐观锁实现有两种。一是内置的VersionColumn()VersionColumn() version: number;每次更新时TypeORM 自动把 version 加 1如果更新时 version 已经被别人改过会抛PessimisticLockVersionError或更新行数为 0。另一种是自己维护 version 字段在更新 SQL 里加上WHERE version 传入的版本号再根据受影响行数判断是否冲突。后者更灵活适合跨表操作的场景。使用乐观锁时有一个体验问题冲突后直接抛异常用户需要重试。所以在接口层面我会捕获冲突异常返回“操作冲突请刷新后重试”而不是 500。这属于并发控制的一部分代码上多写几行但用户感知会好很多。6. 迁移、同步与上线安全网必须织好6.1 synchronize 为什么不能上生产synchronize: true这个配置会应用启动时自动根据实体变化调整数据库表结构。开发阶段非常省事实体改了重启服务表就同步了不用写任何 DDL。但生产环境开synchronize无异于埋雷。它虽然不会真的删除所有列但会自动执行 DROP 或 ALTER 操作代价不可预测。我亲历过一个线上事故同事在实体里把一个列名从status改成state没有写迁移脚本直接部署。应用启动后TypeORM 检测到数据库里没有state列自动执行了ALTER TABLE DROP COLUMN status整列数据瞬间丢失。事后复盘发现这是synchronize加自动执行的组合导致。所以我的铁律是开发环境可以开测试环境谨慎开生产环境一律synchronize: false所有表结构变更必须走迁移。6.2 迁移命令与>//>npm run typeorm -- migration:run -d src/data-source.ts npm run typeorm -- migration:revert -d src/data-source.ts如果需要自动生成迁移文件npm run typeorm -- migration:generate -d src/data-source.ts src/migrations/AddOrderTablemigration:generate会把当前数据库结构和实体结构做对比生成增量迁移。这个命令很好用但首次使用前一定要确认数据库和实体的基线一致否则它会根据差异生成一堆你不需要的 ALTER 语句甚至误判。生成出来的迁移脚本必须人工过一遍确认没有包含额外的 DROP 操作再提交。6.3 迁移管理中的几个实际问题迁移文件命名我觉得可以带上日期和功能描述比如1700000000000-create-order-table.ts方便排序和追溯。TypeORM 通过时间戳保证迁移执行顺序但如果你用migration:create手工创建需要自己写时间戳默认会用当前时间问题不大。migration:revert只能回滚最后一条迁移。如果一次部署了三条迁移想回滚到部署前就得执行三次revert。所以我的建议是尽量保持一批迁移的数量少一个功能一组同时在迁移脚本里把反向操作写好让每条迁移都有清晰的up和down。在迁移里写数据修复逻辑时只写 DDL 和 DML不要放 SELECT 查询也不要依赖业务代码因为迁移是在部署阶段执行的业务代码可能还没生效。还有一个小坑如果数据库里已经有表且不是通过 TypeORM 迁移创建的migration:run会执行模型里已有的迁移记录报错说某张表已存在。这种情况要么用migration:generate对比生成基线迁移要么把已有的表手动记录到migrations表让 TypeORM 认为它们已经迁移过了。每张表的历史来源不同处理方案也不同但核心原则是一样的数据库结构变更必须可控、可追溯、可回滚。7. 日志、慢查询与调优让 TypeORM 稳定运行7.1 日志分级与慢查询阈值TypeORM 默认的日志配置一贯粗暴logging: true会在控制台打印所有 SQL 和参数。开发环境看两眼还行生产环境这么开基本等于刷屏日志量大到没法看还影响性能。我的生产配置是分级记录{ logging: [error, warn], maxQueryExecutionTime: 1500, }maxQueryExecutionTime的作用是超过设定毫秒数的查询会自动打印警告日志包含 SQL 和耗时。这是定位慢查询最简单的方式。此外TypeORM 提供了自定义 Logger 接口你可以把慢查询 JSON 化后输出到日志平台按天归档方便后续分析。如果你只是在本地开发想在控制台看到 SQL建议logging: [query, error]不要带schema和migration否则输出太乱。7.2 连接池参数与并发连接池参数我已经在第二章提过这里说几个调优时容易混淆的指标。应用启动后连接数的增长是一个动态过程不是启动时全部建好。如果你的服务 QPS 不高但每个请求耗时较长连接数很容易把连接池占满。这时优先检查业务代码里有没有事务没有释放或者查询是否触发了 N1。N1 问题的典型特征是日志里总是先出现一条主表查询紧接着出现很多条结构相似的子表查询。原因通常是用了relations加载一对多关联但没有用join或者没有做批处理。QueryBuilder 的leftJoinAndSelect会把关联表的结果合并成一条查询但只适用于加载下一层关系多层关系时依然可能出现多条查询。这种情况要么拆查询要么用 DataLoader 思路批量处理。还有一点连接池的使用峰值可以结合监控数据。我的经验值是单个 Node.js 实例的connectionLimit设置在 10 到 30 之间具体看数据库 CPU 和连接数上限。不要一次性给到 100连接池开得再大数据库处理不过来也白搭反而把数据库打满。7.3 几个高频异常排查最后列几个我在实际运维中反复遇到的 TypeORM 异常配合排查思路能帮你省下不少时间。异常现象常见原因处理方式Cannot read properties of undefined (reading xxx)实体未通过forFeature注册到当前模块检查实体是否在TypeOrmModule.forFeature中列出Repository not foundTypeORM 0.3.x 下使用了废弃的EntityRepository改用DataSource.getRepository().extend()Entity metadata for #xxx was not found实体未加载到连接的entities数组检查autoLoadEntities或entities配置QueryFailedError: ER_NO_DB_ERROR数据库不存在或连接指向错误库名先确认数据库已创建再检查连接配置Migration ... has already been run本地元数据表和实际执行记录不一致检查migrations表记录确认是否需要删除某条记录TimeoutError: queryrunner timeout连接池耗尽或事务未释放检查createQueryRunner是否都调用了release()这些异常的共同特点是报错信息离真实问题很远。尤其“Cannot read properties of undefined”往往不是真正访问了未定义属性而是某个 Provider 没有被正确注入。遇到这类错误我的排查顺序是先看模块的imports和providers再看实体的装饰器是否完整最后看依赖版本。按照这个顺序走大部分问题都能在十分钟内定位。日志和慢查询调优这块本质上就是让你对系统的运行状态有感知。TypeORM 默认行为偏“静默”很多潜在问题不主动记录就不会暴露。把日志分级和慢查询阈值配好相当于给系统装了仪表盘后面再怎么迭代心里都有底。根据我个人的实际使用体验NestJS 和 TypeORM 这对组合最大的挑战不是功能不够而是默认行为太多、版本差异太大。保持数据访问层风格统一、严格走迁移流程、事务边界用 QueryRunner 手动控制这三点都做到之后TypeORM 在项目里会变得非常稳定。最后再分享一个小技巧在开发环境把logging: [query, error]打开配合数据库工具查看真实 SQL你会发现很多旧代码生成的 SQL 和预期完全不同——这一步能把数据访问层里 80% 的隐患提前暴露出来。
返回列表