作为常年跟SQL打交道的人,我翻看自己的笔记时发现“约束”这一章被画满了记号。很多初学者觉得约束不过是建表时顺手写的几个单词,实际上一旦数据量上来、业务逻辑变复杂,约束设计得好不好,直接决定你是优雅地维护数据,还是天天在深夜补数据、删重复记录、对账对到怀疑人生。这篇笔记我就把自己在实际项目中关于SQL约束的思考、踩坑和调试经验完整梳理一遍,从最基础的概念到容易翻车的细节,再到实用的运维查询,尽量写得让你能直接照着用。
先给个整体定位:约束是数据库保证数据完整性、一致性的第一道防线,也是在应用层代码之外最便宜、最可靠的守门员。它能帮我们挡掉重复数据、非法值、悬空引用等大多数脏数据问题。适合刚学完增删改查想进阶的开发者、正在设计表结构的后端工程师,以及那些被线上脏数据折磨到想重构的运维同学参考。
1. 约束到底是干什么的:数据完整性的四道防线
聊约束之前得先统一认知:数据库里的脏数据不是靠运气避免的,是靠机制。约束就是那套机制,它的本质是“规则前置”——在你往表里写入数据之前,数据库先帮你把不符合规则的数据拦下来。如果把一张表比作一个小区的门禁系统,约束就是每一道门的安保规则:你是谁、能进哪个区域、随身携带的东西是否符合规定,全部要过检。
我在实际项目里最深的体会是:约束不只是给数据库看的,更是给团队所有人看的“契约文档”。你写下一个NOT NULL,等于告诉所有后来者:这个字段必须有值,业务逻辑里不允许空着;你写下一个UNIQUE,等于声明:这里不允许重复,任何试图插入重复值的操作都是非法的。这种契约如果靠开发者在应用层各自判断,很容易漏,而且口径不统一。数据库层面的约束是集中式的强制规则,谁来了都得守。
常见的约束体系可以分成四类,对应四类最典型的数据完整性问题:
第一类是非空约束,解决的是“字段缺失”的问题。比如用户表的手机号,订单表的订单号,这些字段一旦为空,下游统计、关联查询全都会出乱子。空值在SQL里是个很特殊的存在,它不等于0,也不等于空字符串,它表示“未知”。未知值参与计算时会把结果也变成未知,经典的三值逻辑让很多新手栽跟头。非空约束就是直接从源头禁止这种不确定性进入核心字段。
第二类是唯一约束,解决的是“数据重复”的问题。用户身份证号、商品条码、流水单号,这些业务上必须唯一的字段,光靠应用层查询再插入是防不住并发的——两个请求同时判断“不存在”,然后同时插入,就重复了。唯一约束让数据库在索引层面直接拒绝重复键,这才是真正可靠的兜底。
第三类是主键约束,它其实是“非空+唯一”的组合,但它还有另外一层更重要的使命:确立记录的身份标识。主键是每一行数据的身份证号,它不仅能防止重复,还是外键引用的锚点。没有主键的表就像一个没有门牌号的房间,别人想引用你都找不到坐标。
第四类是外键约束和检查约束,它们守护的是“引用完整性”和“域完整性”。外键保证子表里的引用不会指向不存在的父记录,检查约束保证字段取值落在一个合理的范围内,比如年龄不能为负数、状态码必须在一个枚举集合里。这两类约束是业务规则最直接的数据库表达。
想清楚这四道防线,你就明白约束设计不是写代码,而是在画业务边界。每一张表、每一个字段,都应该问一句:这里的合法数据是什么?边界在哪里?边界全部定清楚,表结构才谈得上稳定。
2. 六大约束逐一拆解:语法、场景与设计心法
约束的语法不难,难的是知道什么时候该用哪一个、怎么用才不给自己挖坑。我按实际项目中使用频率从高到低,把六大约束逐个过一遍,每个都会带上具体场景、写法和容易忽视的细节。
2.1 非空约束(NOT NULL):最便宜的数据质量保险
写法很简单,建表时字段后面跟NOT NULL,或者用ALTER TABLE来修改:
CREATE TABLE users ( id INT NOT NULL, nickname VARCHAR(50) NOT NULL, phone VARCHAR(20) -- 允许为空 ); ALTER TABLE users ALTER COLUMN nickname VARCHAR(50) NOT NULL;这里有个值得注意的设计取舍:什么样的字段才应该设为非空?我的原则是“凡是在业务逻辑里被当作关键条件、展示主信息、参与计算或关联的字段,都必须非空”。比如订单表的order_no、支付表的transaction_id、用户表的account,这些字段空了整行数据就没有意义。
但也有故意留空的场景。比如用户表的phone字段,如果业务允许用户跳过绑定手机号,那它就不该是非空。很多人一上来把所有字段都设为非空,结果插入数据时处处报错,最后不得不填一堆占位符,反而制造了另一种脏数据。我的经验是:非空约束要跟着“业务必填项”走,而不是跟着“字段存在感”走。
还有一个小坑:NOT NULL和默认值经常搭配使用。如果一个字段既要必填,又要简化插入逻辑,就可以给它设一个DEFAULT,比如状态字段默认0,创建时间默认CURRENT_TIMESTAMP。这样保证了非空,又不用每次插入都显式赋值。
2.2 唯一约束(UNIQUE):并发防重的唯一可靠手段
唯一约束在业务上的常用场景包括身份证号、邮箱、手机号、订单号、商品编码等。写法有两种,列级和表级:
CREATE TABLE employees ( id INT PRIMARY KEY, id_card VARCHAR(18) UNIQUE, -- 列级唯一约束 email VARCHAR(100), CONSTRAINT uk_emp_email UNIQUE (email) -- 表级唯一约束,可命名 );如果是多个字段组合起来才要求唯一,比如“同一个用户对同一商品只能有一个购物车记录”,就需要表级组合唯一约束:
CREATE TABLE cart ( user_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL, CONSTRAINT uk_cart_user_product UNIQUE (user_id, product_id) );这里要特别提醒一个和NULL相关的经典问题:在多数关系型数据库中,唯一约束允许出现多个NULL值。比如email设了唯一约束,但允许用户不填邮箱,那么两行的email都为NULL时不会被当作重复。这个行为初看很反直觉,但仔细一想也合理——NULL表示未知,两个“未知”到底是不是同一个“未知”?数据库选择放行,是为了不误伤合法数据。
如果你需要的是“邮箱不管填没填都不能重复”,那就得另想办法,比如用生成列加上过滤索引,或者在应用层做判断。大多数业务场景其实接受“NULL可以多条”这个行为,所以我在设计时一般不折腾,但只要业务上明确要求“要么为空要么唯一”,就要提前意识到这个细节。
唯一约束的性能影响也值得说一句:它是通过唯一索引实现的,所以插入时会增加索引维护的开销。对高频插入的表,索引越多写入越慢,这是必然的权衡。但优先保证数据正确性更重要,性能优化可以通过批量插入、分区表等手段来对冲。
还有一个容易被忽略的点:唯一约束要起名字,尤其是表级约束。起名规则我一般统一采用uk_表名_字段名,例如uk_cart_user_product。别小看命名的事,后期排查问题、写迁移脚本时,没有名字的约束在数据库系统表里会生成一堆随机ID,处理起来非常痛苦。
2.3 主键约束(PRIMARY KEY):表设计的定海神针
主键约束是唯一约束加上非空约束的组合,但它承担了更重要的角色:记录的唯一身份标识。每张表都应该有主键,这话我说了很多遍,但还是见到不少表设计成“无主键的堆表”,后果是数据重复时连精确定位删除都做不到,只能靠ROW_NUMBER()窗口函数去重才能收拾残局。
主键的写法很简单:
CREATE TABLE orders ( id BIGINT PRIMARY KEY, order_no VARCHAR(32) NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 也可以在表级声明复合主键 CREATE TABLE order_items ( order_id BIGINT NOT NULL, line_no INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL, PRIMARY KEY (order_id, line_no) );主键选择上,我的建议分两种情况。一种是单表单机场景,优先用自增整数或者雪花ID充当代理主键。自增整数简单高效,索引紧凑,写入性能好;分布式场景则用雪花ID之类的应用生成ID,避免自增冲突。另一种是业务上确实存在天然唯一标示的组合,比如订单明细里的(order_id, line_no),可以设计成复合主键,因为这组字段本身就是业务上的记录身份。
关于主键的生成策略,《阿里巴巴Java开发手册》里有一句话我很赞同:在数据表结构设计中,主键字段最好与业务字段解耦。什么意思?就是不要拿手机号、身份证号这些真实业务标识做主键。因为业务标识是会变的——手机号可能换,身份证号也可能因为录入错误被修正。一旦主键值要更新,所有引用它的外键都要跟着改,那就是一场灾难。所以我的铁律是:主键用和业务无关的代理键,业务唯一性交给唯一约束去保证。
复合主键也不是完全没有代价。它会让所有引用该表的外键都变成复合外键,关联查询时条件更繁琐,索引也会更大。所以能用单列代理主键解决的就别用复合主键,除非复合键确实就是记录不可再分的身份单元。
2.4 外键约束(FOREIGN KEY):关系型数据库的灵魂
外键是关系型数据库区别于一般键值存储的关键能力。它的作用是让子表中的字段值必须精确匹配父表中的主键或唯一键值,从而保证引用完整性。举个最常见的例子:订单明细表里的product_id必须能在商品表里找到,否则这张订单里的商品是“幽灵商品”,后续库存、账单全对不上。
建表时外键的写法:
CREATE TABLE order_items ( id BIGINT PRIMARY KEY, order_id BIGINT NOT NULL, product_id BIGINT NOT NULL, quantity INT NOT NULL, CONSTRAINT fk_oi_order FOREIGN KEY (order_id) REFERENCES orders(id), CONSTRAINT fk_oi_product FOREIGN KEY (product_id) REFERENCES products(id) );外键约束最考验设计能力的部分是删除时的行为策略,有三种常用选项:
RESTRICT/NO ACTION:父表记录被删除时,如果子表还有引用,则删除失败。这是最保守的方式,杜绝孤儿数据。CASCADE:父表记录删除时,子表相关记录一并删除。适合订单和订单明细这种整体生命周期绑定的关系。SET NULL:父表记录删除时,子表引用字段置空。适合“商品被删除,但订单里的历史信息需要保留”的场景。
我自己的实践经验是:是否真的要在数据库层面启用外键,要看团队规模和系统复杂度。在互联网高并发场景下,很多团队会选择在应用层维护“伪外键”——也就是表中存着product_id,但不在数据库层面加FOREIGN KEY。原因有几个:高频写入时外键检查会带来额外的锁和开销;分库分表后外键约束在分布式环境下很难全局生效;业务逻辑里已经做了完整性校验,再把校验放在数据库里显得重复。
但是,如果系统并发量并不大、数据准确性要求特别高,比如财务、订单、医疗、政务类系统,我强烈建议启用外键。它会实实在在阻止一大批数据错误,省掉你无数排查脏数据的夜晚。有时候我跟人讨论到这儿,对方会说“外键影响性能所以我们不用”,但等线上出现了订单关联不存在的商品的脏数据,再回头看,那点性能开销可能远小于数据修复的人力成本。
如果你因为特殊原因没有启用外键,那我至少建议你建好索引。外键字段如果没有索引,关联查询时父表子表来回扫描,性能会差得离谱。即使不加外键约束,order_id、product_id上也要建索引,这个是底线。
2.5 检查约束(CHECK):把业务规则搬进数据库
检查约束用来限定字段的取值范围,比应用层if判断更防漏。比如年龄不能为负、折扣不能大于100%、订单状态只能取指定枚举等。写法:
CREATE TABLE employees ( id INT PRIMARY KEY, age INT NOT NULL, salary DECIMAL(10,2) NOT NULL, status VARCHAR(20) NOT NULL, CONSTRAINT chk_age_range CHECK (age >= 18 AND age <= 65), CONSTRAINT chk_salary_positive CHECK (salary >= 0), CONSTRAINT chk_status_valid CHECK (status IN ('ACTIVE', 'INACTIVE', 'LEFT')) );检查约束的最大价值在于:它把业务规则下沉到了数据库,任何入口的非法数据都进不来——不管是管理后台、报表导入,还是直接连数据库跑的脚本。这就避免了“历史遗留脏数据”的经典问题:老系统没有校验,后来接的新系统做了一堆防御,还是挡不住脏数据通过旧入口流入。
我遇到过的一个典型场景:某系统的订单金额字段,业务上要求必须大于零,但旧代码里没有校验,结果半年后报表里出现了一条金额为-50的异常订单。后来排查发现是一个批处理脚本少写了负号判断。加上CHECK (amount > 0)之后,这种错误从源头就被拦截了。所以我说,检查约束是“比你更早发现bug的哨兵”。
要注意的是,检查约束在MySQL 8.0.16之前的版本中会被解析但实际不生效,这一点让很多人踩过坑。如果你在用MySQL,要先确认版本,别以为加了约束就万事大吉。SQL Server、PostgreSQL、Oracle的检查约束都是正常强制的。
还有一个灵活用法:检查约束可以用来实现“逻辑上互斥”的校验。比如“如果订单是线下支付,则必须有支付时间,不能为空;如果是线上支付,则必须填写支付平台流水号”,这种复杂性完全可以用两三个CHECK组合出来,把应用层复杂的if判断变成数据库的声明式规则。
2.6 默认值约束(DEFAULT):让字段自己照顾自己
严格来说,默认值不算标准的“约束”分类,但它跟约束的配合非常紧密,能减少插入时的遗漏问题,也能配合非空约束控制字段合法性。我常常把它当成“兜底约束”来看。常见的默认值写法:
CREATE TABLE users ( id INT PRIMARY KEY, nickname VARCHAR(50) NOT NULL, points INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP );默认字段最适合放这些值:计数类字段(积分、次数、库存初始值)、状态类字段(启用/禁用)、时间类字段(创建时间、更新时间)。有了默认值,INSERT语句可以少写一半字段,代码也更简洁。
这里有一个非常实用的小技巧,尤其适用于SQL Server场景:用NEWID()或NEWSEQUENTIALID()作为uniqueidentifier类型字段的默认值,生成GUID主键。很多人在热词里搜“sql 默认值guid”,实际上就是想解决分布式系统中自增主键冲突的问题。在SQL Server里可以这样建:
CREATE TABLE tokens ( id UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(), token_code VARCHAR(64) NOT NULL, created_at DATETIME DEFAULT GETDATE() );NEWID()生成的GUID是完全随机的,适合作为令牌、票据这类不需要顺序的标识。但如果你把它当主键,随机GUID会让索引页频繁分裂,写入性能变差。这时考虑NEWSEQUENTIALID(),它在Windows系统上保证新生成的GUID比之前的更大,顺序插入对索引更友好。不过注意,NEWSEQUENTIALID()只能用作默认值,不能在普通查询里直接调用。
默认值的另一个坑是:默认值只在显式不给字段赋值时生效。如果你明确插入了NULL,那默认值不会生效,字段仍然是NULL。所以要让“不填时自动用默认值且不出现NULL”,还得配合NOT NULL一起使用。尤其对于points INT NOT NULL DEFAULT 0,如果插入语句里写了points = NULL,照样报错或存入NULL,这取决于列的约束,这个细节最容易让人困惑。
3. 约束的保管与运维:从系统表查元数据到日常巡检
约束建好了,不代表一劳永逸。随着业务迭代,我们需要时不时查看表里有哪些约束、约束是否生效、某个字段是否被约束保护。不同数据库的查询语法各不相同,我把自己常用的几套查询整理出来,都是生产环境验证过的。
3.1 SQL Server:用系统视图管理约束
SQL Server里,约束的元数据主要藏在sys.objects、sys.columns、sys.key_constraints、sys.foreign_keys、sys.check_constraints这些视图里。最实用的一个查询是把某张表上的所有约束汇总出来:
SELECT t.name AS table_name, o.name AS constraint_name, o.type_desc AS constraint_type FROM sys.objects o JOIN sys.tables t ON o.parent_object_id = t.object_id WHERE t.name = 'orders' AND o.type IN ('PK', 'UQ', 'F', 'C', 'D') ORDER BY o.type;这里type字段的取值含义分别是:PK主键,UQ唯一约束,F外键,C检查约束,D默认约束。日常巡检时我会再聚合一下,看每张表的约束数量,数量明显偏少的表就要警惕是不是漏加约束了。
如果想看某个外键具体关联到哪张表的哪个字段,可以用sys.foreign_key_columns关联:
SELECT fk.name AS fk_name, tp.name AS parent_table, cp.name AS parent_column, tr.name AS referenced_table, cr.name AS referenced_column FROM sys.foreign_keys fk JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id JOIN sys.tables tp ON fkc.parent_object_id = tp.object_id JOIN sys.columns cp ON fkc.parent_object_id = cp.object_id AND fkc.parent_column_id = cp.column_id JOIN sys.tables tr ON fkc.referenced_object_id = tr.object_id JOIN sys.columns cr ON fkc.referenced_object_id = cr.object_id AND fkc.referenced_column_id = cr.column_id WHERE fk.name = 'fk_oi_order';在SQL Server里删除约束也需要注意,比如删除默认约束要先拿到约束的名字:
ALTER TABLE users DROP CONSTRAINT DF__users__points__1234; -- 名字从sys.objects中查新增约束的标准格式是ALTER TABLE ... ADD CONSTRAINT ...。这里再提一句,SQL Server对默认约束的命名很“随意”,如果不手动起名,系统会自动生成类似DF__users__points__1234这种带数字后缀的名字,迁移脚本里引用这种名字非常脆弱,所以我建议在创建表时,所有的约束都显式命名,统一前缀规范:主键pk_、外键fk_、唯一uk_、检查chk_、默认df_。
3.2 MySQL:约束查询和版本差异
MySQL里约束的信息可以查询information_schema.table_constraints:
SELECT table_name, constraint_name, constraint_type FROM information_schema.table_constraints WHERE table_schema = 'your_db' AND table_name = 'orders';通过information_schema.key_column_usage还能进一步拿到约束对应的字段:
SELECT constraint_name, column_name, referenced_table_name, referenced_column_name FROM information_schema.key_column_usage WHERE table_schema = 'your_db' AND table_name = 'order_items';这里要特别再次强调MySQL版本差异:8.0.16之前,CHECK约束是“只读不检查”的,你在建表时写了CHECK它也不报错,数据也不会被拦截。很多老项目的MySQL 5.7表面上跑得好好的,一但升级到8.0,突然开始报检查约束的错误,就是因为新版本的检查约束真正开始生效了。这个坑在升级数据库版本时尤其常见,需要提前把历史数据全部梳理一遍,确保都通过了约束规则再升级。
3.3 PostgreSQL:约束信息一把梭
PostgreSQL的pg_constraint表提供了非常完整的约束信息:
SELECT conname AS constraint_name, contype AS constraint_type, pg_get_constraintdef(oid) AS constraint_definition FROM pg_constraint WHERE conrelid = 'orders'::regclass;contype取值包括:p主键、u唯一、f外键、c检查、x排他。最贴心的是pg_get_constraintdef可以直接把约束的定义语句解析出来,比如CHECK ((salary >= 0)),一眼就能看清约束的规则,省得再去翻建表脚本。我在排查问题时经常开一个这样的查询,把所有外键的定义原样打出来,辅助判断删除顺序,避免因为引用关系导致删表失败。
3.4 约束运维的日常巡检清单
我给自己列过一个“约束健康检查”清单,每次接手一个新数据库都会先跑一遍:
- 所有核心业务表是否都有主键?没有主键的优先补上。
- 关键业务唯一字段(订单号、身份证号、邮箱)是否都加了唯一约束?
- 所有外键字段是否都有索引?尤其那些未启用外键约束的“伪外键”字段。
- 是否存在为空但不该为空的字段?通过
SELECT COUNT(*) FROM 表 WHERE 字段 IS NULL抽样检查。 CHECK约束是否真的在生效?在测试环境插入一条非法数据验证。- 约束命名是否规范?随机名字的约束在迁移脚本里是隐患。
这些巡检不一定天天做,但每次大版本升级、表结构变更、重构前,我都会对照这个清单过一遍。很多线上故障的根因,其实在建表那一刻就埋下了,巡检只是把隐患提前挖出来。
4. 约束的常见报错与排查实战
约束本身不复杂,但实际使用中因为表结构、数据状态、语法差异,报错信息五花八门。我把这几年遇到的高频问题整理成了一个速查表,附带排查思路,方便你直接对照处理。
| 场景 | 典型报错 | 核心原因 | 解决思路 |
|---|---|---|---|
| 插入重复值 | Duplicate entry 'xxx' for key 'uk_...' | 违反唯一约束 | 先查是否已有相同数据,确认业务上是否可以存在,如果允许则去掉唯一约束,不允许则需要清洗已有数据 |
| 插入空值 | Column 'xxx' cannot be null | 违反非空约束 | 确认业务上该字段是否真的必填,若是则补齐值再插入;若否则考虑取消非空约束或加默认值 |
| 删除父表记录 | Cannot delete or update a parent row: a foreign key constraint fails | 子表仍有引用数据 | 根据业务决定是CASCADE删除子表、SET NULL,还是先清理子表再删除 |
| 更新主键 | Cannot change column 'id': used in a foreign key constraint | 主键被外键引用 | 尽量避免改主键;确需修改时先更新子表引用,或临时禁用外键检查(生产库慎用) |
| 检查约束不生效 | 数据插入成功但应失败 | MySQL版本低于8.0.16 | 检查版本,升级或改用触发器实现校验 |
| 默认值没生效 | 插入NULL后字段仍为NULL | 默认值只对未指定字段生效,显式NULL会覆盖默认值 | 插入时要么不写该字段,要么判定业务上是否可为空 |
| SQL Server安全连接报错 | driver cannot ... SSL... | 不是约束问题,是连接配置问题 | 这类问题要排查JDBC/驱动和服务器加密配置,与表结构约束无关,别混淆 |
上面表格里的前六项都和约束直接相关,最后一项我在热词榜单里看到了,顺带提一句:报错里带“SSL”字样时,先检查客户端连接字符串、驱动版本和数据库实例是否强制加密,不要跑到表结构里找原因。它和约束完全是两码事。
再分享一个线上事故级的实际排查案例。上个月我接到一个工单,说报表里出现了重复的订单记录,但订单表明明建了唯一索引,理论上不可能重复。我第一反应是索引没有真正生效。后来查了表结构才发现:唯一约束建在order_no上,但业务上真正应该唯一的是(order_no, tenant_id)——因为系统多租户,每个租户的订单号可以相同。当初建表时只看到了单租户模式,后来加了租户维度就忘了更新约束。结果就是两个租户各生成一张“单号一样”的订单,报表一跨租户聚合就炸了。
这个案例说明两个问题:一是约束设计必须紧跟业务模型的演进,表结构变了,约束定义也要同步评审;二是排查重复类问题时,要把“唯一键字段组合”从头到尾捋一遍,不要默认现有约束就是正确的。后来我把约束改成复合唯一(tenant_id, order_no),又写了一次数据清洗脚本把历史脏数据去重,才算彻底解决。
还有一次,同事急着上线,往一张已有数据的表上加NOT NULL约束,结果ALTER TABLE一直卡住。原因是表里有几千条记录该字段本来就是NULL,加非空约束之前需要先处理历史数据。正确顺序应该是:先写UPDATE把NULL改成合法的默认值,再执行ALTER TABLE加约束。这个顺序问题看着简单,但生产环境执行时容易因为数据量太大导致事务超时,我建议分批更新,比如每1万行提交一次。
排查约束相关问题的通用方法论其实就三条:
- 先看数据再看约束。报错信息里带着字段名和约束名,先把对应数据查出来,理解它为什么会被拦。
- 用元数据查询确认约束真的存在且定义正确。我见过有人在测试环境改了约束,生产环境没同步,结果行为不一致。
- 改约束前先备份或者用事务包裹。尤其是对已有大表的
ALTER TABLE,先在测试库演练一遍,预估耗时。
这几条听着朴素,但配合上面的速查表,能解决绝大多数约束纠纷。
5. 约束设计的一些进阶经验和反模式
单个约束的使用门槛不高,难的是在一整套业务里面把约束设计得合理、可维护、不反弹。我总结了一些进阶心得和反面教训,这些用真金白银换来的经验,比语法本身更值得留个心眼。
第一,约束的“度”要合适,不能过松也不能过紧。过松,比如外键不加、状态值不设检查约束,脏数据就源源不断;过紧,比如所有字段全部非空、所有关系全部CASCADE删除,会导致业务扩展极其痛苦,想加一个非核心字段都要动表结构。我的原则是“核心链路严,辅助字段松”。订单、支付、库存这类核心链路上的表,约束要齐全、规则要严格;日志、配置、报表临时表,则尽量少加约束,方便快速写入和迭代。
第二,外键CASCADE删除要慎用。用户在界面上删除一个订单,你希望订单明细一起删吗?有些场景确实希望,比如购物车的勾选项。但有些场景不希望,比如订单和发货单的关系——删订单不代表发货记录要删,因为财务可能还要对账。如果设置了CASCADE,一旦误删或者批量清理,影响面会被无限放大。我更推荐在关联紧密、生命周期完全一致的关系上使用CASCADE,比如主订单与订单明细、主表与附件表;而对有历史留痕需求的关系,用RESTRICT或SET NULL更安全。
第三,约束名命名规范不是小事。我在前面反复强调过命名,因为现实中吃过亏。某次迁移脚本要删除一个默认约束,但开发环境里系统自动生成的约束名和预发环境不一样,脚本直接执行报错。从那之后,我要求所有新表结构里的约束一律显式命名,并且把命名规则写进团队的建表规范里。
第四,警惕“我用应用层校验,所以不需要数据库约束”的思路。应用层校验确实重要,但它是分散的、容易遗漏的。比如某个后台管理工具、某个数据导入脚本、某个临时修复SQL,都可能绕过业务系统的校验逻辑直接写库。只要绕过一次,脏数据就进来了。数据库约束恰恰是最后一道不管谁都得遵守的防线。即使在应用层做了校验,数据库层面我依然建议加上关键约束,这叫“双层保险”。
第五,约束变更要走完整的审批与验证流程。改约束不是改注释,它会影响所有写入路径。在大型表上加唯一约束、加非空约束,都需要评估锁表时间。如果在线上业务高峰期直接执行ALTER TABLE ... ADD CONSTRAINT,很可能把整个写入链路拖垮。我的做法是把这类变更放到低峰期,并且先在一台只读实例上模拟执行,看耗时和锁情况再决定方案。
这些经验总结成一句话:约束设计是数据架构的一部分,不是建表时的附属动作。它应该在需求分析阶段就参与进来,和字段、索引、分区一起被认真设计。凡是事后补救的约束,多多少少都要付出数据清洗或者流程变更的代价。
前面聊了这么多,其实就是在说一件事:约束是我们给数据世界立的规矩,规矩立得好,后面省下的是无数个通宵排查数据的夜晚。我个人在实际操作中的体会是,每次新建一张业务表,我都会花十分钟把每个字段从头到尾问一遍:这个值能为空吗?能重复吗?取值范围是什么?和别的表是什么关系?这十分钟的思考,往往能在未来省下十个小时的返工。如果你也想把数据库这关把得更牢,不妨从把你手头最关键的几张表打开,把约束情况完整地摸一遍底开始。别等到脏数据找上门,才想起约束这个老朋友。