1. 为什么很多系统不是死在代码上,而是死在表结构里
做了十来年的后端,我见过太多“代码写得很漂亮,数据库一塌糊涂”的项目。最典型的一个场景,某电商系统上线三个月,订单表十几个字段全是varchar(255),状态位靠字符串拼接,关联查询动不动就全表扫描。负责人跟我说“业务跑得挺快,就是有点卡”,我看了一眼慢查询日志,单条订单查询跑了三秒,磁盘I/O直接被打满。
这还不是最要命的。最要命的是,因为从一开始表结构设计就没想清楚,等到业务需要加一个“订单来源渠道”时,开发同学只能硬生生往订单表里塞列。塞了三个之后,表宽超过三十个字段,插入和更新都开始出现锁等待。到了这一步,再想重构已经很难——数据量上来了,上游下游都在依赖这些字段,你动一个字段名都要牵一发动全身。
所以说,数据库设计原则不是“理论知识”,而是每一个会写SQL的人都该掌握的工程底线。这篇内容我准备从实际踩坑的角度出发,把数据库设计里最容易忽略、也最容易让你将来加班的原则一条条拆开讲,包括范式与反范式的取舍、主键选择的坑、索引设计的顺序、命名规范的意义,以及我在真实项目里总结出来的一套可落地的表结构设计流程。不管你是刚入行的开发,还是已经写了几年SQL想要系统梳理一遍的工程师,这篇都能帮你在下一次建表时少掉几根头发。
2. 先搞清楚两个基础概念:什么是“好的设计”,什么是“坏的设计”
2.1 好的数据库设计不是“看起来很正规”,而是“能扛住业务变化”
很多教材喜欢强调范式、完整性、一致性这些抽象概念。但真正落到工程里,好的设计只有一个判断标准:当业务需求发生变更时,你改动的成本是否可控。
我举个例子。假设一个简单的用户系统,你需要存用户的昵称和手机号。坏的设计是什么样的?把所有信息塞进一张表,包括用户的登录密码、收货地址、订单记录、优惠券……这看起来“一步到位”,实际上当你要改收货地址的数据类型时,整个用户表都要锁一遍。好的设计会把用户基本信息、收货地址、订单信息分开,各自独立演进,表与表之间通过外键或逻辑关联联接。这样你改地址表时不会影响订单表,改订单表时不影响用户表。
这里有个“度”的问题,后面我会详细讲反范式,这里先让大家建立概念:数据库设计的第一要义是降低未来变更的传播面。
2.2 坏设计的五个典型征兆,中三条就该重构了
根据我的经验,一张表如果出现下面这些征兆,基本可以判定为“坏设计”:
- 一个字段里塞了多段信息,比如
remark字段里既存备注又存状态,还存了操作人。 - 同一张表里有大量空字段,比如20个字段里10个是NULL,说明这张表管了太多不相关的事。
- 字段命名混乱:既有
username又有user_name,还有UserName。 - 大量使用
text或varchar(500)存结构化的JSON,且后续又要针对这些JSON做查询。 - 没有任何索引,或者索引乱建到写速度被严重拖慢。
如果一张表命中三条以上,那我已经开始考虑怎么拆分和迁移了。坏设计往往不是一两天形成的,而是每次业务加需求时“顺手”加一个字段,日积月累,最终成了无法收拾的局面。这也是为什么我在带队时强制要求:每个新表的设计都必须经过评审,任何临时加字段的行为必须有书面记录。
2.3 为什么说“性能问题”其实是设计问题
不少同学会把慢查询归罪于“数据库性能差”,或者“服务器配置低”。但绝大多数情况下,性能问题的根源都在表结构设计——你选的类型不对,索引没建对,关联关系混乱,导致优化器根本走不了最优路径。
举个具体的例子。有一个日志表,主键是自增ID,但你每天要按created_at查最近一小时的数据。如果created_at没有索引,那就是全表扫描。可很多人建表时根本不会想“未来这个表要怎么查”,只顾着“先能跑”。等到数据量上了百万,再补索引就麻烦了——ALTER TABLE锁表时间长,在线变更又有各种限制。
所以,设计阶段就得考虑查询模式。这不是CLI工具能帮你自动解决的,必须由懂业务的人把查询路径梳理清楚。我在后文讲索引设计时,会详细讲解如何从查询需求反推索引字段,以及怎么验证索引效果。
3. 范式与反范式:别把教科书上的规则当成金科玉律
3.1 三范式到底在说什么,用一张订单表讲清楚
几乎所有数据库入门书都会讲范式:第一范式(1NF)、第二范式(2NF)、第三范式(3NF)。但你问很多工作了几年的人“为什么订单表里的商品名称要冗余一份”,他们会一脸茫然。这里我用最朴素的表达解释一下。
- 第一范式:字段不可再分。比如“地址”字段里如果同时包含了省、市、区,那它就是一个可分的原子字段,严格来说应该拆开。现代数据库设计里,地址作为一个复合结构经常用JSON字段存储,这算是为了便利性做的妥协,但核心原则是:一个字段只表示一个属性。
- 第二范式:消除部分依赖。说的是在联合主键的情况下,非主键字段必须依赖于全部主键,而不是只依赖于其中一部分。一个典型的反面例子是:一个订单明细表用
(订单ID, 商品ID)作为联合主键,却把“订单状态”放在这个表里——订单状态其实只依赖订单ID,和商品ID无关。这种设计会把同一个订单的状态复制到所有明细行上,一旦更新状态,就得同步更新多行,极易出错。 - 第三范式:消除传递依赖。即非主键字段之间不能存在依赖关系。比如订单表里存了
customer_id,又存了customer_phone,而customer_phone是从customer_id查出来的,这就是传递依赖。正确的做法是删除customer_phone,需要时通过customer_id关联用户表查询。
范式的本质是让数据只存一份,避免因重复存储导致的不一致和更新异常。但请注意,范式是设计理想,不是强制规范。在很多高并发读多写少的场景下,完全按范式设计反而会把简单的查询变成多次关联,严重影响性能。这也是为什么会有“反范式”设计。
3.2 什么时候应该故意“违反”第三范式
我自己最常遇到的反范式场景是冗余字段。
比如订单列表页,需要展示订单里的商品名称和商品快照。如果严格按第三范式,你只能根据product_id去商品表关联查询。但商品名称是会变的!今天叫“移动电源”,明天可能改成“充电宝”。如果订单表里不冗余一份商品名称,那么历史订单展示时就会跟着商品表变化,用户从后台看订单记录,明明当初买的是“移动电源”,现在却显示“充电宝”,这就造成了数据事实被篡改的错觉。
解决方案就是在订单明细表里冗余一个product_name_snapshot字段,在生成订单时把当时的商品名称写死。这确实是冗余,但它是为了保存历史事实而故意为之。再比如,有些报表场景需要频繁统计订单金额,如果每次都去关联优惠券表、商品表计算折扣,代价太大。这时就可以在订单表里冗余一个final_amount字段,在下单时算好存进去,后续只读这个字段。
反范式不是乱冗余,而是有目的的冗余。我个人的判断标准就三条:
- 冗余的字段几乎不会单独更新,生命周期和主记录一致。
- 冗余能显著减少高频查询的关联次数。
- 你能接受极端情况下,冗余字段和源数据在毫秒级时间窗内短暂不一致(通常通过异步任务最终一致性解决)。
如果三条都满足,那该冗余就冗余,不要有心理负担。
3.3 一张表如何做到“既满足业务又方便查询”
在实际设计里,我常用“基础表 + 冗余投影表”的方式来平衡范式与性能。所谓基础表,就是高度规范化的核心实体表,保证写入一致性;所谓冗余投影表,就是专门为查询场景建立的宽表,数据从基础表同步过去,可以有重复字段,但换来了极致的查询性能。
一个很常见的例子是“订单中心”。订单的基础表按第三范式设计,把订单头、订单明细、支付信息、优惠信息拆开。但这些信息在后台管理页面几乎总是要被一起展示。于是我们建了一张订单宽表,把订单头、商品名称快照、支付金额、优惠金额、收货人姓名等字段都冗余进去。订单变化时通过消息队列同步更新宽表。这样后台查询直接select * from order_wide where order_id = ?,一条SQL搞定,不用关联五张子表。
这套方案的核心代价是引入了数据同步延迟。但通过消息队列加回调重试,可以做到秒级甚至毫秒级一致,绝大多数业务场景都能接受。比起复杂度,它换来的是查询路径的极大简化。我做过一个项目,把原来的十几张表关联查询改成宽表查询后,接口响应时间从800ms降到了80ms,效果非常显著。
4. 主键选择的纠结:自增ID、UUID还是雪花ID
4.1 各种主键方案的优缺点对比,一张表看懂
主键设计是数据库里最基础也最容易翻车的一个决策。我看到很多新手第一个问题就是:到底用自增ID还是UUID?这里直接把我多年实践整理的对比表放上来:
| 主键方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 自增ID | 简单、有序、占用空间小、索引性能好 | 可被遍历,容易暴露业务量;分布式环境下生成有瓶颈 | 单库单表、内部系统、对数据安全要求不高的场景 |
| UUID | 全球唯一、生成简单、不暴露业务量 | 无序、占用空间大(36字符)、索引随机插入导致页分裂严重 | 分布式系统、需要合并多来源数据的场景 |
| 雪花ID | 有序递增、趋势递增、分布式唯一、不依赖数据库 | 依赖时钟回拨问题、需要实现生成器 | 大中型分布式系统、订单系统、需要全局唯一且趋势递增的场景 |
我在实际项目中,大部分内部管理系统的表,直接用自增ID就够了。为什么?因为内部系统没有多少写入并发,也不会暴露给外部恶意用户,自增ID简单可靠,B+树索引基于有序数值插入性能极佳。但如果你做的是面向C端的交易系统,订单号之类的一定要避免自增ID——别人拿着订单号一个接一个遍历,你的订单总量、单量变化全被看光了。这时候我用雪花ID自建一个ID生成服务,既能保证全局唯一,又能保持索引插入的有序性。
4.2 为什么UUID做主键会让索引膨胀得厉害
有一个项目曾经用UUID做主键,我把数据导到测试库后发现一个现象:主键索引的大小比数据本身还大一倍。原因就在于UUID是随机字符串,插入时B+树节点位置完全随机,导致大量页分裂和碎片。而自增ID或雪花ID是有序的,新数据总是插入到叶子节点的最右端,避免了频繁的页分裂。
所以在单库单表场景,只要业务允许,优先选BIGINT自增。在分布式场景,如果不想引入额外的ID生成中间件,可以用BIGINT加“时间戳+机器号+序列号”的拼装方案,这就是雪花ID的精髓。不要一上来就为了“微服务”选UUID,等你的数据量到了百万级,再回头看索引膨胀带来的额外存储和性能损耗,你会后悔的。
4.3 关于主键的一个冷门细节:要不要有业务含义
另一个容易忽略的点是:主键最好没有任何业务含义。比如用身份证号做主键,看起来方便,但身份证号是不可变的吗?不,某些情况下需要变更。一旦作为主键变更,所有关联表的外键都得跟着改,这是灾难。我在设计用户表时,哪怕逻辑上身份证号是唯一的,我也不会把它设为主键,而是用一个user_id自增ID或雪花ID,身份证号单独加唯一索引。
这样做的好处很多:业务变更不会影响主键结构;联合索引时可以自由调整业务字段;对外暴露的ID还可以做成混淆映射,而不影响内部关联。总体原则就是:主键是数据库层面的唯一标识,不是业务标识,两者分开最稳妥。
5. 索引设计:大部分人的索引习惯都反了
5.1 先想查询,再建索引,这个顺序不能乱
很多项目建索引的方式是“跑得慢就跑一条加一条”,这是典型的打补丁思维。正确的方式,是在表结构设计阶段就梳理出核心查询路径,然后为这些路径设计索引。
具体的做法是:把系统里所有主要页面和接口列的查询条件列出来,分析哪些字段会出现在WHERE、ORDER BY、GROUP BY、JOIN ON里。把这些字段找出来后,再根据字段的区分度、更新频率来决定需要建哪些索引。比如“状态”字段,区分度极低,只有几种值,单独建索引基本没用;但如果和“创建时间”组成复合索引,则可能覆盖到“按状态筛选并按时间排序”的查询。
我常用一个原则叫“最左前缀原则的一个变种”:把等值查询的字段放在索引最前面,把范围查询的字段放在最后面。原因是B+树可以快速定位等值条件,再在范围内扫描。比如查询“某个用户最近一个月的订单”,正确的复合索引是(user_id, created_at),user_id是等值条件,created_at是范围条件。
5.2 复合索引设计的一个真实案例
做一个模拟项目的时候,有个订单查询页,搜索条件包括:商家ID、订单状态、订单创建时间范围。当时工程师给这三列建了三个独立索引,结果查询依然很慢。我一看执行计划,MySQL只用了一个索引(通常是区分度最高的那个),剩下的条件靠回表过滤,导致几百毫秒的延迟。
我的调整方案很简单,把三个索引合成为一个复合索引:(merchant_id, status, created_at)。因为商家ID区分度高且是等值查询,放最前面;状态区分度低但用于等值过滤,放中间;时间用于范围过滤,放最后。改完以后,同样的查询走索引扫描,响应时间从几百毫秒降到了十几毫秒。
这个案例告诉我们:独立索引多不等于查询快,很多时候反而浪费存储和更新成本。索引设计必须站在查询路径的角度去组合字段,而不是“每个字段都加一个”。
5.3 为什么不能过分迷信“全表扫描”
有些同学会拿“数据量小,全表扫描无所谓”来为自己不建索引辩护。数据量一万行确实无所谓,但数据量涨到一千万行,全表扫描就不是性能问题,而是稳定性问题。尤其是在分库分表之后,单表数据量虽然降低,但如果每个分片都全表扫描,累加起来照样卡。
全表扫描并不可怕,可怕的是它伴随着不可预期的锁和巨大的IO放大。我更建议在写任何SQL前都先想清楚这个查询有没有可能走索引。你可以用EXPLAIN查看执行计划,看有没有出现type=ALL。如果出现了,而且数据量很大,那基本说明SQL或索引设计有问题。
还有一个常见误区:万能的SELECT *。在索引覆盖的情况下,SELECT *会导致无法利用覆盖索引,必须回表。如果你只需要几个字段,就把它们列出来,这样可以使用“覆盖索引”,减少一次回表查询。这一步看似很小,在高并发场景下对数据库的压力是天壤之别。
6. 字段类型选择的细节:这里省一个字节,那里省一次锁
6.1 数值类型不是“越大越好”,也不是“越小越好”
很多人在建表时习惯什么数字都用INT,日期都用DATETIME,字符串一律VARCHAR(255)。这种习惯一旦成型,表变得臃肿,索引变得低效。
字段类型的选择,核心依据是你能预估的值的范围。如果只是一个年龄字段,用TINYINT UNSIGNED就够了(0-255),非要建INT就是四倍的空间浪费。如果是一个状态枚举,用TINYINT或SMALLINT就好。如果你的主键是雪花ID(19位),用BIGINT,用INT会溢出。金额字段,千万不要用FLOAT或DOUBLE,二进制浮点数无法精确表示十进制小数,建议用DECIMAL(10,2)或者BIGINT存分。付款系统的金额错误是会引发严重线上事故的,这是底线。
日期类型方面,如果是区间的日期查询,建议直接用DATETIME或TIMESTAMP,不要存为字符串,否则排序和范围计算会让索引完全失效。如果你的业务跨时区,可能还要考虑TIMESTAMP带时区的问题,这个多一嘴:不要为了省事把所有时间都存成“本地时间”,建议统一存UTC时间戳,应用层做时区转换。
6.2 字符串设计:为什么我很少用VARCHAR(255)
VARCHAR(255)是个流行但危险的默认值。从存储角度,VARCHAR是变长的,长度上限多代表的是行格式里记录最大长度的字节数变大,ROW格式下会占用更多存储空间。更重要的是,在GROUP BY或ORDER BY时,VARCHAR(255)会消耗更多临时表空间。
那么字符串字段到底取多长?我的建议是:根据业务上限估算再乘一个保险系数。比如手机号,国际最多15位数字,你存VARCHAR(20)就绰绰有余;昵称,常见的限制是50个字符以内,你存VARCHAR(50)即可。不要一看到“姓名”就VARCHAR(255),没意义的。可以用VARCHAR(64),既兼容中文也兼顾长度。
另外,对于超长文本或JSON,我不会直接放TEXT或LONGTEXT,而会专门拆到一张“详情表”里,主表只保留一个detail_ref或干脆用外键关联。为什么?因为主表行长度会影响每一行存储的页大小和索引效率。把大字段拆出去,主表能放更多行,索引扫描时也随手带出了更少的数据页,性能自然提升。
6.3 NULL值的处理:一个经常被忽视的设计决策
字段要不要允许NULL,看似小事,但对查询有实际影响。如果某列有NULL,在WHERE中就要写IS NULL,COUNT(列)会跳过NULL,UNIQUE索引在MySQL里允许多个NULL。实际开发中,我更喜欢用显式的默认值来替代NULL,比如状态字段用0表示“无效”,1表示“有效”,不要存NULL。时间字段如果表示“未设置”,可以用0000-00-00(需要关闭严格模式)或一个大年份(如1970-01-01),但要统一约定。
NULL带来的另一个问题是,很多ORM框架在处理NULL时容易出错。比如Java里给Long类型字段赋NULL,再传给前端就会出现序列化异常。与其在后端各种判空,不如从源头设计上减少NULL的扩散。当然,有些字段确实可能无值,比如“备注”字段,允许NULL没问题。我只是建议大家在设计每个字段时都问一句:这个字段什么时候会没有值?这个值能不能用默认值表达?
7. 命名规范和外键使用:让团队协作不再产生“沟通事故”
7.1 统一命名规范的价值,比你想的大得多
我在代码评审时最反感的就是字段命名混乱:有人用userName,有人用user_name,还有人用username。这种混乱会导致你搜代码、写SQL时都得先猜。团队协作中,命名一致性不仅仅是美观,更是减少沟通成本的关键。
我推荐一套简单容易执行的规范:
- 表名、字段名统一使用小写字母,单词间用下划线分隔。
- 表名用复数(如
users、orders、order_items),语义明确。 - 所有表必须有主键,统一叫
id。 - 外键字段用“关联表名单数_id”的形式,比如
order_id、user_id。 - 时间字段统一叫
created_at、updated_at,如果在分库环境下还会有deleted_at做软删除标记。 - 布尔字段用
is_或has_前缀,比如is_active、is_deleted。
这套规范一执行,即使项目交接给新人,看表结构也能猜个八九不离十。我在数据库评审中,看到命名不规范的直接打回,不为别的,就为将来少点事。
7.2 外键到底要不要用?我的答案分两种情况
关于外键,业内争论已久。我的观点是:
- 在OLTP(在线事务处理)系统里,尽量不用物理外键。物理外键带来的问题是:每次插入子表记录时都要去校验父表是否存在,增加锁竞争;删除父表记录时,如果子表有引用,就会触发级联删除或者报错,很容易误删数据;在高并发写入场景下,物理外键的性能损耗不可小觑。很多大型互联网公司都明确规定禁用物理外键。
- 在需要强数据完整性的系统里,用物理外键。比如财务系统、账户系统的核心表,误插一条无主的记录后果很严重,这时候宁可多一些性能损耗,也要保证完整性。
如果不用物理外键,就要靠应用层来保证外键逻辑。比如删除一个用户前,先查一下他有没有订单,有就先处理订单。这个逻辑是必须在业务代码里做好的。另一种做法是用“软删除”替代物理删除,避免外键挂连的尴尬。
7.3 软删除与唯一索引的冲突处理
说到软删除,有个经典坑:你在users表里加了唯一索引(email),但业务要求保留已删除的用户记录,所以用deleted_at标记删除。当你再次注册同一个邮箱时,就会插入两条email相同但deleted_at不同的记录,唯一索引直接报错。
这个问题的解决思路一般有三种:
- 把唯一索引改成
(email, deleted_at),但删除多条同邮箱记录时还是会冲突。 - 用一个
is_deleted布尔字段,但只能删一次。 - 删除时给
email加上后缀,比如xxx+deleted_{id},保留邮箱的唯一性。
这里没有银弹,得视业务而定。我一般倾向第三种方案,因为它能保证唯一索引不被破坏,同时还能保留原始邮箱的痕迹,方便后续做数据恢复或审计。
8. 一套可落地的表结构设计流程,从需求到建表只需六步
8.1 第一步:梳理业务实体与关系
接到需求后,我先不动手建表,而是画实体关系图。把业务里的核心名词提取出来:用户、订单、商品、优惠券……然后标记它们之间的关系:一对多、多对多、一对一。多对多关系通常需要一张中间表,比如“用户-角色”就是中间表,存储user_id和role_id。
这个过程一定要邀请业务方一起参与。很多人只看需求文档,理解偏差到建完表才发现,返工成本极高。我记得有一次做会员体系,需求方说“用户可以有多个会员等级”,团队理解成了“用户表加一个等级字段”,结果开发完才发现完全不对。这里的关键在于:关系梳理清楚之后,表的数量和边界基本就定了。
8.2 第二步:为每个实体定义核心字段与类型
每个实体表,先列出“必不可少”的字段,再列“未来可能需要”的字段。必不可少字段比如主键、创建时间、更新时间、业务状态;未来字段比如扩展属性的预留。但是,我不是特别推荐大量预留字段,因为真正到用的时候,字段类型和语义可能完全不符。更推荐用“JSON扩展字段”的方式,允许后续业务差异化的属性放到一个extraJSON列里,需要专门查询再考虑拆列。
字段类型严格按业务值域选择,参考我前面提到的方法。这一步的关键是不要急,每写一个字段都问自己:这个字段会被怎么更新?会被怎么查询?什么情况下会为空?
8.3 第三步:设计索引前,先列查询场景
表字段定稿后,暂时不要建索引,而是把与该表相关的所有查询场景写出来。包括列表页的筛选、详情页的主键查询、报表聚合的维度。然后按优先级把最核心高频的查询场景挑出来,设计复合索引。
我用一个简单的模板来列查询场景:
- 场景名称:待办订单列表页
- 筛选条件:
merchant_id = ? and status = ? and created_at between ? and ? - 排序条件:
created_at desc - 高频程度:每天数万次
- 索引建议:
(merchant_id, status, created_at)
有了这样的清单,索引设计就不会拍脑袋了。
8.4 第四步:用数据量和增长速度校准设计
设计表结构时,一定不能忽略预估的数据量和增长速度。如果预估一年内单表数据量超过500万行,就要认真考虑分区或分表方案。如果写入并发高,要避免更新热点字段过多的设计。
数据量会反过来影响字段类型选择。比如状态字段虽然只有几种值,但如果这个表要配合其他表做JOIN,状态字段放进索引的价值就要重新评估。另外,如果数据快速增长,就要记录归档、删除策略,从设计层面就给数据留出“出口”。
8.5 第五步:评审与测试
表结构设计完成后,组织一次技术评审。评审不光看字段对不对,还要让不同后端同学模拟走一遍核心SQL,确认执行计划合理。有条件的,可以在测试库灌入一批模拟数据(尽量接近线上数据量),跑一遍慢查询日志,看看有没有场景漏掉了索引。
这一步很关键,因为很多问题,在只有几百条数据的测试库里是完全看不出来的。我在实践中吃过大亏:测试库查询5毫秒,上了生产数据量大了直接卡死。从那以后,我的每一项表结构变更都必须附带数据量预估和索引验证结果。
8.6 第六步:写文档与维护计划
表结构建成之后,设计文档要被维护起来。包括每张表的用途、每个字段的含义、索引的作用、查询场景列表。最好直接在数据库的注释里把字段说明写清楚,这样后人看表结构时不用摸黑。
我见过太多项目,表结构文档建完就不更新了,等接手的人看到注释还停留在一期版本的说明,而实际表结构已经变了五版。如果不想让后人骂你,请在每次变更表结构时同步更新注释和文档。这不是可选的,是基本素养。
9. 几个实战中反复遇到的坑,替你踩过了
9.1 时间字段存成了字符串,导致索引失效
之前接手的某系统,为了“展示方便”,把时间一律存在varchar(20)里,格式还五花八门:有2024-01-01 12:00:00,有2024/01/01。等到要做按月统计报表时,SQL里写where create_time >= '2024-01-01' and create_time < '2024-02-01',虽然看起来能用,但字符串比较效率和正确性都堪忧。我后来花了两个晚上写脚本把所有历史数据清理成DATETIME列,才把这个问题根治。
记住:数据库里的时间字段只能存时间类型。展示格式化交给应用层,不要为了显示方便去牺牲存储的合理性。
9.2 唯一索引与大小写敏感
MySQL默认的utf8mb4排序规则是utf8mb4_general_ci,是不区分大小写的。所以你给邮箱字段建唯一索引时,Test@Example.com和test@example.com会被视为重复。很多业务其实希望邮箱不区分大小写,那这个行为就正好。但如果你的业务需要区分大小写,记得指定utf8mb4_bin或utf8mb4_0900_as_cs排序规则。
这个坑很隐蔽,等出问题时你根本不会往排序规则上想。查了半天数据,看起来完全不同的两条记录,数据库却告诉你“违反唯一约束”。我第一次遇到时整整排查了一个下午,最后看了眼建表语句才恍然大悟。
9.3 不要为了“通用”设计一个万能大表
还有一种设计我坚决反对:把所有业务数据都塞进一张“元数据表”,用key-value方式来存。这种设计确实很“灵活”,可以随时加属性而不改表。但它的代价极沉重:每一条业务记录都要多行存储,查询时要做大宽表透视,索引很难建立,数据校验一塌糊涂。做这种“灵活设计”的项目,后期没有一个不后悔的。
真正的灵活,是设计出能适应变化的表结构,而不是取消结构。我的建议是,如果实在有大量不确定的属性,用JSON列加特定索引(MySQL 5.7之后支持生成列)都比全表key-value好得多。记住这句话:过度抽象,是比过度冗余更可怕的敌人。
10. 数据库设计不是一次性的,要把它当成“长期演进”
最后想聊聊心态问题。很多同学把建表当成一次性的体力活,建完就扔到一边不管了。但数据库设计和你写代码一样,是需要迭代、重构、持续优化的。
我现在的习惯是:每两个月复盘一次线上几张核心表的慢查询日志和索引使用情况。哪张表出现了全表扫描,哪张索引一次都没用过,哪张表的数据量增速超出预期……这些都能告诉我,设计是否需要调整。加上现在在线DDL工具已经比较成熟,支持在业务低峰期变更表结构,但这并不代表你可以一开始就随便设计。
我也见过很棒的团队,专门有“表结构评审日”,每个新功能上线前,都要过一遍表结构。这种机制的价值,不是找出“错”的设计,而是让每个参与者都重新思考一遍关系、字段和查询路径,把系统性问题扼杀在萌芽阶段。
说回我自己的体会,做了这么多年数据库相关工作,最自豪的项目,不是把某个接口性能优化了多少倍,而是把一张已经臃肿到十几个表靠垃圾SQL硬关联的系统,重构成为一套结构清晰、索引合理、查询路径明确的新模型。那次重构之后,不仅线上故障大幅减少,连新同学接手业务时上手都快了不少。这就是数据库设计的价值——它不像写一个功能那样看得到产出,但它决定了你整个系统的天花板在哪里。
如果你现在正打算建一个新表,或者即将重构一个老系统,不妨停下来,把上面这套思路过一遍。先想清楚边界,再动手写SQL,你真的会感谢自己。