2. 数据建模基础

在进入 Prisma 或 TypeORM 的具体 API 之前,必须先建立一套稳定的关系型数据建模心智模型。ORM 只是把数据库结构映射成代码,如果底层概念不清楚,写出来的 Schema 或 Entity 往往只是“能跑”,却难以维护。本节围绕实体、关系、主键、外键、索引与约束展开,并对比 Prisma Schema 与 TypeORM Entity 在建模思想上的差异。

一、实体、属性与关系:从业务对象到表结构

关系型数据库用二维表表达业务对象。一个“实体”通常对应一张表,实体的“属性”对应列,实体的一个具体实例对应一行记录。例如电商场景中,User(用户)、Product(商品)、Order(订单)都是实体。实体之间通过关系连接,常见有三类:

  • 一对一(1:1):如用户与其实名认证信息,一个用户只有一条认证记录。
  • 一对多(1:N):如用户与订单,一个用户可下多笔订单,每笔订单只属于一个用户。
  • 多对多(M:N):如订单与商品,一笔订单含多个商品,一个商品可出现在多笔订单中。关系型数据库无法直接表达多对多,必须引入中间表(如 OrderItem),把 M:N 拆成两个 1:N。

建模的第一步,是判断业务对象之间的基数(cardinality)与可选性(optionality)。基数回答“一个对多个还是多个对多个”,可选性回答“关系是否必须存在”。例如订单必须属于某个用户(必选),而用户可以不填手机号(可选)。这两个判断直接决定外键是否允许为 NULL、是否加唯一约束。

二、主键、外键与约束:保证数据完整性

主键(Primary Key)唯一标识一行记录,不可重复、不可为 NULL。常见选择有两类:自增整数(如 BIGINT AUTO_INCREMENT)和全局唯一标识(如 UUID、ULID)。自增主键写入快、索引小,但暴露业务量且分库分表时易冲突;UUID 便于分布式生成,但随机写入会带来索引页分裂,通常用有序 UUID(如 UUIDv7)缓解。

外键(Foreign Key)是子表中指向父表主键的列,用于表达 1:N 关系。外键约束保证“子表引用的父记录必须存在”,并可定义级联行为:ON DELETE CASCADE(父删子删)、ON DELETE RESTRICT(有子记录时禁止删父)、ON DELETE SET NULL(父删后子外键置空)。选择哪种级联,取决于业务语义:订单明细通常随订单级联删除,而用户被删除时订单往往应保留(改为软删除或 RESTRICT)。

约束(Constraint)还包括:非空(NOT NULL)、唯一(UNIQUE)、检查(CHECK,如 price >= 0)、默认值(DEFAULT)。约束是数据库层的最后一道防线,即使应用层校验被绕过,数据仍能保持合法。

三、索引:用空间换查询速度

索引是加速查询的数据结构,最常见是 B+ 树。没有索引时,查询需要全表扫描;有索引后,可按值快速定位。但索引并非越多越好:每个索引都会增加写入成本(INSERT/UPDATE/DELETE 需维护索引)并占用存储。

常见索引类型与适用场景:

  • 主键索引:唯一且非空,通常聚簇存储。
  • 唯一索引:保证列值唯一,如 email。
  • 普通索引:加速等值与范围查询,如 created_at。
  • 复合索引:多列组合,遵循最左前缀原则。例如 (user_id, created_at) 可加速“某用户按时间排序的订单”,但单独查 created_at 用不上。
  • 外键索引:多数数据库不会自动为外键建索引,需手动添加,否则父表删除时子表检查会全表扫描。
判断索引是否生效,可用 EXPLAIN 查看执行计划:若 type 为 ALL 表示全表扫描,ref 或 range 表示命中索引。示例中的执行计划为教学假设,实际结果依赖数据分布与优化器。

四、Prisma Schema 与 TypeORM Entity 的建模思想对比

两者都做“代码到表”的映射,但哲学不同。Prisma 使用独立的 schema.prisma 声明式 DSL,模型、字段、关系、索引集中描述,再通过 prisma migrate 生成 SQL 迁移;TypeORM 则用 TypeScript 类与装饰器,把实体定义直接写在代码里,通过 @Entity、@Column、@ManyToOne 等装饰器表达结构。

下面用同一业务模型(用户与订单)展示两种写法:

// Prisma Schema
model User {
  id     String  @id @default(uuid())
  email  String  @unique
  orders Order[]
}

model Order {
  id        String   @id @default(uuid())
  userId    String
  total     Decimal  @db.Decimal(10, 2)
  createdAt DateTime @default(now())
  user      User     @relation(fields: [userId], references: [id], onDelete: Restrict)

  @@index([userId, createdAt])
}
// TypeORM Entity
@Entity()
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  email: string;

  @OneToMany(() => Order, (order) => order.user)
  orders: Order[];
}

@Entity()
@Index(['userId', 'createdAt'])
export class Order {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column('decimal', { precision: 10, scale: 2 })
  total: string;

  @CreateDateColumn()
  createdAt: Date;

  @ManyToOne(() => User, (user) => user.orders, { onDelete: 'RESTRICT' })
  @JoinColumn({ name: 'userId' })
  user: User;

  @Column()
  userId: string;
}

对比可见:Prisma 的关系由两侧共同声明(orders 与 user),外键与索引写在模型内,迁移由工具生成;TypeORM 的关系由装饰器双向声明,外键列需显式 @Column 或由 @JoinColumn 生成,索引可用 @Index 类级声明。Prisma 更接近“先设计 Schema,再生成客户端”,TypeORM 更接近“用类描述表,运行时同步或迁移”。

用户—订单模型:实体、关系、主键、外键与索引 User(用户) 🔑 id PK · uuid email UNIQUE · NOT NULL name NULL 允许 createdAt DEFAULT now() Order(订单) 🔑 id PK · uuid 🔗 userId FK → User.id total DECIMAL(10,2) createdAt DEFAULT now() status CHECK in ('paid','shipped') 📇 idx (userId, createdAt) 1 : N 一个用户多笔订单 ON DELETE RESTRICT 有订单时禁止删除用户 复合索引最左前缀 (userId, createdAt) 可加速用户订单时间排序 Prisma Schema(声明式 DSL) • 独立 .prisma 文件集中描述模型 • 关系两侧共同声明,外键/索引写在模型内 • prisma migrate 生成 SQL 迁移 TypeORM Entity(装饰器类) • 实体类与装饰器直接写在 TS 代码中 • @ManyToOne / @JoinColumn 双向声明关系 • 迁移或 synchronize 同步表结构

五、建模时的关键判断与常见陷阱

第一,主键选择要尽早确定。若业务可能分库分表或需要对外暴露 ID,优先 UUID/ULID;若单库自增且不暴露,自增整数更高效。第二,外键约束与级联行为必须与业务语义一致,不能默认全部 CASCADE,否则误删父记录会连带清空子数据。第三,索引要围绕真实查询建立:先看 WHERE、JOIN、ORDER BY 用到的列,再决定复合索引顺序,避免“每个列都建单列索引”的浪费。第四,唯一约束与业务校验要区分:邮箱唯一应由数据库 UNIQUE 保证,应用层只做友好提示。

常见陷阱:在 TypeORM 中只写 @ManyToOne 而不写 @JoinColumn,可能导致外键列名不符合预期;在 Prisma 中忘记在关系另一侧声明数组字段,会导致 prisma validate 报错。两者都应在修改模型后运行迁移或校验命令,确认数据库结构与代码一致。

理解这些底层概念后,无论使用 Prisma 还是 TypeORM,都能把业务关系准确翻译成表结构、外键与索引,并为后续的查询优化、迁移管理和数据一致性打下基础。