Skip to content

数据库约束


在关系型数据库中,约束(Constraint) 是一种用于保证数据完整性与一致性的机制。它的存在就像数据库的“法律条文”,规定了表中数据应当遵守的规则,从而防止无效、错误或矛盾的数据被插入或修改。例如,不能让年龄字段出现负数、不能让主键重复、不能出现没有关联到主表的外键记录等。
约束的主要目标是让数据库自己去维护数据的正确性,而不是完全依赖程序代码来验证。合理地设置约束,不仅能提高数据的质量,还能提升查询性能,减少逻辑错误。

从整体上看,数据库约束可分为两大类:

  1. 列级约束(Column Constraint):只作用于单个字段,例如 NOT NULLDEFAULTCHECK 等。

  2. 表级约束(Table Constraint):作用于整个表或多个字段的组合,例如 PRIMARY KEYFOREIGN KEYUNIQUE 等。

这些约束共同构建了数据库的“防线”,确保数据在录入、修改、删除的每个环节都符合法规。

NOT NULL 用于保证某个字段的值不能为空。
在实际业务中,这通常用于表示“必须填写”的字段,比如姓名、身份证号、主键ID等。

CREATE TABLE person (
id INT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
age INT
);

如果尝试插入 name 为空的数据,数据库会直接拒绝。
这种约束的意义在于——数据的存在本身有业务价值,不能缺省。

DEFAULT 用于在插入数据时,如果该字段未显式赋值,则自动填入一个默认值。
常见于状态字段、创建时间字段等。

CREATE TABLE orders (
id INT PRIMARY KEY,
status VARCHAR(10) DEFAULT 'pending',
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

默认值可以保证业务逻辑的连贯性,避免出现 NULL 导致的计算或判断异常。

主键(Primary Key)是唯一标识一条记录的字段或字段组合。
一个表只能有一个主键约束,但主键可以由多个列联合构成(称为复合主键)。

CREATE TABLE student (
student_id INT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
class_id INT
);

主键自动具备唯一性(UNIQUE)非空(NOT NULL) 的特征。
主键的选择应该符合“稳定且唯一”的原则,如身份证号、UUID、业务ID等。

UNIQUE 确保字段中的值不重复,但允许出现 NULL
它与主键的区别在于:一个表中可以有多个唯一约束,但只能有一个主键。

CREATE TABLE user_account (
user_id INT PRIMARY KEY,
username VARCHAR(30) UNIQUE,
email VARCHAR(50) UNIQUE
);

唯一约束常用于保证登录名、手机号、邮箱等字段的唯一性。

FOREIGN KEY 是用来建立表与表之间关联关系的重要机制。
它确保子表的数据必须引用主表中已存在的记录,从而维护引用完整性

CREATE TABLE department (
dept_id INT PRIMARY KEY,
dept_name VARCHAR(50)
);
CREATE TABLE employee (
emp_id INT PRIMARY KEY,
emp_name VARCHAR(50),
dept_id INT,
FOREIGN KEY (dept_id) REFERENCES department(dept_id)
);

有了外键约束,数据库会禁止插入一个不存在部门的员工,也不会允许删除仍被员工引用的部门。
不过在高并发系统中,外键约束有时会被逻辑约束替代(在代码层面验证),以避免影响性能。

CHECK 用于设定字段值必须满足的逻辑条件,比如数值范围、特定模式等。
MySQL 8.0 开始才正式支持此约束。

CREATE TABLE product (
id INT PRIMARY KEY,
price DECIMAL(10,2) CHECK (price >= 0),
discount INT CHECK (discount BETWEEN 0 AND 100)
);

如果插入价格为负数或折扣超过 100 的数据,数据库会报错。
这种约束可以有效防止明显无效的数据进入系统。

AUTO_INCREMENT 是 MySQL 特有的自增机制,用于自动生成唯一的数字标识,常用于主键字段。

CREATE TABLE log (
id INT PRIMARY KEY AUTO_INCREMENT,
message VARCHAR(255)
);

每插入一条记录,数据库会自动为 id 分配递增值,无需人工干预。
它简化了主键生成逻辑,但在分布式系统中更推荐使用雪花算法(Snowflake)或 UUID。

虽然严格来说不属于“约束”类型,但它们能通过限定字段取值来起到约束作用。

CREATE TABLE task (
id INT PRIMARY KEY,
status ENUM('todo', 'doing', 'done') DEFAULT 'todo'
);

这类约束在字段取值范围有限且固定时非常实用,比如订单状态、性别、审批结果等。

在复杂系统中,约束往往不是单独存在的。例如:

CREATE TABLE enrollment (
student_id INT,
course_id INT,
enroll_date DATE,
PRIMARY KEY (student_id, course_id)
);

这就是复合主键约束,它表示同一个学生不能重复选同一门课程。
设计数据库时,我们需要在性能、灵活性与数据安全之间取得平衡——
过多的约束会降低写入性能,而过少的约束则可能造成数据污染。
因此,在核心数据表(如用户、资金、合同等)中应严格使用约束;
在高频写入的临时表、日志表中则可以适当放宽。

约束会在每一次写操作上增加校验工作量。插入、更新、删除并不是简单的“写块到磁盘”,而是要经过解析、检查、索引维护、行级锁管理、二级索引回表、二进制日志写入等步骤。约束在这些环节中扮演不同角色,带来不同的成本与收益。

这两类约束的“校验成本”几乎可以忽略,但收益很大:减少 NULL 语义分支,帮助优化器用上等值条件,避免 IS NULL/IS NOT NULL 带来的额外路径判断。DEFAULT CURRENT_TIMESTAMPDEFAULT 0 还能减少应用层赋值逻辑,降低序列化开销。

应当广泛使用

唯一性检查一定伴随索引维护,所以它的开销主要是维护那棵 B+Tree(插入/更新时的“插桩”)以及冲突检测(同一键值的并发写入会互相等待)。好处是换来查询侧的巨大收益:等值查找走唯一索引、覆盖索引,计划稳定且代价低。

对读多写少、强一致要求的关键维度(如账号、合同号、业务单号)务必上 UNIQUE/PK;对偏向大量写入且可容忍偶发重复的场景(如埋点日志)则不必。

这是最容易“背锅”的约束。外键每次插入/更新子表键时都要去父表验证存在性,删除父表记录要验证子表不存在引用或执行级联。验证过程如果缺少合适索引,会退化为表扫描;就算有索引,也会带来额外的锁与往返,在高并发下容易形成锁等待与死锁回滚。但外键能从根上消灭脏引用

交易性强、跨表强耦合的数据(资金、合规、审计链)建议保留外键;高并发 OLTP 的热点明细表,常用“逻辑外键”(应用层校验 + 子表外键列建索引但不声明 FK)来换性能与可用性。

MySQL 8 才开始真正执行 CHECK。大多数数值/范围判断的 CPU 开销很小,但复杂表达式正则校验会有感知的 CPU 消耗。多维度业务规则建议下沉到触发器/应用层,不要把 CHECK 当脚本语言用。

小而美的 CHECK(如非负、百分比范围)值得加;复杂规则不要塞进 CHECK

4.5. 自增(AUTO_INCREMENT)与分布式主键

Section titled “4.5. 自增(AUTO_INCREMENT)与分布式主键”

自增主键本身不是约束,但和主键/唯一索引强绑定。自增的好处是写入顺序性好,B+Tree 分裂少;弊端是热点页竞争。分布式主键(Snowflake/UUIDv7)能缓解热点,但会让写入更分散、页分裂增加,读写都有更多随机 I/O。

单库单表且写压不极端时优先自增;多库多主写入或需要全局去重时用分布式主键并配合批量写入

5. 外键与唯一约束的“隐藏成本”

Section titled “5. 外键与唯一约束的“隐藏成本””

在实际落地时最容易踩到的是锁扩大与死锁。约束的校验过程不是纯 CPU,它需要访问索引页和相关行,InnoDB 会据此加锁。

外键典型问题

子表插入需要到父表做存在性检查;父表删除如果 ON DELETE RESTRICT 会被子表引用阻断;ON DELETE CASCADE 会批量删除子表行,容易产生长事务,进而扩大间隙锁范围,放大冲突。两个会话如果同时在父表/子表交叉操作,很容易出现死锁回滚。工程对策有三条:

  1. 确保父表被引用列有索引
  2. 固定 DML 顺序(先父后子、先插父再插子、删除时先删子再删父);
  3. 对于超高并发热点,改逻辑外键 + 事务内显式校验,并在子表外键列上保留普通索引以支撑删除/清理。

唯一约束下的写热点

同一唯一键的并发写会在该键上串行化,这对“去重插入”(如短信验证码、一次性令牌)是好事,但对“高频幂等写”是坏事。常见缓解有:

  1. 把“唯一维度”替换为更细颗粒(减少冲突概率);
  2. 使用插入忽略语义(INSERT IGNORE / ON DUPLICATE KEY UPDATE)降低应用回滚风暴;
  3. 对“幂等写”放到队列批量合并

6. 生产环境如何“加约束不翻车”

Section titled “6. 生产环境如何“加约束不翻车””

真实库里一定存在历史脏数据,一旦直接 ALTER TABLE … ADD CONSTRAINT,校验阶段可能卡住数小时甚至直接失败。正确姿势是“先治理,再上锁”。

  1. 离线排查与回填
    用校验 SQL 找出现存违规记录,修复后再进行下一步。

    -- 为即将加的 UNIQUE(email) 预演:找重复
    SELECT email, COUNT(*) c FROM user GROUP BY email HAVING c > 1;
    -- 为即将加的外键预演:找孤儿
    SELECT o.customer_id
    FROM orders o
    LEFT JOIN customer c ON c.id = o.customer_id
    WHERE c.id IS NULL;
  2. 加索引先于加约束
    对外键/唯一键列先创建合适索引,降低后续校验与运行期开销。

  3. 灰度与可回退
    先在从库或影子库校验 ALTER 时长与风险;必要时使用在线 DDL(ALGORITHM=INPLACE, LOCK=NONE 能用就用),极端情况上 pt-online-schema-change

  4. 监控与告警前置
    上约束同时加上锁等待、死锁、回滚计数的可观测性,触发异常可快速回滚方案。

  5. 应用配合
    部署窗口内把涉及表的核心写流量做降速分批;在应用侧把约束异常转译为用户可理解的业务错误,避免 5xx 风暴。

  • 用“表达式唯一索引”实现“条件唯一”
    MySQL 8 支持函数索引。要实现“只对 active=1 的记录在 email 上唯一”,可以这样写(避免对历史数据施加不必要的唯一性):
CREATE UNIQUE INDEX uq_user_active_email
ON user ((CASE WHEN active = 1 THEN email ELSE NULL END));

这比“全表 UNIQUE(email)”更灵活,更贴近业务约束。

  • 用“生成列 + 索引”控制规则复杂度
    当 CHECK 变复杂时,把规则沉到生成列,再给这个列加 CHECK/索引,让运行期校验与执行计划更可控:
ALTER TABLE product
ADD COLUMN price_ok TINYINT AS (price >= 0 AND price <= 999999) STORED,
ADD CONSTRAINT chk_price_ok CHECK (price_ok = 1);
  • 避免外键但保留删除能力
    即便不用 FK,也要在子表外键列上建普通索引,使“删除父表前查是否被引用”这步变成快速等值查询,同时保留批次清理能力。

  • 幂等与去重的写法
    对需要“唯一一次”的写入,使用

INSERT INTO t(pk, col, ...)
VALUES (?, ?, ...)
ON DUPLICATE KEY UPDATE col = VALUES(col);

把“冲突→异常回滚”变成“冲突→更新”,减少回滚与重试风暴。

加约束从来都不是“要不要”的问题,而是“加在哪里、加到什么程度、配合什么执行策略”。
主键/唯一/非空/默认/小而美的 CHECK 当作工程底线;对外键按业务强度与并发强度分级取舍;对复杂规则用生成列与函数索引做“降解”;所有变更都走治理→演练→灰度→上线的流程。这样做,既能把数据守住,又不会把写入性能耗尽。