数据库 ID 设计:选择 ID 方法和主键策略

发布: (2025年12月7日 GMT+8 10:53)
6 min read
原文: Dev.to

Source: Dev.to

ID 设计的考虑因素

为数据库选择主键(ID)是一个出乎意料的深奥话题。虽然某些框架提供默认值,但你通常需要自行做出选择。在 PostgreSQL 中,自动递增是通过 SERIAL 类型实现的(内部使用 SEQUENCE)。

UUIDCUID2 这样的 ID 方法将时间戳和随机值结合,生成无需中心管理的唯一 ID,使分布式系统能够在不担心冲突的情况下创建数据。

一个帮助组织 ID 选择决策标准的实用视频:
https://www.youtube.com/watch?v=pmqRaEcDxl4

主要 ID 方法对比

方法长度可按时间排序PostgreSQL 存储方式特点
顺序(SERIAL/SEQUENCE)最多 19 位数字○(基本可行)原生简单、可预测
UUID v436 字符×原生标准、随机
UUID v736 字符可存为 UUID 类型可按时间排序
ULID26 字符text 类型可读字符集
CUID224+ 字符×text 类型短、可靠
NanoID21+ 字符×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 方法理由
内容 IDCUID2用于 URL,强调简短
表格内容行 IDUUID 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_idcontent_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:数据库迁移最佳实践 – 如何安全地应用变更
Back to Blog

相关文章

阅读更多 »

PostgreSQL 日志查看

Forem 徽标https://media2.dev.to/dynamic/image/width=65,height=,fit=scale-down,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%...

PostgreSQL MERGE INTO

SQL UPDATE PSMT_INVOICE_M SET SHIPPING_COUNTRY_ID = SRC.COUNTRY_ID, SHIPPING_CITY_ID = SRC.CITY_ID, SHIPPING_TOWN_ID = SRC.TOWN_ID FROM SELECT PM.INVOICE_M_ID,...

PostgreSQL 中的锁

请提供您希望翻译的具体摘录或摘要文本,我才能为您进行翻译。