数据库 ID 设计:选择 ID 方法和主键策略
Source: Dev.to
ID 设计的考虑因素
为数据库选择主键(ID)是一个出乎意料的深奥话题。虽然某些框架提供默认值,但你通常需要自行做出选择。在 PostgreSQL 中,自动递增是通过 SERIAL 类型实现的(内部使用 SEQUENCE)。
像 UUID 和 CUID2 这样的 ID 方法将时间戳和随机值结合,生成无需中心管理的唯一 ID,使分布式系统能够在不担心冲突的情况下创建数据。
一个帮助组织 ID 选择决策标准的实用视频:
https://www.youtube.com/watch?v=pmqRaEcDxl4
主要 ID 方法对比
| 方法 | 长度 | 可按时间排序 | PostgreSQL 存储方式 | 特点 |
|---|---|---|---|---|
| 顺序(SERIAL/SEQUENCE) | 最多 19 位数字 | ○(基本可行) | 原生 | 简单、可预测 |
| UUID v4 | 36 字符 | × | 原生 | 标准、随机 |
| UUID v7 | 36 字符 | ○ | 可存为 UUID 类型 | 可按时间排序 |
| ULID | 26 字符 | ○ | text 类型 | 可读字符集 |
| CUID2 | 24+ 字符 | × | text 类型 | 短、可靠 |
| NanoID | 21+ 字符 | × | text 类型 | 最短、快速 |
选择标准
- ID 会在 URL 中暴露吗? → 若暴露,避免使用顺序 ID。
- 需要基于时间的排序吗? → 使用 UUID v7 或 ULID。
- 写入性能关键吗? → 大数据集使用顺序 ID。
- ID 长度重要吗? → 对 URL 使用 NanoID 或 CUID2。
我的独立项目的采纳策略
默认:CUID2
我在内容 ID(页面、表格、仪表盘等)上使用 CUID2。
为何选 CUID2:
- 短小: 24 个字符(相较于 UUID v4 的 36 个字符)。
- URL 安全: 没有连字符,仅小写字母数字。
- 双击全选友好: 没有连字符,便于一次选中完整 ID。
- 安全可靠: 基于 SHA‑3,难以猜测。
// id-generator.ts
import { init } from '@paralleldrive/cuid2';
// 初始化为固定 24 字符长度
const createCuid = init({ length: 24 });
export function generateContentId(): string {
return createCuid();
}
// 示例: "clhqr8x9z0001abc123def45"
例外:批量处理表使用 UUID v7
对于可能接受批量插入的表(例如 table_rows),我使用 UUID v7。
为何选 UUID v7:
- 插入性能好: 时间有序的 ID 对 B‑tree 索引友好。
- PostgreSQL 兼容: 可存为原生
UUID类型。 - 符合 RFC 标准: 符合 RFC 9562(2024 年制定)。
import { v7 as uuidv7 } from 'uuid';
export function generateRowId(): string {
return uuidv7();
}
// 示例: "018c1234-5678-7abc-9def-0123456789ab"
选择标准汇总
| 使用场景 | ID 方法 | 理由 |
|---|---|---|
| 内容 ID | CUID2 | 用于 URL,强调简短 |
| 表格内容行 ID | UUID v7 | 批量处理,强调性能 |
| 用户 ID | 由 Better Auth 生成 | 委托给认证库 |
复合主键设计
在 单一主键 与 复合主键 之间的选择是另一重要设计决策,尤其在多租户 SaaS 场景下,数据隔离和搜索效率尤为关键。
单主键 vs 复合主键
-- 单一主键
CREATE TABLE contents (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
...
);
-- 复合主键
CREATE TABLE contents (
tenant_id TEXT NOT NULL,
content_id TEXT NOT NULL,
...
PRIMARY KEY (tenant_id, content_id)
);
复合主键的优势
- 索引效率: 快速的租户范围搜索(
tenant_id位于索引前缀)。 - 数据隔离: 防止跨租户数据访问。
- 唯一性保证: 通过
tenant_id与content_id的组合确保唯一。
使用 Drizzle ORM 的定义
import { primaryKey, text } from 'drizzle-orm/pg-core';
export const contents = appContent.table(
'contents',
{
tenant_id: text('tenant_id').notNull(),
content_id: text('content_id').notNull(),
title: text('title').notNull(),
// ...
},
table => ({
pk: primaryKey({ columns: [table.tenant_id, table.content_id] }),
})
);
实用技巧
准备 ID 验证函数
验证函数有助于防止无效 ID 带来的错误。
export function validateCuid2(id: string): void {
const cuid2Regex = /^[a-z0-9]{24}$/;
if (!cuid2Regex.test(id)) {
throw new Error('Invalid CUID2 format');
}
}
export function validateUuidV7(id: string): void {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(id)) {
throw new Error('Invalid UUID v7 format');
}
}
总结
运行良好的方面
- 使用 CUID2 的短小、易处理的 URL。
- 使用 UUID v7 的批量处理性能。
- 使用复合主键实现多租户数据隔离。
需要注意的事项
- 最佳 ID 取决于具体需求(没有唯一答案)。
- 迁移已有数据时需慎重规划。
- 与外部依赖(如认证库)的 ID 格式保持一致。
正如视频所述,最佳 ID 取决于项目需求。选取最适合你使用场景的方案。
明天我将讲解 “数据库迁移最佳实践:如何安全地应用变更”。
本系列的其他文章
- 12/6:使用 Supabase 的模式设计 – 表分区与规范化实战
- 12/8:数据库迁移最佳实践 – 如何安全地应用变更