原创

MySQL表的约束详解与设计实践

MySQL 表的约束,本质上是把“数据规则”直接下沉到数据库层。应用层当然可以做参数校验、业务校验、状态判断,但只要数据最终要落到 MySQL,数据库本身就应该成为最后一道、也是最可靠的一道边界。只依赖 Java、Spring Boot 或前端校验,会面临并发写入、绕过接口、脚本导入、历史数据修复等多种场景,一旦缺少数据库约束,数据一致性就很容易被破坏。

在实际项目中,很多问题看起来是“业务 bug”,本质却是“表结构设计不严谨”。例如用户邮箱被写成空字符串、订单重复提交、明细行引用了不存在的主订单、同一租户下业务编码重复、状态值被写入非法数字,这些问题都属于约束没有设计好,或者对约束能力理解不完整。

MySQL 常见的表约束主要包括:NOT NULLDEFAULTPRIMARY KEYUNIQUEFOREIGN KEYCHECK。此外,AUTO_INCREMENT 虽然通常与主键一起出现,但它更准确地说是列属性,不属于严格意义上的约束。不过在工程实践中,它经常和主键约束一起讨论,因为二者共同决定了标识列的生成和唯一性。


一、为什么必须重视表约束

约束的价值,不是“让建表语句看起来更完整”,而是让数据具备可证明的正确性。这个正确性主要体现在四个方面。

1. 保证数据完整性

完整性可以分为几类:

  • 实体完整性:每一行记录都必须可唯一标识,通常由主键保证
  • 域完整性:字段值必须落在合法范围内,例如不能为空、长度有限、数值范围受限
  • 引用完整性:子表记录引用的父表记录必须真实存在,通常由外键保证
  • 业务唯一性:某些字段不能重复,例如用户名、订单号、租户内编码

如果没有约束,这些规则只能由应用代码“尽量保证”,而不能由数据库“强制保证”。

2. 降低系统耦合

当约束写在数据库里时,Java 服务、定时任务、数据同步程序、脚本工具、管理后台,其实都共享同一套底层规则。否则,每个入口都要重复实现同样的校验逻辑,且很容易不一致。

3. 提高问题暴露速度

约束越靠近数据落库点,问题暴露越早。比起错误数据已经写入表里,隔几天才通过报表发现,插入时直接失败显然更容易定位,也更容易修复。

4. 为后续架构演进打基础

系统从单体走向微服务,从单库走向分库分表之后,数据边界会更复杂。越早在单库阶段建立清晰的约束意识,后续在分布式唯一性、跨服务一致性、读写分离等场景中越不容易失控。


二、MySQL 约束的分类与核心作用

先给出一个直观划分:

约束类型 作用 常见使用位置
NOT NULL 禁止为 NULL 必填字段
DEFAULT 未显式赋值时提供默认值 状态、时间、标志位
PRIMARY KEY 唯一标识一行数据,且不能为空 主键列
UNIQUE 保证值唯一 用户名、邮箱、业务编码
FOREIGN KEY 保证引用关系存在 主从表、字典引用
CHECK 保证值满足表达式条件 状态范围、金额约束

需要特别注意,NULL 和空字符串 '' 不是一回事,0NULL 也不是一回事。很多系统设计问题,都源于把“没值”和“有值但为空”混在一起。


三、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 NULLIS 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 ACTIONRESTRICT 的实际效果通常等价,都是拒绝不满足约束的删除或更新。

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 不能替代复杂业务规则

例如“订单支付时间必须晚于下单时间且必须在超时前,并且优惠券状态要合法,且用户账户正常”,这类复杂跨表、跨状态规则不适合依赖 CHECKCHECK 更适合表达单行内、单表内、可用布尔表达式直接描述的规则。


九、完整示例:一个约束设计相对完整的订单模型

为了更直观,下面给出一组完整建表 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 NULLDEFAULTCHECK 或应用校验不足。


十五、总结

MySQL 表约束不是“可有可无的附属配置”,而是表结构设计中最核心的部分之一。一个设计良好的约束体系,能够把大量隐性的业务规则转化为显性的、可执行的数据库规则,从而让系统具备更强的数据一致性、更好的可维护性以及更低的问题排查成本。

从实践角度看,可以这样理解各类约束的职责边界:

  • NOT NULL 解决字段是否允许缺失
  • DEFAULT 解决未赋值时如何落默认状态
  • PRIMARY KEY 解决一行数据如何被唯一标识
  • UNIQUE 解决业务字段或字段组合不能重复
  • FOREIGN KEY 解决引用关系必须真实存在
  • CHECK 解决字段值必须满足某种表达式条件

真正成熟的数据库设计,不是只会写字段类型,而是能够准确表达数据规则。对于 Java、Spring Boot、MySQL 的业务系统而言,应用层校验、服务层幂等、缓存层防重、消息层补偿都很重要,但它们都不能替代数据库约束的最终兜底作用。

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

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