做MySQL开发这些年,面试新人最怕听到一句话就是"索引我熟、事务我懂",但一问他三大范式立刻支支吾吾;写SQL时count(*)和count(1)用得飞起,却不知道聚合函数在分组和NULL面前还藏着一堆坑。数据库约束、三大范式、聚合函数这三块,恰恰是MySQL基础里最容易被"跳过"又最影响实战能力的内容。约束管的是数据能不能进表,范式管的是表该怎么拆,聚合函数管的是数据怎么汇总统计,三者环环相扣。这篇把三个知识点串成一条完整的入门链路,配合建表语句和统计查询案例一起讲,适合刚学完MySQL增删改查、准备做课程设计或应付面试的初学者。我会把每个概念的"为什么"也拆开说清楚,免得你死记硬背概念,遇到具体表结构照样不会设计。
1. 约束:把数据"不守规矩"的苗头掐死在表结构里
数据库里的数据如果不加约束,就像公司没有规章制度,谁都能乱来:用户表里出现两个相同的手机号、订单表里挂一个不存在的用户编号、价格字段填成负数。约束就是加在表结构上的规则,让数据库在写入数据时自己把关,而不是靠应用层一层层if判断。
1.1 主键约束:一张表的"法定身份证"
主键约束的作用是唯一标识一行记录,它同时满足"非空"和"唯一"两个条件。你可以把它理解为人的身份证号,一张表里不可能两个人有同一个号码,也不可能有人没有号码。
建表时最常见的写法是:
CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, email VARCHAR(100) );这里的AUTO_INCREMENT是自增,配合主键使用,保证每次插入新记录时id自动加1,不需要手动指定。很多人纠结一个问题:要不要用自增id做主键?我的经验是,绝大多数业务表都建议加一个独立的自增主键,因为业务字段(比如手机号、邮箱)一旦作为主键,将来改手机号就很麻烦,而且自增id在聚簇索引中插入时顺序友好,不容易产生页分裂和索引碎片。
主键还有一些进阶写法值得注意:
-- 联合主键:多个字段共同构成唯一标识 CREATE TABLE order_item ( order_id INT, product_id INT, quantity INT, PRIMARY KEY (order_id, product_id) );联合主键在实际业务里非常常见,尤其是明细表和中间表。这里有个容易踩的坑:联合主键的字段顺序会影响索引的查询效率。联合主键本身就是一个联合索引,查询时如果能用到最左前缀,效率就高;如果只查product_id不查order_id,这个索引就用不上。
1.2 外键约束:让表与表之间开始讲"信用"
外键约束是表与表之间的一种引用关系。它规定:子表中的外键字段值,必须在父表的主键列中存在,或者为空。用大白话说,订单表里的user_id不能随便写,必须是user表里真实存在的id。
实际项目中,外键的争议一直很大。互联网公司的大流量场景普遍禁用物理外键,理由很直接——外键约束会强制数据库在执行插入、更新时去检查关联表,这在高并发下会带来明显的性能损耗;而且MySQL的外键检查在分库分表后会彻底失效。但在教学、课程设计、小型管理系统里,物理外键能让数据一致性有保障,写起来也省心。
MySQL里创建外键的语法:
CREATE TABLE orders ( id INT PRIMARY KEY, user_id INT, order_no VARCHAR(50), CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES user(id) );外键约束还附带一些行为规则,最常用的两个是ON DELETE CASCADE和ON DELETE SET NULL:
CASCADE:删父表记录时,自动删掉子表里引用它的记录。适合"订单明细跟订单同生死"的关系。SET NULL:删父表记录时,子表外键字段自动置空。适合"删除部门但保留员工,把部门id清空"的场景。RESTRICT(默认):有子表记录引用时,不允许删除父表记录。
我在教学时建议初学者尽量手动模拟外键关系(也就是逻辑外键),比如在应用层先查父表再插子表。这样既能理解外键的含义,又不会被数据库的物理约束卡住,尤其当你以后要面对的是分布式系统时,逻辑外键是必然选择。
1.3 唯一、非空、默认、检查:把规则写在表上,而不是靠应用层
除了主键和外键,还有一批"轻量级"约束,它们单独看很简单,但组合起来威力很大。
**唯一约束(UNIQUE)**保证一列或一组列的值不重复。典型场景是手机号、身份证号。要注意:唯一约束允许有多个NULL值,因为MySQL认为NULL != NULL。这个特性在业务上需要特别留意,比如注册时"邀请码"字段允许为空,但隐含的业务逻辑是"同一个邀请码不能被多个人使用",如果有两行都是NULL,MySQL不会拦。
**非空约束(NOT NULL)**是最容易被忽略但最值得重视的约束。很多人图省事,把所有列都允许NULL,结果查询时到处碰壁——统计函数、字符串拼接、where判断都要额外处理NULL。我的习惯是:凡是业务上必须有值的列,一律NOT NULL;可以没有值的列,也尽量用默认值兜底。
**默认约束(DEFAULT)**指定不填时的默认值。比如创建时间默认当前时间:
create_time DATETIME DEFAULT CURRENT_TIMESTAMP这个在MySQL 8.0里很方便,5.7也支持。
**检查约束(CHECK)**在MySQL 8.0.16之前是"名义存在",写了也不生效;从8.0.16开始才开始真正校验。比如:
price DECIMAL(10,2) CHECK (price >= 0)如果你还在用5.7,千万别指望CHECK帮你拦数据,只能在应用层做校验。
1.4 约束不是越多越好:性能与维护成本的取舍
我刚带项目时也犯过"约束控"的毛病,觉得约束越全越安全,结果一张用户表加了五六个唯一索引、三个外键,插入效率明显变慢,而且每次业务方要求修改业务规则,光改约束就要拖很久。
约束的正确打开方式是"分级管理":核心数据一致性靠主键、唯一约束、NOT NULL守住,外键能不用就不用;枚举范围校验(比如性别、状态)优先用ENUM或TINYINT加默认值;复杂的业务规则(比如库存不能为负、优惠券过期状态)放在应用层做事务控制,而不是堆在表结构里。
简单说,约束是数据库的第一道防线,但不能是唯一一道防线。
2. 三大范式:从"大而全"到"小而专"的表设计进化
范式是关系数据库设计的一套理论,简单理解就是"如何把一张大表拆成多张小表"。三大范式分别是第一范式(1NF)、第二范式(2NF)、第三范式(3NF)。很多人觉得这些概念抽象,其实它们解决的都是一类很实际的问题:数据冗余、更新异常、插入异常。
2.1 第一范式:列不可再分,拒绝"复合属性"
第一范式的要求非常朴素:每一列都必须是不可分割的原子值。说白了,一个字段里不能塞多个值。
反例非常典型,比如你设计一个订单表,字段是商品列表,值写成"手机x2,耳机x1,充电器x3"。这看起来方便,但当你需要统计"耳机卖了多少个"时,就要在字符串里做切片解析,痛苦至极。
正确做法是拆成两张表:订单表和订单明细表,一个商品占一行。第一范式是所有数据库表设计的底线,它不允许一个列里有"张飞、关羽、刘备"这种逗号分隔的多值。关系数据库是针对行和列的二维表结构,不是为了存"列表"而设计的。
在设计表时,我判断是否满足1NF的标准很简单:假设我要对这个字段做统计,是否需要先用字符串函数拆分?如果需要,就不符合1NF。
2.2 第二范式:消除部分依赖,别让非主键列"看人下菜"
第二范式建立在第一范式基础上,核心要求是:非主键列必须完全依赖于主键,不能只依赖联合主键的一部分。一句话,"部分依赖"指的就是:表用了联合主键,但某个非主键列只看主键中的某一列就能确定。
举个例子,你有一张选课成绩表,主键是(student_id, course_id),里面有course_name(课程名)、student_name(学生姓名)、score(分数)。
score完全依赖于(student_id, course_id),因为分数就是某个学生某门课的成绩。student_name只依赖student_id,跟你选哪门课没关系——这就是部分依赖。course_name只依赖course_id,也是部分依赖。
如果不拆分,后果很明显:同一个学生选了10门课,student_name就要重复存10次,数据冗余。而且如果学生还没选课,他就没法被记录在表中,这叫插入异常。
解决方法是拆成三张表:学生表、课程表、选课成绩表。这就是第二范式做的事情——把联合主键拆开,让每一列都只依赖完整的业务主键。
实际工作中,部分依赖的识别很考验人,因为很多人不会刻意去想"这个字段到底依赖哪个键"。我的技巧是:先问自己这张表的每一行记录代表的实体是什么,再想每一列描述的到底是这个实体本身,还是别的实体。如果描述的是别的实体,就该拆出去。
2.3 第三范式:消除传递依赖,别让"由他来"变成"由他的他来"
第三范式的要求是:非主键列不能依赖于另一个非主键列。换句话说,非主键列之间不能存在传递关系。
举个典型例子:订单表里有customer_id和customer_name,还有customer_address。customer_id依赖主键order_id,没问题;但customer_name和customer_address其实是依赖customer_id的——它们是客户的信息,不是订单的信息。这就是传递依赖:order_id → customer_id → customer_address。
结果是一个客户下10个订单,他的姓名和地址就要重复存10次;客户搬家了,你又要更新10条历史订单里的地址。拆解方案是把客户信息单独拆成客户表,订单表里只保留customer_id作为外键。
第三范式在真实开发中经常被有意违反,这就是"冗余字段"的来源。比如在订单表里直接冗余一个user_name,换来的是查询时少关联一张表,减少了join开销。这在读多写少的互联网系统里很常见。范式的价值在于指导你发现问题,而不是禁止你使用冗余。关键分辨点是:冗余字段是否高频改动,如果频繁更新,就不适合冗余。
2.4 范式学完要"解构":实际项目中怎么妥协
很多初学者学完三大范式,就开始把业务表拆得七零八落,什么都想满足第三范式。但生产环境里,完全满足范式化的数据库往往是"学术正确,性能灾难"。
我上过的真实教训:电商订单列表页,老板要看用户昵称,于是我把订单表和用户表分开,每次查询都要join一次。用户量涨到几百万后,join变慢,不得不把user_name冗余回订单表,用同步脚本维护一致性。说白了,范式是"理想模型",反范式是"业务妥协"。设计表时先按范式拆分,理清实体边界,再根据查询场景做有限度的冗余。这个顺序不能颠倒,否则你连实体边界都分不清,上来就胡乱加冗余字段,只会更乱。
三大范式的另一种理解方式:第一范式管列,第二范式管"主键和非主键"的关系,第三范式管"非主键和非主键"的关系。有了这个框架,面试官怎么问都不慌。
3. 聚合函数:一行一行看数据太慢,直接"汇总"才有意义
约束和范式解决的是表结构怎么设计,聚合函数解决的是数据怎么统计。MySQL的聚合函数,就是在一组数据上进行计算并返回单一值的函数。最常见的五个是COUNT、SUM、AVG、MAX、MIN。它们也是报表统计的基础。
3.1 五大聚合函数:COUNT、SUM、AVG、MAX、MIN的用法与边界
先看一个最简单的统计需求:订单表里总共有多少单、总金额多少、平均金额多少、最大单多少、最小单多少。
SELECT COUNT(*) AS order_count, SUM(total_amount) AS total_amount, AVG(total_amount) AS avg_amount, MAX(total_amount) AS max_amount, MIN(total_amount) AS min_amount FROM orders;这段SQL体现了聚合函数的基本特征:多行输入,单行输出。
这里的边界要分清楚:
- COUNT(*):统计行数,包含NULL行。
- COUNT(column):统计该列非NULL值的个数。
- COUNT(DISTINCT column):统计该列去重后的非NULL值个数。
实际开发里,"统计用户数"我总会被问用count(*)还是count(1)。在MySQL 5.7和8.0中,两者的性能差异几乎可以忽略,重点是你统计的语义对不对。count(1)在优化器里会被转换成count(*)一样的效果,不需要纠结。
SUM和AVG在遇到NULL时也有自己的行为:不参与计算。比如10行数据有2行是NULL,SUM只累加8行,AVG也是除以8而不是除以10。这在求平均分时尤其容易产生认知偏差,必须先明确业务口径。
3.2 GROUP BY:分组统计的真正威力
聚合函数单独用只能得到全局汇总,配合GROUP BY才能体现价值。GROUP BY的作用是把行按某个字段分组,然后对每组分别聚合。
举个例子,统计每个用户的订单总额:
SELECT user_id, SUM(total_amount) AS total_amount FROM orders GROUP BY user_id;再复杂一点,按月统计订单量:
SELECT DATE_FORMAT(create_time, '%Y-%m') AS month, COUNT(*) AS order_count, SUM(total_amount) AS total_amount FROM orders GROUP BY DATE_FORMAT(create_time, '%Y-%m');这里有个非常重要的问题:SELECT列表里只能出现分组字段和聚合函数。分组字段是user_id,你最多再选DATE_FORMAT后的月份;但你不能去selectorder_no,因为同一组里有多个order_no,数据库不知道该显示哪一个。
在MySQL 5.7默认配置下,你select非分组字段也不会报错,它会随机取一行。这个"随机"在数据量大的时候容易引发难以排查的Bug。MySQL 8.0默认开启ONLY_FULL_GROUP_BY,会直接报错,强制你遵循规范。这点后文会详细讲。
分组统计还有一个延伸用法:GROUP BY多个字段,分组维度就变成了"多个字段组合成一组"。比如统计每个用户每个月的订单总额:
SELECT user_id, DATE_FORMAT(create_time, '%Y-%m') AS month, SUM(total_amount) FROM orders GROUP BY user_id, DATE_FORMAT(create_time, '%Y-%m');3.3 HAVING vs WHERE:过滤顺序的隐形坑
分组之后还想过滤怎么办?比如只想要订单总额超过1000的用户。刚接触SQL的人最容易写错的位置是把聚合条件放在WHERE里:
SELECT user_id, SUM(total_amount) AS total FROM orders WHERE SUM(total_amount) > 1000 GROUP BY user_id;这条SQL会报错,因为WHERE是在分组前对原始行进行过滤,而聚合函数SUM这时候还没开始计算,where根本不知道total_amount。正确写法是使用HAVING:
SELECT user_id, SUM(total_amount) AS total FROM orders GROUP BY user_id HAVING total > 1000;我把WHERE和HAVING的区别总结成一句话:WHERE过滤数据行,HAVING过滤分组。执行逻辑上,WHERE在GROUP BY之前执行,HAVING在GROUP BY之后执行。
实际开发中还有个性能技巧:能用WHERE筛掉的数据,绝不要留到HAVING里。比如统计"已支付订单中每个用户的总金额",应该先WHERE status = 'paid'再分组,而不是先分组再HAVING status。因为分组计算是有开销的,先过滤能大幅减少参与分组的数据量。
3.4 NULL天生"自成一派":聚合函数与NULL的相处之道
聚合函数遇上NULL,是基础中最容易被忽视的高级话题。
第一,COUNT(column)会忽略NULL,COUNT()不会。假设一张表的email列有5行数据,其中3行是NULL,COUNT(email)返回2,COUNT()返回5。这个差异在做基数校验时非常危险,比如你想查"有多少用户填了邮箱",就必须用COUNT(email)。
第二,SUM、AVG、MAX、MIN都会忽略NULL行,只对非NULL值计算。AVG(NVL(val,0))和AVG(val)的结果完全不一样:前者把NULL当0算,后者直接不算NULL。业务上"平均工资"到底是只算有工资的人,还是把没工资的也按0算?这就是SQL写法和业务口径的关系,你必须在写之前想清楚。
第三,NULL与比较运算的结果永远是NULL,不是假也不是真。所以WHERE amount = NULL永远查不到数据,必须用IS NULL。这个坑在入门阶段出现的频率极高,我见过不少新同事排查半天,最后发现是等于NULL写成了等号。
聚合函数还经常和CASE WHEN组合使用,实现条件统计。比如统计订单表中的男性用户数和女性用户数:
SELECT COUNT(CASE WHEN gender = 'male' THEN 1 END) AS male_count, COUNT(CASE WHEN gender = 'female' THEN 1 END) AS female_count FROM users;这里没有用ELSE 0,COUNT只会统计非NULL的CASE结果,等于只统计满足条件的行。这种写法比多个子查询干净得多,值得收藏。
4. 综合案例:一张订单表,把约束、范式、聚合函数串起来
理论学完不落地等于白学,我用一个完整的电商订单场景,把前面三章内容串成一条流程:先按范式和约束设计表,再写统计报表SQL。
4.1 先设计表:约束 + 范式落地
假设我们要做一个简单的电商系统,涉及的数据实体有:用户、商品、订单、订单明细。按第三范式的思路,四个实体拆成四张表。
用户表:
CREATE TABLE `user` ( id INT PRIMARY KEY AUTO_INCREMENT, phone VARCHAR(20) NOT NULL UNIQUE, nickname VARCHAR(50) NOT NULL, gender TINYINT NOT NULL DEFAULT 0 COMMENT '0未知 1男 2女', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP );商品表:
CREATE TABLE `product` ( id INT PRIMARY KEY AUTO_INCREMENT, product_name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL CHECK (price >= 0), stock INT NOT NULL DEFAULT 0 );订单主表:
CREATE TABLE `orders` ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, order_no VARCHAR(32) NOT NULL UNIQUE, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT '0未支付 1已支付 2已取消', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user_id (user_id), CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES `user` (id) );订单明细表:
CREATE TABLE `order_item` ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, price DECIMAL(10,2) NOT NULL COMMENT '下单时的快照价格', CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES `orders` (id) );这里每个字段的设置都能解释出理由:
phone用了NOT NULL UNIQUE,因为手机号是登录凭证,必须唯一;order_no加UNIQUE,防止并发下生成重复订单号;order_item.price是商品价格快照,不是实时关联product表的价格。这是数据一致性设计里很重要的一点——订单一旦生成,价格就不能跟着商品调价变,否则财务对账会出大问题;- 外键用于教学场景没问题,生产环境考虑去掉;
- 用
COMMENT给字段加注释,是数据库设计的良好习惯,时间长了你会回来感谢自己的。
范式层面,订单明细表的主键是自增id,所有非主键列完全依赖于id,符合第二范式;订单表中没有冗余用户昵称和商品名称,符合第三范式。
4.2 再出报表:聚合函数查询统计
表设计好之后,统计需求就变得非常清爽。
需求一:统计每个用户的订单数和总消费金额,只统计已支付订单。
SELECT user_id, COUNT(*) AS order_count, SUM(total_amount) AS total_spent FROM orders WHERE status = 1 GROUP BY user_id ORDER BY total_spent DESC;需求二:找出消费总额超过1000的用户,以及他们的平均客单价。
SELECT user_id, COUNT(*) AS order_count, SUM(total_amount) AS total_spent, AVG(total_amount) AS avg_order_amount FROM orders WHERE status = 1 GROUP BY user_id HAVING total_spent > 1000 ORDER BY total_spent DESC;需求三:统计销量前3的商品。此时要join明细表和商品表:
SELECT p.product_name, SUM(oi.quantity) AS sold_quantity FROM order_item oi JOIN product p ON oi.product_id = p.id GROUP BY p.id, p.product_name ORDER BY sold_quantity DESC LIMIT 3;这个SQL里GROUP BY同时写了p.id和p.product_name,是为了满足ONLY_FULL_GROUP_BY的要求——product_name是id的函数依赖,按理说MySQL 8.0也允许,但我习惯写全,避免在5.7和8.0之间切换时遇到玄学报错。
4.3 常见报错与调试思路
新手做综合案例时,最常碰到的几个报错我直接列出来:
第一,Column 'xxx' must appear in the GROUP BY clause or be used in an aggregate function。这是ONLY_FULL_GROUP_BY报错,说明你select了非分组字段。解决办法要么把字段加进GROUP BY,要么改成MIN(xxx)、MAX(xxx)等聚合写法。
第二,Invalid use of group function。一般是聚合函数写到了WHERE或ON子句里,比如WHERE COUNT(*) > 1。记住:WHERE不认识聚合函数,HAVING才认识。
第三,外键插入失败Cannot add or update a child row。说明你插入的子表外键值,在父表里不存在。先查父表是否有对应记录,或者确认外键字段是否允许NULL。
调试思路有个共性原则:先把条件逐步放宽,比如去掉HAVING看分组结果是否正确,再去掉WHERE看基础数据是否正常,一层层定位问题出在过滤还是分组上。这种"分层排查"的思路比对着报错干猜有效得多。
5. MySQL 8.0实操笔记:sql_mode、版本差异与性能调优方向
最后写点实操层面的经验,这部分是书本上很少详细讲,但你在真实环境一定会遇到的东西。
5.1 ONLY_FULL_GROUP_BY 为什么让人头疼
MySQL 5.7之后默认开启ONLY_FULL_GROUP_BY这种严格的SQL模式,8.0里也是默认开启的。它要求SELECT列表中的非聚合字段必须全部出现在GROUP BY子句中。前文说过,5.7默认配置下不检查,只随机取值;8.0默认检查,直接报错。
很多从5.6迁移到8.0的项目,第一波报错往往就是这条。我建议初学者不要在配置文件里关掉它,而是养成规范写SQL的习惯。因为ONLY_FULL_GROUP_BY报错本质上是在提醒你:"你的分组逻辑不严谨,查询结果可能随机"。
附上查看当前sql_mode的语句:
SELECT @@sql_mode;如果确实遇到维护老系统需要临时关闭,可以这样设置,但我强烈不建议在正式环境做:
SET SESSION sql_mode = (SELECT REPLACE(@@sql_mode, 'ONLY_FULL_GROUP_BY', ''));5.2 5.7 vs 8.0:约束与聚合函数的差异点
这篇文章涉及的内容在两个大版本间有几个需注意的差异:
- CHECK约束:MySQL 5.7只解析不执行,8.0.16开始真正生效。我在5.7上曾经天真地写了CHECK(gender IN ('male','female')),结果插入非法数据照样成功,白高兴一场。
- 窗口函数:8.0引入了ROW_NUMBER()、RANK()等窗口函数,做排名统计(比如求每个部门工资前3名)简洁很多。但窗口函数不属于聚合函数范畴,建议先掌握GROUP BY基础,再进阶窗口函数。
- 隐式类型转换:两个版本都存在,需要注意。比如把字符类型字段和数字比较时,MySQL会尝试把字符串转成数字,查不到数据别怀疑索引,先看类型匹配问题。
如果你还在用5.7,至少有两点要注意:一是外键和CHECK约束不能全信,逻辑校验要留在应用层;二是写分页查询时尽量用ORDER BY加确定字段,避免数据结果不稳定。
5.3 给新手的练习路径
这三块内容,我建议按下面的路线练:
第一步,建一个简单的学生选课系统,包含学生表、课程表、成绩表,动手实践主键、唯一约束、外键,然后把成绩表拆成满足第二范式、第三范式的样子。
第二步,写统计SQL:每个学生的平均分、最高分、总选课数;每门课程的选课人数,用COUNT和GROUP BY配合HAVING过滤。
第三步,给自己出几个真实业务问题,比如"统计每个学生有成绩的课程数"和"统计每个学生的选课数"为什么结果可能不同——前者用COUNT(score),后者用COUNT(*)——把NULL语义搞清楚。
第四步,故意写几条错误SQL,触发ONLY_FULL_GROUP_BY和HAVING的报错,再根据报错信息修复,比做一百道填空选择题都管用。
我在教学和带新人时,一直强调基础要慢工出细活。约束、范式、聚合函数这三个概念孤立看都不难,难的是组合起来解决实际业务问题。把这一篇里的表和SQL亲手跑一遍,再自己改一改字段和条件,以后再遇到面试或真实开发里的统计需求,你就不只是"眼熟概念",而是真的能动手写出来。