3、数据库设计(上):ER图绘制、范式与反范式、MySQL/SQLite表结构设计实战
数据库设计,说白了就是给数据找个好房子住。房子设计得好,数据存取就快,后期维护也省心。我见过太多项目,前期图省事随便建表,结果上线半年后,每次加个功能都要改表结构,改得程序员想骂娘。
这一章,咱们就聊聊怎么把数据库设计得既规范又实用。我会结合自己踩过的坑,带你从ER图开始,一路走到MySQL和SQLite的表结构实战。
3.1 从业务需求到ER图:画图是门手艺
拿到一个需求,别急着写SQL。先画图。ER图(实体-关系图)就是你和业务方沟通的桥梁。
实体是什么? 说白了就是业务中的对象。比如用户、订单、商品。每个实体都有属性,比如用户有姓名、邮箱、手机号。
关系是什么? 实体之间的联系。一个用户可以有多个订单,这叫一对多。一个学生可以选多门课,一门课也可以被多个学生选,这叫多对多。
我个人习惯用 Chen氏表示法 或者 乌鸦脚表示法。乌鸦脚更直观,适合给非技术人员看。
核心原则: 先画实体,再画关系,最后填属性。别一上来就纠结字段类型。
举个例子,一个简单的电商系统:
- 用户(User):用户ID、用户名、邮箱、注册时间
- 订单(Order):订单ID、用户ID、总金额、下单时间
- 商品(Product):商品ID、商品名、价格、库存
- 订单项(OrderItem):订单ID、商品ID、数量、单价
关系:用户和订单是1对N,订单和商品是M对N(通过订单项实现)。
嗯,这里要注意:多对多关系一定要拆成两个一对多。我在项目中遇到过有人直接在订单表里存商品ID列表,用逗号分隔。结果查某个商品卖了多少单时,SQL写得跟天书一样。千万别这么干。
3.2 范式与反范式:规范与性能的博弈
范式是数据库设计的理论指导。但理论归理论,实战中得灵活变通。
3.2.1 三大范式速览
| 范式 | 核心要求 | 通俗理解 |
|---|---|---|
| 第一范式(1NF) | 每列不可再分 | 一个字段只存一个值,别搞列表 |
| 第二范式(2NF) | 非主键列完全依赖于主键 | 联合主键时,别让某列只依赖主键的一部分 |
| 第三范式(3NF) | 非主键列不传递依赖于主键 | 别存冗余数据,比如用户姓名在订单表里又存一遍 |
第一范式:比如用户表里有个“兴趣爱好”字段,存成“篮球,游泳,读书”。这就违反了1NF。应该拆成单独的兴趣表。
第二范式:比如订单明细表,主键是(订单ID, 商品ID)。如果你把“商品名称”也放进来,它就只依赖于商品ID,不依赖于订单ID。这就违反了2NF。
第三范式:比如订单表里存了“用户姓名”。明明可以通过用户ID查到,为什么还要存?这就是冗余。除非你有特殊理由。
我的经验: 大部分系统做到第三范式就够了。BCNF、第四范式这些,除非你搞金融或复杂报表,否则别碰。过度范式化会让查询变得极其痛苦。
3.2.2 反范式:什么时候该打破规则?
范式化减少了冗余,但增加了查询时的JOIN操作。JOIN多了,性能就下来了。
我曾经做过一个报表系统,数据量上千万。完全按第三范式设计,每次跑报表要JOIN七八张表,耗时十几秒。后来我把一些常用字段冗余到主表里,查询时间降到了1秒以内。
什么时候该反范式?
- 读多写少:比如商品详情页,频繁查询,很少修改。可以把分类名称、品牌名称冗余到商品表里。
- 统计报表:预计算一些汇总字段,比如“订单总数”、“总销售额”,直接存到用户表里。
- 避免复杂JOIN:如果某个查询要JOIN超过3张表,考虑冗余。
警告: 反范式是有代价的。冗余数据意味着更新时要同步修改多处。我曾经因为忘记同步更新,导致线上数据不一致,排查了整整两天。所以,反范式前一定要想清楚:更新频率高不高?能不能接受最终一致性?
3.3 MySQL vs SQLite:选型与实战
MySQL和SQLite都是关系型数据库,但定位完全不同。
| 特性 | MySQL | SQLite |
|---|---|---|
| 部署方式 | 客户端-服务器模式 | 嵌入式,单文件 |
| 并发能力 | 高并发,多用户 | 低并发,单用户为主 |
| 适用场景 | Web应用、企业系统 | 移动端、桌面端、小工具 |
| 数据类型 | 丰富(JSON、空间数据等) | 基础类型(INTEGER、TEXT、REAL) |
| 存储限制 | 取决于硬件,可达TB级 | 通常2GB以内(理论140TB) |
我个人习惯:Web后端用MySQL,原型验证或小工具用SQLite。你想想看,如果只是做个个人博客,用MySQL还得装个服务,SQLite一个文件搞定,多省事。
3.4 表结构设计实战:从ER图到DDL
好,理论讲完了,咱们来点实际的。假设我们要设计一个简单的博客系统,包含用户、文章、评论。
3.4.1 用户表 (users)
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
avatar_url VARCHAR(255),
bio TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
注意几个点:
- UNIQUE约束:用户名和邮箱必须唯一,这是业务要求。
- password_hash:千万别存明文密码!存哈希值。
- created_at和updated_at:每个表都应该有这两个字段,方便排查问题。
3.4.2 文章表 (posts)
CREATE TABLE posts (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
status ENUM('draft', 'published', 'deleted') DEFAULT 'draft',
view_count INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
这里我用了 ENUM 来表示文章状态。为什么不用TINYINT?因为ENUM可读性更好,查数据时直接看到是'draft'还是'published',不用猜0和1代表什么。
嗯,这里要注意:ON DELETE CASCADE 表示删除用户时,他的文章也会被删。如果你不想这样,可以用 ON DELETE SET NULL 或者干脆不加外键约束。我在项目中遇到过因为外键约束导致删除失败的情况,后来干脆在应用层处理逻辑删除。
3.4.3 评论表 (comments)
CREATE TABLE comments (
id INT AUTO_INCREMENT PRIMARY KEY,
post_id INT NOT NULL,
user_id INT NOT NULL,
parent_id INT DEFAULT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE
);
parent_id 是用来实现嵌套评论的。如果评论是回复某条评论,parent_id就指向那条评论的ID。如果是最顶层评论,parent_id为NULL。
这种设计叫 邻接表模型,简单直观。但查询多层嵌套时性能会差一些。如果你需要频繁查询评论树,可以考虑用 嵌套集模型 或者 闭包表。不过对于大多数博客系统,邻接表已经够用了。
3.4.4 索引设计:别让查询变慢
表建好了,但数据一多,查询就慢。这时候需要索引。
-- 为文章表的 user_id 加索引,方便查询某个用户的所有文章
CREATE INDEX idx_posts_user_id ON posts(user_id);
-- 为评论表的 post_id 加索引,方便查询某篇文章的所有评论
CREATE INDEX idx_comments_post_id ON comments(post_id);
-- 为评论表的 parent_id 加索引,方便查询某条评论的子评论
CREATE INDEX idx_comments_parent_id ON comments(parent_id);
索引不是越多越好。每个索引都会增加写入时的开销。我见过有人给每个字段都加了索引,结果写入速度慢得像蜗牛。记住:只为查询频繁的字段加索引。
3.5 SQLite版本:轻量级实现
如果你用SQLite,DDL基本一样,只是有些细微差别:
-- SQLite 不支持 ENUM,用 TEXT 加 CHECK 约束替代
CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
status TEXT CHECK(status IN ('draft', 'published', 'deleted')) DEFAULT 'draft',
view_count INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- SQLite 的索引创建方式一样
CREATE INDEX idx_posts_user_id ON posts(user_id);
注意:SQLite的 AUTOINCREMENT 和MySQL略有不同。SQLite默认就会自增,加上AUTOINCREMENT只是保证ID不会重复使用。我个人习惯不加,除非有特殊需求。
3.6 避坑指南:我踩过的那些坑
坑1:字段类型选错
我曾经用VARCHAR(255)存IP地址,结果发现IPv6地址根本存不下。后来改用VARBINARY(16)或者直接用字符串存IPv6格式。记住:字段类型要匹配实际数据。
坑2:忘记加唯一约束
有个用户注册系统,没给邮箱加UNIQUE约束。结果同一个邮箱注册了多个账号,导致后续的密码找回功能完全乱套。修复数据花了整整一天。
坑3:过度使用TEXT/BLOB
有人把整个JSON对象存到TEXT字段里,查询时用LIKE '%keyword%'。数据量一上来,查询慢得让人崩溃。正确的做法是:把需要查询的字段单独列出来。
3.7 小结
数据库设计没有银弹。范式化保证数据一致性,反范式化提升查询性能。关键是要根据业务场景做权衡。
我个人建议:先按第三范式设计,上线后通过监控发现慢查询,再针对性地做反范式优化。别一开始就过度设计,也别完全不管规范。
下一章,咱们聊聊数据库设计的下半部分:索引优化、查询优化、以及分库分表策略。到时候我会分享一些真实的性能调优案例,保证让你收获满满。