Skip to content
清晨的一缕阳光
返回

表设计与范式理论

表设计与范式理论

良好的表设计是数据库性能的基石。本文将深入解析范式理论、反范式设计,并提供实用的表设计原则和最佳实践。

一、范式理论

1.1 什么是范式

范式(Normal Form)是数据库表设计的规范化标准,目的是:

1.2 第一范式(1NF)

定义:每个字段都是原子性的,不可再分。

-- ❌ 违反 1NF:字段可再分
CREATE TABLE users (
  id INT PRIMARY KEY,
  name VARCHAR(50),
  address VARCHAR(200)  -- 包含省、市、区
);

-- 数据示例:
-- 地址:"浙江省杭州市西湖区"

-- ✅ 符合 1NF:字段原子化
CREATE TABLE users (
  id INT PRIMARY KEY,
  name VARCHAR(50),
  province VARCHAR(50),
  city VARCHAR(50),
  district VARCHAR(50)
);

-- 数据示例:
-- province: "浙江省"
-- city: "杭州市"
-- district: "西湖区"

1.3 第二范式(2NF)

定义:在 1NF 基础上,非主键字段完全依赖主键,不存在部分依赖。

-- ❌ 违反 2NF:存在部分依赖
CREATE TABLE order_items (
  order_id INT,
  product_id INT,
  product_name VARCHAR(100),  -- 只依赖 product_id
  product_price DECIMAL(10,2), -- 只依赖 product_id
  quantity INT,               -- 依赖 (order_id, product_id)
  PRIMARY KEY (order_id, product_id)
);

-- 问题:
-- 1. 产品信息重复存储
-- 2. 修改产品价格需要更新多行

-- ✅ 符合 2NF:拆分表
CREATE TABLE orders (
  order_id INT PRIMARY KEY,
  -- 订单信息
);

CREATE TABLE products (
  product_id INT PRIMARY KEY,
  product_name VARCHAR(100),
  product_price DECIMAL(10,2)
);

CREATE TABLE order_items (
  order_id INT,
  product_id INT,
  quantity INT,
  PRIMARY KEY (order_id, product_id),
  FOREIGN KEY (order_id) REFERENCES orders(order_id),
  FOREIGN KEY (product_id) REFERENCES products(product_id)
);

1.4 第三范式(3NF)

定义:在 2NF 基础上,非主键字段直接依赖主键,不存在传递依赖。

-- ❌ 违反 3NF:存在传递依赖
CREATE TABLE orders (
  order_id INT PRIMARY KEY,
  user_id INT,
  user_name VARCHAR(50),    -- 依赖 user_id,而非 order_id
  user_phone VARCHAR(20),   -- 依赖 user_id,而非 order_id
  order_date DATETIME
);

-- 问题:
-- 1. 用户信息重复存储
-- 2. 用户修改手机号需要更新多行

-- ✅ 符合 3NF:拆分表
CREATE TABLE users (
  user_id INT PRIMARY KEY,
  user_name VARCHAR(50),
  user_phone VARCHAR(20)
);

CREATE TABLE orders (
  order_id INT PRIMARY KEY,
  user_id INT,
  order_date DATETIME,
  FOREIGN KEY (user_id) REFERENCES users(user_id)
);

1.5 范式关系图

graph LR
    A[非规范化表] --> B[1NF<br/>字段原子化]
    B --> C[2NF<br/>消除部分依赖]
    C --> D[3NF<br/>消除传递依赖]
    D --> E[BCNF<br/>消除主属性依赖]
    
    style A fill:#ffcccc
    style D fill:#ccffcc
    style E fill:#ccffcc

二、反范式设计

2.1 为什么需要反范式

虽然范式化设计有很多优点,但在实际应用中,过度范式化会带来问题:

范式化的缺点:
1. 查询需要多表 JOIN,性能差
2. 索引维护成本高
3. 复杂查询难以优化

反范式通过有意识地引入冗余来提高查询性能。

2.2 反范式设计场景

场景 1:减少 JOIN 操作

-- 范式化设计(需要 JOIN)
SELECT o.order_id, o.amount, u.user_name, u.phone
FROM orders o
JOIN users u ON o.user_id = u.user_id
WHERE o.order_id = 1001;

-- 反范式设计(冗余用户信息)
CREATE TABLE orders (
  order_id INT PRIMARY KEY,
  user_id INT,
  user_name VARCHAR(50),  -- 冗余
  user_phone VARCHAR(20), -- 冗余
  amount DECIMAL(10,2),
  order_date DATETIME
);

-- 查询无需 JOIN
SELECT order_id, amount, user_name, user_phone
FROM orders
WHERE order_id = 1001;

场景 2:统计字段冗余

-- 范式化设计(每次查询需要 COUNT/SUM)
SELECT user_id, COUNT(*) AS order_count, SUM(amount) AS total_amount
FROM orders
GROUP BY user_id;

-- 反范式设计(冗余统计字段)
CREATE TABLE users (
  user_id INT PRIMARY KEY,
  user_name VARCHAR(50),
  order_count INT DEFAULT 0,    -- 冗余
  total_amount DECIMAL(10,2) DEFAULT 0  -- 冗余
);

-- 查询极快
SELECT user_id, order_count, total_amount
FROM users
WHERE user_id = 1;

场景 3:历史数据快照

-- 订单中的商品信息冗余(价格可能变化)
CREATE TABLE order_items (
  order_id INT,
  product_id INT,
  product_name VARCHAR(100),  -- 冗余(下单时的名称)
  product_price DECIMAL(10,2), -- 冗余(下单时的价格)
  quantity INT
);

-- 即使 products 表价格变化,订单中的价格保持不变

2.3 反范式的代价

反范式带来的问题:
1. 数据不一致风险
2. 写入性能下降(需要更新多行)
3. 存储空间增加
4. 应用逻辑复杂化

数据一致性维护

-- 方案 1:应用层维护(推荐)
-- 在代码中同时更新 orders 和 users 表

-- 方案 2:触发器维护(不推荐)
CREATE TRIGGER update_user_stats
AFTER INSERT ON orders
FOR EACH ROW
BEGIN
  UPDATE users 
  SET order_count = order_count + 1,
      total_amount = total_amount + NEW.amount
  WHERE user_id = NEW.user_id;
END;

-- 方案 3:定时任务维护
-- 定期同步统计数据

三、表设计基本原则

3.1 表结构大小控制

-- ✅ 推荐:单表字段数控制在 20 个以内
CREATE TABLE users (
  -- 基础信息(10 个字段)
  id BIGINT PRIMARY KEY,
  username VARCHAR(32),
  password_hash VARCHAR(60),
  email VARCHAR(100),
  phone CHAR(11),
  
  -- 扩展信息(10 个字段)
  avatar_url VARCHAR(255),
  nickname VARCHAR(50),
  gender TINYINT,
  birthday DATE,
  status TINYINT
);

-- ❌ 避免:单表字段过多(超过 50 个)
-- 应该垂直拆分

3.2 垂直拆分

将大表按字段使用频率拆分:

-- 主表(高频访问字段)
CREATE TABLE users (
  id BIGINT PRIMARY KEY,
  username VARCHAR(32),
  email VARCHAR(100),
  status TINYINT,
  created_at DATETIME
);

-- 扩展表(低频访问字段)
CREATE TABLE user_profile (
  user_id BIGINT PRIMARY KEY,
  address TEXT,
  bio TEXT,
  preferences JSON,
  FOREIGN KEY (user_id) REFERENCES users(id)
);

3.3 水平拆分

将大表按数据范围拆分:

-- 按时间拆分
CREATE TABLE orders_2024q1 (...);  -- 2024 年 Q1
CREATE TABLE orders_2024q2 (...);  -- 2024 年 Q2
CREATE TABLE orders_2024q3 (...);  -- 2024 年 Q3
CREATE TABLE orders_2024q4 (...);  -- 2024 年 Q4

-- 按用户 ID 拆分
CREATE TABLE orders_0 (...);       -- user_id % 10 = 0
CREATE TABLE orders_1 (...);       -- user_id % 10 = 1
...
CREATE TABLE orders_9 (...);       -- user_id % 10 = 9

3.4 命名规范

-- ✅ 推荐命名规范
-- 表名:小写字母 + 下划线,复数形式
CREATE TABLE users (...);
CREATE TABLE order_items (...);

-- 字段名:小写字母 + 下划线
CREATE TABLE users (
  user_id BIGINT PRIMARY KEY,  -- 主键
  created_at DATETIME,          -- 创建时间
  updated_at DATETIME           -- 更新时间
);

-- 索引名:idx_字段名
CREATE INDEX idx_user_id ON orders(user_id);

-- 唯一索引:uk_字段名
CREATE UNIQUE INDEX uk_email ON users(email);

-- 外键:fk_本表_关联表
ALTER TABLE orders 
ADD CONSTRAINT fk_orders_users 
FOREIGN KEY (user_id) REFERENCES users(user_id);

四、字段设计最佳实践

4.1 主键设计

-- ✅ 推荐:自增 BIGINT
CREATE TABLE users (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY
);

-- ✅ 推荐:雪花算法 ID
CREATE TABLE orders (
  id BIGINT PRIMARY KEY  -- 雪花算法生成
);

-- ❌ 避免:UUID 作为主键
CREATE TABLE users (
  id CHAR(36) PRIMARY KEY  -- 随机写入性能差
);

-- ⚠️ 注意:避免使用业务字段作为主键
CREATE TABLE users (
  email VARCHAR(100) PRIMARY KEY  -- 邮箱可能变更
);

4.2 时间字段

-- ✅ 推荐:包含创建和更新时间
CREATE TABLE users (
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  deleted_at DATETIME  -- 软删除时间
);

-- ✅ 推荐:时间字段允许 NULL
CREATE TABLE orders (
  paid_at DATETIME,      -- 支付时间(可能未支付)
  shipped_at DATETIME,   -- 发货时间
  completed_at DATETIME  -- 完成时间
);

4.3 软删除设计

-- ✅ 推荐:软删除字段
CREATE TABLE users (
  id BIGINT PRIMARY KEY,
  username VARCHAR(32),
  is_deleted TINYINT DEFAULT 0,  -- 0=正常,1=删除
  deleted_at DATETIME
);

-- 查询时过滤已删除
SELECT * FROM users WHERE is_deleted = 0;

-- 逻辑删除
UPDATE users SET is_deleted = 1, deleted_at = NOW() WHERE id = 1;

4.4 注释规范

-- ✅ 推荐:表和字段都添加注释
CREATE TABLE users (
  id BIGINT UNSIGNED AUTO_INCREMENT COMMENT '用户 ID',
  username VARCHAR(32) NOT NULL COMMENT '用户名',
  email VARCHAR(100) NOT NULL COMMENT '邮箱',
  status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0=禁用,1=正常',
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) ENGINE = InnoDB COMMENT '用户表';

五、索引设计策略

5.1 索引设计原则

-- ✅ 适合建索引的字段
-- 1. 主键和外键
-- 2. WHERE 子句中的字段
-- 3. JOIN 连接的字段
-- 4. ORDER BY 排序的字段
-- 5. GROUP BY 分组的字段

-- ❌ 不适合建索引的字段
-- 1. 数据量小的表
-- 2. 频繁更新的字段
-- 3. 区分度低的字段(如性别)

5.2 联合索引设计

-- ✅ 推荐:根据查询场景设计联合索引
CREATE TABLE orders (
  id BIGINT PRIMARY KEY,
  user_id BIGINT,
  status TINYINT,
  created_at DATETIME,
  INDEX idx_user_status_created (user_id, status, created_at)
);

-- 查询场景:
-- 1. 查询用户的订单
SELECT * FROM orders WHERE user_id = 1;  -- ✅ 使用索引

-- 2. 查询用户某状态的订单
SELECT * FROM orders 
WHERE user_id = 1 AND status = 1;  -- ✅ 使用索引

-- 3. 查询用户某状态的订单并按时间排序
SELECT * FROM orders 
WHERE user_id = 1 AND status = 1 
ORDER BY created_at DESC;  -- ✅ 使用索引

-- ❌ 不符合最左前缀
SELECT * FROM orders WHERE status = 1;  -- 不使用索引

5.3 覆盖索引

-- ✅ 利用覆盖索引避免回表
CREATE TABLE users (
  id BIGINT PRIMARY KEY,
  email VARCHAR(100),
  phone CHAR(11),
  INDEX idx_email_phone (email, phone)
);

-- 覆盖索引查询(只查索引字段)
SELECT id, email FROM users WHERE email = 'test@example.com';

-- ❌ 需要回表
SELECT * FROM users WHERE email = 'test@example.com';

六、常见表设计陷阱

6.1 EAV 模型陷阱

-- ❌ 避免:EAV(Entity-Attribute-Value)模型
CREATE TABLE user_attributes (
  user_id BIGINT,
  attribute_name VARCHAR(50),
  attribute_value VARCHAR(255)
);

-- 数据示例:
-- user_id | attribute_name | attribute_value
-- 1       | height         | 175
-- 1       | weight         | 70
-- 1       | age            | 25

-- 问题:
-- 1. 查询复杂
-- 2. 无法使用类型约束
-- 3. 性能差

-- ✅ 推荐:JSON 或固定字段
CREATE TABLE users (
  id BIGINT PRIMARY KEY,
  attributes JSON  -- 灵活扩展
);

6.2 大字段滥用

-- ❌ 避免:主表包含大字段
CREATE TABLE articles (
  id INT PRIMARY KEY,
  title VARCHAR(200),
  content TEXT,      -- 大字段
  summary TEXT,      -- 大字段
  tags TEXT          -- 大字段
);

-- ✅ 推荐:大字段分离
CREATE TABLE articles (
  id INT PRIMARY KEY,
  title VARCHAR(200),
  created_at DATETIME
);

CREATE TABLE article_content (
  article_id INT PRIMARY KEY,
  content TEXT,
  FOREIGN KEY (article_id) REFERENCES articles(id)
);

6.3 外键依赖

-- ⚠️ 谨慎:使用数据库外键
CREATE TABLE orders (
  user_id BIGINT NOT NULL,
  FOREIGN KEY (user_id) REFERENCES users(id)
);

-- 问题:
-- 1. 影响写入性能
-- 2. 迁移困难
-- 3. 分库分表受限

-- ✅ 推荐:应用层维护外键约束
-- 在代码中检查用户是否存在

七、实战案例

7.1 电商订单表设计

-- 订单主表
CREATE TABLE orders (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '订单 ID',
  order_no CHAR(32) NOT NULL COMMENT '订单号',
  user_id BIGINT UNSIGNED NOT NULL COMMENT '用户 ID',
  amount DECIMAL(10,2) NOT NULL COMMENT '订单金额',
  pay_amount DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '实付金额',
  status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0=待支付,1=已支付,2=已发货,3=已完成,-1=已取消',
  shipping_address JSON COMMENT '收货地址快照',
  remark VARCHAR(255) COMMENT '备注',
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  paid_at DATETIME COMMENT '支付时间',
  shipped_at DATETIME COMMENT '发货时间',
  completed_at DATETIME COMMENT '完成时间',
  deleted_at DATETIME COMMENT '删除时间',
  UNIQUE KEY uk_order_no (order_no),
  INDEX idx_user_id (user_id),
  INDEX idx_created_at (created_at),
  INDEX idx_status (status)
) ENGINE = InnoDB COMMENT '订单表';

-- 订单商品表
CREATE TABLE order_items (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  order_id BIGINT UNSIGNED NOT NULL,
  product_id BIGINT UNSIGNED NOT NULL,
  product_name VARCHAR(100) NOT NULL COMMENT '商品名称快照',
  product_price DECIMAL(10,2) NOT NULL COMMENT '商品单价快照',
  quantity INT NOT NULL COMMENT '数量',
  total_price DECIMAL(10,2) NOT NULL COMMENT '总价',
  INDEX idx_order_id (order_id),
  INDEX idx_product_id (product_id)
) ENGINE = InnoDB COMMENT '订单商品表';

7.2 用户积分表设计

-- 积分账户表
CREATE TABLE user_points (
  user_id BIGINT UNSIGNED PRIMARY KEY COMMENT '用户 ID',
  total_points BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '累计积分',
  available_points BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '可用积分',
  frozen_points BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '冻结积分',
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  INDEX idx_available_points (available_points)
) ENGINE = InnoDB COMMENT '用户积分账户';

-- 积分流水表
CREATE TABLE point_transactions (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  user_id BIGINT UNSIGNED NOT NULL,
  type TINYINT NOT NULL COMMENT '类型:1=收入,2=支出,3=冻结,4=解冻',
  amount BIGINT NOT NULL COMMENT '金额(正数收入,负数支出)',
  balance_before BIGINT NOT NULL COMMENT '变更前余额',
  balance_after BIGINT NOT NULL COMMENT '变更后余额',
  biz_type VARCHAR(50) COMMENT '业务类型',
  biz_id BIGINT COMMENT '业务 ID',
  remark VARCHAR(255) COMMENT '备注',
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_user_id (user_id),
  INDEX idx_created_at (created_at),
  INDEX idx_biz (biz_type, biz_id)
) ENGINE = InnoDB COMMENT '积分流水表';

总结

表设计核心原则:

  1. 遵循范式:减少冗余,避免异常
  2. 适度反范式:权衡读写性能
  3. 控制表大小:字段数<20,单表数据量<1000 万
  4. 合理拆分:垂直拆分(大字段)、水平拆分(大数据量)
  5. 索引优化:根据查询场景设计索引
  6. 预留扩展:软删除、扩展字段、JSON

设计 Checklist

下一篇:字符集与排序规则 - 深入解析 UTF8、UTF8MB4 字符集和排序规则的选择与优化


分享这篇文章到:

上一篇文章
Go 微服务架构实战
下一篇文章
为人父母,情绪稳定到底有多重要