先分享一个我印象特别深的场景。前几年做电商项目,接口幂等逻辑测试全过,结果大促当天同一笔订单被两个并发请求同时创建成功。查到最后,根子不在代码,而在表设计上:订单号字段忘了加唯一约束。那种半夜爬起来导出数据、拿Excel逐行标红的滋味,经历过的人都懂。MySQL 单表约束——主键、非空、唯一、默认——看着就是建表时多写几行代码的事,实际上是你给数据入口设置的最后一道防线。这篇内容适合刚学 MySQL 的人系统过一遍约束概念,也适合平时 SQL 没少写、但从来没有把约束整理成体系的开发者。我会把底层原理、建表写法、修改手法和实际踩坑一起讲透。
1. 约束的根本使命:把数据完整性从应用层下沉到数据层
1.1 没有单表约束时,我在生产环境看到的"脏数据"现场
先说几个我亲眼见过的案例。第一件,用户表没有唯一约束,两个运营后台同时导入Excel,同一个手机号注册了两条账号记录,最后 CRM 系统里同一个客户关联了两个 ID,后续所有统计全部对不上。第二件,订单明细表没有非空约束,前端漏传商品ID,程序没做校验,一条product_id = NULL的记录就这么进了库,下游报表计算金额时这一行被静默忽略,月底对账差出几十万。第三件,状态字段没有默认值,开发偷懒不传status,结果库里一半是 NULL,一半是 0,查询条件WHERE status = 0永远查不全数据。
这些问题的共同点在于:出问题的时间点不在数据写入的那一刻,而在几个月后某个统计需求找上来的时候。脏数据一旦进入生产库,清洗成本极高——你要么写一堆临时脚本去猜当时业务上"应该是什么",要么接受报表数据永远有偏差。更麻烦的是,你根本不知道到底有多少行数据受影响。随着数据量增长,这种不确定性只会越来越大。所以单表约束首要价值不是让建表好看,而是阻止脏数据进入存储层。
1.2 为什么约束比应用层校验更可靠
很多人会说:我在应用接口里做校验不就行了?字段必填我在后端判断一下,唯一性我先查一次库再插入。道理没错,但应用层校验有个天然缺陷:它只覆盖你写的那条业务链路。
现实情况是,一张表通常有多个写入入口:主站接口、管理后台、定时任务、数据同步脚本、运营临时跑的一条 UPDATE、DBA 手工修复数据的 SQL,甚至同事用 Navicat 打开表直接改了几行。你不可能在每个入口都保证校验逻辑一致。等哪天某个脚本绕过业务逻辑直连数据库灌数据,应用层校验就是一张废纸。
数据库约束则不同,它由存储引擎强制执行。只要数据要落进这张表,不管是哪个客户端、哪条链路、哪个人手工敲 SQL,都必须遵守规则。这就好比小区门口的交规,不依赖每个司机自觉,而是有摄像头和交警在管。把完整性规则下沉到数据库层,才是真正对所有写入者一视同仁。
1.3 单表约束全景:四种约束各管一段
在深入细节之前,先用一张表把 MySQL 单表约束的全貌立起来:
| 约束类型 | 关键字 | 核心作用 | 底层实现 |
|---|---|---|---|
| 主键约束 | PRIMARY KEY | 唯一标识每一行,不允许重复和 NULL | 聚簇索引 |
| 非空约束 | NOT NULL | 字段不允许为 NULL | 存储引擎强制检查 |
| 唯一约束 | UNIQUE | 字段值不允许重复,但允许多个 NULL | 唯一二级索引 |
| 默认值约束 | DEFAULT | 未显式赋值时自动填充指定值 | 插入时自动补全 |
补充一句,MySQL 8.0.16 及以后才真正支持 CHECK 约束,在此之前你写的 CHECK 约束语法能通过,但引擎会直接忽略,并没有实际约束力。这个点很多人不知道,我会在后面的实战经验部分专门展开。现阶段先把主键、非空、唯一、默认这四类核心约束吃透,已经能解决 90% 的表设计问题。
2. 主键约束:每张表都该有一张"身份证"
2.1 三种主键定义写法,以及各自适合的场景
主键的定义有两种位置:列级定义,也就是直接跟在字段后面;表级定义,写在字段列表最后。先看列级的写法:
CREATE TABLE student ( id INT PRIMARY KEY, name VARCHAR(32) NOT NULL, class_id INT NOT NULL );这种写法适合单个字段做主键、且主键逻辑简单清晰的情况。如果你需要两个字段联合起来才能唯一标识一行,就得用表级定义:
CREATE TABLE order_item ( order_id BIGINT NOT NULL, item_no INT NOT NULL, product_id BIGINT NOT NULL, quantity INT NOT NULL, PRIMARY KEY (order_id, item_no) );这是典型的复合主键场景。同一个订单下可能有多个商品条目,单独拿order_id当主键会撞车,单独拿item_no也不行,两个字段合起来才唯一。复合主键在建表时只能用表级定义,因为单个字段根本没有"组合"的概念。
还有第三种写法,把主键约束和索引一起显式命名,方便后续对约束的管理:
CREATE TABLE student ( id INT NOT NULL, name VARCHAR(32) NOT NULL, CONSTRAINT pk_student_id PRIMARY KEY (id) );用CONSTRAINT pk_student_id给主键起名,这样你将来查看约束、删除约束时就有明确的标识。但要记住,MySQL 里主键约束名通常固定为 PRIMARY,直接写PRIMARY KEY也不会影响使用,显式命名更多是为了格式统一。
2.2 自增主键为什么是默认选择:一个关于索引结构的理由
主键字段最常用的搭配是AUTO_INCREMENT,也就是自增整数。这样写有存储层面的深层原因:InnoDB 表的数据本身是按照主键聚簇存放的,自增主键在插入时永远追加在索引树的最后,不需要频繁移动已有数据,写入性能最稳定。
而很多新手喜欢用业务单号或者 UUID 做主键,这个出发点可以理解——业务字段将来要按照它查询。但随机字符串做主键会带来两个实际问题:一是插入位置随机,导致页分裂和索引碎片化,写入性能随表变大明显下降;二是 UUID 是 16 字节,比 INT 的 4 字节和 BIGINT 的 8 字节大得多,聚簇索引的所有二级索引里都会冗余这份主键值,内存和磁盘占用都吃亏。
我的习惯是:每个业务表都配一个与业务无关的自增或雪花 ID 做物理主键,至于订单号、手机号这些业务上要唯一的字段,单独用唯一约束去管。主键管"怎么存储",唯一约束管"业务上不能重复",两者职责分开,表设计会清爽很多。
2.3 删除或失效主键后,InnoDB 会怎么处理
MySQL 本身没有"主键失效"这个操作,更常见的场景是你要删除主键,或者因为重建表结构导致主键定义被改动。很多人不知道删除主键之后 InnoDB 内部的连锁反应。
如果你在建表时既没有主键,也没有任何非空唯一索引,InnoDB 会自动生成一个不可见的 6 字节 ROWID 作为聚簇索引依据。如果一张表原本有主键,你执行删除主键之后,InnoDB 会去找表里第一个非空唯一索引充当新的聚簇索引;如果找不到,就会退回隐藏 ROWID 模式。
这里有个容易忽略的代价:删除主键意味着聚簇索引重建,InnoDB 需要把整张表的数据重新组织一遍。表越大,这个操作耗时越长,期间对表的读写都会受影响。所以别把删除主键当成一条简单 SQL,这些操作我都会在第五部分讲 ALTER TABLE 时一起展开。
另外还要提醒一点:不要指望用业务字段同时当主键又当业务唯一键来省事。主键设计要服务于数据存储的稳定性,业务唯一性用 UNIQUE 约束表达,两者的语义完全不同,混在一起只会让后续改动投鼠忌器。
2.4 主键与唯一索引的一个关键区别
主键和唯一索引在"值不能重复"这一点上很像,所以时不时有人混淆。它们的区别集中在三点:第一,一张表只能有一个主键,但可以有多个唯一索引;第二,主键字段不允许为 NULL,唯一索引在 MySQL 里允许有多个 NULL;第三,主键是聚簇索引,直接决定数据物理存储顺序,唯一索引只是普通二级索引。
判断标准很简单:如果这个字段既不能为空、也不能重复、而且每张表只能有一份,那就做主键;如果字段只是业务上不能重复,但允许"还没填"的空状态,优先用唯一约束。后面讲唯一约束的 NULL 行为时,这一点会更清晰。
3. 非空与默认值:两个互补的"守门员"
3.1 NULL 和空字符串不是一回事,这是统计口径错乱的根源
很多初学者以为 NULL 和''差不多,其实这是两类完全不同的值。NULL 表示"这个字段没有值",它不参与任何常规的比较运算;空字符串是一个长度为 0 的字符串类型的真实值。你可以用WHERE phone = ''查询空字符串,但查 NULL 必须用WHERE phone IS NULL。
这个差异在生产环境会直接导致统计结果错乱。比如你统计缺失手机号的用户数,用SELECT COUNT(*) FROM user WHERE phone IS NULL查出来是 500 人,用SELECT COUNT(*) FROM user WHERE phone = ''查出来又是 300 人,两边相加才等于真正缺失的数量。更麻烦的是COUNT(phone)这类聚合函数会自动忽略 NULL,如果业务上"没手机号"存的是 NULL,计算的时候这一行就会人间蒸发。
还有一个容易被忽略的坑:两个字段做字符串拼接时,只要其中一个为 NULL,整个拼接结果就是 NULL。比如CONCAT(first_name, last_name),只要 last_name 是 NULL,哪怕 first_name 有值,输出也是 NULL。在实际业务中,"姓"和"名"这种字段一旦允许 NULL,就会出现大量显示为空的记录。
所以我的建议是:业务上明确"必须要有值"的字段,直接加NOT NULL;确实允许没有值的字段,也要想清楚到底是允许 NULL 还是允许空字符串,二选一制定统一标准。大多数面向报表的业务表里,把字符串字段设计成NOT NULL DEFAULT ''比允许 NULL 更好用,因为查询、聚合、拼接行为都可预期。
3.2 DEFAULT 的常规姿势:时间戳、状态值和开关字段
默认值约束和主键不一样,主键解决"这行是谁",默认值解决"没传时我该填什么"。最常见的场景有三个。
第一个是时间字段。创建时间和更新时间几乎是每张业务表的标配:
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'ON UPDATE CURRENT_TIMESTAMP的意思是,只要这行数据发生 UPDATE,该字段自动刷新为当前时间,不用应用层手动维护。这个写法在 MySQL 5.6.5 以后对 DATETIME 有效,之前版本只有 TIMESTAMP 能用,如果你还在维护老库要注意。
第二个是状态字段。状态码、删除标记、审核标识这些字段应当给出明确的默认值:
status TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1禁用', is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '0未删除 1已删除'这样做的好处是,应用层漏传这个字段时,数据不会变成 NULL,而是落一个确定的初始值。业务上可以放心用WHERE status = 0查询,不用担心 NULL 漏数据。
第三个是展示类字段,比如昵称。用户没设置昵称,你可以给DEFAULT '匿名用户',而不是让字段为 NULL 或者空字符串,前端展示时就不用写一堆判空逻辑。本质上,默认值是在应用层逻辑缺失时,由数据库帮你兜一个合理的底。
3.3 一条建表 SQL,把主键、非空、默认值串起来
光讲概念容易飘,直接看一条我认为典型且完整的用户表建表语句:
CREATE TABLE `user` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', email VARCHAR(64) NOT NULL COMMENT '登录邮箱', phone VARCHAR(20) NOT NULL DEFAULT '' COMMENT '手机号', nickname VARCHAR(32) NOT NULL DEFAULT '匿名用户' COMMENT '昵称', status TINYINT NOT NULL DEFAULT 0 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 '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_email (email) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';这里每个字段都刻意加了NOT NULL,并且能设默认值的都设了默认值。email是登录账号,必须非空,同时加了唯一约束防止重复注册;phone允许还没绑定,但存空字符串而不是 NULL,查询统一;nickname没设置时给一个可见的默认昵称;状态和时间字段全部由数据库填充默认值。这样一张表,应用层即使有字段漏传,数据进库后也一定是完整、可预期的,而不是一堆 NULL。
3.4 sql_mode 对非空约束的影响:为什么有时候没传也不报错
这里必须聊一个让很多人困惑的点:同样是插入数据时字段缺失,有些环境报错,有些环境却能成功。关键在sql_mode。
MySQL 的严格模式由STRICT_TRANS_TABLES控制。开启严格模式后,向一个NOT NULL且没有默认值的字段插入 NULL 或缺失值,会直接报错;关闭严格模式时,MySQL 会静默地把该字段替换成隐式默认值——数值类型填 0,字符串类型填空字符串,然后只给一个警告。很多开发在本地环境没开严格模式,测试时数据照样能插进去,上线后突然发现插入报错,就是这个原因。
你可以在会话里用SELECT @@sql_mode;查看当前设置。我强烈建议生产环境开启严格模式,否则非空约束形同虚设,数据完整性依然无从谈起。这也是很多人以为"我加了 NOT NULL 但好像没生效"的真正幕后黑手。
4. 唯一约束:防重复的"最后防线"
4.1 业务唯一键的典型场景与三种创建方式
主键解决"每行必须不同",但业务上有些字段也有"不能重复"的需求,它们不是主键,却必须唯一,典型如用户邮箱、手机号、订单号、身份证号、优惠券码等。这些场景都要用唯一约束。
创建方式有三种。第一种,列级定义,适合单字段唯一:
CREATE TABLE user ( email VARCHAR(64) NOT NULL UNIQUE );第二种,表级定义并起名,适合单字段唯一且需要显式管理约束名:
CREATE TABLE user ( email VARCHAR(64) NOT NULL, UNIQUE KEY uk_email (email) );第三种,复合唯一约束,适合多字段组合才唯一的情况。比如同一个用户不能对同一件商品重复评价:
CREATE TABLE review ( user_id BIGINT NOT NULL, product_id BIGINT NOT NULL, content VARCHAR(500) NOT NULL, UNIQUE KEY uk_user_product (user_id, product_id) );复合唯一约束里字段顺序有讲究,(user_id, product_id)和(product_id, user_id)创建的索引结构不同,后续查询如果常用WHERE user_id = ?,前者更合适,因为能直接命中索引左前缀。
4.2 MySQL 唯一约束的 NULL 特性:可以"留白"但不能"撞脸"
唯一约束最反直觉的地方在于它对 NULL 的处理。在 MySQL 中,唯一约束允许多个 NULL 同时存在。也就是说,字段email VARCHAR(64) UNIQUE,表中可以插入很多行email = NULL,因为这些 NULL 互不冲突。
这个行为在别的数据库里并不统一,有的只允许一个 NULL,所以在不同数据库间迁移时容易踩雷。但在 MySQL 里,你只要记住:唯一约束保证"非 NULL 值不重复",NULL 之间随意。如果你希望连空值也只能出现一次,那就把字段加上NOT NULL,从根源上禁止 NULL 出现。
还有一个更隐蔽的坑:空字符串''是真实值,不是 NULL。所以一个唯一字段你用DEFAULT ''来代表"未填写",那表中就只能有一条'',第二条插入时就会报重复。设计"可空但需可重复"的字段时,要么允许 NULL,要么干脆不建唯一约束。
4.3 给已有重复数据的表加唯一约束:完整处理流程
这是热搜词里"mysql设置唯一已经有重复数据库"对应的典型场景。直接执行ALTER TABLE user ADD UNIQUE KEY uk_email(email);大概率会报错Duplicate entry。原因是表里已经存在重复值。完整处理流程分三步。
第一步,找出重复数据。用分组聚合定位:
SELECT email, COUNT(*) AS cnt FROM user GROUP BY email HAVING cnt > 1;第二步,处理重复数据。具体怎么处理取决于业务:可以给重复记录里保留一行,更新其他行的 email 为新的唯一值;也可以把重复记录合并,甚至可以删除无效记录。这一步涉及数据修复,操作前一定先备份原表。我自己习惯先建一张备份表,比如CREATE TABLE user_bak_20250101 AS SELECT * FROM user;,再把重复数据处理干净。
第三步,确认没有重复后再添加唯一约束:
ALTER TABLE user ADD UNIQUE KEY uk_email (email);添加成功后再把备份表删掉。整套流程最怕中间跳过第一步直接加约束,错误信息只能告诉你"有重复",不会告诉你是哪几行,等数据量大了之后再回头定位重复项,代价会非常大。
4.4 唯一约束在并发写入中的兜底作用
并发条件下,唯一约束的价值会被放大。典型的场景是发券:两个请求同时过来,应用层都先查了"这张券是否已领取",都发现没有记录,然后同时执行 INSERT。如果券码字段没有唯一约束,这两条都会成功,等于同一张券被发了两次。应用层的先查后插存在天然的竞态窗口,任何并发控制都做不到百分百可靠,但数据库层面唯一约束的校验是原子性的,两个并发 INSERT 中必然有一个失败。
所以我在设计接口时会这样看待唯一约束:它不是让你不写业务校验的借口,而是业务校验之外的兜底防线。业务代码的正常流程该查就查、该报友好错就报,但数据库这层必须保证"无论如何都重复不了"。这也是很多高并发系统里,幂等控制最终落到一张带唯一约束的幂等表上的原因——约束不认网络延迟,不认进程调度,不认分布式时钟。
5. 建表之后的反悔药:用 ALTER TABLE 修改与删除约束
5.1 增删主键:先解除自增,再动手
给一张表加主键很简单:
ALTER TABLE student ADD PRIMARY KEY (id);但要保证id列既没有 NULL 也没有重复值,否则执行会失败。删除主键时有一个关键前置条件:如果主键列是自增的,必须先把自增属性去掉,否则会报错。正确步骤是先改列定义,再删主键:
ALTER TABLE student MODIFY id INT NOT NULL; ALTER TABLE student DROP PRIMARY KEY;第一步去掉AUTO_INCREMENT,第二步才能真正删掉主键。我还想提醒一句:删除主键意味着聚簇索引重建,InnoDB 内部会对整张表重新组织。生产环境的大表千万不要直接执行,最好在维护窗口操作,并且在测试环境先跑一遍预估耗时。
5.2 增删非空与默认值:最容易把列定义写"丢"的操作
增加和删除非空约束,标准的姿势是用 MODIFY 重定义整列:
ALTER TABLE user MODIFY email VARCHAR(64) NOT NULL; ALTER TABLE user MODIFY email VARCHAR(64) NULL;这里最坑的地方在于:MODIFY 是整列覆盖定义,你写的字段类型长度必须和原来完全一致,不然会被顺手改掉。我见过不止一次,有人想给 email 加非空,写成了:
ALTER TABLE user MODIFY email VARCHAR(32) NOT NULL;结果原先的 VARCHAR(64) 被缩成了 VARCHAR(32),存量数据超过 32 字符的长邮箱直接截断或报错。正确的做法是先把完整列定义查出来,在原有基础上加约束:
ALTER TABLE user MODIFY email VARCHAR(64) NOT NULL COMMENT '登录邮箱';默认值的增删语法略有不同,不需要重写整列:
ALTER TABLE user ALTER COLUMN status SET DEFAULT 0; ALTER TABLE user ALTER COLUMN status DROP DEFAULT;给已有数据的表添加非空约束之前,记得先检查列里有没有 NULL。有 NULL 的时候直接加会失败,需要先补全或清洗数据:
UPDATE user SET phone = '' WHERE phone IS NULL;5.3 增删唯一约束:删除时记住它本质是索引
添加唯一约束很容易:
ALTER TABLE user ADD UNIQUE KEY uk_email (email);删除时有个需要记住的点:唯一约束在 InnoDB 里会创建一个唯一二级索引,所以你删除它时用的不是DROP CONSTRAINT,而是DROP INDEX:
ALTER TABLE user DROP INDEX uk_email;如果当初建约束时没起名,MySQL 默认会用字段名作为索引名,比如删email上的唯一约束时,可能用ALTER TABLE user DROP INDEX email;。不确定名字时,先执行SHOW INDEX FROM user;看清楚索引名,再动手。这里顺便提一句,唯一索引和普通索引都靠 DROP INDEX 删除,千万不要因为它是"约束"就去搜 DROP CONSTRAINT,MySQL 会报语法错误。
5.4 查看表结构与约束信息的三个命令
遇到任何"约束到底加没加""索引名是什么"的疑问,最有效的排查方式是看当前的建表语句:
SHOW CREATE TABLE user;一条命令能看到所有约束:主键、非空、默认值、唯一索引全部列出来,比翻当初的建表脚本可靠得多,因为所有 ALTER 修改都已经反映在里面。
想看索引详细信息:
SHOW INDEX FROM user;输出里Non_unique列等于 0 的就是唯一索引,等于 1 的是普通索引,Key_name是索引名。
如果想通过系统表做自动化检查,可以查信息模式:
SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_NAME = 'user';这三个命令配合使用,基本能应对日常所有约束排查需求。我每次做表结构评审时,第一件事就是跑一遍SHOW CREATE TABLE,把全表约束状态摸清楚再下结论。
6. 生产环境使用约束的几条实战经验
6.1 约束命名规范:可读性决定了维护效率
约束名不是为了好看,而是为了三个月后你自己还能一眼看懂。我推荐一套简单的命名规范:主键直接用 PRIMARY KEY,唯一约束统一叫uk_表名_字段名,普通索引叫idx_表名_字段名。比如用户表的 email 唯一约束就是uk_user_email,订单明细表的复合唯一约束就是uk_order_item_order_id_item_no。
这套规范最大的好处是,任何人看到uk_前缀就知道这是一个唯一约束,看到字段名就知道它在约束什么。尤其在执行DROP INDEX时,名字含义清晰能避免误删。如果每个人建约束时都随手起一个毫无规律的名字,等你需要维护几十张表时,查索引名都会查到崩溃。
6.2 大表加约束之前,想清楚窗口期和工具方案
在千万行级别的大表上执行 ALTER TABLE,不管加主键、加唯一约束还是加非空约束,都可能引发长时间的表重建或索引重建,期间会产生锁竞争、IO 飙升、主从延迟。我的经验是三个原则:第一,操作前在测试环境用相同数据量级跑一遍,拿到真实耗时;第二,选择业务低峰期执行,并且提前准备好回滚方案;第三,如果表特别大且不能接受长时间锁表,考虑使用在线变更工具来做,而不是直接拼一条裸的 ALTER 语句。
另外,加唯一约束时如果目标字段在历史数据上已经存在大量重复,前面第四部分说过,直接跑 ALTER 会报错。处理重复数据本身可能涉及 UPDATE 大量行,这个操作同样会影响线上。所以更好的顺序是:先写好数据清理脚本并验证,再执行加约束的 DDL,把数据修复和 DDL 变更拆成两个独立窗口,避免互相影响。
6.3 小心 MySQL 8.0.16 之前"看起来有 CHECK 约束"的假象
我在前面提过一句,这里展开讲。MySQL 8.0.16 之前的版本,CHECK 约束会被 MySQL 解析器接受,语法检查通过,但存储引擎完全忽略它。也就是说你写:
age INT CHECK (age >= 0)它能建表成功,但插入age = -1照样成功。无数人栽在这个"假约束"上:以为加了约束,实际没任何效果。MySQL 8.0.16 之后,CHECK 约束才开始被真正强制执行。
所以如果你还在用 8.0.16 之前的版本,不要依赖 CHECK 做任何完整性校验,业务规则请放到应用层;升级到 8.0.16 以后,CHECK 可以用来做简单的取值范围约束,比如状态字段的枚举值校验。但也要注意不要滥用,过于复杂的 CHECK 表达式会影响写入性能。
6.4 我每次做表结构评审时,最后必查的三件事
这些年经手了不少表结构评审,我发现只要抓住三个核心问题,单表约束这一层基本不会出大乱子。
第一,每张业务表是不是都有主键。没有主键的表在 InnoDB 里只能用隐藏 ROWID 组织数据,不仅无法高效定位行,而且后续做数据变更、日志解析、分库分表都会遇到麻烦。日志表、临时表可以例外,但业务表一定要有主键。
第二,业务上需要唯一的字段是不是真的加了唯一约束。电商的订单号、用户的手机号、支付的流水号,这些"不能重复"的字段,光靠代码保证不够,必须落到数据库层。这也是我今天强调最多的一点。
第三,空值的语义是不是清晰。每个字段到底允许 NULL、还是用空字符串、还是给了默认值,必须有一个统一设计。最怕的是同一个含义的字段,有些表用 NULL,有些表用'',有些表用 0,将来做统计时口径混乱,查出来的数据自己都不敢信。
把这三件事想清楚,你建出来的表至少不会因为约束缺失而在深夜被人叫起来修数据。
最后再分享一个我实际操作中的小习惯:每次新建一张业务表之前,我都会把SHOW CREATE TABLE的输出逻辑在脑子里过一遍,假装自己是一个三个月后接手这张表的同事,看这个表结构能不能一眼读懂。约束名称清晰、每个字段非空和默认值有明确意图、业务唯一键有兜底——这个表设计才算真正过关。