# 复合主键

复合主键意味着，一个实体有多个主键，它们会自动组合成一个“复合主键”。这种对数据库的建模方式有优点也有缺点。我们认为复合主键在实践中存在巨大的劣势，且这些劣势不足以支撑其优点，因此应被视为不良实践并尽量避免。Deepkit ORM 不支持复合主键。本章解释其中原因并展示（更好的）替代方案。

## 缺点

连接并非易事。尽管 RDBMS 中的连接经过了高度优化，但它们在应用中会引入一种始终存在的复杂度，容易失控并导致性能问题。这里的性能不仅包括查询执行时间，也包括开发时间。

## 连接

每一个连接在涉及的字段越多时就会越复杂。尽管许多数据库实现了优化，使得多字段连接本身不一定更慢，但这要求开发者持续、细致地思考这些连接的细节，因为一旦遗忘某个键，就可能导致微妙的错误（因为即使未指定所有键，连接仍然会执行），因此开发者需要了解完整的复合主键结构。

## Indizes

包含多个字段的索引（即复合主键）在查询时会遭遇字段顺序的问题。尽管数据库系统可以优化某些查询，但复杂的结构会使得编写能正确利用所有已定义索引的高效操作变得困难。对于一个多字段索引（例如复合主键），通常需要按正确的顺序定义字段，数据库才会实际使用该索引。如果顺序指定不正确（例如在 WHERE 子句中），很容易导致数据库完全不使用该索引，而是执行全表扫描。知道数据库会以何种方式优化哪些查询是一种进阶知识——新开发者通常并不具备——但一旦开始使用复合主键，这种知识就变得必要，以便你能最大化利用数据库、避免浪费资源。

## Migrationen

一旦你决定某个实体需要新增一个字段来唯一标识它（从而成为复合主键的一部分），这将导致数据库中所有与该实体存在关系的实体都需要调整。

例如，假设你有一个带复合主键的实体 `user`，并决定在不同的表中引用这个 `user` 的外键，例如在中间表 `audit_log`、`groups` 和 `posts` 中。一旦你更改了 `user` 的主键，这些表在迁移时同样都需要调整。

这不仅会让迁移文件复杂得多，还可能在执行迁移时造成较长停机时间，因为模式变更通常需要整个数据库锁或至少表级锁。受诸如索引变更这类重大变更影响的表越多，迁移所需时间越长。而表越大，迁移时间也越长。
以 `audit_log` 表为例。这类表通常有大量记录（数百万级），而你仅仅因为决定使用复合主键并给 `user` 的主键新增一个字段，就不得不在模式变更期间触碰它们。根据这些表的大小，这要么让迁移成本不必要地增加，要么在某些情况下成本高到让更改 `User` 的主键不再具有经济上的合理性。这通常会引出各种变通方案（例如在 user 表上额外增加唯一索引），从而引入技术债，并迟早被列入遗留清单。

对于大型项目，这可能导致巨大的停机时间（从几分钟到数小时），甚至引入全新的迁移抽象系统：本质上是复制表、将记录插入幽灵表，并在迁移后来回切换表。所有这些附加复杂度都会施加到任何与具有复合主键的实体存在关系的实体上，并随着数据库结构变大而愈发严峻。问题只会恶化，且无解（除非彻底移除复合主键）。

## 可发现性

如果你是数据库管理员或数据工程师/科学家，你通常会直接在数据库上工作，并在需要时探索数据。使用复合主键时，任何直接编写 SQL 的人都必须知道所有相关表的正确主键（以及为了获得正确索引优化所需的列顺序）。这种额外的负担不仅会使数据探索、报表生成等复杂化，还可能在复合主键突然发生变化时导致旧 SQL 出错。旧 SQL 可能仍然语法有效、运行正常，但会因为连接中缺少复合主键中新字段而突然返回不正确的结果。只有一个主键要容易得多。这使得查找数据更简单，并确保当你决定改变（例如）用户对象的唯一标识方式时，旧的 SQL 查询仍能正确工作。

## 重构

一旦实体使用了复合主键，重构该键可能会引发大量额外的重构工作。由于拥有复合主键的实体通常没有单一的唯一字段，所有过滤器和关联都必须包含复合键的全部值。这通常意味着代码依赖于对复合主键的了解，因此必须取回所有这些字段（例如用于类似 user:key1:key2 的 URL）。一旦该键发生变化，所有显式使用这类知识的地方，比如 URL、自定义 SQL 查询等，都必须重写。

虽然 ORM 通常能自动创建连接而无需手动指定这些值，但它无法自动覆盖所有其他用例的重构，比如 URL 结构或自定义 SQL 查询，尤其是那些根本未使用 ORM 的地方，例如报表系统和各类外部系统。

## ORM 复杂性

一旦支持复合主键，像 Deepkit ORM 这样的强大 ORM 的代码复杂性会大幅增加。不仅代码和维护会更复杂、因而更昂贵，用户带来的边界情况也会更多，需要修复和维护。查询层、变更检测、迁移系统、内部关系跟踪等的复杂性都会显著上升。综合考虑，构建并支持一个带复合主键的 ORM 的总体成本过高，难以被合理化，因此 Deepkit 不支持它。

## 优点

除此之外，复合主键也有一些优点，尽管非常表面。通过为每张表尽可能少地使用索引，写入（插入/更新）数据会更高效，因为需要维护的索引更少。它还会让模型结构稍微更简洁一些（因为通常会少一列）。然而，如今顺序的、自动递增的主键与非递增主键之间的差异几乎可以忽略不计，因为磁盘空间很便宜，而且这类操作通常只是“仅追加”操作，非常之快。

当然，在某些边缘情况（以及极少数非常特定的数据库系统）中，起初使用复合主键可能更好。但即便在这些系统里，综合（考虑所有成本）来看，不使用它们并转向其他策略可能更有意义。

## 替代方案

复合主键的一个替代方案是使用单个自动递增的数值型主键，通常称为 "id"，并将原本的复合主键转移为一个由多个字段组成的唯一索引。根据所用主键（取决于预计行数）的不同，"id" 每条记录占用 4 或 8 字节。

使用这种策略后，你不再被迫花时间思考并解决上述问题，这将大幅降低不断增长的项目的成本。

具体做法是，每个实体都有一个 "id" 字段，通常位于最前面，默认用它来标识唯一行并用于连接。

```typescript
class User {
    id: number & PrimaryKey & AutoIncrement = 0;

    constructor(public username: string) {}
}
```

作为复合主键的替代方案，你可以使用一个多字段唯一索引。

```typescript
@entity.index(['tenancyId', 'username'], {unique: true})
class User {
    id: number & PrimaryKey & AutoIncrement = 0;

    constructor(
        public tenancyId: number,
        public username: string,
    ) {}
}
```

Deepkit ORM 自动支持递增主键，包括在 MongoDB 中。这是识别数据库记录的首选方式。不过，对于 MongoDB，你也可以使用 ObjectId（`_id: MongoId & PrimaryKey = ''`）作为简单主键。数值型自增主键的替代方案是 UUID，它同样可行（但由于索引代价更高，性能特性会略有不同）。

## 总结

复合主键本质上意味着：一旦采用，所有后续变更和实践使用都将付出更高成本。虽然起初看起来架构更“干净”（因为少了一列），但一旦项目真正发展起来，就会带来显著的实际成本，而且随着项目变大，这些成本会持续增加。

对比利弊的不对称性不难看出，在大多数情况下复合主键并不值得。其成本远大于收益。这不仅对你作为用户如此，对我们作为 ORM 代码的作者与维护者亦然。因此，Deepkit ORM 不支持复合主键。
