图 1:本文封面,每个约束都是一道闸门。
先说明属性:这是一篇历史笔试题复盘,题目出自本地历史笔试资料,与任何公司的当前招聘无关。题面大意:建一张「通讯录」表,要求编号是整型自动编号并做主键、姓名不可空、性别默认值为「男」、出生日期与联系方式可空、备注为长文本,从四个建表语句选项里挑出正确的那个。这类选择题看着是考语法记没记住,其实每个约束背后都对应一类线上脏数据。逐条拆开看。
主键:给每行一个稳定身份。
主键的职责不是「建个索引」这么轻描淡写:在 InnoDB 里表是按主键组织的聚簇索引,主键选成什么样,直接决定数据在磁盘上怎么排。两条硬约束:唯一、非空。工程上还偏好单调、短小、无业务含义——手机号、身份证号这种「天然唯一」的列看着诱人,一旦业务规则变化(换绑、注销重注册),主键就陷进业务泥潭。
自增:把「发号」的责任收进数据库。
AUTO_INCREMENT 让数据库单调分配编号,省去应用层协调。两个细节值得知道。其一,它不是分布式方案:多实例各自发号会撞,分布式下要换成号段模式或雪花算法。其二,序列不回退:插入失败、事务回滚都会消耗自增值,所以主键出现空洞是正常现象,任何「主键连续」的假设都是错的。MySQL 官方文档在 CREATE TABLE 章节把 AUTO_INCREMENT 定义为列级属性,且一张表至多一个自增列、必须建在键上。
非空:NULL 不是「空」,是「未知」。
这是最容易被低估的约束。MySQL 官方文档专门有一节讲 NULL 的坑:NULL 与任何值的比较结果都不是 TRUE,连 NULL 等于 NULL 都不成立;WHERE phone = NULL 永远查不出行,必须用 IS NULL;聚合函数里 COUNT(*) 数行数、COUNT(列) 跳过 NULL,两者混用统计口径就悄悄错位。能声明 NOT NULL 就声明——允许 NULL 应该是列的显式设计决定,而不是默认值。
默认值:把「忘了传」变成显式选择。
题面里「性别默认男」就是一个兜底:调用方没传时落库的是确定值而不是 NULL。默认值本身是业务语义,选什么要想清楚——它会在所有「没传」的场景里替你做决定。
字符集与文本类型:表级决定。
题面的「备注 text」提醒还有表级选项:字符集(CHARACTER SET)和排序规则(COLLATE)建表时定,后期再改要重写整张表;长文本用 TEXT 家族,别把大段内容塞进 CHAR。
约束怎么拦脏数据,跑一遍。
下面用一个小型表引擎模拟这五种约束的拦截行为(教学模型,复刻官方文档描述的语义),用 Node.js 22.15.0 直接运行(node constraints.js):
'use strict';
// 迷你表引擎:按列定义执行约束,看每种约束各自拦下什么脏数据。
// 列定义对照:id INT AUTO_INCREMENT PRIMARY KEY、name NOT NULL、
// gender DEFAULT '男'、email UNIQUE、bio TEXT 允许 NULL。
const schema = [
{ col: 'id', type: 'INT', autoIncrement: true, primaryKey: true },
{ col: 'name', type: 'CHAR(8)', notNull: true },
{ col: 'gender', type: 'CHAR(2)', default: '男' },
{ col: 'email', type: 'VARCHAR(64)', unique: true },
{ col: 'bio', type: 'TEXT', notNull: false },
];
class MiniTable {
constructor(schema) {
this.schema = schema;
this.rows = [];
this.seq = 0;
}
insert(input) {
const row = {};
for (const c of this.schema) {
let v = input[c.col];
if (v === undefined) {
if (c.autoIncrement) v = ++this.seq; // 自增:缺省时由数据库分配
else if (c.default !== undefined) v = c.default; // 默认值:缺省时兜底
else v = null;
}
if (v === null && c.notNull) return `拒绝:列 ${c.col} 声明了 NOT NULL,不能写入 NULL`;
if (v === null && c.primaryKey) return `拒绝:主键 ${c.col} 不能为 NULL`;
if (v !== null && c.unique && this.rows.some(r => r[c.col] === v))
return `拒绝:列 ${c.col} 声明了 UNIQUE,值 "${v}" 已存在`;
row[c.col] = v;
}
this.rows.push(row);
return `写入:${JSON.stringify(row)}`;
}
}
const t = new MiniTable(schema);
console.log(t.insert({ name: '阿黎', email: 'ali@example.com', bio: '后端' }));
console.log(t.insert({ name: '小傅', email: 'fu@example.com' })); // gender 走默认值,bio 允许 NULL
console.log(t.insert({ email: 'noname@example.com' })); // name 缺省且 NOT NULL
console.log(t.insert({ name: '阿黎二号', email: 'ali@example.com' })); // email 撞 UNIQUE
console.log(t.insert({ name: null, email: 'x@example.com' })); // 显式写 NULL 到 NOT NULL 列
console.log(`表内现有 ${t.rows.length} 行,自增序列已到 ${t.seq}`);
// 追问演示:NOT NULL 为什么重要——NULL 不参与等值比较(MySQL 官方文档:
// NULL 与任何值的比较结果都不是 TRUE,COUNT(列) 忽略 NULL)
const ages = [18, null, 25, null, 30];
console.log(`ages=[18,NULL,25,NULL,30]:COUNT(*)=${ages.length},COUNT(age)=${ages.filter(a => a !== null).length},NULL=25 恒不成立`);
实测输出:
写入:{"id":1,"name":"阿黎","gender":"男","email":"ali@example.com","bio":"后端"}
写入:{"id":2,"name":"小傅","gender":"男","email":"fu@example.com","bio":null}
拒绝:列 name 声明了 NOT NULL,不能写入 NULL
拒绝:列 email 声明了 UNIQUE,值 "ali@example.com" 已存在
拒绝:列 name 声明了 NOT NULL,不能写入 NULL
表内现有 2 行,自增序列已到 5
ages=[18,NULL,25,NULL,30]:COUNT(*)=5,COUNT(age)=3,NULL=25 恒不成立
注意最后一行汇总:表内只有 2 行,自增序列却已经到了 5——三次被拒绝的写入各自消耗了一个自增值,这正是真实数据库里「主键有空洞」的来源之一。
图 2:每种约束防一类脏数据,上线后补代价翻倍。
可能的追问。
「上线后才想起来补约束怎么办?」这就是概念图右侧的代价清单:先清洗存量脏数据,再加约束;大表加约束早些年要锁表,现在可以走在线 DDL,但仍要挑低峰;最难缠的是历史代码可能依赖旧的宽松行为,加了非空约束老接口直接报错。「自增主键不够用了呢?」整型自增有上限,大表要么一开始用 BIGINT,要么换分布式发号。「默认值能写表达式吗?」可以,新版本支持表达式默认值,常见用法是把创建时间默认成当前时间戳。约束不是洁癖,它的本质是把数据质量的检查从「人肉兜底」前移成「数据库拒绝」——标注表、实验记录表这种上线后再补约束代价极高的表,建表这一刻就是第一道闸。
你回过头看自己项目里的表结构,有没有「当时图省事、现在想补不敢补」的约束?最后是怎么处理的?欢迎交流。