MySQL表的约束详解与设计实践
MySQL 表的约束,本质上是把“数据规则”直接下沉到数据库层。应用层当然可以做参数校验、业务校验、状态判断,但只要数据最终要落到 MySQL,数据库本身就应该成为最后一道、也是最可靠的一道边界。只依赖 Java、Spring Boot 或前端校验,会面临并发写入、绕过接口、脚本导入、历史数据修复等多种场景,一旦缺少数据库约束,数据一致性就很容易被破坏。
在实际项目中,很多问题看起来是“业务 bug”,本质却是“表结构设计不严谨”。例如用户邮箱被写成空字符串、订单重复提交、明细行引用了不存在的主订单、同一租户下业务编码重复、状态值被写入非法数字,这些问题都属于约束没有设计好,或者对约束能力理解不完整。
MySQL 常见的表约束主要包括:NOT NULL、DEFAULT、PRIMARY KEY、UNIQUE、FOREIGN KEY、CHECK。此外,AUTO_INCREMENT 虽然通常与主键一起出现,但它更准确地说是列属性,不属于严格意义上的约束。不过在工程实践中,它经常和主键约束一起讨论,因为二者共同决定了标识列的生成和唯一性。
一、为什么必须重视表约束
约束的价值,不是“让建表语句看起来更完整”,而是让数据具备可证明的正确性。这个正确性主要体现在四个方面。
1. 保证数据完整性
完整性可以分为几类:
- 实体完整性:每一行记录都必须可唯一标识,通常由主键保证
- 域完整性:字段值必须落在合法范围内,例如不能为空、长度有限、数值范围受限
- 引用完整性:子表记录引用的父表记录必须真实存在,通常由外键保证
- 业务唯一性:某些字段不能重复,例如用户名、订单号、租户内编码
如果没有约束,这些规则只能由应用代码“尽量保证”,而不能由数据库“强制保证”。
2. 降低系统耦合
当约束写在数据库里时,Java 服务、定时任务、数据同步程序、脚本工具、管理后台,其实都共享同一套底层规则。否则,每个入口都要重复实现同样的校验逻辑,且很容易不一致。
3. 提高问题暴露速度
约束越靠近数据落库点,问题暴露越早。比起错误数据已经写入表里,隔几天才通过报表发现,插入时直接失败显然更容易定位,也更容易修复。
4. 为后续架构演进打基础
系统从单体走向微服务,从单库走向分库分表之后,数据边界会更复杂。越早在单库阶段建立清晰的约束意识,后续在分布式唯一性、跨服务一致性、读写分离等场景中越不容易失控。
二、MySQL 约束的分类与核心作用
先给出一个直观划分:
| 约束类型 | 作用 | 常见使用位置 |
|---|---|---|
| NOT NULL | 禁止为 NULL | 必填字段 |
| DEFAULT | 未显式赋值时提供默认值 | 状态、时间、标志位 |
| PRIMARY KEY | 唯一标识一行数据,且不能为空 | 主键列 |
| UNIQUE | 保证值唯一 | 用户名、邮箱、业务编码 |
| FOREIGN KEY | 保证引用关系存在 | 主从表、字典引用 |
| CHECK | 保证值满足表达式条件 | 状态范围、金额约束 |
需要特别注意,NULL 和空字符串 '' 不是一回事,0 和 NULL 也不是一回事。很多系统设计问题,都源于把“没值”和“有值但为空”混在一起。
三、NOT NULL:最基础、也最容易被低估的约束
1. 语义
NOT NULL 表示该列不允许为 NULL。它不意味着该字段一定“有业务意义的值”,它只意味着数据库层面不能存储 NULL。
例如:
CREATE TABLE t_user_basic (
id BIGINT NOT NULL,
username VARCHAR(64) NOT NULL,
nickname VARCHAR(64) NULL,
created_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
这里:
id不能为空username不能为空nickname可以为空created_at不能为空
2. NULL 的工程语义
在数据库设计中,NULL 通常表示:
- 值未知
- 值不存在
- 值尚未提供
- 值不适用
这几个语义并不完全相同,所以是否允许 NULL,不是“看着方便就行”,而要结合业务语义来定。
例如:
- 用户手机号:注册时若允许邮箱注册,则手机号可以为
NULL - 删除时间
deleted_at:未删除时为NULL,已删除时为具体时间 - 用户昵称:如果允许用户不填,则可以为
NULL - 订单总金额:通常不应为
NULL,即使是 0 元订单,也应该存0.00
3. 为什么很多字段应该优先使用 NOT NULL
从工程实践看,大量核心字段都应该尽量设计为 NOT NULL,原因有三点:
第一,查询语义更简单。 如果字段可能为 NULL,那么很多条件判断都不能直接写 =,而需要写 IS NULL 或 IS NOT NULL。
第二,索引与比较行为更稳定。 NULL 参与比较时遵循三值逻辑,不等于任何值,也不等于另一个 NULL。这会让查询、统计和业务判断变复杂。
第三,数据质量更高。 一个允许 NULL 的字段,很容易在应用迭代中逐渐演变成“什么都能塞”的地方。
4. NOT NULL 不等于“不能是空字符串”
下面这个字段虽然是 NOT NULL:
username VARCHAR(64) NOT NULL
但依然可以写入:
''
也就是空字符串。如果业务要求“必须填写用户名且不能全空”,仅靠 NOT NULL 是不够的。还需要:
- 应用层做字符串非空校验
- 必要时结合
CHECK约束 - 或在接入层统一拦截非法输入
5. 典型误区
误区一:所有字段都允许 NULL,图省事
这会导致表结构失去表达力。数据库不再能告诉开发者“哪些字段是业务必需的”。
误区二:把业务默认值伪装成非空
例如把“未知年龄”存成 0,把“未填写性别”存成 0,把“未设置时间”存成 '1970-01-01 00:00:00'。这样会让真实数据和占位数据混在一起,后续统计和业务逻辑都容易出错。
四、DEFAULT:给字段提供缺省值,但不能代替业务设计
1. 语义
DEFAULT 表示在插入记录时,如果没有显式为该列赋值,则自动使用默认值。
例如:
CREATE TABLE t_order_default_demo (
id BIGINT NOT NULL,
order_no VARCHAR(64) NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
is_deleted TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_order_no (order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
这里:
status默认值为0is_deleted默认值为0created_at默认当前时间updated_at默认当前时间,并在更新时自动刷新
2. DEFAULT 的适用场景
DEFAULT 很适合以下字段:
- 状态位,例如
status = 0 - 逻辑删除标记,例如
is_deleted = 0 - 创建时间、更新时间
- 初始化计数,例如
retry_count = 0
3. DEFAULT 的边界
DEFAULT 只在“未赋值”时生效。如果显式插入 NULL,是否允许,取决于字段是否声明为 NOT NULL。
例如字段定义为:
status TINYINT NOT NULL DEFAULT 0
那么:
INSERT INTO ... (status) VALUES (NULL);
会报错,因为 NOT NULL 先起作用。
而如果字段允许 NULL:
status TINYINT NULL DEFAULT 0
那么显式传入 NULL 时,最终值就是 NULL,不会自动替换成 0。
4. 工程上的常见错误
错误一:把默认值当作业务兜底
例如“订单状态默认 0”,并不代表应用层可以不传状态并完全依赖数据库兜底。数据库默认值只能做最后一道保护,不能代替清晰的业务建模。
错误二:默认值不具备业务可解释性
例如:
status TINYINT NOT NULL DEFAULT 99
如果 99 没有明确业务定义,这就是典型的坏设计。
错误三:时间字段设计混乱
很多表只写 created_at DEFAULT CURRENT_TIMESTAMP,却没设计 updated_at 自动更新时间,导致后续更新审计困难。
五、PRIMARY KEY:唯一标识一行数据的核心约束
1. 主键的定义
主键具备两个特征:
- 唯一
- 非空
一张 InnoDB 表只能有一个主键,但这个主键可以由单列组成,也可以由多列组成。
例如单列主键:
CREATE TABLE t_department (
id BIGINT NOT NULL AUTO_INCREMENT,
dept_code VARCHAR(32) NOT NULL,
dept_name VARCHAR(128) NOT NULL,
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_dept_code (dept_code)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
2. 主键与唯一索引的区别
很多人会问:主键和唯一约束都能保证唯一,那为什么还要区分?
核心区别如下:
| 对比项 | PRIMARY KEY | UNIQUE |
|---|---|---|
| 是否允许 NULL | 不允许 | 一般允许,具体行为与列定义有关 |
| 每张表数量 | 只能一个 | 可以多个 |
| 语义 | 行唯一标识 | 某个字段或字段组唯一 |
| InnoDB 聚簇组织 | 主键决定聚簇索引 | 不决定聚簇组织 |
在 InnoDB 中,主键非常关键,因为表数据本身就是按主键组织的,这也是所谓的聚簇索引。主键选择不合理,会影响插入性能、页分裂、二级索引大小以及查询效率。
3. 主键设计原则
原则一:必须稳定
主键不应该频繁变更。主键一旦变化,相关索引和引用都会受影响。
原则二:尽量短
InnoDB 的二级索引叶子节点会保存主键值。主键越长,所有二级索引越大。
原则三:尽量无业务含义或弱业务含义
例如用户表主键一般使用 BIGINT 自增或雪花 ID,而不是手机号、身份证号、邮箱。因为业务字段可能变化、可能脱敏、可能受政策限制。
4. 自增主键与分布式 ID
自增主键
id BIGINT NOT NULL AUTO_INCREMENT
优点:
- 简单
- 插入局部有序
- 适合单库单表
缺点:
- 分库分表后容易冲突
- 暴露增长规律
- 不适合跨系统全局唯一
分布式 ID
如雪花算法、号段模式、Leaf、数据库序列模拟等。 在 Spring Boot + MySQL 的中大型系统里,这类方案更适合微服务架构。
但即便使用分布式 ID,数据库主键约束仍然是必须的。ID 生成方式解决“如何产生值”,主键约束解决“数据库必须唯一且非空”。
5. 联合主键的适用性
联合主键示例:
CREATE TABLE t_user_role_rel (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
granted_by BIGINT NOT NULL,
PRIMARY KEY (user_id, role_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
这类设计在纯关系模型中是合理的,尤其适合中间关联表。但在实际业务开发中,是否使用联合主键要谨慎。
优点:
- 天然表达多列共同唯一
- 省去单独主键列
缺点:
- 外键引用更复杂
- ORM 映射不够友好
- 二级索引可能更大
- 扩展性较差
因此,业务主表通常推荐单列代理主键;纯关系关联表可以视情况使用联合主键或“单列主键 + 唯一约束”。
六、UNIQUE:业务唯一性的主要表达方式
1. 基本语义
UNIQUE 用于保证某列或某几列组合后的值不能重复。
例如:
CREATE TABLE t_user_account (
id BIGINT NOT NULL AUTO_INCREMENT,
username VARCHAR(64) NOT NULL,
email VARCHAR(128) NOT NULL,
phone VARCHAR(32) DEFAULT NULL,
tenant_id BIGINT NOT NULL,
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_username (username),
UNIQUE KEY uk_email (email),
UNIQUE KEY uk_tenant_phone (tenant_id, phone)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
含义是:
username全局唯一email全局唯一(tenant_id, phone)组合唯一
2. 联合唯一约束特别重要
很多业务唯一性不是单字段唯一,而是“某个范围内唯一”。例如:
- 同一租户下用户名唯一
- 同一门店下商品编码唯一
- 同一订单下商品行号唯一
- 同一日期下批次号唯一
这种场景应该使用联合唯一约束,而不是应用层先查后插。
例如:
UNIQUE KEY uk_tenant_user_code (tenant_id, user_code)
3. UNIQUE 与并发安全
这是唯一约束最重要的工程价值之一。 应用层“先查询是否存在,再决定是否插入”在并发下并不可靠:
- 线程 A 查询,不存在
- 线程 B 查询,不存在
- 线程 A 插入成功
- 线程 B 也尝试插入
如果没有唯一约束,重复数据就出现了。 即使使用 Redis 分布式锁,也不能把数据库唯一性保证外包给缓存层。最稳妥的方案仍然是:数据库唯一约束 + 应用层异常处理。
4. NULL 与 UNIQUE 的关系
这是 MySQL 中非常容易误解的一点。
对于允许 NULL 的唯一列,多个 NULL 往往是允许存在的,因为 NULL 不被视为相等值。这意味着:
phone VARCHAR(32) DEFAULT NULL,
UNIQUE KEY uk_phone (phone)
在很多情况下,可以插入多条 phone = NULL 的记录,但不能插入多条相同的非空手机号。
这是否符合业务需求,必须提前想清楚。
如果业务要求“手机号要么不填,要么一旦填写必须唯一”,那么这个设计是合理的。 如果业务要求“手机号是必填且唯一”,那么就应该写成:
phone VARCHAR(32) NOT NULL,
UNIQUE KEY uk_phone (phone)
5. 唯一约束冲突的处理方式
在 Java/Spring Boot 项目中,唯一约束冲突通常会表现为数据库异常,例如 Duplicate entry。此时正确做法是:
- 数据库层保留唯一约束
- 服务层捕获异常并转换为业务可理解提示
- 对关键接口配合幂等设计
而不是删除唯一约束、改成“程序自己保证”。
七、FOREIGN KEY:引用完整性的数据库级保障
1. 基本作用
外键用于保证子表中引用的值,必须存在于父表中。 这可以防止“脏引用”出现,也就是子记录指向了一个根本不存在的父记录。
先看一个完整示例。
CREATE TABLE t_customer (
id BIGINT NOT NULL AUTO_INCREMENT,
customer_no VARCHAR(64) NOT NULL,
customer_name VARCHAR(128) NOT NULL,
level TINYINT NOT NULL DEFAULT 1,
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_customer_no (customer_no)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE t_order_main (
id BIGINT NOT NULL AUTO_INCREMENT,
order_no VARCHAR(64) NOT NULL,
customer_id BIGINT NOT NULL,
total_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00,
status TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_order_no (order_no),
KEY idx_customer_id (customer_id),
CONSTRAINT fk_order_customer
FOREIGN KEY (customer_id)
REFERENCES t_customer (id)
ON DELETE RESTRICT
ON UPDATE RESTRICT
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
2. ON DELETE 与 ON UPDATE 的含义
外键最关键的两个动作定义:
ON DELETEON UPDATE
常见取值包括:
RESTRICTCASCADESET NULLNO ACTION
在 MySQL 中,NO ACTION 与 RESTRICT 的实际效果通常等价,都是拒绝不满足约束的删除或更新。
ON DELETE RESTRICT
如果父表记录被子表引用,则不允许删除父表记录。
这是最稳妥、最符合大多数业务语义的方式。
ON DELETE CASCADE
删除父记录时,自动删除所有关联子记录。
适用于明确从属关系且允许物理联删的场景,例如某些临时配置项、明细缓存数据。但在核心业务表上要非常谨慎,避免误删扩散。
ON DELETE SET NULL
删除父记录后,把子表外键列设为 NULL。 前提是子表外键列必须允许 NULL。
这种方式适合“关联对象可被解绑”的场景。
3. 外键使用的现实争议
在很多互联网项目里,尤其是高并发系统和分库分表系统,开发团队常常不在数据库层面显式创建外键,而是通过应用代码维护关联关系。
原因主要包括:
- 担心写入性能受影响
- 担心删除更新操作被约束阻塞
- 分库分表后外键难以跨库维护
- 复杂业务下更倾向应用层显式控制
这不意味着外键“不好”,而是说明是否启用外键,要结合系统形态。
4. 我的建议
适合启用外键的场景
- 中后台系统
- 单库单表或单库多表
- 强一致要求高
- 数据维护入口多
- 业务关系清晰稳定
谨慎或不启用的场景
- 高并发核心交易链路
- 分库分表后的跨库引用
- 需要高度灵活的数据迁移、归档、异步清洗
- 由应用层统一负责引用校验和删除策略的复杂架构
即便不启用物理外键,也应该:
- 保留清晰的“逻辑外键”字段命名
- 建立必要索引
- 在应用层严格执行引用校验
- 通过数据巡检任务持续校验脏数据
否则所谓“不用外键”,往往会退化成“不要约束”。
八、CHECK:值范围约束,MySQL 8.0.16 及以上才真正生效
1. 版本差异必须明确
这是一个非常关键的版本点。
- MySQL 8.0.16 之前:
CHECK语法可以写,但通常不会真正被执行 - MySQL 8.0.16 及以上:
CHECK约束开始被真正支持和校验
所以如果你的生产环境是 MySQL 5.7、MySQL 8.0.15 及以下,那么不要错误地以为写了 CHECK 就一定能拦住非法数据。
2. 基本示例
CREATE TABLE t_product (
id BIGINT NOT NULL AUTO_INCREMENT,
product_code VARCHAR(64) NOT NULL,
product_name VARCHAR(128) NOT NULL,
price DECIMAL(18,2) NOT NULL,
stock INT NOT NULL DEFAULT 0,
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_product_code (product_code),
CONSTRAINT chk_price_non_negative CHECK (price >= 0),
CONSTRAINT chk_stock_non_negative CHECK (stock >= 0),
CONSTRAINT chk_status_valid CHECK (status IN (0, 1))
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
这个表表达了几个很明确的规则:
- 商品价格不能小于 0
- 库存不能小于 0
- 状态只能是 0 或 1
3. CHECK 的适用场景
非常适合约束以下内容:
- 状态值范围:
status IN (0,1,2) - 数值非负:
amount >= 0 - 评分边界:
score BETWEEN 0 AND 100 - 日期先后关系:
end_time >= start_time
4. CHECK 不能替代复杂业务规则
例如“订单支付时间必须晚于下单时间且必须在超时前,并且优惠券状态要合法,且用户账户正常”,这类复杂跨表、跨状态规则不适合依赖 CHECK。 CHECK 更适合表达单行内、单表内、可用布尔表达式直接描述的规则。
九、完整示例:一个约束设计相对完整的订单模型
为了更直观,下面给出一组完整建表 SQL,覆盖主键、非空、默认值、唯一、外键、检查约束等常见能力。
1. 客户表
DROP TABLE IF EXISTS t_order_item;
DROP TABLE IF EXISTS t_order_main;
DROP TABLE IF EXISTS t_customer;
CREATE TABLE t_customer (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
customer_no VARCHAR(64) NOT NULL COMMENT '客户编号',
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称',
email VARCHAR(128) DEFAULT NULL COMMENT '邮箱',
phone VARCHAR(32) DEFAULT NULL COMMENT '手机号',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
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_customer_no (customer_no),
UNIQUE KEY uk_email (email),
UNIQUE KEY uk_phone (phone),
CONSTRAINT chk_customer_status CHECK (status IN (0, 1))
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户表';
2. 订单主表
CREATE TABLE t_order_main (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
order_no VARCHAR(64) NOT NULL COMMENT '订单号',
customer_id BIGINT NOT NULL COMMENT '客户ID',
total_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '订单总金额',
paid_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '已支付金额',
status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-待支付,1-已支付,2-已取消,3-已完成',
remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
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_order_no (order_no),
KEY idx_customer_id (customer_id),
CONSTRAINT fk_order_customer
FOREIGN KEY (customer_id)
REFERENCES t_customer (id)
ON DELETE RESTRICT
ON UPDATE RESTRICT,
CONSTRAINT chk_total_amount_non_negative CHECK (total_amount >= 0),
CONSTRAINT chk_paid_amount_non_negative CHECK (paid_amount >= 0),
CONSTRAINT chk_paid_le_total CHECK (paid_amount <= total_amount),
CONSTRAINT chk_order_status CHECK (status IN (0, 1, 2, 3))
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单主表';
3. 订单明细表
CREATE TABLE t_order_item (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
order_id BIGINT NOT NULL COMMENT '订单ID',
line_no INT NOT NULL COMMENT '行号',
product_code VARCHAR(64) NOT NULL COMMENT '商品编码',
product_name VARCHAR(128) NOT NULL COMMENT '商品名称',
unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '单价',
quantity INT NOT NULL DEFAULT 1 COMMENT '数量',
line_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '行金额',
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_order_line (order_id, line_no),
KEY idx_order_id (order_id),
CONSTRAINT fk_order_item_order
FOREIGN KEY (order_id)
REFERENCES t_order_main (id)
ON DELETE CASCADE
ON UPDATE RESTRICT,
CONSTRAINT chk_unit_price_non_negative CHECK (unit_price >= 0),
CONSTRAINT chk_quantity_positive CHECK (quantity > 0),
CONSTRAINT chk_line_amount_non_negative CHECK (line_amount >= 0)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单明细表';
这一组表结构体现了几个关键设计点:
- 每张表都有主键
- 重要字段使用
NOT NULL - 状态字段提供明确默认值
- 订单号、客户编号等业务标识使用唯一约束
- 明细表通过
(order_id, line_no)保证同一订单下行号唯一 - 订单引用客户,明细引用订单,引用关系由外键保证
- 金额、数量、状态范围通过
CHECK约束限定
这就是一个数据库能够主动表达业务规则的典型例子。
十、约束与索引的关系:相互关联,但不是同一个概念
很多初学者容易把约束和索引混为一谈。二者确实密切相关,但并不等价。
1. 约束是规则,索引是结构
- 约束关注的是“数据能不能这样存”
- 索引关注的是“数据怎样更快找到”
2. 主键和唯一约束通常依赖索引实现
在 MySQL 中:
PRIMARY KEY会创建主键索引UNIQUE会创建唯一索引
但普通索引:
KEY idx_customer_id (customer_id)
并不意味着 customer_id 唯一,也不意味着它不能为空。
3. 外键列通常需要索引
外键约束涉及父子表关联检查,因此相关列通常需要索引支持。 实践中,即使数据库会自动处理部分索引要求,也建议显式创建,避免结构表达不清。
十一、约束设计中的高频误区
1. 只在应用层做校验,不在数据库层加约束
这是最常见也最危险的问题。应用层校验不能替代数据库约束,只能补充数据库约束。
2. 主键使用业务字段
例如把手机号设为主键。这样会带来:
- 业务字段变更困难
- 隐私与脱敏问题
- 外键引用成本上升
- 索引变大
3. 唯一性依赖“先查后插”
在并发环境下,这种方式不可靠。必须有数据库唯一约束兜底。
4. 把 NULL、空字符串、0 混为一谈
这会造成统计错误、查询条件混乱和业务语义失真。
5. 误信低版本 MySQL 的 CHECK 会生效
如果版本低于 MySQL 8.0.16,就不能把核心合法性校验寄托在 CHECK 上。
6. 滥用 CASCADE 删除
ON DELETE CASCADE 用起来很方便,但在核心业务表上容易导致误删扩散。 例如误删一个用户,连带订单、地址、日志、优惠券记录全被删掉,这通常不是你想要的结果。
7. 缺少组合唯一约束
例如订单明细只给了主键,没有给 (order_id, line_no) 唯一约束,结果同一订单出现两个第 1 行明细,数据库也不会报错。
十二、Spring Boot 项目中如何正确看待数据库约束
1. Bean Validation 不能替代数据库约束
在 Spring Boot 中,常见做法是用:
@NotNull@NotBlank@Size@Min@Max
这些注解对接口参数校验很有价值,但它们只能约束“经过当前应用入口的数据”。 如果是批量导入、定时任务、管理脚本、数据修复 SQL、其他服务调用,仍然可能绕过这些注解。
所以正确关系是:
- 应用层校验:提升用户体验,尽早返回明确错误
- 数据库约束:最终保证数据不会被写坏
2. ORM 自动建表不能替代人工审查
无论是 JPA、Hibernate,还是 MyBatis 配合初始化脚本,都不能因为“框架能自动生成表”就放弃对 DDL 的精细设计。
尤其是这些内容,必须人工确认:
- 主键策略
- 唯一约束
- 联合唯一
- 外键策略
- 默认值
- 字符集与排序规则
- 审计字段
- 索引命名
3. 异常转换要做好
当数据库约束触发异常时,服务层要把技术异常转成业务语义。例如:
- 用户名已存在
- 订单号重复
- 关联客户不存在
- 状态值非法
不能把底层 SQL 异常原样抛给前端。
十三、生产环境中的约束设计建议
1. 每张核心业务表都应该有主键
不要设计“无主键表”,除非是极少数特殊场景的临时表或归档中间表。 在 InnoDB 中,无主键表会引入隐藏行 ID,通常不利于维护与性能分析。
2. 核心字段尽量 NOT NULL
尤其是:
- 主键
- 外键字段
- 状态字段
- 时间字段
- 金额字段
- 数量字段
- 逻辑删除字段
3. 业务唯一性尽量下沉到数据库
例如:
- 用户名
- 邮箱
- 手机号
- 租户内编码
- 订单号
- 支付流水号
4. 默认值必须有明确语义
不要为了方便插入而设置神秘默认值。默认值要么是业务初始状态,要么是稳定的系统缺省值。
5. 对状态、金额、数量等字段优先考虑 CHECK
前提是生产版本至少为 MySQL 8.0.16。 如果是低版本,则必须通过应用层和巡检机制补上。
6. 外键是否启用,取决于架构阶段
单库单表或中后台系统,建议优先启用。 高并发、分库分表系统,可以不启用物理外键,但必须保留逻辑外键设计和数据巡检。
7. 命名要清晰
建议统一命名规范,例如:
- 主键:
PRIMARY KEY - 唯一约束:
uk_xxx - 普通索引:
idx_xxx - 外键:
fk_xxx - 检查约束:
chk_xxx
这样在排障和维护时可读性会高很多。
十四、如何判断一个约束设计是否合理
可以用下面四个问题快速自检:
1. 如果绕过应用,直接执行 SQL,这张表还能保证数据不乱吗
如果答案是否定的,说明数据库约束不足。
2. 并发插入时,重复数据能否被数据库直接拦住
如果不能,说明唯一约束设计不足。
3. 删除或更新父数据时,子数据会不会出现悬挂引用
如果会,说明外键或逻辑引用控制不足。
4. 状态、金额、数量是否有非法取值进入表中的可能
如果有,说明 NOT NULL、DEFAULT、CHECK 或应用校验不足。
十五、总结
MySQL 表约束不是“可有可无的附属配置”,而是表结构设计中最核心的部分之一。一个设计良好的约束体系,能够把大量隐性的业务规则转化为显性的、可执行的数据库规则,从而让系统具备更强的数据一致性、更好的可维护性以及更低的问题排查成本。
从实践角度看,可以这样理解各类约束的职责边界:
NOT NULL解决字段是否允许缺失DEFAULT解决未赋值时如何落默认状态PRIMARY KEY解决一行数据如何被唯一标识UNIQUE解决业务字段或字段组合不能重复FOREIGN KEY解决引用关系必须真实存在CHECK解决字段值必须满足某种表达式条件
真正成熟的数据库设计,不是只会写字段类型,而是能够准确表达数据规则。对于 Java、Spring Boot、MySQL 的业务系统而言,应用层校验、服务层幂等、缓存层防重、消息层补偿都很重要,但它们都不能替代数据库约束的最终兜底作用。
如果把数据库当作纯存储容器,而不是规则执行边界,系统初期也许跑得很快,但随着并发上升、入口增多、团队扩大、数据量增长,问题一定会集中爆发。相反,只要在建表阶段就把约束设计清楚,很多数据一致性问题在进入生产环境之前就已经被消灭了。