TypeORM 精要:常用方法、事务与锁的完全手册

44 阅读12分钟

TypeORM 是一个成熟的 Node.js 对象关系映射(ORM)框架,支持 TypeScript。它允许开发者通过 类和装饰器 定义数据库模型,支持 Data MapperActive Record 模式。其核心优势在于强大的类型检查、跨数据库支持(MySQL/PostgreSQL等)以及灵活的 QueryBuilder

你想了解如何快速在 NestJS 项目中配置 TypeORM 吗?

一、查询类方法——详解 + 示例

1、 find(options?)

作用

  • 查询列表数据
  • 后台分页
  • 简单条件筛选
  • 不涉及复杂联表 / 子查询

🔧 options 结构-参数

import { EntityRepository, Repository, In, Like, Between } from 'typeorm';
import { User } from './entity/User';

// 假设我们正在一个 Repository 或 Manager 中调用 find
const users = await userRepository.find({
    /**
     * 1. select: 指定要查询的字段
     * 默认会查询所有字段。如果指定,则只返回这些列。
     */
    select: {
        id: true,
        firstName: true,
        lastName: true,
        role: { id: true,name:true } // 返回给前端role属性(实体)中id和name字段
    },

    /**
     * 2. where: 查询过滤条件
     * 可以是对象、对象数组(表示 OR),以及配合特定的查询操作符。
     Not(value): 不等于
     LessThan(value) / LessThanOrEqual(value): 小于 / 小于等于
     MoreThan(value) / MoreThanOrEqual(value): 大于 / 大于等于
     Like(value): 模糊查询 `%...%`
     ILike(value): 忽略大小写的模糊查询(Postgres)
     Between(from, to): 范围查询
     IsNull(): 判断空值
     */
    where: {
        firstName: "Timber",
        lastName: "Saw",
        age: MoreThan(18), // 使用内置操作符
        role: In(["admin", "editor"]), // 范围查询
        // 数组形式表示 OR 逻辑: [{ firstName: "A" }, { firstName: "B" }]
    },

    /**
     * 3. relations: 关联加载
     * 指定要跟随主表一起查出来的“关联属性名”(即实体类中定义的变量名)。
     * 底层会自动执行 LEFT JOIN 操作。
     */
    relations: {
        role: true,  // 加载与该实体关联的 role 属性 即role对应的实体
        photos: true,   // 加载 photos 数组
        videos: {       // 进入 videos 实体内部
            videoAttributes: true // 继续加载 videos 实体里定义的 videoAttributes 关联
        }
    }
   // 数组字符串写法
   // relations: ["role", "photos", ]

    /**
     * 4. order: 排序规则
     * key 为字段名,value 为 "ASC" 或 "DESC"。
     */
    order: {
        firstName: "ASC",
        id: "DESC",
    },

    /**
     * 5. skip: 跳过的记录数 (Offset)
     * 通常用于分页,配合 take 使用。
     */
    skip: 0,

    /**
     * 6. take: 获取的记录数 (Limit)
     * 指定返回多少条数据。
     */
    take: 10,

    /**
     * 7. cache: 启用查询缓存
     * 可以是布尔值,也可以是包含 id 和 milliseconds 的对象。
     */
    cache: {
        id: "users_list_cache",
        milliseconds: 30000 // 缓存30秒
    },

    /**
     * 8. join: 手动 Join 逻辑 (不常用)
     * 比 relations 更灵活,可以定义 alias。
     */
    join: {
        alias: "user",
        leftJoinAndSelect: {
            profile: "user.profile",
            photo: "user.photos"
        }
    },

    /**
     * 9. withDeleted: 包含软删除记录
     * 如果实体开启了 @DeleteDateColumn,设为 true 会查出被软删除的数据。
     */
    withDeleted: false,

    /**
     * 10. loadRelationIds: 仅加载关联 ID
     * 设为 true 时,关联字段会返回 ID 而不是完整的对象。
     */
    loadRelationIds: false,

    /**
     * 11. loadEagerRelations: 加载饿汉式关联
     * 默认为 true。如果设为 false,即使实体定义了 eager: true 也不会加载。
     */
    loadEagerRelations: true,

    /**
     * 12. transaction: 事务锁 (高级用法)
     * 仅在特定的数据库系统和版本中支持。
     */
    // lock: { mode: "optimistic", version: 1 }
});

💡 示例:用户查询

// service层代码
async list(name: string) {
  return await this.userRepository.find({
    relations:{
        role:true // 加载角色信息
    },
    where: { 
        name:Like(`%${name}%`) // 模糊查询用户名
    },
    select: {
        id: true,
        username: true,
        role: {
          id: true, //  角色Id
          name: true, // 只查出角色名称
          code: true,
        },
        createTime: true,
        updateTime: true,
        age: true,
        status: true,
    },
    order: { createTime: 'DESC' },
  });
}

// 返回结果
[
     {
        "id": 27,
        "username": "user",
        "age": 18,
        "status": 1,
        "role": { // 只返回为true的字段
          "id": 2,
          "name": "普通用户",
          "code": "user",
        },
        "createTime": "2026-01-28 15:39:02",
        "updateTime": "2026-01-28 15:39:02"
      },
    ]

2、 findOne(options)

特点

  • 必须有 where
  • 返回实体对象,查不到返回 null(不会抛错)
  • 支持 relations / order / select
  • 不支持take / offect
const post = await this.postRepository.findOne({
  where: { id },
});

💡 示例:详情页

async detail(id: number) {
  const post = await this.postRepository.findOne({ where: { id } });

  if (!post) {
    throw new NotFoundException('文章不存在');
  }

  return post;
}

3、 findBy({})(简化版)

特点

  • find查询的简化版不需要where
  • 仅支持裸查询裸 { id: 1,... }
  • relations,select,order等均不支持
  • 不需要分页 / 排序 / 联表时推荐使用

🔍 本质

findBy === find({ where })

示例

this.postRepository.findBy({
  status: 'draft',
});

4、 findOneBy({})(简化版)

特点

  • findOne查询的简化版不需要where
  • 仅支持裸查询裸 { id: 1,... }
  • relations,select,order等均不支持

🔍 本质

repo.findOne({where:{id:1}});

示例

this.postRepository.findOneBy({
  id: '1',
});

5、 count(options?)

常见用途

  • 分页总数
  • 状态统计
const total = await this.postRepository.count({
  where: { status: 'published' },
});

💡 分页实战(基础版)

async list(page: number, size: number) {
  const [list, total] = await Promise.all([
    this.postRepository.find({
      take: size,
      skip: (page - 1) * size,
    }),
    this.postRepository.count(),
  ]);

  return { list, total };
}

6、 findAndCount(options?) ⭐⭐

分页的“黄金方法”

特点

  • 一次调用
  • 实际执行 两次 SQL 查数据 、查总数
const [items, total] = await repository.findAndCount(options);

💡 示例:

async getRecordPage(page = 1, pageSize = 10) {
  const [data, total] = await this.recordRepo.findAndCount({
    relations: ['user', 'device'],
    where: { auditStatus: 1 },
    order: { id: 'DESC' },
    skip: (page - 1) * pageSize,
    take: pageSize,
  });

  return {
    list: data,
    total,
    pageSize,
    currentPage: page,
  };
}

🤔 为什么不用 find + count

  • 前端分页组件必须知道 total
  • 两次查询容易条件不一致
  • findAndCount = 官方封装的最佳实践,保证了条件的一致性

二、持久化方法(Persistence)——重点

1、 create(entityLike) ⚠️(最容易被误解)

特点

  • create 不会写数据库
  • 根据实体类,映射出响应的新增数据
// dto:前端传今天的数据定义
const post = this.postRepository.create(dto);

📌 正确用法

create + save = 标准新增

永远不要只用 create,不会写入数据库中


2、 save(entity)(最推荐)

特点

  • 可以是新增也可以是更新
  • 底层通过 主键(Primary Key):一般是id 来决定执行 INSERT 还是 UPDATE

💡 新增

// 使用create实例化
async create(dto: CreatePostDto, authorId: number) {
  const post = this.postRepository.create({
    ...dto,
    authorId,
  });

  return this.postRepository.save(post);
}

💡 修改(强烈推荐)

async update(id: number, dto: UpdatePostDto) {
  const post = await this.postRepository.findOne({ where: { id } });
  if (!post) throw new NotFoundException();

  Object.assign(post, dto);
  return this.postRepository.save(post);
}

📌 什么时候一定用 save?

  • 有业务校验
  • 需要实体钩子(BeforeUpdate)
  • 可读性优先

3、 update(criteria, partialEntity)

特点

  • 不校验是否存在
  • 不触发钩子
  • 不返回实体
// 将 ID 为 1 的用户年龄改为 25
await userRepository.update(1, { age: 25 });

// 将所有状态为 "pending" 的订单改为 "active"
await orderRepository.update({ status: 'pending' }, { status: 'active' });

update vs save 对比表

特性update()save()
SQL 步骤直接 UPDATE先 SELECT 再 INSERT/UPDATE
性能极高相对较低(双倍数据库交互)
局部更新仅更新传入字段如果传入不完整对象,可能覆盖其他字段
生命周期钩子不触发触发
级联关系不支持支持
返回值UpdateResult (受影响行数等)完整的实体对象

4、 insert(entityLike)

特点

  • 直接 INSERT SQL
  • 不触发钩子
  • 不返回实体
await this.userRepository.insert(list);

📌 只在 100% 确定是新增时用(如批量导入)

/**
 * 简单的批量导入示例
 */
async function simpleBatchImport() {
  const usersToImport = [
    { username: '张三', age: 18, status: 'active' },
    { username: '李四', age: 20, status: 'active' },
    { username: '王五', age: 22, status: 'inactive' },
  ];

  // 直接使用 repository.insert
  // 注意:它接受一个对象,或者一个对象数组
  const result = await userRepository.insert(usersToImport);

  console.log('插入受影响的行数:', result.identifiers.length);
  return result;
}

5、 remove(entity)

特点

  • 需要先查实体
  • 触发钩子
  • 支持级联
// 必须先拿到实例对象
const user = await userRepository.findOneBy({ id: 1 });
if (user) {
    await userRepository.remove(user); // 传入的是对象
}

6、 delete(criteria)

特点

  • 不查数据库
  • 不触发钩子
  • 不支持级联
// 通过 ID 删除
await userRepository.delete(1);
// 通过条件删除
await userRepository.delete({ status: 'inactive' });

三、createQueryBuilder —— 真正拉开差距的地方

核心链式方法统计表

分类方法名用途简述
基础选择select(), addSelect()指定查询的字段
主体表from()指定查询的主表
关联查询leftJoin(), innerJoin(), leftJoinAndSelect()关联其他表(含数据加载)
过滤条件where(), andWhere(), orWhere()设置查询过滤条件
排序分页orderBy(), skip(), take()排序与分页(推荐用于实体)
分组聚合groupBy(), having()分组与聚合过滤
结果执行getOne(), getMany(), getRawOne(), getRawMany()执行查询并返回结果
  • select("alias.field") : 初始化要查询的字段。如果不调用,默认查询实体的所有字段。
  • addSelect("alias.field") : 在现有查询字段基础上增加字段(常用于隐藏字段的临时开启)。
  • from(Entity, "alias") : 定义查询的主表及别名。
  • leftJoinAndSelect("user.profile", "profile") : 这是最常用的关联方法。它执行 LEFT JOIN 并自动将关联表的数据映射到主实体的属性中
  • leftJoin("user.profile", "profile") : 仅执行关联,但不加载数据。常用于需要根据关联表的字段进行 WHERE 过滤,但结果里不需要该表数据的场景。
  • where("user.id = :id", { id: 1 }) : 设置初始条件。注意:重复调用 where()覆盖之前的条件。
  • andWhere() / orWhere() : 在已有条件基础上增加 ANDOR 逻辑。建议始终使用参数化占位符(:id)防止 SQL 注入 take(n)skip(m) : 在处理 relations 时,必须使用 takeskip 而不是 limitoffset。这是因为 take/skip 会正确处理一对多关系导致的重复行问题,而 limit 可能会截断 不完整的数据

💡 示例 1:复杂的连表与字段筛选

// 查询用户及其角色,要求:用户必须是激活状态,且只返回特定的字段
const users = await userRepository.createQueryBuilder("user") // 定义主表别名 user
  .leftJoinAndSelect("user.role", "role") // 关联并加载角色数据
  .select([
    "user.id", 
    "user.username", 
    "role.name" // 只选用户 ID、用户名和角色名
  ])
  .where("user.status = :status", { status: 'active' })
  .andWhere("role.code = :code", { code: 'ADMIN' })
  .orderBy("user.createTime", "DESC") // 按创建时间倒序
  .getMany(); // 获取数组结果

💡 示例 2:聚合统计与分组

// 统计每个角色的用户数量,且只显示人数大于 5 的角色。
const stats = await userRepository.createQueryBuilder("user")
  .innerJoin("user.role", "role")
  .select("role.name", "roleName") // 选取角色名
  .addSelect("COUNT(user.id)", "userCount") // 统计人数
  .groupBy("role.id") // 按角色 ID 分组
  .having("COUNT(user.id) > :limit", { limit: 5 }) // 过滤统计结果
  .getRawMany(); // 注意:聚合查询通常返回原始数据(Raw),而不是实体对象

💡 示例 3:高阶分页查询

// 统计每个角色的用户数量,且只显示人数大于 5 的角色。
const [list, total] = await userRepository.createQueryBuilder("user")
  .leftJoinAndSelect("user.photos", "photo")
  .where("user.age > :age", { age: 18 })
  .skip(0) // 跳过 0 条
  .take(10) // 取 10 条
  .getManyAndCount(); // 同时返回查询到的数组和总记录数

📌 核心提示

  1. getMany vs getRawMany
    • 如果你想要嵌套的对象结构(ORM 风格),用 getMany()
    • 如果你在用 SUM, COUNT, MAX聚合函数,或者自定义了 select 别名,用 getRawMany()
    • getOne VS getRawOne 同理

四、dataSource事务

🔑 事务的特性

  • 要么全成功,要么全失败
  • 并发事务互不干扰
  • 事务前后数据状态合法

✅ 支持的操作

  • manager.find
  • manager.findOne
  • manager.save
  • manager.update
  • manager.delete
  • manager.createQueryBuilder

➡️ 场景

“只要有两条写操作,而且它们在逻辑上不能分开,就必须上事务。”

👉 dataSource.transaction 的执行流程(非常重要)

你不需要手写 BEGIN / COMMIT / ROLLBACK

  1. 从连接池中取一个连接
  2. BEGIN
  3. 执行回调函数里的所有操作
  4. 没有异常 → COMMIT
  5. 有异常 → ROLLBACK
  6. 释放连接

💡 示例1:事务中更新 + 插入

在事务中使用findOne(实体,{})之类的方法,需要加上实体,否则找不到数据

await this.dataSource.transaction(async (manager) => {
  
  // 1. 更新操作:第一个参数必须是 Order 实体
  // 语法:manager.update(实体, 条件, 内容)
  await manager.update(Order, id, { status: 'paid' });

  // 2. 插入操作:第一个参数必须是 OrderLog 实体
  // 语法:manager.insert(实体, 内容)
  await manager.insert(OrderLog, { orderId: id, action: 'pay' });

});

💡 示例2:事务 + createQueryBuilder(最常见组合) 所有 QueryBuilder 都必须来自 manager

// 1. 发起事务:通过 dataSource 开启一个事务环境
// manager 是一个实体的管理器,它被绑定到了当前的事务连接上
await this.dataSource.transaction(async (manager) => {

  // --- 第一部分:更新订单状态 ---
  
  await manager
    .createQueryBuilder()           // 2. 创建一个查询构建器实例
    .update(Order)                  // 3. 指定要更新的实体(Order 表)
    .set({ status: 'paid' })        // 4. 设置更新字段:将 status 改为 'paid'
    .where('id = :id', { id })      // 5. 设置过滤条件:匹配传入的 id,使用 :id 占位符防注入
    .execute();                     // 6. 执行 SQL 语句(生成 UPDATE order SET status = 'paid' WHERE id = ...)

  // --- 第二部分:插入操作日志 ---

  await manager
    .createQueryBuilder()           // 7. 再次创建一个查询构建器实例
    .insert()                       // 8. 指定本次操作为插入(INSERT)
    .into(OrderLog)                 // 9. 指定插入的目标表(OrderLog 表)
    .values({                       // 10. 定义要插入的数据对象
      orderId: id,                  //     记录关联的订单 ID
      action: 'pay'                 //     记录操作动作为 'pay'
    })
    .execute();                     // 11. 执行 SQL 语句(生成 INSERT INTO order_log ...)

  // 12. 闭包结束:
  // 如果上述两步都成功,事务会自动 COMMIT(提交)
  // 如果其中任何一步报错,事务会自动 ROLLBACK(回滚),保证数据一致性
});

💡 示例3:使用

await this.dataSource.transaction(async (manager) => {
  
  // 1. 更新操作:第一个参数必须是 Order 实体
  // 语法:manager.update(实体, 条件, 内容)
  await manager.update(Order, id, { status: 'paid' });

  // 2. 插入操作:第一个参数必须是 OrderLog 实体
  // 语法:manager.insert(实体, 内容)
  await manager.insert(OrderLog, { orderId: id, action: 'pay' });

});

五、乐观锁 vs 悲观锁(高并发必备)

一句话先记住:
乐观锁 = 不锁数据,靠“版本号”防止被别人改过
悲观锁 = 先锁住数据,别人必须等我用完

✅ 乐观锁

  • 不锁表
  • 用 version 控制
  • 读多写少
// 数据库中多一个字段
@VersionColumn()
version: number;
---------------------------
// 如果 version不匹配 TypeORM 抛异常
// 在数据库层面的本质
UPDATE device
SET status = 'active', version = version + 1
WHERE id = 1 AND version = 3;

💡 示例:多个管理员同时编辑设备信息,不希望“后保存的人覆盖前一个人

// TypeORM中实体类的定义
@Entity('tb-device')
export class Device {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  content: string;

  @VersionColumn()
  version: number;
}

// service层

/** 
1.  打开编辑页 → 读 version
2.  提交保存
3.  version 不一致 → 数据已被其他人修改,请刷新重试”
*/
try {
  const device = await device.findOneBy({ id: 1 });
  device.content = '这是张三修改的';
  await device.save(device); // 自动校验 version
} catch (err) {
  throw new ConflictException('数据已被其他人修改,请刷新重试');
}


🔒 悲观锁

  • 先锁住数据
  • 其他事务只能等
  • 适合写多 / 高并发 / 不能出错的场景

悲观锁模式详解表

模式名称对应的 SQL核心解释适用场景
pessimistic_writeFOR UPDATE写锁。事务 A 锁定行后,其他事务不能读(指加锁读)也不能写,必须等待事务 A 提交。最常用。如:电商扣减库存、银行转账,确保数据被你读取到修改完成期间没人能动。
pessimistic_readLOCK IN SHARE MODE共享锁。多个事务可以同时读取该行,但都不能修改。只有当所有共享锁都释放后,才能写。防级联删除。如:读取父表数据时防止别人删掉它,但允许别人同时也来读。
pessimistic_write_or_failFOR UPDATE NOWAIT立即失败锁。尝试加写锁,如果该行已经被别人锁住了,不排队等待,直接报错。高响应要求。如:抢购位,如果拿不到锁直接告诉用户“系统繁忙”,而不是让用户转圈等待。
pessimistic_partial_writeFOR UPDATE SKIP LOCKED跳过锁。尝试锁定多行时,如果某些行被锁了,直接忽略它们,只返回未被锁定的行。消息队列/任务领取。如:多个 Worker 抢任务,谁没被锁就领谁,互不干扰,极大提高并发效率。
lock: { mode: 'pessimistic_write' }

💡 示例1:必须在事务中

/** 在事务结束前
  其他更新请求会排队等待
  保证数据绝对安全
*/
await dataSource.transaction(async (manager) => {
  const device = await manager.findOne(Device, {
    where: { id: 1 },
    lock: { mode: 'pessimistic_write' },
  });

  device.status = 'busy';
  await manager.save(device);
});

💡 示例2:库存扣减

await dataSource.transaction(async (manager) => {
  const product = await manager.findOne(Product, {
    where: { id: productId },
    lock: { mode: 'pessimistic_write' },
  });

  if (product.stock <= 0) {
    throw new BadRequestException('库存不足');
  }

  product.stock -= 1;
  await manager.save(product);
});