先把结论撂这儿:面试里问了一百遍的“count(1)、count(*)、count(主键id)到底谁快”,在绝大多数场景下答案是“差不多”,但如果你只知道这个结论,不知道背后那几层筋和骨头在哪,那下次换个表结构、换个版本,照样掉坑。这篇文章我直接照庖丁解牛的路子来,不背结论,一层层把这三把刀从语义、优化器、索引结构到大表实践全部拆开,顺便把经常被人忽略的count(可空列)和二级索引的坑也一起填了。
适合谁看?准备MySQL面试的人、被线上慢查询count卡过的开发、以及想搞清楚“为什么加了个索引count反而变了”的运维。前两部分讲原理,中间部分是执行计划实测,后面是真正的性能优化和大表方案,看完你基本能把这几个count从里到外讲明白。
1. 先搞清楚“数”的定义:COUNT(expr)与NULL的那笔账
很多人第一步就走错了,以为count是在“数行”。其实它不是。COUNT(expr)的完整语义是:统计表达式expr不为NULL的行数。括号里写的不是“行”,是“表达式”,这个区别就是后面所有坑的总根源。
1.1 三个表达式为什么“看起来一样”
拿一张只有两列的表举例:
sql复制CREATE TABLE t_user (
id BIGINT PRIMARY KEY,
nickname VARCHAR(50) NULL
);
然后执行:
sql复制SELECT COUNT(*) FROM t_user; -- 返回表中总行数
SELECT COUNT(1) FROM t_user; -- 每行都生成常量1,1永不为NULL,返回总行数
SELECT COUNT(id) FROM t_user; -- id是主键,主键不可能为NULL,返回总行数
这三条在“结果是总行数”这一点上完全一致,因为:
*的语义是“这一行存在”,只要行存在就计数,不看列。1是常量表达式,对每一行求值结果都是数字1,非NULL,所以等价于计行数。id是主键列,InnoDB规定主键列不允许为NULL,所以判断“id不为NULL”也等价于计行数。
但注意第四条:
sql复制SELECT COUNT(nickname) FROM t_user; -- 只统计nickname不为NULL的行数
如果表里有100万行,但只有80万人填了昵称,这条语句返回80万。这就是最常见的业务统计口径翻车现场:你以为你在数人,其实你在数“填了资料的人”。有些报表系统里,线上用户总量和活跃用户量差好几倍,查来查去最后定位到一句COUNT(可空字段),这种问题用EXPLAIN根本看不出来,只能靠语义审计。
1.2 一个反直觉的验证实验
想验证上面说的“表达式求值”机制,可以直接跑一段没有表也能跑的SQL:
sql复制SELECT COUNT(NULL); -- 结果 0
SELECT COUNT(1); -- 结果 1
普通select语句里,COUNT(NULL)返回0,COUNT(1)返回1,这就说明count的本质确实是“统计非NULL表达式值的个数”,而不是“数行”。
所以,从语义层面先理出结论:
| 写法 | 统计内容 | 结果是否等于总行数 |
|---|---|---|
| COUNT(*) | 所有行 | 是 |
| COUNT(1) | 常量表达式非NULL的行 | 是 |
| COUNT(主键id) | 主键非NULL的行 | 是 |
| COUNT(可空列) | 该列非NULL的行 | 可能小于总行数 |
| COUNT(不可空列) | 该列非NULL的行 | 等于总行数 |
实际开发里,我见过太多人为了“性能”去写COUNT(非空业务字段),结果某天DBA把那列的约束从NOT NULL改成NULLABLE后,统计值悄悄变了,排查了半天。所以安全第一的写法永远是COUNT(*),语义最稳,不会受列约束变化影响。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
