我工作头几年有个特别深的体会:MySQL 安装教程、数据库命令大全这类内容随手就能搜到,但一个人从“会写增删改查”到“能独立设计一张合理的业务表”,中间其实缺了一大环。这一环就是 MySQL 库表设计规范。很多新人在建表时总觉得“表嘛,能存下数据不就行了”,但等项目跑起来、数据量上来、报表要统计、索引要优化的时候,当初建表时偷的懒全都会加倍还回来。
这篇内容就是要解决这个问题。我会从需求梳理、命名规范、字段类型、索引设计到完整案例,把一套可以直接照着抄的库表设计流程讲透。默认你用的是 MySQL 8.0、InnoDB 引擎,字符集统一 utf8mb4。如果你是刚入行的后端开发、在校学生,或者准备面试但被问到“你怎么设计一张表”就发怵,这篇应该能给你一个比较完整的框架。
1. 开工之前先这样拆需求:别让表结构输在起跑线
1.1 需求落到表之前,先回答四个问题
我见过很多新人拿到需求后的第一反应是:打开 navicat,新建表,凭感觉敲字段。这个流程基本一定会出问题。正确做法是先别碰 SQL,把需求里的“数据”拆一遍,至少回答清楚四件事。
第一,这个表的核心实体是谁。用户、订单、课程、商品,这些才叫实体,一个核心实体对应一张主表。比如订单和订单明细是两个不同粒度的东西,硬塞到一张大宽表里,后面加商品数量、改价格、查明细时就是灾难。第二,每个字段是不是原子值。手机号、邮箱、省市区这些不能混在一个字段里,后期要按某个维度统计时你会想哭。第三,哪些字段会被查询、排序、去重或作为唯一标识。这些字段基本都要有对应的索引方案,决定了字段类型不能乱选。第四,数据量级和写入速度有没有预期。每日百万订单和每日几千订单,建表时的主键策略、分区规划完全不同。
有一个很常见的反例,就是新人把订单主表设计成包含商品名称、商品数量、商品单价、收货人详细地址等等的大宽表。感觉查订单时一条 SQL 全出来了,特别省事。可一旦用户一个订单下三件商品,你打算怎么存?复制三行订单行还是三个字段平铺?前者产生大量冗余主单数据,后者终有一天要加第四件商品时只能改表。正确思路是把订单拆成 order_main 和 order_item 两张表,主表存一次下单动作的公共信息,明细表存每条商品的独立信息。
1.2 命名规范:一次定好,减少以后的“回车”
命名这件事看起来是洁癖,其实是工程问题。统一命名最大的价值是:三个月后你自己回来看表,或者别的同事接手时,不用猜字段含义。
我建议一套比较通用的规范,你们可以按团队情况微调,但一定要定下来:
- 表名、字段名全部使用小写字母加下划线,不要用驼峰。
userId这种命名在 Linux 环境、不同客户端工具之间容易踩大小写敏感的坑,全部小写最稳。 - 表名建议单数,因为
students和student混在一起反而更乱。关键是全团队统一,不要一半表用单数一半用复数。 - 用业务模块前缀分类,比如用户模块
user_info、user_address,订单模块order_main、order_item,一眼就知道这张表是干嘛的。 - 不要用 MySQL 保留字做表名或字段名。经典翻车就是把订单表命名成
order,然后每个 SQL 都得写反引号,SELECT * FROMorder``,丑且容易埋雷。改成order_main或者order_info,瞬间清净。 - 字段命名要能表达业务含义,
name太泛,user_name就好很多。时间字段统一create_time、update_time,不要这张表写created_at,那张表写gmt_created。 - 索引命名也要统一:普通索引
idx_字段_字段,唯一索引uk_字段_字段。例如idx_user_create_time、uk_order_no。
关于外键命名和物理外键,这里多说一句。生产环境我强烈建议不要真的建 MySQL 外键约束,外键会让每次插入子表时都去锁查父表,大批量写入时性能损耗很大,后面做分库分表时更是牵一发动全身。正确做法是在应用层保证关系完整性,建表时给关联字段加上普通索引,靠逻辑外键维护关系。ER 图上关系照画,但数据库层不要加 FOREIGN KEY 约束。
1.3 公共字段:每张表都应该有的“底座”
业务表里有些字段几乎是必备的,除非你有非常明确的理由不用。我建表时基本都会带上主键、创建时间、更新时间,然后根据业务决定是否加逻辑删除标记。
一个常规示例:
mysql复制CREATE TABLE `user_info` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键',
`user_name` VARCHAR(50) NOT NULL COMMENT '用户名',
`mobile` VARCHAR(20) NOT NULL COMMENT '手机号',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用 1-正常',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除 1-已删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_mobile` (`mobile`),
KEY `idx_user_name` (`user_name`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户信息表';
create_time 和 update_time 这两个字段建议放在每一张业务表里,更新的那一个配合 ON UPDATE CURRENT_TIMESTAMP,能在绝大多数场景下自动维护最后修改时间。别小看这个,线上排查数据对不上、追踪脏数据来源时,有没有 update_time 效率完全是两码事。逻辑删除 is_deleted 看团队习惯,如果确认不需要保留历史数据,删掉也可以,但一旦用了,后面所有查询都必须记得带 is_deleted = 0 条件,这块也容易踩坑,后面章节细说。
还要强调一下注释。每个字段都加 COMMENT,这是最容易被新手忽略的。真实项目里一张表十个字段、二十个字段,一周后你根本不可能全记住 status 的 0 和 1 是什么意思,更别说别人。字段注释不仅是写给别人看的,更是写给你自己看的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 字段类型的正确打开方式:类型选择决定索引是否有效
2.1 int(5) 不是你想的数字长度,别再被误导了
新手最容易从网上看到“int(5)”这种写法,然后以为是最大长度。实际上,MySQL 里的整数类型长度从来不由括号里的数字控制,那个数字叫“显示宽度”,而且只有在配合 ZEROFILL 时才有意义,作用只是让数字在显示时补 0 到指定位数。它的存储范围和占用空间完全不变。
比如 INT(5),存进去 123,如果没开 ZEROFILL,返回还是 123;开了 ZEROFILL,显示出来是 00123,但存的值还是 123。MySQL 从 8.0.17 开始已经把整数显示宽度标记为废弃,网上大量“int(5) 代表最长 5 位”的说法是错得离谱的,如果还在研究这个括号数字,可以直接跳过。
真正要关心的整数类型是这几个:TINYINT 占 1 字节,适合存状态值、年龄这种小范围数值;SMALLINT 占 2 字节;MEDIUMINT 占 3 字节;INT 占 4 字节;BIGINT 占 8 字节。主键、雪花 ID、订单流水这类只增不减的大整数,直接上 BIGINT UNSIGNED,别省那 4 个字节。状态字段用 TINYINT,业务上只有 0~127 个枚举基本够用,不要写成 INT,浪费空间还影响索引体积。
这里还有个经典翻车:手机号到底用什么类型。手机号不是拿来计算的数字,不能用 INT,一个手机号 11 位,INT 最大也就 21 亿左右,存不下;用 BIGINT 倒是存得下,但手机号前面如果有 0(比如座机号码)就会丢,而且也没必要用数值类型去存一个纯展示的标识。正确做法是 VARCHAR(20)。类似的还有订单号、身份证号这类看起来像数字但其实不需要做加减运算的标识,都用字符串存更安全。
2.2 金额永远用 decimal,不要让 float 背黑锅
用 FLOAT 或 DOUBLE 存金额是新手经常犯的错。浮点数在计算机里是二进制近似表示,经典问题就是 0.1 + 0.2 在二进制浮点里不等于 0.3,而是得到 0.30000000000000004。单条数据看上去误差不大,但累计几万条订单之后做 SUM,金额就会出现分级别的误差,财务对账的时候欲哭无泪。
金额、汇率、费率这类精确数值,统一用 DECIMAL(M,D)。M 是总位数,D 是小数位数。比如订单金额 DECIMAL(10,2),意思是总共 10 位数,其中小数点后 2 位,整数部分最多 8 位,对绝大多数业务已经够用。如果你要存大额交易或者累计余额,可以把 M 调大,比如 DECIMAL(18,2),但不要为了“保险”盲目放到 DECIMAL(30,10),位数越大占用的存储空间越大,索引也更大。搞清楚业务量级再定,一般 10 到 18 位总精度足够。
关于 DECIMAL 还有一点要注意:SQL 里写 SUM(amount) 出来的是定点数,但如果你在应用层把它转成 float 或 double 再运算,精度还是会丢。接入层到展示层都用字符串或者高精度小数类型来处理,别在中间某一步转成浮点。
2.3 字符串和时间类型:varchar 不是越长越好,默认排序规则决定大小写行为
很多新人习惯把所有字符串都写成 VARCHAR(255),觉得长度给大点总没错。这会让 InnoDB 在建立索引时因为字段过长而受限,一些前缀索引、排序操作也会更慢。VARCHAR 的长度应该尽量贴近业务真实最大长度。用户名 VARCHAR(32) 或 VARCHAR(50) 一般够用,手机号 VARCHAR(20),邮箱 VARCHAR(64),省去给超大余量。当然如果某一天长度不够,扩字段长度这类 DDL 成本相对可控,所以一开始长度宁可略大一点,也不要因为长度不够导致频繁改表。
再就是 TEXT 类型。大段文本、富文本内容、JSON 整包直接存 TEXT 或 MEDIUMTEXT,最大的问题在于所有对 TEXT 的排序和索引都需要指定前缀长度,没法对整列建普通索引,而且行溢出后会把数据存到额外页,查询时性能很不稳定。举个现实场景:如果你把用户上传的图片 base64 字符串直接塞进 MEDIUMTEXT,表行会非常大,索引效率、同步速度都会很惨。正确方案是文件本身放对象存储,数据库里只存文件 URL 或对象 key,类型用 VARCHAR 就够了。
时间类型的选择也常见,DATE 只存日期,DATETIME 和 TIMESTAMP 都能存日期和时间。TIMESTAMP 占 4 字节,但范围只到 2038 年;DATETIME 占 8 字节,范围大得多。除非你明确需要时间戳自动转换时区,否则业务表默认用 DATETIME 最省心。另外,不要用 VARCHAR 去存时间字符串,哪怕格式是 2025-01-01 00:00:00,字符串类型没法利用时间范围索引,排序和比较的逻辑也容易出问题。
关于字符集和大小写,既然 MySQL 默认排序规则是 utf8mb4_0900_ai_ci,这里的 _ci 就表示 case-insensitive,也就是不区分大小写。所以新手经常问“为什么 MySQL 自动忽略大小写”其实是正常现象,它由排序规则决定。如果业务上确实需要区分大小写,比如用户名、邀请码这类字符,可以把字段或表的排序规则设置成 utf8mb4_bin,这样查询时大小写敏感。这里要特别注意保持数据库内部排序规则一致,否则两张表关联查询时类型一样但排序规则不一样,可能直接报“Illegal mix of collations”错误。
2.4 NOT NULL 与默认值:别让程序为 NULL 背锅
新手建表时,很多字段会默认允许 NULL,因为感觉“这个值可能没有”。这是一个很大的坑,NULL 在 SQL 里有三值逻辑,它不是空字符串,也不是 0,而是“不知道”和“不存在”。当你写 WHERE status != 1 时,所有 status IS NULL 的行都不会被查出来,这是新手排查半天都找不到原因的高频 bug。
COUNT(字段) 也会忽略 NULL 行,但 COUNT(*) 不会。如果你统计某列非空数量,发现数字对不上,先看看是不是 NULL 在捣乱。索引方面,虽然 MySQL 的 B+ 树允许索引列存在 NULL,而且某些场景下可以继续用索引,但 NULL 会让枚举判断、汇总统计和排序的语义变得很别扭,容易埋雷。
给字段设计默认值时,我们遵循一个宽松但实用的原则:业务上一定存在的字段设 NOT NULL,比如订单金额、创建时间;业务上可能暂时没有但能给出合理空值语义的字段,也尽量 NOT NULL 加默认值,比如手机号可以为空就存默认空字符串 '',状态字段给默认 0 或 1。凡是需要表示“未设置”的枚举字段,建议给一个显式的 0 状态,而不是允许 NULL。真到了必须允许 NULL 的场景,比如某些可空扩展属性,再单独开绿灯,但一定要写清注释。
很多人觉得“默认值而已,无所谓”,实际不是。上线以后如果应用层某次插入漏了这个字段,有默认值的数据依然正确;允许 NULL 的字段可能把 NULL 写进库,报表和查询逻辑全被污染。字段约束就是数据的“契约”,前期写得越严,后面脏数据越少。
3. 索引设计:不是每个字段都配拥有
3.1 主键怎么选:自增与 UUID 的真实差距
主键是整张表最重要的索引,因为它直接决定了 InnoDB 聚簇索引的物理存储顺序。InnoDB 表本身就是按主键索引组织的,数据行就是主键索引的叶子节点。
自增主键 BIGINT UNSIGNED AUTO_INCREMENT 在单机 MySQL 下是最省心的方案。新插入的行主键比之前大,InnoDB 直接在索引末尾追加,页分裂概率极低,写入效率高。如果你担心业务主键被遍历,可以用随机 UUID 或雪花 ID 做主键。但随机 UUID 是个大坑:它毫无顺序,每次插入都落在索引树的随机位置,会频繁触发页分裂和页重组,写入性能会显著下降。而且 UUID 通常存成 VARCHAR(36),次级索引里会冗余一份主键,占的空间比 BIGINT 大得多,整个索引体积都跟着膨胀。
我的建议是:单机单库、流量不大的业务,直接 BIGINT UNSIGNED AUTO_INCREMENT;如果公司已经规划分库分表,或者主键需要暴露在外且不想被遍历,可以用雪花 ID、号段模式之类的分布式 ID,但底层字段类型要存 BIGINT,不要存字符串。UUID 只在非常特殊的离线合并场景下才考虑,而且要能接受它的写入放大和碎片问题。
3.2 联合索引:谁在前,谁在后,用统计说话
很多新人建索引是一个字段一个普通索引,比如 user_id 一个索引,create_time 一个索引。但实际查询如果是按用户查订单并排序,单列索引往往只能命中一个,另一个字段还要回表排序。这时候联合索引就很有价值,比如 (user_id, create_time)。
联合索引排序规则是“最左前缀”:在 B+ 树里先按第一列排,再按第二列排,依此类推。所以查询条件里如果没有第一列,索引的后缀一般无法充分利用。比如索引 (user_id, create_time),WHERE user_id = 1 ORDER BY create_time 可以漂亮地走索引;但如果是 WHERE create_time > '2024-01-01',这个索引就用不上了,因为树没有按 create_time 作为全局第一排序键。
那么联合索引里的字段顺序怎么放?一个比较实用的经验:区分度高的放前面,经常用来做等值查询的放前面,范围查询和排序字段放后面。但如果一个字段在绝大多数查询里都是等值条件,另一个字段区分度虽高但只用于排序,通常是等值字段放前更合理。别靠感觉,直接跑一句 SQL 看区分度:
mysql复制SELECT COUNT(DISTINCT user_id) / COUNT(*) AS user_cardinality,
COUNT(DISTINCT status) / COUNT(*) AS status_cardinality
FROM order_main;
数值越接近 1,区分度越高。不过这不代表区分度低的字段就不能放联合索引前面,比如 status 区分度低,但如果你经常 WHERE status = 1 后再过滤,那它放前面可能也能减少扫描范围,具体还是要靠 EXPLAIN 验证。设计阶段给一个原理级判断,上线前用真实 SQL + EXPLAIN 复核,才是完整闭环。
联合索引还有一个附带好处:如果查询只需要索引里的字段,就能走覆盖索引,不需要回表查数据行。比如 WHERE user_id = 1 ORDER BY create_time DESC,只要查询列都能在 (user_id, create_time) 这个索引里找到,Extra 里会显示 Using index,这就是比较理想的状态。
3.3 什么样的写法会让索引白建
索引建好了,不等于 SQL 就一定能用上。有几种非常典型的写法会把索引废掉,新手在 EXPLAIN 里看到 type = ALL 时往往很懵,其实就是这些原因导致的。
第一,对索引字段套函数。比如 WHERE DATE(create_time) = '2025-01-01',这一下就把 create_time 上的 B+ 树顺序打乱了,无法做范围定位。正确写法是写成范围条件,比如 WHERE create_time >= '2025-01-01 00:00:00' AND create_time < '2025-01-02 00:00:00'。第二,隐式类型转换。如果 mobile 是 VARCHAR,你写
