写 SQL 这件事,很多人学完增删改查就觉得掌握了数据库,可真到要动手建表的时候,反而露怯了。MySQL 表设计看起来就是几条CREATE TABLE语句的拼凑,实际上它决定了后面所有 SQL 好不好写、查询快不快、业务能不能撑住流量。这篇是 MySQL 系列的第 4 篇,我不打算把官方文档再抄一遍,而是按我自己这些年做业务系统时的经验,把表设计从需求拆分、字段选型到索引取舍、反范式应用、上线前检查,完整地过一遍。适合刚学完 SQL 语法、准备开始设计真实业务表的读者,也适合已经写过不少表、但总被历史包袱拖累的开发者对照自查。
1. 写建表语句之前:我的第一版表总是先拆三张图
很多人拿到需求第一反应是打开客户端敲CREATE TABLE,字段想到一个加一个,结果表建出来一堆冗余、职责混乱,等到写查询时才发现怎么关联都别扭。我自己的习惯正好相反:先别碰 DDL,把需求拆成“实体 — 关系 — 字段”三层,想清楚了再动手。
1.1 别用“感觉”建表:先提炼实体,再连线关系
无论是订单、会员、库存还是内容社区,第一步永远是问自己:这个系统里到底有哪些“东西”需要被记录下来?这个东西不是页面上的输入框,而是业务里的对象。比如设计一个电商小程序的订单模块,脑子里应该先浮现这几类实体:用户、商品、订单、订单明细、支付记录。然后才轮到关系:用户和订单是一对多,订单和商品是多对多,而这种多对多关系通常要拆成订单明细表来表达,因为一张订单里同一个商品买了几件、成交价多少、当时快照是什么,这些都是订单明细自己的属性。
新手最常见的错误,是把产品经理的需求描述直接搬进一张表。比如“用户下一单,包含了商品 ID、商品名、商品价格、数量、收货地址、备注”,然后就真的建了一张order表,字段里塞product_id、product_name、product_price、quantity。这样做的后果是:一个订单买了三件商品,就得拆成三行订单记录,而且这三行的订单号相同、收货地址相同,却要重复存储一遍;后续要改收货地址就要同步改三行;要统计订单数还得DISTINCT order_no。这已经不是表设计的问题,而是模型错了。
我现在的做法是:实体拆完之后,再给关系画线。一对多的关系直接在“多”的那张表上放外键列;多对多的关系老老实实用中间表;如果两张表之间除了关系本身还带着额外属性,那这个关系自己就应该是一张实体表。订单明细就是典型的“关系实体化”——它不只是订单和商品的连接,它还承载数量、单价、优惠分摊这些数据。把这个想明白,字段怎么分就自然清楚了。
1.2 字段能不能再拆细:把“可计算”和“可存”分开想
实体和关系定了,接下来的字段拆分要从业务本质去判断。这个字段描述的是实体的固有属性,还是某个可以随时算出来的结果?如果是后者,除非有明确的性能理由,否则优先考虑不落库。
举两个高频例子:用户的年龄和出生日期,应该存birthday而不是age,因为年龄每年都会变,存年龄的人第二天就可能出现“昨天 18 岁,今天 19 岁”的笑话;订单表里的订单总金额和明细金额,这两个字段看似冗余,但它们代表的是业务快照,属于有意为之的重复,后面第 4 部分会展开讲。还有一类隐藏地雷是“用字符串存数字”。手机号、身份证号这类看着像数字、实际上不参与加减乘除的字段,别用INT或BIGINT存。手机号存整数会丢掉前导 0,身份证号有校验位X,更不能当数字处理。判断标准很简单:如果这个值你永远不对它做算术运算,那它就倾向于是字符串。
字段粒度还会影响未来的查询效率。比如地址字段,到底是拆成省、市、区、详细地址四列,还是只放一个address?我的结论是,如果业务需要按城市统计、筛选,就一定要拆;如果只是打单时原样输出,那合并成一列反而省事。表设计最忌讳的是“为了将来可能用上”而堆字段,将来真的要用时再拆再迁成本更高,但这不是让你去预留一堆无用列,而是让你把当前业务真正看重的那几个维度拆到位。
2. 字段类型取舍:为“未来数据”选类型,不为“现在的 demo”选类型
实体和字段确定了,接下来就是给每个字段选类型。这一步看似基础,实际是大坑重灾区,因为类型决定了存储空间、比较规则、索引效率,甚至决定了这个表能否撑到两年后还不需要重构。我的核心原则是:按五年后的数据量去评估,别按上线第一天的数据量去评估。
2.1 整数和浮点数都有边界,别让值域成为隐藏的雷
先列一张常用的整数类型对照表,方便对照着选:
| 类型 | 字节数 | 有符号范围 | 无符号范围 |
|---|---|---|---|
| TINYINT | 1 | -128 ~ 127 | 0 ~ 255 |
| SMALLINT | 2 | -32768 ~ 32767 | 0 ~ 65535 |
| MEDIUMINT | 3 | -8388608 ~ 8388607 | 0 ~ 16777215 |
| INT | 4 | -2147483648 ~ 2147483647 | 0 ~ 4294967295 |
| BIGINT | 8 | 约正负 9.22 乘以 10 的 18 次方 | 0 ~ 约 1.84 乘以 10 的 19 次方 |
选择逻辑其实很简单:先估算这列数据的量级,然后至少留一倍的余量。比如状态码用TINYINT就够了,你要是给状态字段开个INT也不是不行,只是浪费 3 个字节而已;但反过来,主键 ID 一定要慎重。我见过一个流水表的自增主键用了INT,结果日增千万级的业务跑了半年就逼近 21 亿的上界,DBA 半夜被叫起来改表,这种教训一次就够记住了。现在新表涉及主键、流水号这类持续增长的列,我直接默认BIGINT UNSIGNED,存储成本多 4 个字节,换来的是几年内不用提心吊胆。
浮点数类型的坑更隐蔽。FLOAT和DOUBLE是近似存储,DECIMAL是精确存储。凡是跟金额、余额、费率、积分相关的字段,一律用DECIMAL(M,D),不要再有别的念头。我看过有人用DOUBLE存优惠金额,累计统计一段时间后经常出现 0.01 的误差,排查半天才发现是精度问题。DECIMAL的M是总位数,D是小数位数,比如DECIMAL(12,2)表示总长 12 位、小数 2 位,能存到十亿级别的金额,绝大多数业务够用。这里要提醒一句:FLOAT不是不能用,只是在不需要精确计算的场景,比如经纬度、温度这类本身就有测量误差的数据,你用DOUBLE反而是合适的。
2.2 字符串选型:varchar(255) 不是万能默认值
字符串类型里最常用的是CHAR和VARCHAR,两者的区别是定长与变长。CHAR(N)固定占用 N 个字符的空间,适合长度几乎不变的值,比如订单状态码、MD5 后的定长哈希;VARCHAR(N)按实际内容长度存储,但额外有记录长度的开销,适合标题、备注、名称这类长短不一的文本。
让我特别想强调的,是VARCHAR(255)的滥用问题。很多新手不管什么字段都写VARCHAR(255),觉得反正能放得下。但VARCHAR的长度不只是“能存多少字符”,它在 MySQL 里还影响了排序和临时表的内存分配。内存临时表是按VARCHAR(N)的最大长度来分配空间的,VARCHAR(255)和VARCHAR(20)在涉及ORDER BY、GROUP BY、DISTINCT时,内存开销可能差出不少。更麻烦的是 utf8mb4 字符集下,一个字符最多占 4 字节,老版本 InnoDB 单列索引有 767 字节限制时,VARCHAR(255)根本无法直接建前缀全列索引,得用前缀索引绕过。所以我的习惯是:名称类字段按业务真实上限来,通常 50 或 100 就够;URL、备注这类取 255 以内;真正的大文本另说。
大文本和二进制内容,比如文章正文、图片二进制流,应该用TEXT或BLOB。注意两点:TEXT列不能有默认值,而且一般不建议和业务主表放在一起频繁查询,因为行变大会导致页分裂、缓冲区命中率下降。如果正文很大,或者你几乎不会在列表页查它,建议拆一张附表,用主键关联,需要时再单独取。
JSON类型是 MySQL 5.7 之后很受欢迎的选择,适合存储结构不固定的配置、扩展属性。但我不建议把JSON当成“偷懒万能箱”。JSON 列的更新通常需要整列重写,而且没法像普通列一样走常规索引;如果某个字段将来要作为高频查询条件,就别放 JSON,老老实实拆成独立列。能把 JSON 用好的人,是先把普通列设计完了,剩下真正多变、低频查询的扩展信息才丢进 JSON。
2.3 时间字段别踩时区坑,NULL 要尽量少“随手放”
时间字段是另一个高频选择困难点。MySQL 常用的是DATETIME和TIMESTAMP。DATETIME不依赖时区,范围是 1000 年到 9999 年,占用 8 字节;TIMESTAMP内部会转成 UTC,输出时按会话时区转换,占用 4 字节,范围只到 2038 年。如果系统未来有跨时区部署的可能,我倾向用TIMESTAMP或让应用层统一存 UTC 时间再转。如果只是国内业务、单体应用,DATETIME用起来最直观,排查问题时一眼能看懂。
TIMESTAMP有一个经典红利:可以直接DEFAULT CURRENT_TIMESTAMP,配合ON UPDATE CURRENT_TIMESTAMP自动维护创建时间和更新时间。但注意,一个表里如果经常更新某一行,ON UPDATE CURRENT_TIMESTAMP会改变updated_at,这是期望行为;如果是那种“每次查询都要回写最后访问时间”的表,这种设计反而会频繁推进更新时间,要想清楚是否真的是你想要的效果。
NULL 值的设计也常被忽略。很多人在建表时对“不确定”的列直接允许 NULL,省心。但 NULL 在索引里比较特殊:唯一索引允许有多个 NULL 值,WHERE条件对 NULL 的过滤也不利于索引利用,统计函数还会自动忽略 NULL。我的建议是,业务上明确“没有”的字段用空字符串或 0 加NOT NULL DEFAULT表达;业务语义上允许“未知”的字段才用 NULL。不要让同样一个字段,既出现 NULL 又出现空串,含义还完全不同,那以后写代码会被自己坑到。
3. 主键和索引:表设计里最容易被低估的成本
字段类型选完之后,紧接着要面对的是主键和索引。这部分的决策直接决定这张表在数据量上来之后是“越跑越顺”还是“越来越卡”。很多开发者只关心查询时建了索引就行,却忽略了主键本身的物理存储逻辑,以及索引过多带来的写入负担。
3.1 主键选择:自增、UUID 还是业务键
InnoDB 是聚簇索引组织表,数据按照主键顺序物理排列。也就是说,主键不仅仅是一个约束,它直接决定了数据落盘的方式。自增主键是默认推荐,因为新纪录总是插在 B+ 树的末尾,页分裂概率小,写入性能稳定。这个结论本身没问题,但要看场景。
如果系统需要做数据合并、多库写入甚至是分库分表,自增主键会产生全局冲突,这时候有人就会想到 UUID。但随机 UUID 有个棘手的地方:它完全没有顺序性,插入新记录时主键可能在 B+ 树中间随机位置,导致大量页分裂、随机 IO 增多,写性能会明显下降。如果一定要用 UUID,我建议用有序的 UUID 方案,或者在 MySQL 8 里配合UUID_TO_BIN这类函数处理成可排序的二进制格式。更现实的替代方案是雪花算法生成的整数 ID,既保留了时间有序性,又解决了多机冲突。
关于业务主键,比如订单号,我的意见是:可以拿订单号当主键,但要先想清楚长度。字符串主键会让聚簇索引和所有二级索引的体积变大,因为二级索引的叶子节点存的都是主键值。一个 32 位的订单号主键,比 8 字节的 BIGINT 主键,在索引体积上的差距不是一点半点。所以更常见的做法是:表内用自增主键或雪花 ID,订单号另建唯一索引。这样既满足业务查询,又不拖累索引空间。
还有一种情况我会用复合主键,就是纯粹的关联表。比如用户和角色的关系表,主键直接设为(user_id, role_id),比单独加一个自增主键更贴合业务。复合主键的列顺序也有讲究:把区分度更高的列放前面,或者按查询条件最常用的列放前面,都能提升索引效率。别在这样的表上无脑加自增 ID,那是纯量外的浪费。
3.2 索引数量要以 SQL 为驱动,而不是反推
索引设计最正确的时间点,是表结构定稿后、上线前,把已知的高频 SQL 拿出来逐条核对。基本原则是:等值条件列放联合索引前面,范围条件列放后面,然后观察有没有覆盖索引的可能。比如订单表常见的查询是WHERE user_id = ? AND status = ? ORDER BY created_at DESC,那我就会建一个(user_id, status, created_at)的联合索引,让等值、等值、排序三段都吃到索引,而不是建三个单独的索引再让 MySQL 自己选。
这里有个容易犯的错:在同一个列上建了唯一索引,又建了一个普通索引。很多人以为两者功能不同,实际上唯一索引本身就包含普通索引的全部能力,重复建只会白白增加写入开销和空间占用。另一个常见问题是索引列顺序反了,比如在(status, user_id)上建索引,但业务查询永远以user_id为第一条件,那这个索引基本发挥不出效果,因为最左前缀原则要求先命中第一列。
索引不是越多越好。每个索引在插入、更新、删除时都要同步维护,写入密集的表如果索引建得又多又乱,磁盘 IO 会被拖垮。我见过一张日写入百万行的日志表,光是索引就建了七八个,结果业务高峰期写入性能很差,后来砍到只剩两个关键索引才缓解。表设计的阶段就要把“写多读少”和“读多写少”分清楚,前者克制索引数量,后者可以适当给查询让路。
外键约束也是一个值得提的点。物理外键能保证数据完整性,但在高并发写入时,每次更新都要检查关联表,锁竞争和开销都不小;分库分表后物理外键更是基本不可用。现在很多团队的实践是“逻辑外键”——表间不建 FOREIGN KEY 约束,但保留外键列并建索引,由应用层保证一致性。对于一致性要求极高、事务边界相对清晰的内部系统,物理外键依然有它的价值;但从通用业务系统的角度,我更倾向于逻辑外键加应用层兜底,因为这样更灵活,性能也更好控。
4. 范式与反范式,不是背理论,是算账
大学里学的三大范式,很多人工作后很快忘光了,或者反过来,成了教条。范式是降低冗余、避免更新异常的一套理论,它本身没有错,但实际业务表设计是另一种形态:要在一致性、查询性能、开发效率之间算清一笔账,而不是“范式越高越高级”或“反范式就是不讲武德”。
4.1 商品改名后,订单明细为什么连不上
我经常用订单明细表来讲解范式与反范式的取舍。按第三范式的要求,订单明细表里不应该存product_name,更不应该存当时的商品单价,因为商品名称和价格都能从商品表里查到,存了就是冗余。
但业务现实是:商品价格会调整,商品名称会修改,商品甚至可能下架删除。如果订单明细只存一个product_id,用户翻看历史订单时就会遇到问题——你查到了商品 ID,但商品表里的名称和价格已经变成今天的了,甚至商品已经删了,历史订单显示“商品不存在”。这在法律层面和用户体验上都是不可接受的。订单是业务发生时的快照,它必须记录下单那一刻的商品名称、成交单价、快照图片。所以这个冗余不但要存,而且是核心字段。
所以真正合理的做法是:商品表里维护的是“当前态”信息,订单明细表里维护的是“下单时快照”信息。两张表各有各的职责,谈不上谁冗余谁。要不要冗余,先问一个业务问题:当源数据变化后,这个历史记录需要跟着变吗?如果不需要,那就应该冗余;如果需要,才考虑通过关联去查。把这个问题想清楚,绝大多数范式争议都能平息。
4.2 冗余和计算列什么时候引入
除了快照冗余,业务里还常见“汇总冗余”。比如订单表存一个total_amount,订单明细表里又能算出来,这是一种典型的反范式设计。它的引入理由是:高频查询需要展示订单总金额,如果每次都SUM(order_item.amount),在单表数据量大时对数据库压力不小。只要在同一个事务里同时更新订单头和订单明细,总量和明细就能保持一致,这个冗余就是安全的。
比汇总字段更进一步的是“汇总表”。比如用户积分,按第三范式,余额可以用流水表累加得到,但高并发下每次查询都重放流水是不现实的。于是落一张积分账户表,专门存当前余额和版本号,更新时通过事务或乐观锁保证一致。这里的关键是:冗余数据必须在可接受的时延内保持一致。能在同一个事务里维护的就用事务,跨系统了就用对账任务兜底,但绝不能让两处数据长期“各说各话”。
我对这类设计有一个实用经验:开始先按范式建模,让数据模型干净清晰;等出现真实可见的性能问题后,再去加冗余、加汇总,并记录清楚冗余字段的维护口径。而不是一开始就把冗余铺得到处都是,因为冗余一旦失控,排查数据不一致的代价比查询性能的收益高一个数量级。
5. 表结构定稿前,用我这套清单做一次“上线前体检”
表设计得再自信,上线前也值得按清单过一遍。这相当于给自己一段冷静时间,把隐患消灭在改表成本最低的阶段。下面这十项是我每隔一段时间就会更新一遍的个人检查表,每次都真能查出问题。
| 检查项 | 检查内容 | 常见问题 |
|---|---|---|
| 命名规范 | 表名、列名是否全小写下划线,是否避开保留字 | 用desc、order、group做列名,SQL 到处要加反引号 |
| 主键策略 | 主键是否明确,类型是否有冗余,是否用业务无关键 | 字符串长主键、无主键表、随机 UUID 主键 |
| 字符集统一 | 库、表、列是否统一为 utf8mb4,排序规则是否一致 | 联表时出现字符集不一致导致的隐式转换,索引失效 |
| 时间字段 | 是否都有created_at/updated_at,类型是否统一 | 有的表用 datetime,有的表用 bigint,排序比较混乱 |
| 类型匹配 | 数值型是否参与算术,字符串长度是否合理 | 手机号、身份证等用整型存储,长度随手 255 |
| NULL 语义 | NULL 与默认值是否表达明确语义 | 一个字段既允许 NULL 又用空串,代码判断复杂 |
| 索引覆盖 | 高频 SQL 是否都能走索引,联合索引顺序是否符合左前缀 | 查询条件第一列不是索引首列 |
| 索引去重 | 是否存在唯一索引和普通索引同时存在 | 同一列UNIQUE KEY和KEY同时建 |
| 冗余可控 | 冗余字段是否明确了维护者与更新时机 | 到处冗余但不知道谁更新,数据对不上 |
| 大字段处理 | 大文本、JSON 是否合理拆表或拆分 | 正文和列表字段混在同一张大表中,行体积过大 |
除了这张表,我还会做两个额外的推演。第一个是“数据量乘以 100 之后还成立吗”:现在这张表 100 万行,你设计的索引和字段还撑得住吗?撑不住就现在调整;第二个是“明天需求改动,这张表改起来痛不痛”。改表从来不只是执行一条ALTER TABLE,它涉及数据迁移、业务双写、回滚方案。如果你在设计阶段就预感到某个字段大概率要变,比如把单值改成多值,就直接拆成关联表,别等上线后再造。
顺便说一个改表的实例:我接手过一个老表,status字段是VARCHAR(50),业务里实际只用了 0、1、2 三个值,但历史数据里混进了各种莫名其妙的脏值,后来要迁移成TINYINT,光数据清洗就花了两天。这就是类型选型过于随意的代价。而另一个反面案例是,某业务在设计账号余额表时直接定了BIGINT + DECIMAL(12,2) + 乐观锁版本号,一年后业务增长十倍,这个表几乎没动过,只加了两个二级索引就扛住了。
用我自己的话总结:表设计是一场面向未来的投资,前期的“多想一点”能换成后续的“少改一次”。你在字段类型上多花五分钟,可能省掉未来 DBA 的一次深夜紧急变更;你在索引上克制一点,可能换来写入链路更平稳的长期运行。这些经验不是一次就能补齐的,但只要你每次建表前都拿这套思路过一遍,踩坑的频率一定会肉眼可见地往下降。最后再分享一个实操习惯:建表语句写完别急着执行,先保存到 git 仓库,配合mysqldump导出的结构文件一起 review,让同事也能看到这张表当时的业务意图,对后续维护非常有帮助。