数据库设计这事,我干了十来年,见过太多项目毁在第一步建表上。表结构一旦定下来,后面改一次的成本远高于你想象。MySQL作为最常用的关系型数据库,建表语法本身不难,难的是你知不知道每个关键字背后在做什么,约束该怎么用,哪些坑是前人已经踩平了的。这篇就把MySQL表的创建和约束彻底讲透,从一个真实业务场景出发,从数据类型选型到约束配置,再到改表操作和生产事故复盘,适合刚入门想系统学建表的人,也适合已经写了一段时间SQL但没深究过原理的开发者。
1. 建表前必须想明白的三件事
1.1 先回答"这张表存什么、给谁用、怎么查"
我不止一次看到有同事打开Navicat就直接写CREATE TABLE,写到一半卡住了,回头问我"订单金额用什么类型好"。这种问题说明压根没想清楚业务需求,就急着上手。
建表本质上是在做数据建模,不是写SQL。你在设计一张表之前,脑子里必须有三张图:业务流程图、数据流图、查询路径图。只有当你清楚数据从哪来、经过哪些处理、最终被谁以什么方式查询时,你才知道这张表要定义哪些字段,每个字段该用什么类型,索引怎么建,约束加在哪里。
举个例子,我最近在帮朋友做一个购书网站的后端,其中一个核心需求是用户下单。我们先不管具体SQL长什么样,把问题拆开:
- 用户信息需要记录什么?用户名、密码、手机号、邮箱、状态、注册时间
- 订单信息需要记录什么?订单号、下单用户、总金额、状态、下单时间
- 订单里有哪些商品?商品快照名称、单价、数量,注意是快照,不是关联商品表
之所以要存商品快照而不是直接关联商品表,是因为商品信息会变——改价、改名、下架,订单作为历史数据必须保留下单那一刻的真实情况。这个决策直接决定了表结构怎么设计,也决定了哪些字段该加约束。
在写任何建表语句之前,先花30分钟把这些问题梳理清楚。这是我在无数项目里验证过的经验:设计阶段多花的时间,会在开发和维护阶段以十倍的时间省回来。表结构设计错了,连改都无从下手,很多时候只能推倒重建。
1.2 数据类型的选型是地基,选错很难改
数据类型选错是建表最常见的错误之一。我见过有人用VARCHAR存日期,用DECIMAL存手机号,用TEXT存很短的状态码。这些做法短期内能跑通,但长期来看无一不是隐患。数据类型不只是"存得下"的问题,它决定了存储空间、查询性能、索引效率、计算精度,甚至数据一致性。
MySQL的整数类型有这么几个档位:
| 类型 | 字节数 | 有符号范围 | 无符号范围 | 适用场景 |
|---|---|---|---|---|
| TINYINT | 1 | -128~127 | 0~255 | 状态值、开关标记 |
| SMALLINT | 2 | -32768~32767 | 0~65535 | 枚举值、数量 |
| MEDIUMINT | 3 | -8388608~8388607 | 0~16777215 | 中等数量的计数 |
| INT | 4 | -2147483648~2147483647 | 0~4294967295 | 常规ID |
| BIGINT | 8 | -9223372036854775808~9223372036854775807 | 0~18446744073709551615 | 雪花ID、大表主键 |
这里有个常见的误解,MySQL老版本里INT(11)那个括号里的数字不是存储长度,而是显示宽度,在MySQL 8.0里连显示宽度都被移除了。所以别再纠结写INT(11)还是INT(10),直接写INT就够了。给状态字段用TINYINT,给主键用BIGINT UNSIGNED,别问为什么,问就是省空间、抗增长。
小数类型的选型就更讲究了。FLOAT和DOUBLE是浮点数,存在精度损失,你在程序里算可能看不出来,但做金额累加、财务对账的时候,0.1加0.2不等于0.3这种经典问题就来了。金额字段一律用DECIMAL,它是以字符串形式存储的定点数,精度完全可控。比如DECIMAL(10,2)表示总位数10位,其中小数2位,整数部分8位,最大能存99999999.99。
日期类型也是个容易踩坑的地方。DATE占3字节存年月日,DATETIME占8字节存年月日时分秒,范围从1000年到9999年,TIMESTAMP占4字节,范围只有1970年到2038年。TIMESTAMP还有个坑:它受时区影响,写入时按会话时区转换,读取时再转回来。如果你做的是面向全球用户的应用,我建议直接用DATETIME,省得被时区问题折腾。DATETIME在MySQL高版本里已经支持到微秒精度了,用DATETIME(3)就能存毫秒。
字符串类型里CHAR和VARCHAR最常用。CHAR(N)是定长,N最大255,不足的补空格,存取速度快但浪费空间,适合长度固定的数据如MD5哈希、手机号这种——其实手机号建议用VARCHAR,因为有些国家手机号带前缀符号。VARCHAR(N)是变长,存多少用多少,额外用1~2字节记录长度,N在utf8mb4字符集下最多65535字节,但注意那是字节数不是字符数,中文一个字符占3字节,所以VARCHAR(100)实际上要占300字节的空间。这个区别很多新手忽略,导致建表时长度算错。
还有一类是TEXT系列,包括TINYTEXT、TEXT、MEDIUMTEXT、LONGTEXT。能用VARCHAR就尽量别用TEXT,因为TEXT类型的字段在InnoDB里可能放到溢出页存储,导致查询时要多一次磁盘IO,而且TEXT字段不能有默认值(除非MySQL 8.0.13之后的表达式默认值),不能直接建索引除非指定前缀长度。如果你的数据量小于65535字节,VARCHAR完全够用。我之前接手过一个表,里面一个描述字段用了LONGTEXT,实际存的都是不超过几百字的短文,每次查询都要多付一次IO代价,后来改成VARCHAR(500),查询速度立竿见影。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. CREATE TABLE逐行拆解:从零构建一张靠谱的业务表
2.1 一张完整用户表的建表语句
理论讲多了没用,直接上手。基于刚才的购书网站场景,我先把用户表建出来,然后逐行解释每个部分在干什么:
sql复制CREATE TABLE `user` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`username` VARCHAR(50) NOT NULL COMMENT '用户名',
`password_hash` CHAR(60) NOT NULL COMMENT '密码哈希值',
`email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱',
`phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1启用,0禁用',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`),
UNIQUE KEY `uk_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
我们来逐段分析:
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,这是主键字段的标准写法。BIGINT给你足够的扩展空间,UNSIGNED让范围翻倍,NOT NULL保证主键值不为空,AUTO_INCREMENT让数据库自动生成自增值。为什么要用自增主键而不是UUID?因为自增主键是聚簇索引,插入数据时是顺序写,性能高,占用空间小,而UUID是无序的,会导致频繁的页分裂和碎片。
username VARCHAR(50) NOT NULL,用户名必填,所以加NOT NULL。password_hash CHAR(60)用CHAR而不是VARCHAR,因为bcrypt哈希值固定60字符,定长存储更快。email和phone是可选的,所以用DEFAULT NULL。这里有个细节,DEFAULT NULL可以简写成不写,字段默认就允许NULL,但显式写出来更清晰。
status TINYINT NOT NULL DEFAULT 1,用TINYINT存状态值,默认1表示启用。注意我加了COMMENT注释,这个习惯极其重要。建表时不写COMMENT,三个月后你自己都看不懂这个字段是干什么的。我见过太多项目,字段名叫flag,没有任何注释,鬼知道flag=1是什么意思。
created_at和updated_at用了DEFAULT CURRENT_TIMESTAMP,updated_at还加了ON UPDATE CURRENT_TIMESTAMP,意思是在插入时自动填当前时间,更新记录时自动刷新更新时间。这两个字段是无脑该加的,任何业务表都应该有,不然出了数据问题你连排查的时间线都没有。
最后是表级约束:PRIMARY KEY指定主键,两个UNIQUE KEY保证用户名和邮箱不重复。表尾的ENGINE=InnoDB指定存储引擎,DEFAULT CHARSET=utf8mb4指定字符集,COLLATE指定排序规则。
2.2 引擎、字符集、排序规则的底层逻辑
先说存储引擎。MySQL默认是InnoDB,99%的场景你都应该用它。InnoDB支持事务(ACID)、行级锁、外键、崩溃恢复,这是MyISAM完全不具备的。MyISAM只在一些极端的只读场景下有性能优势,但为了那点性能放弃事务和数据安全,绝对不值得。MySQL 8.0里MyISAM连系统表都不用了,你还用它干嘛?
再说字符集,这个坑最多。MySQL的utf8字符集其实不是真正的UTF-8,它最多只支持3字节,而常用的emoji表情和一些生僻汉字需要4字节存储。在MySQL 8.0之前,如果你想存emoji,必须用utf8mb4。MySQL 8.0之后默认字符集已经是utf8mb4了,但如果你在低版本上升级项目,或者从MySQL 5.7迁移到8.0,一定要检查所有表的字符集。
我把话放这:新建表一律用utf8mb4,排序规则一律用utf8mb4_unicode_ci。utf8mb4_general_ci虽然速度略快一点点,但它在处理某些语言的排序规则时不够准确,比如德语、法语的特殊字符。unicode_ci在准确性和性能之间更均衡。
还有个小细节是COLLATE排序规则影响的是字符串比较大小和排序,比如WHERE username = 'admin'查询时,如果排序规则不同,可能影响比较结果。更重要的是A、B两表关联时,如果字符集或排序规则不一致,MySQL就无法使用索引,只能先转换再比较,性能暴跌。后面我会专门讲这个坑。
2.3 临时表与克隆表:两种特殊建表方式
日常开发中除了标准的CREATE TABLE,还有两种特殊建表方式非常实用。
临时表用CREATE TEMPORARY TABLE创建,它只在当前会话可见,会话结束自动删除。我在做数据库迁移数据清洗的时候特别喜欢用临时表,比如要把一张旧表的复杂查询结果存下来逐步处理,又不想污染业务库。临时表的表名可以和正式表重名,临时表会隐藏正式表,这既是便利也是坑,用完记得删。
克隆表结构有两种方式:
sql复制-- 方式一:只复制表结构
CREATE TABLE user_bak LIKE user;
-- 方式二:复制结构和数据
CREATE TABLE user_bak AS SELECT * FROM user;
方式一LIKE完整复制表结构,包括所有列的属性、索引、约束,但不含数据。方式二AS SELECT会复制数据,但不会复制索引和约束,而且如果SELECT语句里有表达式,新表的列类型可能和原表不一致。所以做结构迁移时推荐用LIKE,需要数据再单独INSERT INTO SELECT,这样才能保证新表和原表结构完全一致。
还有个技巧,导出建表语句用SHOW CREATE TABLE user\G,MySQL会返回完整的建表语句,包括所有细节。我在做数据库备份和迁移时,这个命令是最高频使用的。
3. 约束体系解读:谁在替数据质量把关
约束是MySQL表设计中价值最高但也最容易被忽略的部分。约束的本质是让数据库替你的程序把关数据质量,把脏数据挡在门外。很多团队把校验全部放在应用层,数据库层完全不设防,这是很危险的做法——万一漏了一条校验路径,脏数据就进去了。数据库层的约束是最后一道防线,必须设。
3.1 NOT NULL与DEFAULT:最不起眼却最常用的一对
NOT NULL和DEFAULT这两个约束看似简单,用好了能省无数的事情。先说NOT NULL,它保证字段必须有值。为什么重要?因为NULL值在SQL里是一个很特殊的存在,NULL = NULL的结果不是真而是NULL,NOT IN子查询遇到NULL直接返回空——各种诡异行为都源于NULL。如果字段逻辑上必须有值,就应该加NOT NULL,从源头杜绝NULL的不可控性。
DEFAULT和NOT NULL经常配合使用。我刚才用户表里的status TINYINT NOT NULL DEFAULT 1,逻辑就是:创建用户时如果不传status,数据库自动填1。这样应用层就可以少写一行代码。
MySQL 8.0.13及以上版本还支持表达式默认值,比如:
sql复制CREATE TABLE event_log (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
event_time DATETIME NOT NULL DEFAULT (NOW()),
event_uuid VARCHAR(36) NOT NULL DEFAULT (UUID()),
PRIMARY KEY (id)
);
UUID()函数外面必须加括号,这是表达式默认值的语法要求。这个功能在老版本里没有,如果你的代码要兼容MySQL 5.7,就别用表达式默认值,老老实实在应用层生成。
还有一点容易混淆:允许NULL的字段和空字符串是两回事。NULL表示未知、不存在,空字符串表示有值但值为空。统计COUNT的时候NULL不计入,空字符串会计入。我建议在业务上明确区分这两个概念:不允许为空的字段加NOT NULL,可空的字段用DEFAULT NULL,但查询时留意NULL的判断要用IS NULL而不是= NULL。
3.2 PRIMARY KEY与AUTO_INCREMENT:主键的设计逻辑
主键是表的灵魂,它唯一标识一行记录。InnoDB是聚簇索引组织表,数据行物理存储顺序就是主键顺序,所以主键的选择直接影响写入性能。
自增主键是默认首选,原因有三:写入顺序递增,追加在B+树末尾,不触发页分裂和移动;主键值短,二级索引的叶子节点存的是主键值,主键越短,二级索引占用空间越小;查询走主键索引最快,回表次数也少。
主键设计的几个原则,我踩过坑之后总结出的:
- 主键要短。比如能用INT不用BIGINT(当然现在BIGINT更稳妥),因为所有二级索引都隐含主键列,主键越长,索引越大,内存里能装的索引页越少。
- 主键要有序。UUID不适合做InnoDB主键,因为它是随机生成的,插入时会导致B+树频繁分裂和页重排,性能下降严重。如果非要用UUID,推荐用UUID_SHORT()或者雪花算法生成的有序ID,或者干脆用自增主键,UUID放到业务字段里存。
- 主键要稳定。不要用业务字段做主键,比如手机号、身份证号。手机号会换,身份证号有隐私问题,而且一个用户可能有多个手机号。主键必须是一个和业务逻辑无关的纯标识字段。
AUTO_INCREMENT还有一个细节:它是全局递增的,不保证连续。事务回滚、删除数据都会导致编号出现空洞。有人看到主键从1跳到100就觉得数据库出了问题,其实这是完全正常的。不要指望主键号连续,它只保证唯一和递增。
3.3 UNIQUE约束:与主键分工完全不同
UNIQUE约束保证字段值在表内唯一,它和主键有三点关键区别:
- 一张表只能有一个主键,但可以有多个UNIQUE约束
- 主键不允许NULL,UNIQUE字段允许NULL,而且NULL之间不互斥——也就是说UNIQUE字段可以有多个NULL
- UNIQUE会自动创建唯一索引,可以用来加速查询
回到用户表的设计,UNIQUE KEY uk_username (username)和UNIQUE KEY uk_email (email)保证用户名和邮箱在注册时不能重复。这就是数据库层的最后一道防线,就算应用层没做重复校验,数据库也会拒绝。
UNIQUE约束还常用来处理幂等问题。比如支付回调表,同一笔订单的支付通知可能来多次,你在业务表里处理幂等的方式是在回调记录表上为order_id加UNIQUE约束,第二次插入相同订单号直接报DUPLICATE KEY错误,你捕获异常返回成功即可。
唯一索引和普通索引的性能对比:唯一索引在查询时可以在找到第一个匹配值后立即停下,普通索引需要继续扫描到边界。所以理论上唯一索引查询略快。但写入时唯一索引要做唯一性检查,略慢。总体来说,只要字段逻辑上要求唯一,就加UNIQUE,不要省。
还有一个经验是注意复合唯一约束,比如订单明细表里(order_id, product_id)联合唯一,保证同一订单里不会出现两行相同商品,这个约束极其实用。
3.4 FOREIGN KEY:关联完整性与性能的博弈
外键约束是MySQL里争议最大的一个功能。支持者说它能保证数据完整性,反对者说它影响性能、导致死锁、不适合高并发。两边说的都对,关键看场景。
外键的完整语义是:子表插入或更新外键列时,必须在父表的主键或唯一键中存在,否则报错;父表删除或更新被引用的行时,会根据ON DELETE和ON UPDATE定义的规则处理。先看一个带外键的订单表怎么写:
sql复制CREATE TABLE `orders` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '订单ID',
`order_no` VARCHAR(32) NOT NULL COMMENT '订单编号',
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '下单用户ID',
`total_amount` DECIMAL(10,2) NOT NULL COMMENT '订单总金额',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态:0待支付,1已支付,2已发货,3已完成,4已取消',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间',
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 COMMENT='订单表';
这里CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES user(id)就是外键约束。意思是user_id这个列的值必须能在user表id列中找到,否则插入失败。
外键的ON DELETE和ON UPDATE规则有以下几种:
CASCADE:父表删除或更新时,子表自动跟着删除或更新SET NULL:父表删除或更新时,子表的外键字段置为NULL(前提是外键字段允许NULL)RESTRICT:默认行为,有子表引用时,父表不能删除或更新NO ACTION:在MySQL里和RESTRICT同义
我在生产环境几乎不使用ON DELETE CASCADE,因为级联删除在数据量大的时候容易产生大量锁和长事务,而且容易误删。删除功能尽量用软删除(加一个is_deleted字段),硬删除只留给人手动操作。
外键带来的问题也值得说:插入子表数据时要先检查父表,产生额外的锁竞争;删除父表数据时如果有子表引用,可能触发锁等待甚至死锁。在高并发写入场景,尤其是订单、日志这类表,我不建议加外键,而是把引用关系放在应用层保证。但在权限管理、字典表、配置表这类低频写入、强一致性的场景,外键是很好的数据完整性保障,该加就加。
我的建议是:交易核心链路少用外键,用应用层保证;后台管理、配置类系统可以用外键。两者没有绝对的对错,取决于你的业务对一致性和性能的权衡。
3.5 CHECK约束:等待多年终于可用的"自检员"
CHECK约束用于定义字段的合法取值范围。有意思的是,MySQL 8.0.16之前,CHECK约束在语法上会接受,但实际上不生效——它只是被解析后直接忽略。所以如果你还在用MySQL 5.7,写CHECK等于白写。从8.0.16开始,CHECK才真正强制生效。
CHECK约束的写法和作用:
sql复制CREATE TABLE `product` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '商品ID',
`product_name` VARCHAR(200) NOT NULL COMMENT '商品名称',
`price` DECIMAL(10,2) NOT NULL COMMENT '售价',
`original_price` DECIMAL(10,2) NOT NULL COMMENT '原价',
`stock` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '库存',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1上架,0下架',
PRIMARY KEY (`id`),
CONSTRAINT `chk_price_positive` CHECK (`price` >= 0),
CONSTRAINT `chk_original_price_ge_price` CHECK (`original_price` >= `price`),
CONSTRAINT `chk_product_status` CHECK (`status` IN (0, 1))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';
这里三个CHECK约束分别检查:价格非负、原价不低于售价、状态只能取0或1。这些规则如果用程序校验,每个写入入口都要写一遍,保不齐哪个漏了。放在数据库层面,规则只定义一次,所有入口都生效。
CHECK的嵌套和表达式也很灵活,支持逻辑运算符:
sql复制CONSTRAINT `chk_age_valid` CHECK (`age` BETWEEN 0 AND 150),
CONSTRAINT `chk_price_or_gift` CHECK (`price` > 0 OR `is_gift` = 1)
不过CHECK约束不是万能的,它不能引用其他表的字段,不能引用自定义函数,只能用确定性表达式。涉及跨表校验的还是得靠外键或应用层。但单字段或同表多字段的取值校验,CHECK是精准且高效的工具,MySQL 8.0之后强烈建议用起来。
4. 改表操作:上线之后才意识到表结构要调整
4.1 ALTER TABLE增删改列
建表的时候考虑再周全,业务一跑起来还是会发现要加字段、改类型、删约束。ALTER TABLE是上线后的必备技能,但也往往是生产事故的源头。MySQL DDL的机制是8.0之前很多操作会锁全表,MySQL 8.0引入了INSTANT算法和INPLACE算法,很多DDL操作可以在线执行,但仍然要谨慎。
常见的ALTER TABLE操作:
sql复制-- 添加字段
ALTER TABLE `user` ADD COLUMN `nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称' AFTER `username`;
-- 修改字段类型
ALTER TABLE `user` MODIFY COLUMN `phone` VARCHAR(20) NOT NULL COMMENT '手机号';
-- 修改字段名
ALTER TABLE `user` CHANGE COLUMN `phone` `mobile` VARCHAR(20) DEFAULT NULL COMMENT '手机号';
-- 删除字段
ALTER TABLE `user` DROP COLUMN `nickname`;
-- 添加索引
ALTER TABLE `user` ADD INDEX `idx_status` (`status`);
-- 添加唯一约束
ALTER TABLE `user` ADD UNIQUE KEY `uk_phone` (`phone`);
-- 添加外键
ALTER TABLE `orders` ADD CONSTRAINT `fk_orders_user` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`);
-- 删除外键
ALTER TABLE `orders` DROP FOREIGN KEY `fk_orders_user`;
-- 删除主键
ALTER TABLE `user` DROP PRIMARY KEY;
每一条都要注意风险。ALTER TABLE在大表上执行必须评估锁和耗时,数据量百万级以内还好,千万级以上的表,一次MODIFY COLUMN可能需要几分钟甚至更久,期间可能导致主从延迟、连接堆积。我现在对大表的操作流程是:先在从库上执行看耗时,凌晨低峰期操作,用gh-ost或者pt-online-schema-change这类在线DDL工具,操作前手动备份。
4.2 用约束变化的实际案例看风险
有一个经典案例:某业务上线后,用户反馈手机号重复注册了。排查发现表里没有给phone加UNIQUE约束,应用层的校验又有并发漏洞——两个请求同时带着同一个新手机号注册,都通过了校验,然后都插入成功。解决办法就是给phone加唯一约束:
sql复制ALTER TABLE `user` ADD UNIQUE KEY `uk_phone` (`phone`);
这个操作本身简单,但坑在存量数据。如果表里已经有重复的手机号,ALTER TABLE会直接报错:Duplicate entry。你必须先把重复数据处理干净,才能加唯一约束。我的处理步骤:
- 找出重复手机号:
sql复制SELECT phone, COUNT(*) FROM `user` GROUP BY phone HAVING COUNT(*) > 1;
- 把重复数据的手机号改成NULL或加后缀标记(保留一条)
- 再执行ALTER TABLE加唯一约束
所以,约束不是上线后想加就能加的,最好在表设计的初期就把唯一性规则定清楚,否则等脏数据进来了,清数据的过程够你喝一壶的。
还有一次,我在大表上加NOT NULL约束,结果执行了半天都没结束。原因是这个字段存量数据里有NULL值,加上NOT NULL需要全表扫描并更新这些行。后来我分三步走:先UPDATE把NULL填上,再执行MODIFY,最后验证约束生效,果然快多了。
MODIFY COLUMN还有一个隐蔽的坑:如果你只改了列名,忘记了原有的其他属性,MySQL会按你写的内容重建列,原本的DEFAULT、NOT NULL、COMMENT可能全部丢失。比如:
sql复制ALTER TABLE `user` MODIFY COLUMN `email` VARCHAR(150);
这行会把email原有的DEFAULT NULL和COMMENT '邮箱'全部丢掉。所以MODIFY COLUMN时必须把列的所有属性完整写一遍:
sql复制ALTER TABLE `user` MODIFY COLUMN `email` VARCHAR(150) DEFAULT NULL COMMENT '邮箱';
这个坑我踩过不止一次,每次都是教训。
5. 建表踩坑实录:这些生产事故都源于设计失误
5.1 字符集不一致引发的乱码与索引失效
有次排查一个慢查询,一条WHERE带中文条件的SQL,表里明明有索引,执行计划却显示全表扫描。我一看原因,表的字符集是utf8mb4,而连接条件列是utf8,MySQL在比较前要做隐式字符集转换,索引自然用不上。
类似的情况还发生在关联查询中:A表user表的username是utf8mb4,B表临时表的username是utf8,两表JOIN时username做关联,结果也是全表扫描。修复方案是把两张表的字段强制转为同一字符集:
sql复制SELECT * FROM A INNER JOIN B ON A.username = CONVERT(B.username USING utf8mb4);
但这只是临时方案,治本的办法是统一所有表的字符集和排序规则。我以前经历过一次全网改造:把几百张表从utf8改成utf8mb4,改完后乱码问题消失,JOIN查询性能明显提升。操作也简单,逐表执行:
sql复制ALTER TABLE `table_name` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
注意是CONVERT TO不是DEFAULT CHARACTER SET,前者会转换现有数据的存储编码,后者只改默认值不影响存量数据。
5.2 外键设计不当导致的死锁
有一次生产环境线上订单支付接口突然大量报错,看日志是死锁。排查过程费了不少劲,最后定位到死锁源头是订单表和支付流水表之间的外键。
支付流水表结构大致是这样设计的:
sql复制CREATE TABLE `payment_flow` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`order_id` BIGINT UNSIGNED NOT NULL,
`amount` DECIMAL(10,2) NOT NULL,
`status` TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_order_id` (`order_id`),
CONSTRAINT `fk_payment_orders` FOREIGN KEY (`order_id`) REFERENCES `orders` (`id`)
) ENGINE=InnoDB;
当两个支付请求同时到达,线程A执行支付流水插入时,需要给orders表id对应的行加S锁(外键检查),线程B也执行类似操作。如果此时双方又恰好存在级联更新或删除,锁顺序不一致,就容易形成循环等待,触发死锁。
我在复盘时发现:这个核心交易链路上的外键并没有发挥应有的作用,订单状态由应用层保证,支付流水和订单的关联也有明确的业务逻辑,加外键纯粹是锦上添花,反而增加了锁竞争。最终我采用了业界更主流的分层方案:交易核心表不加外键,只建普通索引,数据完整性由应用层事务保证;后台管理库、配置库保留外键。这之后死锁问题再没出现过。
5.3 隐式类型转换让索引形同虚设
这又是一个"建表时类型选错,上线后SQL注定要出问题"的典型。用户表里phone字段当初建表时用了VARCHAR(20),但查询代码里传的参数是数字类型,比如:
sql复制SELECT * FROM `user` WHERE phone = 13912345678;
这是数字,不是字符串。MySQL在比较时会先把phone转为数字,再和参数比较。这样一来,phone列上的索引(如果有)就无法使用,MySQL只能全表扫描。看执行计划会显示type=ALL,而不是eq_ref或ref。
这不是SQL写法的问题,是数据类型选型埋下的雷。如果你一开始就明确phone是字符串类型,查询时应该写成phone = '13912345678'。但更糟糕的是,很多团队没人意识到这一点,线上表结构不动,查出来慢就加索引,加了索引还是慢,最后才发现是类型转换的锅。
同样的坑也出现在INT字段和字符串比较、日期字段和字符串比较上。我的建议是:WHERE条件里的参数类型必须和字段类型严格一致。程序里写SQL时,明确用参数绑定,不要拼接字符串,框架层面的ORM大多会自动处理,但原生SQL一定要小心。
6. 一点个人建议
说了这么多,几件小事希望你能记在心里。
第一,把建表语句纳入版本管理。 我见过太多项目,数据库结构没有任何人说得清楚,开发本地一套,测试一套,生产一套,全靠运维手动同步。正确做法是把所有建表和ALTER语句放到项目的db目录下,按版本号管理,上线时按序执行。这样谁改过表结构、改了什么、什么时候改的,全部有迹可循。
第二,每张表必须有三个字段:id、created_at、updated_at。 这个是铁律。不管是用户表还是配置表,这三个字段都应该存在。id做物理主键,created_at记录创建时间,updated_at记录最后修改时间。没有时间字段的表,出了数据问题你是无法排查的。
第三,约束宁多勿少,但别乱用。 该非空的非空,该唯一的唯一,该CHECK的CHECK。但外键要分场景,交易核心少用,后台配置多用。约束是数据质量的第一道防线,应用层校验是第二道,缺一道都可能出事。
我在实际开发中还有个习惯:建表先用EXPLAIN验证查询方案,再决定索引和约束。把表建好、数据灌入、把高频查询跑一遍,看执行计划是否合理,再调整索引。很多性能问题不是上线后才暴露的,而是建表的时候就已经注定。只不过那时没有数据,你感觉不到而已。
建表这件事,门槛很低,天花板很高。把每个字段的语义想透、把每种约束的机制吃透,你设计的表才能既抗住业务压力,又经得起时间考验。希望这篇能帮你少走一些弯路。
