数据库约束
在关系型数据库中,约束(Constraint) 是一种用于保证数据完整性与一致性的机制。它的存在就像数据库的“法律条文”,规定了表中数据应当遵守的规则,从而防止无效、错误或矛盾的数据被插入或修改。例如,不能让年龄字段出现负数、不能让主键重复、不能出现没有关联到主表的外键记录等。
约束的主要目标是让数据库自己去维护数据的正确性,而不是完全依赖程序代码来验证。合理地设置约束,不仅能提高数据的质量,还能提升查询性能,减少逻辑错误。
1. 约束的作用与分类
Section titled “1. 约束的作用与分类”从整体上看,数据库约束可分为两大类:
-
列级约束(Column Constraint):只作用于单个字段,例如
NOT NULL、DEFAULT、CHECK等。 -
表级约束(Table Constraint):作用于整个表或多个字段的组合,例如
PRIMARY KEY、FOREIGN KEY、UNIQUE等。
这些约束共同构建了数据库的“防线”,确保数据在录入、修改、删除的每个环节都符合法规。
2. 常见约束类型
Section titled “2. 常见约束类型”2.1. NOT NULL(非空约束)
Section titled “2.1. NOT NULL(非空约束)”NOT NULL 用于保证某个字段的值不能为空。
在实际业务中,这通常用于表示“必须填写”的字段,比如姓名、身份证号、主键ID等。
CREATE TABLE person ( id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, age INT);如果尝试插入 name 为空的数据,数据库会直接拒绝。
这种约束的意义在于——数据的存在本身有业务价值,不能缺省。
2.2. DEFAULT(默认值约束)
Section titled “2.2. DEFAULT(默认值约束)”DEFAULT 用于在插入数据时,如果该字段未显式赋值,则自动填入一个默认值。
常见于状态字段、创建时间字段等。
CREATE TABLE orders ( id INT PRIMARY KEY, status VARCHAR(10) DEFAULT 'pending', create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP);默认值可以保证业务逻辑的连贯性,避免出现 NULL 导致的计算或判断异常。
2.3. PRIMARY KEY(主键约束)
Section titled “2.3. PRIMARY KEY(主键约束)”主键(Primary Key)是唯一标识一条记录的字段或字段组合。
一个表只能有一个主键约束,但主键可以由多个列联合构成(称为复合主键)。
CREATE TABLE student ( student_id INT PRIMARY KEY, name VARCHAR(50) NOT NULL, class_id INT);主键自动具备唯一性(UNIQUE) 和 非空(NOT NULL) 的特征。
主键的选择应该符合“稳定且唯一”的原则,如身份证号、UUID、业务ID等。
2.4. UNIQUE(唯一约束)
Section titled “2.4. UNIQUE(唯一约束)”UNIQUE 确保字段中的值不重复,但允许出现 NULL。
它与主键的区别在于:一个表中可以有多个唯一约束,但只能有一个主键。
CREATE TABLE user_account ( user_id INT PRIMARY KEY, username VARCHAR(30) UNIQUE, email VARCHAR(50) UNIQUE);唯一约束常用于保证登录名、手机号、邮箱等字段的唯一性。
2.5. FOREIGN KEY(外键约束)
Section titled “2.5. FOREIGN KEY(外键约束)”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));有了外键约束,数据库会禁止插入一个不存在部门的员工,也不会允许删除仍被员工引用的部门。
不过在高并发系统中,外键约束有时会被逻辑约束替代(在代码层面验证),以避免影响性能。
2.6. CHECK(检查约束)
Section titled “2.6. CHECK(检查约束)”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 的数据,数据库会报错。
这种约束可以有效防止明显无效的数据进入系统。
2.7. AUTO_INCREMENT(自增约束)
Section titled “2.7. AUTO_INCREMENT(自增约束)”AUTO_INCREMENT 是 MySQL 特有的自增机制,用于自动生成唯一的数字标识,常用于主键字段。
CREATE TABLE log ( id INT PRIMARY KEY AUTO_INCREMENT, message VARCHAR(255));每插入一条记录,数据库会自动为 id 分配递增值,无需人工干预。
它简化了主键生成逻辑,但在分布式系统中更推荐使用雪花算法(Snowflake)或 UUID。
2.8. ENUM / SET(枚举约束)
Section titled “2.8. ENUM / SET(枚举约束)”虽然严格来说不属于“约束”类型,但它们能通过限定字段取值来起到约束作用。
CREATE TABLE task ( id INT PRIMARY KEY, status ENUM('todo', 'doing', 'done') DEFAULT 'todo');这类约束在字段取值范围有限且固定时非常实用,比如订单状态、性别、审批结果等。
3. 复合约束
Section titled “3. 复合约束”在复杂系统中,约束往往不是单独存在的。例如:
CREATE TABLE enrollment ( student_id INT, course_id INT, enroll_date DATE, PRIMARY KEY (student_id, course_id));这就是复合主键约束,它表示同一个学生不能重复选同一门课程。
设计数据库时,我们需要在性能、灵活性与数据安全之间取得平衡——
过多的约束会降低写入性能,而过少的约束则可能造成数据污染。
因此,在核心数据表(如用户、资金、合同等)中应严格使用约束;
在高频写入的临时表、日志表中则可以适当放宽。
4. 不同约束对性能的影响
Section titled “4. 不同约束对性能的影响”约束会在每一次写操作上增加校验工作量。插入、更新、删除并不是简单的“写块到磁盘”,而是要经过解析、检查、索引维护、行级锁管理、二级索引回表、二进制日志写入等步骤。约束在这些环节中扮演不同角色,带来不同的成本与收益。
4.1. NOT NULL & DEFAULT
Section titled “4.1. NOT NULL & DEFAULT”这两类约束的“校验成本”几乎可以忽略,但收益很大:减少 NULL 语义分支,帮助优化器用上等值条件,避免 IS NULL/IS NOT NULL 带来的额外路径判断。DEFAULT CURRENT_TIMESTAMP、DEFAULT 0 还能减少应用层赋值逻辑,降低序列化开销。
应当广泛使用。
4.2. UNIQUE 与 PRIMARY KEY
Section titled “4.2. UNIQUE 与 PRIMARY KEY”唯一性检查一定伴随索引维护,所以它的开销主要是维护那棵 B+Tree(插入/更新时的“插桩”)以及冲突检测(同一键值的并发写入会互相等待)。好处是换来查询侧的巨大收益:等值查找走唯一索引、覆盖索引,计划稳定且代价低。
对读多写少、强一致要求的关键维度(如账号、合同号、业务单号)务必上 UNIQUE/PK;对偏向大量写入且可容忍偶发重复的场景(如埋点日志)则不必。
4.3. FOREIGN KEY(外键)
Section titled “4.3. FOREIGN KEY(外键)”这是最容易“背锅”的约束。外键每次插入/更新子表键时都要去父表验证存在性,删除父表记录要验证子表不存在引用或执行级联。验证过程如果缺少合适索引,会退化为表扫描;就算有索引,也会带来额外的锁与往返,在高并发下容易形成锁等待与死锁回滚。但外键能从根上消灭脏引用。
交易性强、跨表强耦合的数据(资金、合规、审计链)建议保留外键;高并发 OLTP 的热点明细表,常用“逻辑外键”(应用层校验 + 子表外键列建索引但不声明 FK)来换性能与可用性。
4.4. CHECK
Section titled “4.4. CHECK”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 会批量删除子表行,容易产生长事务,进而扩大间隙锁范围,放大冲突。两个会话如果同时在父表/子表交叉操作,很容易出现死锁回滚。工程对策有三条:
- 确保父表被引用列有索引;
- 固定 DML 顺序(先父后子、先插父再插子、删除时先删子再删父);
- 对于超高并发热点,改逻辑外键 + 事务内显式校验,并在子表外键列上保留普通索引以支撑删除/清理。
唯一约束下的写热点
同一唯一键的并发写会在该键上串行化,这对“去重插入”(如短信验证码、一次性令牌)是好事,但对“高频幂等写”是坏事。常见缓解有:
- 把“唯一维度”替换为更细颗粒(减少冲突概率);
- 使用插入忽略语义(
INSERT IGNORE/ON DUPLICATE KEY UPDATE)降低应用回滚风暴; - 对“幂等写”放到队列或批量合并。
6. 生产环境如何“加约束不翻车”
Section titled “6. 生产环境如何“加约束不翻车””真实库里一定存在历史脏数据,一旦直接 ALTER TABLE … ADD CONSTRAINT,校验阶段可能卡住数小时甚至直接失败。正确姿势是“先治理,再上锁”。
-
离线排查与回填:
用校验 SQL 找出现存违规记录,修复后再进行下一步。-- 为即将加的 UNIQUE(email) 预演:找重复SELECT email, COUNT(*) c FROM user GROUP BY email HAVING c > 1;-- 为即将加的外键预演:找孤儿SELECT o.customer_idFROM orders oLEFT JOIN customer c ON c.id = o.customer_idWHERE c.id IS NULL; -
加索引先于加约束:
对外键/唯一键列先创建合适索引,降低后续校验与运行期开销。 -
灰度与可回退:
先在从库或影子库校验ALTER时长与风险;必要时使用在线 DDL(ALGORITHM=INPLACE, LOCK=NONE能用就用),极端情况上pt-online-schema-change。 -
监控与告警前置:
上约束同时加上锁等待、死锁、回滚计数的可观测性,触发异常可快速回滚方案。 -
应用配合:
部署窗口内把涉及表的核心写流量做降速或分批;在应用侧把约束异常转译为用户可理解的业务错误,避免 5xx 风暴。
7. 工程技巧
Section titled “7. 工程技巧”- 用“表达式唯一索引”实现“条件唯一”
MySQL 8 支持函数索引。要实现“只对 active=1 的记录在 email 上唯一”,可以这样写(避免对历史数据施加不必要的唯一性):
CREATE UNIQUE INDEX uq_user_active_emailON user ((CASE WHEN active = 1 THEN email ELSE NULL END));这比“全表 UNIQUE(email)”更灵活,更贴近业务约束。
- 用“生成列 + 索引”控制规则复杂度
当 CHECK 变复杂时,把规则沉到生成列,再给这个列加 CHECK/索引,让运行期校验与执行计划更可控:
ALTER TABLE productADD 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 当作工程底线;对外键按业务强度与并发强度分级取舍;对复杂规则用生成列与函数索引做“降解”;所有变更都走治理→演练→灰度→上线的流程。这样做,既能把数据守住,又不会把写入性能耗尽。