从餐厅点餐系统崩溃说起:数据库表设计常犯的5个错误新手必看的三范式入门与反范式实践完整指南
凌晨三点,城市的霓虹灯已经熄灭,但某连锁餐厅的后厨监控室里,值班经理老王正对着满屏红色的报错信息发呆。
“又崩了。”
这是这家餐厅本周第三次点餐系统崩溃。问题出在周五晚高峰,三千多名顾客同时下单,订单数据像洪水一样涌入数据库,结果服务器直接宕机。技术团队排查了整整一夜,最终发现罪魁祸首是一个看起来”没啥问题”的订单表。
老王把技术负责人小李叫到办公室,把一叠打印出来的表结构图拍在桌上:”这表是谁设计的?”
小李挠了挠头:”是我…刚毕业没多久,照着网上的教程写的。”
“教程没告诉你,表设计这玩意儿,跟盖楼一样,地基打歪了,楼盖得越高越危险。”老王点了根烟,”明天开始,重新学。”
这个故事不是虚构的,它发生在去年。而小李犯的错误,90%的初学者都踩过。今天咱们就从头到尾,把这个话题掰开了揉碎了讲清楚。
一、那个让餐厅崩溃的订单表,到底长啥样
先来看看小李当时写的表结构。
CREATE TABLE orders (
id INT PRIMARY KEY AUTO_INCREMENT,
customer_name VARCHAR(100),
customer_phone VARCHAR(20),
customer_address VARCHAR(500),
dish_name VARCHAR(200),
dish_price DECIMAL(10, 2),
dish_category VARCHAR(50),
quantity INT,
order_time DATETIME,
status VARCHAR(20),
total_amount DECIMAL(10, 2),
remark TEXT
);
看起来挺正常的,对吧?有订单号、有顾客信息、有菜品信息、有金额。很多新手第一次学数据库,写出来的表都长这样。
但问题恰恰就在这里。
这个问题表,把”订单”和”菜品”混在了同一张表里。 想象一下,一个顾客点了5道菜,这个订单就要写5行记录。而如果这个顾客点了10道菜,就是10行。订单信息和菜品信息重复存储,数据量呈指数级增长。
更致命的是,当餐厅搞促销活动,比如”宫保鸡丁”今天半价,小李需要在每一行里手动更新价格。如果有10万条历史订单记录,这就是10万次UPDATE操作。
系统崩溃的那个周五,三千多名顾客同时下单,每一单平均点4-5道菜,就是超过一万条INSERT操作。而数据库因为数据冗余和索引混乱,处理速度越来越慢,最终OOM(内存溢出)崩溃。
这就是典型的表设计灾难。下面咱们说说新手最常犯的5个错误,以及怎么避免。
二、错误一:把所有信息塞进一张表
这是新手最常见的错误,没有之一。
你可能觉得,”一张表多方便,查数据不用JOIN,多简单”。但数据库设计不是写Excel表格,”简单”往往意味着”混乱”。
问题演示
还是刚才那个订单表的例子,我们把问题摊开来看。
假设餐厅有100道菜,每天平均有500个订单,每个订单平均点3道菜。那么一天下来:
订单行数 = 500 × 3 = 1500 行
看起来不多?但一年就是:
1500 × 365 = 547,500 行
三年内超过一百万行。而每一行里,顾客姓名、电话、地址都可能重复存储。一个活跃顾客如果点了10次餐,他的信息就会在数据库里出现10次。
更严重的问题是数据不一致。
想象一下这个场景:
顾客张三(电话13800138000)第一次下单,地址填的是”朝阳区建国路88号”。第二次下单,他搬新家了,地址改成”海淀区中关村大街1号”。
但因为历史订单是独立存储的,第一次订单的地址不会自动更新。如果你想要查看”张三所有订单”,你会看到两个不同的地址。哪个是对的?没人知道。
这就是数据冗余导致的不一致性问题。
正确做法
把订单和订单明细分开,订单和顾客分开。
-- 顾客表
CREATE TABLE customers (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
phone VARCHAR(20) NOT NULL UNIQUE,
address VARCHAR(500),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 菜品表
CREATE TABLE dishes (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(200) NOT NULL,
price DECIMAL(10, 2) NOT NULL,
category VARCHAR(50),
description TEXT,
is_available BOOLEAN DEFAULT TRUE
);
-- 订单表(只存订单级别的信息)
CREATE TABLE orders (
id INT PRIMARY KEY AUTO_INCREMENT,
customer_id INT NOT NULL,
total_amount DECIMAL(10, 2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
delivery_address VARCHAR(500) NOT NULL,
remark TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (customer_id) REFERENCES customers(id)
);
-- 订单明细表(存每道菜的记录)
CREATE TABLE order_items (
id INT PRIMARY KEY AUTO_INCREMENT,
order_id INT NOT NULL,
dish_id INT NOT NULL,
quantity INT NOT NULL DEFAULT 1,
unit_price DECIMAL(10, 2) NOT NULL, -- 下单时的价格,用于历史记录
FOREIGN KEY (order_id) REFERENCES orders(id),
FOREIGN KEY (dish_id) REFERENCES dishes(id)
);
这样设计之后:
- 顾客信息只存一次,不会重复
- 菜品价格只存在菜品表里,促销时只改一处
- 订单明细独立存储,查询和更新都不会互相影响
- 历史订单的价格被锁定在
unit_price字段,不会因为菜品调价而改变
这才是正确的”分而治之”。
三、错误二:随意使用大文本字段存储结构化数据
很多新手有一个习惯:遇到不知道怎么设计的数据,直接丢进TEXT字段。
“这个备注信息有时候长有时候短,用TEXT吧。” “这个地址有时候有邮编有时候没有,也用TEXT吧。” “这个用户偏好,格式不固定,还是TEXT吧。”
看起来很方便,但实际上这是埋了一颗定时炸弹。
问题演示
假设你在订单表里加了一个preferences字段,用来存顾客的口味偏好:
ALTER TABLE customers ADD COLUMN preferences TEXT;
然后你在程序里这样存数据:
# 错误做法:把结构化数据当字符串存
preferences = {
"spicy_level": "medium",
"no_onion": True,
"preferred_delivery_time": "18:00-19:00",
"allergies": ["花生", "海鲜"]
}
customer.preferences = json.dumps(preferences)
数据看起来是存进去了,但问题来了:
你没办法用SQL高效查询。
想要找出”所有对花生过敏的顾客”,你没法直接写:
-- 这种查询效率极低,而且不可靠
SELECT * FROM customers WHERE preferences LIKE '%花生%';
为什么不行?因为LIKE查询会全表扫描,数据量大时性能极差。而且如果数据格式稍有变化(比如”花生”写成了”花生过敏”),查询就会遗漏。
数据一致性也没法保证。
TEXT字段不会做类型检查。如果有人存入了非法的JSON,数据库不会报错,但你的程序会崩溃。
正确做法
如果数据有结构,就应该有结构化的存储方式。
方案一:拆分成独立的列
ALTER TABLE customers ADD COLUMN spicy_level VARCHAR(20);
ALTER TABLE customers ADD COLUMN no_onion BOOLEAN DEFAULT FALSE;
ALTER TABLE customers ADD COLUMN preferred_delivery_start TIME;
ALTER TABLE customers ADD COLUMN preferred_delivery_end TIME;
ALTER TABLE customers ADD COLUMN allergies VARCHAR(500); -- 用逗号分隔
查询就简单了:
-- 找出所有对花生过敏的顾客
SELECT * FROM customers WHERE allergies LIKE '%花生%';
-- 找出偏好微辣的顾客
SELECT * FROM customers WHERE spicy_level = 'medium';
方案二:使用JSON类型(MySQL 5.7+ / PostgreSQL)
ALTER TABLE customers ADD COLUMN preferences JSON;
# 存
customer.preferences = {
"spicy_level": "medium",
"no_onion": True,
"preferred_delivery_time": "18:00-19:00",
"allergies": ["花生", "海鲜"]
}
# 查 - MySQL 可以直接查询JSON内部
SELECT * FROM customers
WHERE JSON_EXTRACT(preferences, '$.allergies')
LIKE '%花生%';
虽然JSON查询比直接列查询慢,但至少结构是清晰的,数据库也能做基本的校验。
核心原则:有结构的数据,不要当成字符串存。
四、错误三:主键设计混乱
主键是表的核心,但很多新手对主键的理解停留在”自增ID”这一层。
常见的三种错误主键设计
错误1:用业务字段当主键
CREATE TABLE orders (
order_number VARCHAR(32) PRIMARY KEY, -- 用订单号当主键
customer_id INT,
total_amount DECIMAL(10, 2),
...
);
订单号是”ORD202401010001”这样的格式。用作主键有什么问题?
- 订单号会变化(比如顾客取消订单后重新下单,可能生成新号)
- 字符串比较比整数比较慢得多
- 如果订单号规则改变,主键也要跟着改,牵一发而动全身
错误2:用联合主键滥用
CREATE TABLE order_items (
order_id INT,
dish_id INT,
quantity INT,
PRIMARY KEY (order_id, dish_id) -- 联合主键
);
联合主键本身不是问题,但这里有个隐患:如果同一个订单里点了两份”宫保鸡丁”(比如一份微辣一份中辣),这个表就存不下了。你应该把”订单ID+菜品ID+备注”作为联合主键,或者干脆用自增ID。
错误3:主键没有索引意识
CREATE TABLE products (
id INT, -- 忘了设PRIMARY KEY
name VARCHAR(200),
category VARCHAR(50),
price DECIMAL(10, 2)
);
没有主键的表,MySQL会用第一列创建聚簇索引。如果第一列是name,那所有查询都会基于name排序存储。这会导致:
- 插入性能下降(需要按字母顺序找位置)
- 随机查询性能不可预测
- 表结构不清晰,别人看不懂你的设计意图
正确的主键设计原则
CREATE TABLE orders (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, -- 整数主键,高效
order_number VARCHAR(32) NOT NULL UNIQUE, -- 业务号单独加唯一索引
customer_id INT NOT NULL,
total_amount DECIMAL(12, 2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_customer (customer_id),
INDEX idx_status (status),
INDEX idx_created (created_at)
);
主键应该满足这几个条件:
- 唯一性:每个主键值对应一行数据
- 不可变性:主键值不会改变
- 简洁性:尽量用INT或BIGINT,不要用字符串
- 无业务含义:主键不应该携带业务信息,业务信息交给其他字段
五、错误四:忽略索引的设计
很多新手设计完表之后,发现查询慢,然后开始到处加索引。这是典型的”先建房子再想装电梯”。
索引应该在表设计阶段就规划好,而不是出了问题再补救。
没有索引的查询是什么样的
假设你有这样一张表:
CREATE TABLE orders (
id INT PRIMARY KEY AUTO_INCREMENT,
customer_id INT,
status VARCHAR(20),
created_at DATETIME,
total_amount DECIMAL(10, 2)
);
没有任何额外索引。现在你要查:
-- 查询某个用户最近30天的订单
SELECT * FROM orders
WHERE customer_id = 12345
AND created_at > DATE_SUB(NOW(), INTERVAL 30 DAY)
ORDER BY created_at DESC;
MySQL会怎么做?全表扫描。如果这张表有100万条记录,每一次查询都要读完100万行。
索引设计的基本原则
原则一:WHERE子句中的字段,优先考虑加索引
ALTER TABLE orders ADD INDEX idx_customer_created (customer_id, created_at);
原则二:高基数字段优先建索引
基数是指字段中不同值的数量。比如”性别”字段只有男女两个值,基数低,建索引意义不大。而”订单号”基数极高,非常适合做索引。
原则三:联合索引遵循最左前缀原则
-- 这个索引可以加速以下查询:
-- WHERE customer_id = 123
-- WHERE customer_id = 123 AND created_at > '2024-01-01'
-- 但不能加速:WHERE created_at > '2024-01-01'(缺少最左列)
ALTER TABLE orders ADD INDEX idx_customer_created (customer_id, created_at);
原则四:不要过度索引
每个索引都会增加写入开销。INSERT、UPDATE、DELETE操作都需要维护索引。如果一张表每天有10万次写入,却建了10个索引,性能会显著下降。
一般建议单表索引不超过5个。
六、错误五:忽视数据类型的选择
数据库里数据类型很多,但很多新手只用VARCHAR和INT,忽略了其他类型。
几个常见的错误选择
错误1:用VARCHAR存数字
CREATE TABLE products (
id INT PRIMARY KEY,
price VARCHAR(20), -- 价格用字符串存?
stock INT
);
后果:
- 没法做数学运算(
price + 10会报错或得到奇怪结果) - 排序按字典序,”9”比”10”大
- 存储空间更大(字符串比数字占更多字节)
正确做法:price DECIMAL(10, 2)
错误2:用INT存日期
CREATE TABLE orders (
id INT PRIMARY KEY,
order_date INT -- 存成 20240101?
);
后果:
- 没法用日期函数(DATE_FORMAT、DATE_SUB等)
- 可读性极差
- 容易出错(2024年1月1日写成202411,是1月还是11月?)
正确做法:order_date DATE 或 created_at DATETIME
错误3:用TEXT存布尔值
CREATE TABLE users (
id INT PRIMARY KEY,
is_vip VARCHAR(10) -- 存"是"或"否"?
);
后果:
- 取值不统一(有人存”是”,有人存”true”,有人存”1”)
- 查询麻烦(
WHERE is_vip = '是'还是WHERE is_vip = 'true'?) - 浪费空间
正确做法:is_vip BOOLEAN 或 is_vip TINYINT(1)
错误4:DECIMAL精度设置错误
-- 错误:精度不够
CREATE TABLE orders (
total_amount DECIMAL(5, 2) -- 最大只能存 999.99
);
-- 一个1000元的订单,直接溢出报错
正确做法:DECIMAL(12, 2) 或更大,确保能容纳最大可能的金额。
数据类型选择速查表
| 数据类型 | 适用场景 | 常见错误 |
|---|---|---|
| INT/BIGINT | 自增ID、计数、状态码 | 用VARCHAR存数字 |
| DECIMAL(m,n) | 金额、精确计算 | 精度设置过小 |
| VARCHAR(n) | 文本、名称、地址 | 长度设置不合理 |
| TEXT | 长文本、备注、描述 | 用TEXT存结构化数据 |
| DATE/DATETIME | 日期时间 | 用INT存日期 |
| BOOLEAN/TINYINT | 是/否、状态 | 用VARCHAR存布尔值 |
| ENUM | 固定选项 | 选项可能变化时滥用 |
七、三范式入门:数据库设计的”宪法”
讲完了常见错误,咱们来聊聊数据库设计的理论基础——范式(Normalization)。
范式是一系列规则,用来指导你怎么设计表结构,让数据更合理、更少冗余。
第一范式(1NF):确保每列都是不可再分的最小单位
核心要求:表中的每一列都应该是原子性的,不能继续拆分。
违反1NF的例子:
-- 错误:一个字段里存了多个值
CREATE TABLE students (
id INT PRIMARY KEY,
name VARCHAR(100),
phone_numbers VARCHAR(200) -- "13800138000,13900139000"
);
phone_numbers字段里存了两个电话号码,这就违反了1NF。
正确做法:
方案一:拆成多列(适合固定数量)
CREATE TABLE students (
id INT PRIMARY KEY,
name VARCHAR(100),
phone1 VARCHAR(20),
phone2 VARCHAR(20)
);
方案二:拆成独立的表(适合数量不固定)
CREATE TABLE students (
id INT PRIMARY KEY,
name VARCHAR(100)
);
CREATE TABLE student_phones (
id INT PRIMARY KEY,
student_id INT,
phone_number VARCHAR(20),
FOREIGN KEY (student_id) REFERENCES students(id)
);
记住:1NF是最基本的要求,几乎所有数据库设计都会遵守。
第二范式(2NF):确保非主键列完全依赖于主键
核心要求:在满足1NF的基础上,非主键列必须完全依赖于整个主键(而不是部分依赖)。
2NF主要影响的是联合主键的场景。
违反2NF的例子:
-- 订单明细表,联合主键是 (order_id, dish_id)
CREATE TABLE order_items (
order_id INT,
dish_id INT,
dish_name VARCHAR(200), -- 这个问题在这里
dish_price DECIMAL(10, 2), -- 这个问题在这里
quantity INT,
PRIMARY KEY (order_id, dish_id)
);
问题在哪?dish_name和dish_price只依赖于dish_id,而不依赖于order_id。如果你知道dish_id=1,你就知道这道菜叫”宫保鸡丁”,价格是28元,跟它出现在哪个订单里无关。
这就是部分依赖——非主键列只依赖于主键的一部分。
正确做法:
-- 菜品表
CREATE TABLE dishes (
id INT PRIMARY KEY,
name VARCHAR(200) NOT NULL,
price DECIMAL(10, 2) NOT NULL
);
-- 订单表
CREATE TABLE orders (
id INT PRIMARY KEY,
customer_id INT,
total_amount DECIMAL(10, 2)
);
-- 订单明细表(只存关联关系和数量)
CREATE TABLE order_items (
order_id INT,
dish_id INT,
quantity INT,
unit_price DECIMAL(10, 2), -- 下单时的价格,用于历史记录
PRIMARY KEY (order_id, dish_id),
FOREIGN KEY (order_id) REFERENCES orders(id),
FOREIGN KEY (dish_id) REFERENCES dishes(id)
);
这样,dish_name和dish_price只存在于dishes表里,order_items表只存关联关系,消除了部分依赖。
第三范式(3NF):确保非主键列之间没有传递依赖
核心要求:在满足2NF的基础上,非主键列不能依赖于其他非主键列。
违反3NF的例子:
CREATE TABLE employees (
id INT PRIMARY KEY,
name VARCHAR(100),
department_id INT,
department_name VARCHAR(100), -- 这个问题
department_location VARCHAR(200) -- 这个问题
);
department_name和department_location依赖于department_id,而不是直接依赖于id。这就是传递依赖。
后果是什么?如果你把”技术部”的地址从”3层”改成”5层”,你需要更新所有技术部员工的记录。如果有一百个技术部员工,就要更新一百行。而且如果漏更新了一行,数据就不一致了。
正确做法:
CREATE TABLE departments (
id INT PRIMARY KEY,
name VARCHAR(100),
location VARCHAR(200)
);
CREATE TABLE employees (
id INT PRIMARY KEY,
name VARCHAR(100),
department_id INT,
FOREIGN KEY (department_id) REFERENCES departments(id)
);
现在,部门信息只存一份,员工表只存department_id。修改部门信息时,只改一行。
范式总结
| 范式 | 核心要求 | 解决的问题 |
|---|---|---|
| 1NF | 每列不可再分 | 多值字段 |
| 2NF | 非主键列完全依赖主键 | 部分依赖 |
| 3NF | 非主键列不依赖其他非主键列 | 传递依赖 |
满足三范式之后,你的数据库基本就不会有冗余和不一致的问题了。
八、反范式实践:什么时候可以打破规则
讲完范式,必须聊聊反范式(Denormalization)。
很多新手学完范式之后,走向另一个极端:不管三七二十一,把所有表都拆得干干净净,查询的时候疯狂JOIN。结果系统性能崩了。
范式是理论,反范式是实践。真正优秀的数据库设计,是在两者之间找到平衡。
什么时候应该反范式?
场景一:读多写少的系统
如果你的系统90%的操作是查询,只有10%是写入,那么适当冗余数据可以大幅提升查询性能。
比如电商网站的商品表。商品的基本信息(名称、价格、分类)几乎不变,但被查询了亿万次。如果把分类名称冗余到商品表里,查询时就不需要JOIN分类表了。
-- 反范式设计:商品表冗余分类信息
CREATE TABLE products (
id INT PRIMARY KEY,
name VARCHAR(200),
price DECIMAL(10, 2),
category_id INT,
category_name VARCHAR(100) -- 冗余字段,避免JOIN
);
当分类名称变化时,用触发器或应用层逻辑同步更新:
-- 触发器:分类名称变化时,自动更新商品表
CREATE TRIGGER update_category_name
AFTER UPDATE ON categories
FOR EACH ROW
BEGIN
UPDATE products
SET category_name = NEW.name
WHERE category_id = NEW.id;
END;
场景二:高频查询的热点数据
订单表是高频查询表。每次用户查看订单详情,都需要JOIN订单表、订单明细表、菜品表、顾客表。四表JOIN,性能开销不小。
可以适当冗余:
CREATE TABLE orders (
id INT PRIMARY KEY,
customer_name VARCHAR(100), -- 冗余,避免JOIN顾客表
customer_phone VARCHAR(20), -- 冗余
total_amount DECIMAL(10, 2),
status VARCHAR(20),
created_at DATETIME,
dish_summary TEXT -- 冗余:下单时的菜品摘要
);
dish_summary存的是下单时的菜品信息快照,比如:
宫保鸡丁 x2 (¥28/份), 麻婆豆腐 x1 (¥22/份)
这样查看历史订单时,不需要再JOIN订单明细和菜品表,直接读这个字段就行。
场景三:数据统计和报表
报表查询通常需要聚合大量数据,频繁JOIN代价极高。这时可以做预聚合:
-- 每日销售汇总表(预计算)
CREATE TABLE daily_sales_summary (
date DATE PRIMARY KEY,
total_orders INT,
total_amount DECIMAL(12, 2),
avg_order_amount DECIMAL(10, 2),
top_dish_id INT,
top_dish_name VARCHAR(200)
);
每天晚上定时任务计算第二天的汇总数据。报表查询时直接读这张表,秒出结果。
反范式的代价
反范式不是免费的午餐,它有几个明显的代价:
1. 数据一致性风险
冗余数据意味着同一份信息存了多份。任何一处修改,都需要同步到其他所有地方。漏改一处,数据就不一致了。
解决方案:用触发器、事件调度器、或者应用层统一更新。
2. 存储空间增加
冗余数据占用更多磁盘空间。虽然磁盘现在很便宜,但数据量大了之后,备份、迁移的成本也会上升。
3. 写入性能下降
每次INSERT或UPDATE,不仅要写主表,还要维护冗余字段。写入操作变慢了。
4. 设计复杂度上升
反范式设计比范式设计更复杂。你需要考虑哪些数据冗余、冗余多少、如何同步、如何保证一致性。
反范式决策树
你的系统是读多还是写多?
├── 读远多于写 → 考虑反范式
│ ├── 查询频繁JOIN → 冗余关联表的常用字段
│ ├── 报表查询慢 → 预聚合统计字段
│ └── 热点数据读取慢 → 缓存常用数据
└── 写远多于读 → 坚持范式
├── 数据一致性优先 → 严格遵循3NF
└── 写入性能优先 → 减少索引,精简表结构
九、餐厅系统的完整设计案例
回到开头的餐厅点餐系统,咱们用今天学到的知识,重新设计一遍。
需求分析
- 顾客可以注册账号,也可以匿名下单
- 餐厅有多个菜品,分不同分类
- 一个订单可以包含多道菜
- 订单有状态流转:待支付 → 已支付 → 制作中 → 配送中 → 已完成
- 需要统计每日销售额、热门菜品等
- 促销活动期间,菜品价格可能临时调整
表结构设计
-- 顾客表
CREATE TABLE customers (
id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
phone VARCHAR(20) NOT NULL UNIQUE,
name VARCHAR(100),
email VARCHAR(200),
address VARCHAR(500),
is_vip BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_phone (phone),
INDEX idx_created (created_at)
);
-- 菜品分类表
CREATE TABLE categories (
id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
parent_id INT UNSIGNED,
sort_order INT DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
FOREIGN KEY (parent_id) REFERENCES categories(id),
INDEX idx_parent (parent_id)
);
-- 菜品表
CREATE TABLE dishes (
id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
category_id INT UNSIGNED NOT NULL,
name VARCHAR(200) NOT NULL,
description TEXT,
price DECIMAL(10, 2) NOT NULL,
image_url VARCHAR(500),
is_available BOOLEAN DEFAULT TRUE,
stock INT DEFAULT -1, -- -1表示不限制库存
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES categories(id),
INDEX idx_category (category_id),
INDEX idx_available (is_available),
INDEX idx_name (name)
);
-- 订单表
CREATE TABLE orders (
id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
order_number VARCHAR(32) NOT NULL UNIQUE,
customer_id INT UNSIGNED,
total_amount DECIMAL(10, 2) NOT NULL,
discount_amount DECIMAL(10, 2) DEFAULT 0,
delivery_fee DECIMAL(10, 2) DEFAULT 0,
final_amount DECIMAL(10, 2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
delivery_address VARCHAR(500) NOT NULL,
delivery_phone VARCHAR(20) NOT NULL,
remark TEXT,
estimated_delivery_time DATETIME,
actual_delivery_time DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (customer_id) REFERENCES customers(id),
INDEX idx_order_number (order_number),
INDEX idx_customer (customer_id),
INDEX idx_status (status),
INDEX idx_created (created_at),
INDEX idx_status_created (status, created_at)
);
-- 订单明细表
CREATE TABLE order_items (
id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
order_id INT UNSIGNED NOT NULL,
dish_id INT UNSIGNED NOT NULL,
dish_name VARCHAR(200) NOT NULL, -- 冗余:下单时的菜名
unit_price DECIMAL(10, 2) NOT NULL, -- 冗余:下单时的价格
quantity INT NOT NULL DEFAULT 1,
subtotal DECIMAL(10, 2) NOT NULL, -- = unit_price * quantity
remark TEXT,
FOREIGN KEY (order_id) REFERENCES orders(id),
FOREIGN KEY (dish_id) REFERENCES dishes(id),
INDEX idx_order (order_id),
INDEX idx_dish (dish_id)
);
-- 每日销售汇总表(反范式:预聚合)
CREATE TABLE daily_sales (
date DATE PRIMARY KEY,
total_orders INT DEFAULT 0,
total_amount DECIMAL(12, 2) DEFAULT 0,
total_discount DECIMAL(10, 2) DEFAULT 0,
avg_order_amount DECIMAL(10, 2),
top_dish_id INT UNSIGNED,
top_dish_name VARCHAR(200),
top_dish_quantity INT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
关键设计说明
1. 订单号独立于主键
order_number VARCHAR(32) NOT NULL UNIQUE
订单号用于对外展示和查询,主键id用于内部关联。两者分离,互不影响。
2. 订单明细冗余菜名和价格
dish_name VARCHAR(200) NOT NULL,
unit_price DECIMAL(10, 2) NOT NULL,
这是典型的反范式做法。菜品名称和价格变化不影响历史订单记录。用户查看”上个月买的宫保鸡丁多少钱”,数据是准确的。
3. 每日销售汇总表
CREATE TABLE daily_sales (...)
这是为报表查询做的预聚合。每天凌晨计算前一天的数据,报表查询时直接读这张表,避免实时聚合大表。
4. 状态字段加索引
INDEX idx_status (status),
INDEX idx_status_created (status, created_at)
订单状态查询是最频繁的操作之一,复合索引可以加速”查询某段时间内特定状态的订单”这类查询。
十、给新手的几点实用建议
建议一:先画ER图,再建表
很多新手拿到需求就直接写SQL,结果设计到一半发现漏了字段、漏了表,推倒重来。
在动手之前,先用纸笔画一画实体之间的关系:
顾客 ----< 订单 >---- 菜品
|
v
订单明细
ER图画清楚了,表结构自然就清晰了。
建议二:从小处着手,逐步迭代
不要试图一次设计出完美的系统。先做出MVP(最小可行产品),上线之后根据实际使用情况逐步优化。
小李的餐厅系统,第一版可以只有订单表和订单明细表。等用户量上来、性能出现问题了,再引入顾客表、菜品表、分类表,逐步规范化。
建议三:数据migration要谨慎
线上系统改表结构,尤其是加字段、改字段类型,一定要做好备份和回滚方案。
-- 安全加字段的步骤:
-- 1. 先加字段(允许NULL)
ALTER TABLE orders ADD COLUMN dish_summary TEXT;
-- 2. 用脚本批量填充历史数据(分批执行,避免锁表)
UPDATE orders SET dish_summary = '...' WHERE id BETWEEN 1 AND 1000;
UPDATE orders SET dish_summary = '...' WHERE id BETWEEN 1001 AND 2000;
-- ...
-- 3. 验证数据正确后,改为NOT NULL
ALTER TABLE orders MODIFY COLUMN dish_summary TEXT NOT NULL;
建议四:定期review表结构
每季度花点时间review一下现有的表结构,看看有没有可以优化的地方。随着业务发展,原来的设计可能不再适用。
十一、总结
数据库表设计是一门平衡的艺术。
你需要在数据一致性和查询性能之间找平衡,在规范化和反规范化之间找平衡,在设计复杂度和系统可维护性之间找平衡。
新手最容易犯的错误,归根结底就一个字:懒。
- 懒得拆分表,把所有数据塞进一张表
- 懒得选数据类型,什么都用VARCHAR
- 懒得加索引,等出问题了再补
- 懒得设计主键,随便找个字段充数
但数据库设计就像盖房子,地基打得牢不牢,直接影响你能盖多高。一个设计糟糕的表结构,随着数据量增长,会变成难以维护的债务,最终拖垮整个系统。
记住小李的故事。那家餐厅在系统崩溃之后,用了两周时间重构了数据库,性能提升了十倍,再也没出过问题。而那段重构的痛苦经历,让小李彻底明白了表设计的重要性。
希望这篇文章能帮你避开那些坑,设计出既规范又高效的数据库表结构。
毕竟,好的设计,是系统稳定的第一道防线。
