数据库设计这件事,很多人的理解其实停留在“建几张表”这个层面。我在日常工作中接触过不少后端开发者,SQL写得挺溜,但一说到数据库设计就下意识直接打开客户端开始建表,建到一半发现业务字段漏了,过两天又要加需求,于是反复 ALTER TABLE,改到最后一张表三四十个字段,外键全靠代码里拼字符串,索引冗余得一塌糊涂。这就是典型的跳过了设计环节的代价。《数据库系统原理》里关于数据库设计那一章,讲的就是一套完整的方法链:需求分析、概念结构设计、逻辑结构设计、物理结构设计。这套流程不是教科书里用来应付考试的,而是真正能帮你减少返工、降低维护成本的操作规范。
这篇内容我会完全按照实际项目推进的顺序来写,不讲空泛的理论,而是把每一步掰开揉碎:怎么从一段模糊的业务描述里识别实体和联系,ER模型怎么转换成关系模式,范式化到底怎么用才不至于走火入魔,物理设计阶段哪些选择最容易埋雷。适合正在复习数据库系统原理的学生、想系统提升建表能力的后端开发,以及所有被改表需求折磨过的从业者。
1. 先建模再建表:数据库设计的三层分工与真实价值
很多人问过我一个问题:设计数据库为什么非要先画ER图?直接建表不也一样吗?短期看确实一样,甚至更快,但拉长到项目生命周期来看,差别就出来了。数据库设计在流程上天然分三层,每层解决不同的问题,这也是数据库系统原理课程里反复强调的框架。
1.1 概念模型:把业务语义画成一张大家都能看懂的图
概念模型就是常说的ER模型,也就是实体-联系模型。这一层的作用是把业务需求翻译成一种不依赖任何数据库产品的信息结构表达。你要回答的问题只有三个:有哪些实体,实体有什么属性,实体之间是什么联系。这个阶段完全不考虑主键怎么设、索引怎么建、用MySQL还是PostgreSQL。
为什么要单独抽出这一层?因为它是整个团队沟通的语言。我试过直接拿建表SQL和产品经理对需求,对方看着一堆 FOREIGN KEY 和 UNIQUE 约束满脸问号;但把ER图画出来,圆圈框框加连线,产品经理三分钟就能指出“订单和用户的关系是不是反了”,这个沟通效率是建表语句给不了的。更重要的价值在于,概念模型只关心“业务是什么”,它不随技术栈变化,今天用MySQL,明天换PostgreSQL,概念模型基本不用动。
1.2 逻辑模型:把ER图翻译成关系模式
逻辑模型做的事情,就是把概念模型里的实体、属性、联系,转换成具体的二维表结构,也就是关系模式。这一步要确定每个表的主键、外键,要把多对多联系拆成中间表,要做范式化检查消除冗余和更新异常。
这层是数据库设计的技术核心。很多建表翻车现场都发生在这一层被跳过的时候——实体确实识别出来了,但联系处理错了,多对多没拆表,一对一硬生生建了两张表还互相关联,导致查询时被迫做多层嵌套JOIN。逻辑模型的核心产出物是一组关系模式,用简单的记号表示就是 表名(属性1, 属性2, ...),主键加下划线,外键用箭头表明来源。
1.3 物理模型:只在数据库方言层面做手脚
物理模型是最后一步,针对你要实际部署的数据库产品做细节调整:选择存储引擎,指定字符集和排序规则,决定字段的具体类型,设计索引策略,考虑分区和归档方案。这一层的特点是高度依赖具体产品,换一个数据库,物理模型的调整幅度可能很大。
我把三层流程比做盖房子:概念模型是建筑师画的效果图,逻辑模型是结构施工图,物理模型是现场的装修用料清单。一个常见的错误是很多人把三层混在一起做,建表的同时又想业务又想索引,结果每层都没想清楚。数据库系统原理这门课最值得记住的一点就是:每一层都可以独立变化。需求变了改概念模型,概念变了改逻辑模型,性能瓶颈了改物理模型,这样改动面是收敛的,而不是每次需求变化都要动到建表语句。
2. 实体识别与属性设计:从一段模糊需求里抠出ER图
概念模型设计的第一步是需求分析,但现实中的需求往往不是清晰的功能列表,而是一段话:“我们要做一个在线书店,用户可以注册、浏览图书、下单,每本书有作者和出版社。”这段话里怎么提取实体、属性、联系?这里有一套实操方法。
2.1 找实体:名词不一定都是实体,动词不一定不是实体
最常见的办法是名词分析法。把业务描述里的名词圈出来,看每个名词是否有独立存在的必要。判断标准主要有三条:它是不是有多个实例?需不需要单独管理?会不会被其他实体引用?比如“用户”有成千上万个实例,需要维护账号数据,会被订单引用,那必然是实体。“绿色”如果是图书封面的一个属性,它就不是实体。“出版社”看着是个名词,但如果业务里只需要存出版社的名字,那它可以先作为图书的属性;一旦你还想存出版社地址、联系电话、成立年份,甚至维护出版社的主页链接,那它就应该升级为独立实体。
动词分析也不能忽略。比如“购买”是联系,“下单”是联系,这会在后面映射成外键关系。还有一种情况容易被漏掉:有些名词在特定上下文里其实是在描述联系,比如“订单明细”,它描述的是“订单”和“图书”之间的购买联系,同时自身带有数量、快照价格等属性,这种名词在设计里对应的是中间表实体。我有个经验法则:遇到一个随时可能需要单独查询、单独统计的名词,优先考虑它是实体,而不是属性。
2.2 定属性:原子性、直存与归属判断
属性设计有三个原则,每个都有对应的反面案例。
第一是原子性,这是第一范式的基本要求。字段不能再分,比如“收货地址”如果包含省、市、区、详细地址,具体需不需要拆分,取决于你是否会按城市做统计。如果你要做“某城市的订单量分布”,那就必须拆;如果永远只是整串打印,那存成一个字段也能接受。但“标签”这类字段如果存成“小说,科幻,悬疑”的逗号分隔串,后续任何按标签筛选的需求都会变成噩梦,这种时候就应该拆成关联表。
第二是存直不存算。尽量不要在表里存可以由其他字段推导的派生数据。比如“订单总金额”如果可以由明细数量和单价的乘积加总得到,那它通常不应该作为物理字段存下来,否则每次修改明细都要同步修改总金额,必然出现不一致。例外是高频查询场景,可以引入冗余列,但那属于后文要讲的反范式化,是主动决策,不是默认操作。
第三是归属判断。一个属性到底挂在哪个实体下面,标准是看它对哪个实体的实例有直接依赖。比如“出版日期”是图书的性质,挂在图书上。“下单时间”是订单的性质,挂在订单上。容易犯的错是把另一张表的属性顺手塞过来,比如在订单表里存“用户手机号”,理由是查询订单时想避免JOIN用户表。这个设计短期好用,但一旦用户改了手机号,历史订单里的号码就显示旧值,业务上很容易引发纠纷。如果一定要存,请把它理解成下单时的快照字段,而不是用户当前属性。
2.3 判联系:一句话里的量词关系就是映射基数
联系的类型判断,本质是看实体实例之间的对应数量。一对多、多对多、一对一,这些映射基数完全可以从业务描述的量词关系里读出来。
试着分析在线书店场景。用户和订单:“一个用户可以下多个订单,一个订单只能属于一个用户”,这是典型的一对多,用户是1端,订单是多端。图书和作者:“一本书可以由多个作者合著,一个作者也可以写多本书”,这是多对多。图书和出版社:“一本书只能属于一个出版社,一个出版社可以出版很多书”,这又回到了一对多,不过方向变了。
我还见过一个很容易混淆的案例:订单和订单明细。它们看起来是包含关系,但映射基数其实是“一个订单包含多条明细,一条明细只属于一个订单”,也是一对多。这个识别很关键,它决定了后面订单明细表要不要设计独立的订单外键。
判定联系时有一个隐含问题:同一个实体对之间可能同时存在多种联系。比如用户和订单,除了“下单”的购买联系,还有“退单”“收藏”等联系。设计时应该把不同业务语义拆成不同的联系来建模,而不是混在一条线里。一个常见做法是在联系上附加角色名,比如“用户-下单订单”和“用户-退单订单”,逻辑模型里会体现在外键字段的命名上。
3. ER模型转关系模式的映射规则:一张表怎么说话
概念模型里用的是实体和连线,到了逻辑模型阶段就要把它们转成表结构。这套映射规则是《数据库系统原理》里最实用、也是我平时帮人评审表结构时最常检查的部分。规则本身不复杂,但每一条背后都有实际代价。
3.1 一对多:把外键交给“多”的那一方
一对多联系转关系模式的规则只有一句话:在“多”那一方加外键,指向“1”那一方的主键。用户和订单是一对多,订单表加一个 user_id 外键即可;出版社和图书是一对多,图书表加 publisher_id 外键即可。
为什么外键一定要放在多端而不是1端?因为从存储效率看,1端放外键意味着它需要存储多个关联值,只有一种办法能实现:逗号分隔的字符串或JSON数组。那等于把所有关联查询都变成文本解析,既慢又容易出错。从多端放外键,每一行只需要存一个值,B+树索引可以高效检索,JOIN时也能直接走索引。这个规则是所有映射规则里最稳的,几乎不存在例外。
3.2 一对一:能合就合,要拆也是明确理由
一对一联系在关系模式里最灵活:要么合并到一张表,要么双方互相加外键,要么完全独立靠相同主键关联。我的默认策略是:能合就合。因为实际业务里真正需要拆成两表的一对一场景很少,强行拆开只会让查询多一次JOIN,写入多一次分发。比如用户和用户详情,如果只是偶尔存个自我介绍,那就直接在用户表里加一列,不要为了“听起来规范”而拆表。
什么时候才值得拆?我总结过三类合理场景。第一是冷热分离:主表被高频访问,而某个超大字段很少被读取,比如文章表和文章正文表,正文可能几万字,和文章标题、浏览数这类高频字段放一起会导致页面查询扫描的数据量偏大。第二是敏感数据隔离:支付凭据、证件照片这类字段为了权限管理方便,可以单独成表。第三是横向扩展需求:有些业务希望把同一实体的不同属性按用途分成多表,比如基础资料表和扩展资料表在一对一前提下可以分别走不同的缓存策略。
3.3 多对多:中间表承载关系与附加属性
多对多联系是所有联系里最需要谨慎处理的一种。规则同样只有一句话:必须拆出一个中间表,中间表至少包含两个外键,分别指向两端的主键,两者的组合一般就是中间表的主键。图书和作者的多对多关系,映射成 book_author(book_id, author_id),主键用联合主键。
中间表的价值不只是消除重复,更重要的是它可以挂载关系本身的属性。继续用在线书店的例子:订单和图书其实天然是多对多——一个订单包含多本图书,一本图书也可以出现在多个订单中。但这个多对多联系不是一个纯关系,它还带有“购买数量”和“下单时刻的价格快照”,于是中间表就升级成了订单明细表 order_detail(order_id, book_id, quantity, unit_price)。这里可以看到,ER建模时识别出的每一个联系,落到关系模式时都要想一想:联系自身是否有属性?如果有,这个联系就倾向于设计成独立行的实体表,而不是只靠外键关联。
3.4 外键到底建还是不建:物理外键与逻辑外键之争
这是我在项目评审中争论最多的问题。按照ER转关系模式的规则,外键是必须体现在表里的,但“体现在表里”不等于“要在物理层声明 FOREIGN KEY 约束”。这里存在两种风格。
物理外键的优势是数据库层面保证引用完整性。你永远不可能插入一条指向不存在用户的订单,级联删除和更新也由数据库代为处理。对于内部管理系统、中小型项目,我强烈建议使用物理外键,它能帮你拦住非常多低级Bug。
逻辑外键则是不声明 FOREIGN KEY,但仍然保留外键字段,由应用层在事务里校验引用关系。这种做法的理由是高并发写入场景下,FOREIGN KEY 约束会带来额外的锁开销和校验成本,而且级联删除的隐式行为在大表上极其危险——一个 DELETE 操作可能索引扫描范围失控。但代价是应用层一旦忘了校验,脏数据就进来了。
我的建议是:默认物理外键,除非你能明确说出逻辑外键的性能收益和应对脏数据的机制。不要在第一版设计里就把外键省了,那等于用未来的数据一致性换今天的省事。
4. 范式化不是越高越好:1NF到BCNF怎么落地判断
范式这块内容是数据库系统原理里最劝退的理论,但它在实践里真的有用。有用之处不在背定义,而在帮你发现表结构里的“隐性异常点”。我尽量用实际能遇到的场景来讲。
4.1 从红皮书概念回到真实数据:三种依赖怎么识别
范式递进的核心是消除依赖和异常。最容易在实际表里遇到的三种依赖是函数依赖、部分函数依赖和传递函数依赖。
函数依赖就是由某个属性能确定另一个属性,比如用户表里 username 可以确定 email,这就是 username → email 的函数依赖。部分函数依赖发生在复合主键场景下:当主键是(A, B)组合时,如果某个非主键属性只依赖于A或只依赖于B,那就是部分依赖。传递函数依赖是指 A → B 且 B → C,于是 A → C 就是传递依赖,典型例子是用户表存了城市名,又存了城市邮编,邮编其实由城市名决定,这意味着当你需要更新某个城市的邮编时,要同时更新所有用户记录。
识别这三种依赖的实操方法是:拿到一张表,先把所有属性列出来,然后挨个问“这个属性到底由哪个键决定”。能清晰回答的,表结构大概率是健康的;回答得含含糊糊“应该是由订单决定吧”,那就要警惕了。
4.2 一个订单明细表的范式检查过程
假设初期有人设计了这么一张订单明细表:
order_detail(order_id, book_id, book_title, quantity, unit_price)主键是 (order_id, book_id)。这张表违反了第二范式。为什么?因为 book_title 只依赖于 book_id,和 order_id 无关,它是一次部分函数依赖。表里如果同一个订单买了两本相同的书(实际业务可能不允许,但假设存在),或者同一本书出现在1000个订单里,它的书名就被冗余存了1000遍,一旦书名变更,你必须写一个巨大的 UPDATE 语句去修正所有订单明细。
修正方案很简单:把和图书本身相关的属性全部移到图书表,订单明细表只保留订单和图书的关联及订单侧的业务属性:
order_detail(order_id, book_id, quantity, unit_price)这里有一个细节值得注意:unit_price 是下单时刻的价格快照,它不是从 book 表实时读来的,由订单本身决定,甚至由一次促销活动决定。所以它不属于部分依赖,留在明细表反而正确。这就体现了一个重要理念:范式化检查要结合业务语义,不要机械地看到“某字段和某字段逻辑相关”就强行拆分。
第三范式的检查同样可以用订单表演示。假设 orders 表里除了 user_id,还存了 user_email,且 user_email 完全由 user_id 决定,那就通过传递依赖引入了冗余。用户改邮箱后,历史订单里的邮箱不会自动变,而且没有任何说明告诉你这个字段是快照。所以第三范式的直观判断法就是:如果两个列之间存在“中间依赖链”,且中间依赖属性不在本表作为键存在,就应该把那列挪走。
4.3 什么时候该反范式:先规范后下沉
范式化不是目的,数据一致性才是。反范式化(故意引入冗余)时有发生,但要分清场合。读多写少、查询链路长、统计报表这类场景,适当的冗余能极大简化查询。比如在订单列表页需要高频展示“用户昵称”,每次都要 JOIN 用户表,压力大了以后把 nickname 冗余到订单表就是一种常见优化。
但我的原则是:第一版设计必须走规范路线,字段归位,依赖清晰。上线后真遇到性能瓶颈,再基于监控数据逐列下沉。反范式化应该是显式决策,每个冗余字段都要在表注释里标明来源、同步方式和失效容忍度,而不是一开始就为了方便随手加。很多表的字段就是这么失控的——一开始觉得冗余省事,后面没人记得这是冗余,改数据时也不同步,最终呈现出来的数据互相矛盾。
5. 物理设计翻车重灾区:存储引擎、字段类型与索引的选择逻辑
逻辑设计解决了表结构的问题,物理设计决定这套表结构在具体数据库产品上能不能跑得稳。这个阶段的选择非常多,每一个选错都是埋雷,而且往往不是立刻爆,是等数据量上去了才爆。
5.1 引擎选型:InnoDB与MyISAM的实际差距
以常见的MySQL为例,引擎选择基本就是 InnoDB 和 MyISAM 之间的对比。我遇到过不止一个项目的早期表用了 MyISAM,理由是查询快、全文索引方便。等后面要支持事务或者高频并发更新时,才意识到 MyISAM 的表级锁会让整个表的所有写操作串行化,一个慢更新就能堵住全表的写入。
核心差异可以直接看这几点:
| 维度 | InnoDB | MyISAM |
|---|---|---|
| 事务支持 | 支持ACID事务 | 不支持 |
| 锁粒度 | 行级锁 | 表级锁 |
| 外键 | 支持 | 不支持 |
| 崩溃恢复 | 通过redo log恢复,可靠性高 | 无事务日志,崩溃后易丢数据甚至损坏 |
| 全文索引 | 5.6以后支持 | 原生支持 |
对绝大多数业务系统,答案已经非常明确:InnoDB。事务和外键是数据库可靠性的底线,行级锁保证并发写不互相阻塞,崩溃恢复能力更是生产环境的基本要求。只有极少数纯读的归档库、日志表,或者明确不需要事务的场景,我才会考虑 MyISAM。还有一个容易忽略的点:表和数据库的字符集要显式设置,通常用 utf8mb4,它支持完整Unicode字符,包括各种生僻字和特殊符号。不要用默认的 latin1,也不要为了省空间用 utf8,因为 utf8 在MySQL里根本不是真正的全量UTF-8,很多字符存不进去。
5.2 字段类型选错是隐性炸弹
字段类型这个坑属于“选的时候不觉得,数据量上来才疼”的典型类别。我挑几个平时看得最多的类型问题讲。
整数类型方面,INT 与 BIGINT 的选择要看业务上限。INT 最大值约21亿,用来存订单号、用户ID这种会随业务增长而增长的数据,风险很大。我自己建表时,核心业务表主键和业务编号一律 BIGINT UNSIGNED,因为相比几百年都不会用到的存储空间差异,避免未来迁移主键类型的成本更划算。状态位和年龄这种取值范围极小的用 TINYINT 就够了,不要任何整数类型都上 INT,表大了以后每一个字节的浪费都会放大。
字符串方面,定长字段用 CHAR,变长字段用 VARCHAR。一个经常被忽略的问题是 VARCHAR 长度设得过大。很多人在设计备注字段时直接 VARCHAR(5000),理由是“可能存长文本”,这会导致临时表爆内存和索引失效。真要存长文本就用 TEXT,不要硬塞到 VARCHAR 里。另外,如果字段只存英文标识符、用户名等,注意区分是否要加 utf8mb4_bin 这种区分大小写的排序规则,电商场景里用户名、优惠码这类字段大小写不敏感匹配的需求还蛮常见,规则选错会有很多隐性问题。
金额字段是重灾区中的重灾区。绝对不要用 FLOAT 或 DOUBLE 存钱。二进制浮点在十进制表达上是近似值,存 0.1 这种小数会有精度误差,累计到后面账户对不上账。金额必须用 DECIMAL,精度和标度要提前约定,比如 DECIMAL(10,2) 一般适合百万级以内的金额;如果要存大额交易或精确到更多小数位,相应扩大精度。
日期时间方面,DATETIME 和 TIMESTAMP 都能存日期时间,但行为有区别。TIMESTAMP 的取值范围到2038年,且受时区影响,自动转换非常反直觉;DATETIME 范围大且不随时区变。存订单时间、创建时间这类业务时间,我建议一律 DATETIME,把时区转换交给应用层处理。另外建议每个表都建 created_at 和 updated_at 两个审计字段,虽然稍微增加插入成本,但排查数据来源时有巨大价值。
5.3 主键与索引:设计阶段最容易埋雷的地方
主键设计的首要原则是选择与业务无关、不会变的列。自增ID是默认首选:单调递增,写入时对B+树友好,不会页分裂,索引体积也小。业务主键比如身份证号、手机号、订单编号,永远存在被修改的可能性,或者因为规则变更导致长度和字符集变化,因此不要直接做主键。
但自增ID在分布式场景下也有问题——多个节点同时生成可能会冲突或用完,序列号不好协调。所以分库分表、分布式ID方案里,雪花ID或类似方案更常见:它是一个BIGINT,内含时间戳、机器ID、序列号,整体趋势递增,且全局唯一。这类方案需要应用层生成,但能很好地规避单库自增的瓶颈。如果选了雪花ID,表主键一定是 BIGINT,不要用 INT。
索引设计关键点是联合索引的字段排列顺序。最左前缀原则是数据库系统原理里的老话题:索引 (user_id, status, created_at) 可以服务 WHERE user_id=? 、WHERE user_id=? AND status=? 这类查询,但无法直接服务 WHERE status=? 开头的查询。所以设计联合索引时,顺序应该是“等值条件在前,范围条件在后”,区分度高、能过滤更多行的字段放前面。
除了字段顺序,还要检查索引冗余。一个表如果已经存在 (user_id, order_time) 联合索引,又单独建了一个 user_id 索引,后者就是冗余的,因为联合索引的最左前缀已经能覆盖按 user_id 的单列查询。冗余索引不仅浪费磁盘空间,还拖慢写入速度。我有个习惯:上线前把一张表的索引列表打印出来,看是否每一条索引都能说清楚被哪个查询路径使用,说不清楚的就不建。
6. 完整走一遍数据库设计:模拟项目X从模糊需求到建表SQL
前面讲了方法,最后完整走一遍流程。我用一个虚拟的在线书店系统作为例子,把从需求描述到建表SQL的每一步都展示出来。这套路线图经过多次调整后,我自己一直沿用,每次做新项目都按这个顺序来。
6.1 需求描述与实体识别
假设拿到手的需求只有这么一句话:“我们要做一个在线书店,用户可以注册登录、浏览图书并下单;每本图书可以关联多个作者,且属于某个出版社;一个订单里可以包含多本图书。”同时补充一点业务细节:下单时要把当时的图书价格记录下来,方便后续对账;用户需要维护售后地址。
先做实体识别。圈名词:在线书店、用户、图书、订单、作者、出版社、价格、地址。按前面的标准分析:用户、图书、订单、作者、出版社都有多个实例且会被引用,是实体;“价格”属于图书或订单明细,“地址”作为用户的属性还是实体要看是否需要单独管理。由于售后地址可能一个用户有多个,它就有成为独立实体或独立表的倾向。这里为了演示,我先把收货地址处理成订单上的快照字段,不再独立成表。
实体清单:
用户 user 图书 book 作者 author 出版社 publisher 订单 orders 订单明细 order_detail联系分析如下:用户和订单,一个用户有多个订单,一对多;订单和明细,一个订单有多条明细,一对多;图书和明细,一个图书可以被多个明细引用,一对多;图书和作者,多对多;图书和出版社,多对一。
6.2 关系模式与范式检查
按映射规则,多对多联系需要中间表,于是产生书-作者中间表,同时订单-图书的多对多通过订单明细表来承载。所有关系模式如下(主键加下划线,外键加注释):
user(id, username, password_hash, nickname, email, created_at) book(id, title, publisher_id, price, stock, created_at) author(id, name, bio) book_author(book_id, author_id) -- 主键为(book_id, author_id) publisher(id, name, address, phone) orders(id, order_no, user_id, status, total_amount, consignee, address, created_at) order_detail(id, order_id, book_id, quantity, unit_price)这里有两个设计点需要说明。第一,订单明细表我故意没有使用复合主键(order_id, book_id),而是用单列自增主键 id。原因是:同一订单里同一本书可能出现多次,比如用户凑单拆单时产生两行相同图书的记录,复合主键会直接堵死这种场景;另外明细表的单列主键配合 order_id 外键能更灵活地处理轮询、分页和部分更新。第二,unit_price 是下单快照价格,不是图书当前价格。范式的角度看,它是订单业务行为的结果字段,不属于图书实体属性,所以不构成部分依赖。表设计完成后我会再做一轮范式自查:明细表非主属性 quantity、unit_price 是否完全依赖于主键 id?是,没有部分依赖;图书表的 price 是由当前业务决定的,不依赖出版社;订单表的 total_amount 其实可以由明细加总得到,属于派生字段,但它存在高频查询场景,我选择显式冗余并加上注释说明由应用层在提交订单时计算并写入。这样整个设计在3NF层面是站得住的,同时个别冗余点在射程范围内。
6.3 物理设计与完整建表SQL
物理设计阶段,按前面章节的方法:引擎选 InnoDB,字符集选 utf8mb4,主键统一 BIGINT UNSIGNED AUTO_INCREMENT,金额用 DECIMAL(10,2),时间用 DATETIME。图书和作者的中间表使用联合主键。订单编号 order_no 设置唯一索引,因为业务上要用它做查询和对账,且数据库层面要防重复。订单明细表需要两个索引:order_id 是为了按订单取明细,book_id 是为了按图书维度统计数据;由于明细表主键已经是 id,order_id 和 book_id 上没有更合适的联合索引,就分别建单列索引即可。
建表SQL如下:
CREATE TABLE user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(64) NOT NULL, password_hash VARCHAR(256) NOT NULL, nickname VARCHAR(64) DEFAULT NULL, email VARCHAR(128) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE publisher ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(128) NOT NULL, address VARCHAR(256) DEFAULT NULL, phone VARCHAR(32) DEFAULT NULL, PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE author ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(64) NOT NULL, bio VARCHAR(1024) DEFAULT NULL, PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE book ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, title VARCHAR(256) NOT NULL, publisher_id BIGINT UNSIGNED NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT UNSIGNED NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_publisher_id (publisher_id), CONSTRAINT fk_book_publisher FOREIGN KEY (publisher_id) REFERENCES publisher (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE book_author ( book_id BIGINT UNSIGNED NOT NULL, author_id BIGINT UNSIGNED NOT NULL, PRIMARY KEY (book_id, author_id), KEY idx_author_id (author_id), CONSTRAINT fk_ba_book FOREIGN KEY (book_id) REFERENCES book (id), CONSTRAINT fk_ba_author FOREIGN KEY (author_id) REFERENCES author (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE orders ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, user_id BIGINT UNSIGNED NOT NULL, status TINYINT NOT NULL DEFAULT 0, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, consignee VARCHAR(64) NOT NULL, address VARCHAR(256) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES user (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE order_detail ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, order_id BIGINT UNSIGNED NOT NULL, book_id BIGINT UNSIGNED NOT NULL, quantity INT UNSIGNED NOT NULL DEFAULT 1, unit_price DECIMAL(10,2) NOT NULL, PRIMARY KEY (id), KEY idx_order_id (order_id), KEY idx_book_id (book_id), CONSTRAINT fk_od_order FOREIGN KEY (order_id) REFERENCES orders (id), CONSTRAINT fk_od_book FOREIGN KEY (book_id) REFERENCES book (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这个建表SQL到这里就可以直接交付了。需要说明的是,库存 stock 字段放在图书表里,在真实的高并发电商系统里往往需要单独设计库存服务或者乐观锁扣减,这里只是给一个常规单体应用的原型,不影响你体会数据库设计的主线逻辑。另一个值得强调的细节是 order_detail 表为什么必须存 unit_price 而不是查询时 JOIN books 表实时取价。因为一本书后续上调价格,历史订单如果实时取当前书价,对账立刻乱套。下单那一刻的价格是业务契约的一部分,必须固化在明细里。这个细节我见过太多项目一开始没做,后面补数据补得极其痛苦。
最后分享一个我自己的习惯:每次设计完一套表结构,我会在一张纸上把各表主外键关系重画一遍,然后拿给同事讲五分钟,看看对方能不能听懂每个字段为什么存在。如果讲的过程里出现太多“这是当时顺手加的一个字段”这种话,大概率是设计没想清楚。数据库设计的质量很难靠工具校验出来,真正可靠的检查方式,就是看每个设计决策能否说出理由。这个习惯帮我躲过了不少莫名其妙的建表返工,也推荐给你。