原创

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 默认值为 0
  • is_deleted 默认值为 0
  • created_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 与并发安全

这是唯一约束最重要的工程价值之一。 应用层“先查询是否存在,再决定是否插入”在并发下并不可靠:

  1. 线程 A 查询,不存在
  2. 线程 B 查询,不存在
  3. 线程 A 插入成功
  4. 线程 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 DELETE
  • ON UPDATE

常见取值包括:

  • RESTRICT
  • CASCADE
  • SET NULL
  • NO 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 的业务系统而言,应用层校验、服务层幂等、缓存层防重、消息层补偿都很重要,但它们都不能替代数据库约束的最终兜底作用。

如果把数据库当作纯存储容器,而不是规则执行边界,系统初期也许跑得很快,但随着并发上升、入口增多、团队扩大、数据量增长,问题一定会集中爆发。相反,只要在建表阶段就把约束设计清楚,很多数据一致性问题在进入生产环境之前就已经被消灭了。

正文到此结束
评论插件初始化中...
Loading...