---
title: "6.2 数据库基础概念"
description: "关系型数据库核心概念详解：表、行、列、主键、外键、关系类型、约束、数据类型"
---

# 6.2 数据库基础概念

> **本节目标**：彻底搞懂关系型数据库的核心概念——表、行、列、主键、外键、关系类型、约束机制。

---

## 小红的外卖创业

小红是个行动派。她发现校园里的外卖平台配送费太贵、商家选择太少，于是决定自己做一个校园外卖平台——只服务本校，配送靠同学兼职，抽成比美团低一半。

她先梳理了一下需要管理的数据：

- 学生用户：谁在用这个平台？手机号、昵称、宿舍地址
- 校园商家：食堂档口、奶茶店、水果店，每家有名字、评分、营业时间
- 菜品菜单：每家店卖什么、多少钱、有没有图片
- 订单记录：谁在哪家店买了什么、花了多少钱、现在什么状态
- 订单明细：一个订单里具体点了哪些菜、各几份

小红一开始想用 Excel 管理——毕竟她大学里用 Excel 做过课程表、记过账，感觉挺顺手的。

但她很快发现：一个订单里可能有三道菜，每道菜属于不同的分类，同一道菜可能出现在几百个订单里。这些数据之间的关系像一张网，Excel 的"一行一条记录"根本装不下。她试过在一个格子里用逗号分隔多道菜名，但查询和统计时简直是噩梦。

她需要的是一个能管理"数据之间关系"的工具——这就是**关系型数据库**。

"关系型"这三个字是关键。它不是说数据库能帮你管理人际关系，而是说它擅长管理**数据与数据之间的关系**：用户和订单的关系、订单和菜品的关系、商家和菜品的关系。这些关系在 Excel 里很难表达，但在关系型数据库里是天然支持的。

---

## 表：比 Excel 强大一百倍的"工作表"

数据库里的**表（Table）**，你可以先理解为一个"超级 Excel 工作表"。如果你用过 Excel，理解数据库表就有了一个很好的起点。

先看相似的地方：

| Excel 概念 | 数据库概念 | 说明 |
|-----------|-----------|------|
| 工作表 Sheet | 表 Table | 一类数据一张表，比如 `users` 表存所有用户 |
| 一行数据 | 行 Row（也叫记录） | 一个具体的用户、一笔具体的订单 |
| 表头列名 | 列 Column（也叫字段） | 数据的属性，比如 `phone`、`address` |

再看不同的地方——这些差异才是数据库真正的价值，也是 Excel 永远做不到的：

**数据类型严格。** Excel 里一列可以混放文字和数字，你在"年龄"列写个"很大"也没人拦你。数据库不行——`integer` 类型的列只能放整数，你写个文字进去，数据库直接报错拒绝。这看起来"不方便"，但恰恰是保证数据质量的关键。想象小红的外卖平台，如果"价格"列里混进了文字，结算时就会出错。数据库的严格类型从源头杜绝了这种问题。

**约束机制。** 数据库能设置规则：手机号不能重复（UNIQUE）、昵称不能为空（NOT NULL）、订单的用户 ID 必须指向一个真实存在的用户（FOREIGN KEY）。违反规则的数据写不进去，从源头杜绝脏数据。这些规则一旦设定，无论是谁、通过什么方式写入数据，都必须遵守——不像 Excel，任何人都能随意修改任何格子。

**表间关联。** 这是数据库最核心的能力，也是它叫"关系型"数据库的原因。多张表可以通过"外键"建立关系，然后用 SQL 的 JOIN 操作把关联数据一次性查出来。比如"查出小红的所有订单以及每个订单里的菜品"，一条 SQL 搞定。在 Excel 里，你得在多个 Sheet 之间来回跳，手动对照 ID，效率极低。

**并发安全。** 100 个用户同时下单，数据库通过事务和锁机制保证每笔订单都正确写入，不会互相覆盖、不会丢数据。Excel 做不到这一点——两个人同时编辑同一个文件，轻则产生冲突，重则丢失修改。

下面这个交互组件，让你直观感受一张表的结构——点击列名可以查看每个字段的详细说明：

<DatabaseVisualizer />

---

## 主键：每行数据的身份证

在现实世界里，每个人有一个身份证号，全国唯一，用来在各种系统里标识"你是谁"。去银行开户要身份证号，去医院挂号要身份证号，买火车票要身份证号——所有系统都通过这个号码来确认"你就是你"。

数据库里也一样。每张表都需要一个**主键（Primary Key）**，用来唯一标识每一行数据。就像身份证号标识一个人，主键标识一条记录。

主键的规则很简单，就三条：

- **唯一**：不能有两行的主键值相同（就像不能有两个人的身份证号一样）
- **不能为空**：每行必须有主键值（每个人都必须有身份证号）
- **不可变**：一旦分配，不应该修改（你不会隔三差五换身份证号）

实际开发中，主键通常用 `id` 列。现代 PostgreSQL 推荐用 `bigint generated always as identity`——意思是"用一个大整数做 ID，由数据库自动生成，每次自动 +1"（比老式的 `serial` 更标准、更安全）：

```sql
CREATE TABLE users (
  id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  phone text   NOT NULL
);
```

> Drizzle ORM 里写 `serial('id').primaryKey()` 也完全可以，AI 生成的代码两种都常见。核心是：让数据库自动分配 ID，你不需要操心。

你可能会问：手机号也是唯一的，为什么不用手机号当主键？

原因是手机号有"业务含义"——用户可能换号。一旦主键被其他表引用（比如订单表里存了 `user_id`），改主键就意味着要改所有引用它的地方，牵一发动全身。所以业界的最佳实践是：**用一个无业务含义的自增 ID 当主键**，简单、稳定、高效。

---

## 外键：表与表之间的线索

如果说主键是"我是谁"，那外键就是"我和谁有关系"。

**外键（Foreign Key）** 是一张表里指向另一张表主键的列。它的作用是建立表与表之间的关联，同时保证关联的数据真实存在。

举个例子：小红的 `orders` 表里有一列 `user_id`，它的值必须是 `users` 表里某个真实存在的 `id`。这就是外键。通过这个 `user_id`，你可以从一笔订单追溯到下单的用户——"这笔订单是谁下的？`user_id` 是 42，去 `users` 表查 id=42 的那行，哦，是小红。"

```sql
CREATE TABLE orders (
  id          serial PRIMARY KEY,
  user_id     integer REFERENCES users(id),  -- 外键，指向 users.id
  amount      real    NOT NULL,
  status      text    DEFAULT '待支付'
);
```

外键带来三个好处：

**保证数据一致性。** 你不能给一个不存在的用户创建订单。如果 `users` 表里没有 id=999 的用户，往 `orders` 表插入 `user_id=999` 的订单会直接报错。这就是"引用完整性"——你引用的东西必须真实存在，不能指向一个不存在的用户。

**建立关联。** 通过 `user_id`，你可以从一笔订单追溯到下单的用户，也可以从一个用户查到他的所有订单。数据之间的关系变得清晰可查。

**级联操作（连锁反应）。** 删除一个用户时，他的所有订单怎么办？数据库可以自动删除他的所有订单（级联删除），或者阻止删除（如果还有关联订单就不让删）。这个行为可以在建表时配置。

::: warning 外键列必须加索引
PostgreSQL **不会**自动给外键列创建索引。如果 `orders` 表有 10 万行，查"某个用户的所有订单"时，没有索引的 `user_id` 会导致全表扫描——从第一行翻到最后一行，像在没有目录的书里逐页找内容——慢 100 倍以上。所以每个外键列都要手动加索引。这个知识点在 6.4 会详细展开。
:::

> 你可以在上方组件的「关系图」tab 中，点击连线查看外键的具体关联方式。

---

## 关系类型详解

数据库表之间的关系，归纳起来就三种。理解这三种关系，你就能看懂任何数据库设计。

### 一对多（1:N）— 最常见

一个用户可以下多个订单，但每个订单只属于一个用户。这就是一对多。

```
users (1) ──→ orders (N)
  id              user_id → users.id
```

实现方式很直觉：在"多"的那张表加一个外键列。`orders` 表里的 `user_id` 指向 `users.id`，就建立了一对多关系。

一对多是最常见的关系类型，你在任何应用里都能找到大量例子：

- 一个商家有多道菜品（商家 → 菜品）
- 一个作者写多篇文章（作者 → 文章）
- 一个班级有多个学生（班级 → 学生）
- 一个用户发多条动态（用户 → 动态）

判断方法也很简单：问自己"一个 A 可以有多个 B 吗？一个 B 只属于一个 A 吗？"如果两个都是"是"，就是一对多。

### 多对多（M:N）— 需要中间表

一个订单包含多道菜，一道菜也出现在多个订单里。这种"双向的多"就是多对多关系。

多对多不能直接用一个外键表示——因为外键只能指向一条记录，没法同时指向多条。解决方案是加一张**中间表**，把多对多拆成两个一对多：

```
orders (M) ←─ order_items ─→ dishes (N)
  id            order_id        id
                dish_id
                quantity
```

`order_items` 就是中间表。每行记录"哪个订单买了哪道菜、几份"。一个订单在 `order_items` 里有多行（点了多道菜），一道菜在 `order_items` 里也有多行（被多个订单点过）。

中间表不只是"连接器"，它还可以存储关系本身的属性。比如 `quantity`（数量）就是"订单和菜品之间的关系"的属性，不属于订单，也不属于菜品，只属于这个关联本身。

更多多对多的例子：

- 学生 ↔ 课程（中间表：选课记录，可以存成绩）
- 用户 ↔ 标签（中间表：用户标签，可以存打标时间）
- 文章 ↔ 分类（中间表：文章分类关联）

### 一对一（1:1）— 拆分大表

一个用户对应一份详细资料。为什么不放在同一张表里？

想象 `users` 表有 50 万用户。每次用户登录，系统都要查 `users` 表验证身份。如果这张表里塞了头像 URL、个人简介、偏好设置、地址列表等一大堆不常用的字段，表就变得很"重"，查询速度会下降。

解决方案是把不常用的字段拆到另一张表：

```sql
CREATE TABLE profiles (
  id       serial PRIMARY KEY,
  user_id  integer UNIQUE REFERENCES users(id),  -- UNIQUE 保证一对一
  avatar   text,
  bio      text
);
```

关键是 `user_id` 上的 **UNIQUE** 约束。没有它，一个用户就可以有多条 profile，变成一对多了。加了 UNIQUE，数据库保证每个 `user_id` 只出现一次，就是严格的一对一。

一对一在实际项目中不太常见，但在这些场景下很有用：

- 用户基本信息 ↔ 用户详细资料（拆分大表）
- 订单 ↔ 发票（不是每个订单都有发票）
- 员工 ↔ 工位（一个员工一个工位）

---

## 数据完整性：为什么约束重要

没有约束的数据库就像没有规则的仓库——什么都能往里塞，迟早乱套。今天塞进去一个"年龄 -5"的用户，明天塞进去一个"价格为空"的商品，后天你的应用就会在各种奇怪的地方崩溃，而且你根本不知道是数据的问题。

**约束（Constraint）** 是数据库自动执行的规则。每次写入数据时，数据库会检查这些规则，不合规的数据直接拒绝，连存都不让存。这就像机场安检——不管你是谁、从哪来，行李都要过 X 光机，违禁品一律拦截。

| 约束 | 作用 | 生活类比 |
|------|------|---------|
| PRIMARY KEY（主键） | 唯一标识每行 | 身份证号，全国唯一 |
| FOREIGN KEY（外键） | 确保引用的数据存在 | 快递单上的收件人必须是真人 |
| NOT NULL（必填） | 不允许为空 | 表单里的必填项，不填不让提交 |
| UNIQUE（不可重复） | 不允许重复 | 手机号不能重复注册 |
| CHECK（条件检查） | 自定义条件检查 | 年龄必须大于 0，评分必须在 1-5 之间 |
| DEFAULT（默认值） | 未填时自动填入默认值 | 订单状态默认"待支付" |

> 在上方组件的「约束演示」tab 中，你可以看到每种约束的正确 vs 违规插入对比。

你可能会想：我在代码里做校验不就行了，为什么还要在数据库层面加约束？

三个原因：

**代码可能有 bug，但数据库约束是最后一道防线。** 你的校验逻辑写错了、漏了一个边界条件、某个 if 判断少了个等号——这些都可能让脏数据溜进去。数据库约束不会犯这种错误，它是铁面无私的守门员。

**数据的入口不止一个。** 你的应用有 API 接口、有后台管理面板、有数据迁移脚本、有定时任务……每个入口都可能写入数据。你不可能在每个入口都完美地实现一遍校验逻辑。但数据库约束只需要定义一次，所有入口都受保护。

**脏数据的清理成本极高。** 想象几万条订单的 `user_id` 指向了不存在的用户——你怎么修？删掉这些订单？那用户的钱怎么办？把 `user_id` 改成某个默认用户？那数据就失真了。预防永远比治疗便宜。

---

## 数据类型速查

每个列都必须指定数据类型。类型选对了，存储高效、查询快速；选错了，轻则浪费空间，重则数据丢失。

常用的 PostgreSQL 数据类型：

| 类型 | 用途 | 示例 | 注意事项 |
|------|------|------|---------|
| `bigint` / `serial` | 主键 ID | 1, 2, 3... | `bigint` 更安全，`serial` 也够用 |
| `text` | 任意长度文本 | '你好世界' | 永远用 `text`，不要用 `varchar(n)` |
| `integer` | 整数 | 42, -1, 0 | 最大 21 亿，够用就行 |
| `boolean` | 真/假 | true / false | — |
| `timestamptz` | 日期时间 | '2024-01-15 09:30:00+08' | 带时区，**不要用** `timestamp` |
| `jsonb` | 结构化 JSON | '{"tags":["vip"]}' | 可建 GIN 索引加速查询 |
| `numeric(10,2)` | 精确小数 | 99.99 | 金额必须用这个，不要用 `real` |
| `uuid` | 全局唯一标识 | 'a1b2c3d4-...' | 分布式系统常用 |

> 在上方组件的「数据类型」tab 中，可以查看每种类型对应的 Drizzle ORM 写法。

::: warning 三个常见的类型陷阱

1. **金额用 `real` / `float`**：浮点数有精度问题（0.1 + 0.2 ≠ 0.3），金额必须用 `numeric` 或 `integer`（单位：分）
2. **时间用 `timestamp`**：不带时区的时间戳会在不同服务器上产生歧义，永远用 `timestamptz`
3. **字符串用 `varchar(255)`**：PostgreSQL 里 `text` 和 `varchar` 性能完全一样，`varchar(n)` 只是多了个长度限制，没有任何性能优势
:::

---

## SQLite vs PostgreSQL

| | SQLite | PostgreSQL |
|--|--------|------------|
| 定位 | 文件型数据库，零配置 | 完整的数据库服务器 |
| 存储 | 单个 `.db` 文件 | 独立的数据库服务进程 |
| 并发 | 单写多读，适合小规模 | 高并发读写，适合生产环境 |
| 数据类型 | 宽松（实际上只有 5 种） | 严格且丰富（50+ 种） |
| JSON 支持 | 基础 | 强大的 `jsonb` 类型 |
| 适用场景 | 本地开发、嵌入式、原型验证 | 生产环境、团队协作、高并发 |
| 本教程建议 | 学习阶段可用，快速上手 | 实际项目推荐，Supabase 免费提供 |

::: tip 选择建议
如果你用 Supabase 或 Neon 等云数据库服务，直接就是 PostgreSQL，不需要纠结。本地开发想快速验证，SQLite 也完全够用——Drizzle ORM 支持两者无缝切换。
:::

---

## 让 AI 帮你设计表结构

理解了这些概念后，你可以用这样的提示词让 AI 帮你设计数据库：

```
我要做一个 [应用描述]，请帮我设计数据库表结构。

要求：
1. 使用 PostgreSQL + Drizzle ORM
2. 列出所有表、字段、类型、约束
3. 标注表之间的关系（一对多/多对多）
4. 每张表都要有 id、created_at、updated_at
5. 用中文注释解释每个字段的用途
```

AI 会生成完整的 Drizzle schema 代码，你可以直接复制到项目里。

---

::: tip 本节核心要点

- **表** = 一类数据的容器，行是记录，列是属性
- **主键** = 每行的唯一 ID，用 `serial` 自增
- **外键** = 表间关联的线索，保证引用完整性
- **三种关系**：一对多（最常见）、多对多（需中间表）、一对一（拆分大表）
- **约束** = 数据库层面的规则守卫，是代码校验之外的最后防线
- **数据类型** = 选对类型，存储高效、查询快速
:::
