做后端开发的同学肯定都写过这么一行 SQL:SELECT COUNT(*) FROM user;,它就是 MySQL 里出场率最高的函数之一 count。我最早接触 count 的时候,以为它就是简简单单数行数,后来在项目里碰上了报表统计少了几条数据、大表查询卡到页面超时、面试被问到 count(*) 和 count(1) 的区别答不上来这些问题,才发现这个函数背后的讲究比想象中多得多。
这篇文章我会把 count 函数从基本语法到性能优化、从笔试考点到实战踩坑,完整拆一遍。无论你是刚学会写 SQL 的新人,还是已经在业务里被 count 坑过的老手,应该都能在里面找到有用或者至少能帮你少走弯路的内容。读的时候建议手边开一个 MySQL 环境,把例子敲一遍,印象会深刻很多。
1. count函数到底是什么:先把基本语法说清楚
1.1 一行SQL看懂count的返回值
count 的函数签名特别简单:COUNT(expr),传入一个表达式,返回一个 BIGINT 类型的数字,表示满足条件的行数。最常见的几种写法是:
sql复制-- 统计表里总共有多少行
SELECT COUNT(*) FROM user;
-- 统计表里总共有多少行(和上面等价)
SELECT COUNT(1) FROM user;
-- 统计 email 字段非空的记录数
SELECT COUNT(email) FROM user;
我见过不少刚入行的同学以为 count(*) 和 count(1) 是两个东西,甚至有人觉得 count(1) 是"查 id=1 的数据",这理解就偏了。count(1) 里的 1 不是条件,它是一个常量表达式,意思是"只要有一行,就加 1"。你可以把 count(*) 看成"数行数",把 count(1) 看成"每一行都记一个数字 1,最后加起来",结果完全一样。
真正容易踩坑的是 count(字段)。如果字段里有 NULL,NULL 这一行不会被计入。举个例子,假如 user 表有 8 条记录,其中 3 条的 email 是空值:
sql复制SELECT COUNT(*) FROM user; -- 返回 8
SELECT COUNT(email) FROM user; -- 返回 5
很多报表少数据的线上事故,就是从这里来的。统计总数就用 count(*),统计"某字段有值"的数量才用 count(字段),这个认知先记住,后面会展开讲。
另外,COUNT(expr) 在没有匹配行时返回 0,不会返回 NULL。比如 SELECT COUNT(*) FROM user WHERE id > 99999; 查不到数据,它返回 0,而不是 NULL,这避免了很多空指针判断的麻烦。
1.2 count的三种身份:行数、非空值数、去重数
把 count 的常见形态放在一起看,会更清楚:
| 写法 | 含义 | 是否统计NULL |
|---|---|---|
COUNT(*) |
表里所有行数 | 统计 |
COUNT(1) |
表里所有行数 | 统计 |
COUNT(列名) |
该列非NULL值的数量 | 不统计 |
COUNT(DISTINCT 列名) |
该列去重后非NULL值的数量 | 不统计 |
前四种里,COUNT(*) 是统计行数,COUNT(1) 也是统计行数,COUNT(字段) 是统计非空值数量,COUNT(DISTINCT 字段) 是统计去重后的非空值数量。它们各自适合不同的业务场景。
举个例子,假设现在有一张订单表,我要一次性统计出三个数:总订单数、已支付订单数、下单客户数。
sql复制SELECT
COUNT(*) AS total_orders,
COUNT(DISTINCT user_id) AS total_users
FROM orders
WHERE status = 'paid';
这里 COUNT(*) 给出的是支付订单总量,COUNT(DISTINCT user_id) 给出的是有多少个不同用户下过单。如果用户一天下三单,COUNT(*) 会是 3,COUNT(DISTINCT user_id) 是 1,两个指标含义完全不同,这就是 count 的"多重身份"在日常写 SQL 时的实际应用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三兄弟之争:count(*)、count(1)、count(字段)到底有什么区别
2.1 从执行计划看count(*)和count(1)
很多年前我还在用 MySQL 5.6 的时候,团队里就有老前辈说"count(1) 比 count() 快,因为 count() 要把所有列取出来数"。"优化"的方法是在所有 select count 的地方改成 count(1),甚至改成 count(id)。这个说法流传得特别广,但它其实是个误解。
MySQL 优化器比你想象中聪明。它看到 count(*) 的时候,并不会真的去读每一行的所有字段,而是会挑一棵最小的索引树去遍历,InnoDB 里辅助索引(二级索引)通常比主键索引小,优化器就会优先用辅助索引来数行数。所以,count(*) 和 count(1) 在绝大多数情况下性能是等价的,区别可以忽略不计。
用 EXPLAIN 看执行计划会更直观。假设 user 表有 100 万数据,id 是主键,name 上有普通索引:
sql复制EXPLAIN SELECT COUNT(*) FROM user;
EXPLAIN SELECT COUNT(1) FROM user;
两个执行计划里,MySQL 大概率都会选择扫描 name 索引,因为 name 索引树比主键树小,扫描的页更少。它们唯一的区别就是每行扫描的时候处理的是"星号"还是"常量 1",这个差异小到可以忽略。
我自己在项目里统一要求团队写 count(*),因为它是 SQL 标准语法,语义最清晰,别人看代码一眼就知道是总数。至于"count(*) 会取所有列所以慢"这个说法,在现在的 MySQL 版本里早就不成立了,别被江湖传言带偏。
2.2 count(字段)的特殊性:NULL过滤
count(字段) 和前面两个不一样,它的语义是"统计这个字段非 NULL 的个数"。字段值为 NULL 的行会被跳过,这就造成了一个很经典的业务坑。
我有一次帮同事排查报表问题,那边有个统计"平台注册兽医人数"的接口,数据总是比预想少 800 多个。查了半天发现,统计 SQL 写的是 SELECT COUNT(vet_level) FROM member,而 vet_level 在会员表里很多用户都没填,存的是 NULL。这些用户确实是真实存在的会员,但他们的 vet_level 是空的,被 count 忽略了。
这个问题的根源不是 SQL 写错了,而是语义理解错了。COUNT(vet_level) 表达的其实是"有多少人有兽医等级",而不是"有多少人是兽医"。如果业务上要统计总人数,就得用 COUNT(*) 或者 COUNT(id)。
还有一个隐藏性能问题。如果字段允许为 NULL,count(字段) 在扫描每一行时都得额外做一次 NULL 判断,比 count(*) 多一点点开销。如果字段没有索引,那更惨,只能走全表扫描。统计行数这类场景,不要碰 count(字段),老老实实用 count(*)。
2.3 到底该用哪个?我的建议
把三种写法按使用场景整理一下,基本可以当规范用:
| 统计需求 | 推荐写法 | 理由 |
|---|---|---|
| 统计表里总行数 | COUNT(*) 或 COUNT(1) |
语义清晰,能用最小索引,性能最好 |
| 统计某字段有值的数量 | COUNT(字段) |
会跳过 NULL,正好符合需求 |
| 统计某字段去重后的数量 | COUNT(DISTINCT 字段) |
标准去重计数 |
| 统计业务记录总数但表很大 | COUNT(id) |
配合主键索引/覆盖索引,习惯上也常见 |
我个人的建议是,除非常明确需要过滤 NULL,否则统计行数一律用 count(*)。在数据库里,语义清晰比纠结那几毫秒重要得多。后面在面试题部分会再提到这个点的标准答法。
3. 实战组合:count在真实业务里的高频场景
3.1 分组统计:count + GROUP BY
count 单独用的情况其实不多,更多时候是和 GROUP BY 一起用。比如后台管理系统里最常见的"按状态分组统计订单量":
sql复制SELECT status, COUNT(*) AS cnt
FROM orders
GROUP BY status;
执行结果会按 status 分组,每组返回一个总数。这个玩意的强大之处在于,它一次扫表就把所有状态的数量算完了,不用写 5 个子查询。
如果还要筛掉那些数太少的分组,可以用 HAVING:
sql复制SELECT status, COUNT(*) AS cnt
FROM orders
GROUP BY status
HAVING cnt > 100;
这里有个不少新手容易写错的地方:WHERE 是在分组前过滤行,HAVING 是在分组后过滤分组。如果条件是对行级数据的筛选,就放 WHERE;如果是对聚合结果的筛选,才用 HAVING。两条语句的执行顺序不一样,对应的业务含义也不一样。
另外提醒一句,MySQL 的 ONLY_FULL_GROUP_BY 模式默认开启的情况下,SELECT 后面出现的普通列必须出现在 GROUP BY 里,否则直接报错。为了避免这种问题,SELECT 里尽量只写分组字段和聚合函数,别把其他裸列带进去。
3.2 去重计数:count + DISTINCT
COUNT(DISTINCT 字段) 是最直接的去重计数写法,例如统计一个活动有多少个用户参与:
sql复制SELECT COUNT(DISTINCT user_id) FROM activity_log;
不过 COUNT(DISTINCT 字段1, 字段2) 这种多列去重的写法要特别注意,它统计的是"两列值完全一样"才算重复。也就是说,(1, 'a') 和 (1, 'b') 算两条不重复数据。
DISTINCT 看起来简单,背后代价却不小。它需要把数据放到内存临时表里做去重,数据量一大就可能临时表落盘,性能会很差。如果表特别大,又确实需要对多个字段去重,我通常建议先用子查询或 GROUP BY 去重,再在外面包一层 count:
sql复制SELECT COUNT(*) FROM (
SELECT user_id
FROM activity_log
GROUP BY user_id
) t;
这个写法在语义上等价于 COUNT(DISTINCT user_id),但执行计划往往更好。GROUP BY 也能借助索引优化去重过程,尤其配合覆盖索引时,比直接 DISTINCT 更加可控。
3.3 条件计数:用CASE WHEN替代多个count
实际业务里经常要在一块报表里同时看多个维度的计数:今日注册用户数、今日有效用户数、今日 VIP 用户数。有些人会写三个 SQL 分别查,其实一次查询就能搞定。
用 COUNT(CASE WHEN ... THEN 1 END) 的写法,逻辑很清爽:
sql复制SELECT
COUNT(*) AS total_users,
COUNT(CASE WHEN status = 1 THEN 1 END) AS active_users,
COUNT(CASE WHEN vip_level > 0 THEN 1 END) AS vip_users
FROM user
WHERE create_date = CURRENT_DATE;
这里有个很巧妙的地方:CASE WHEN 条件不满足时返回 NULL,而 COUNT(字段) 不统计 NULL,所以只有满足条件的行才会被计数。也可以用 SUM(CASE WHEN ... THEN 1 ELSE 0 END),道理差不多。两个写法都可以,用 count 更符合语义。
如果嫌 CASE WHEN 长,MySQL 还支持直接对布尔表达式求和:
sql复制SELECT SUM(status = 1) FROM user;
在 MySQL 里,status = 1 这个布尔表达式返回的值就是 1 或 0,所以 SUM 出来就是满足条件的行数。这个写法特别简洁,但团队里如果有同事不熟悉,阅读成本会高一点。我一般只在手写临时查询时用,写进项目代码里还是用 CASE WHEN 更稳妥。
4. 深挖性能:为什么count(*)在大表上这么慢
4.1 慢的原因:InnoDB引擎的实时统计
接了一个线上慢查询工单,订单表 4000 万行,SELECT COUNT(*) FROM orders 跑了快 6 秒。很多人第一反应是"MySQL 怎么这么菜",但这事得搞清楚原因。
MySQL 最常用的存储引擎是 InnoDB,它没有把表的精确行数直接存起来。之所以不存,是因为 InnoDB 要支持多版本并发控制(MVCC)。不同事务在同一时刻看到的行数可能不一样,比如事务 A 插入了 10 行还没提交,事务 B 不该看到这 10 行;事务 C 删了 5 行没提交,事务 D 应该还能看到。如果引擎在表头存一个"当前总行数",这个数字在并发环境下对谁都不准确。所以 InnoDB 只能每次 count(*) 的时候,实时扫索引算行数。
与之相对,MyISAM 引擎会把表的总行数直接存在表的元数据里,所以不带 WHERE 条件的 SELECT COUNT(*) FROM table 在 MyISAM 里是 O(1) 秒回。但 MyISAM 有表级锁、不支持事务等一大串问题,日常开发基本用不上。面试时如果被问到这两个引擎在 count 上的差别,答案核心就是 MVCC。
另外一个细节:count(*) 即使没带 WHERE 条件,也不会只从主键索引读,优化器会找一棵最小的辅助索引来扫。因为辅助索引占用空间小,每页能装下的记录多,扫描的页数少。所以如果一张表几乎只有一个主键索引,那 count(*) 就只能扫主键索引这棵大树,慢是必然的。
4.2 让count快起来的几个实用手段
先说结论:在 InnoDB 里,count(*) 在大表上很难做到"又精确又快"。你需要根据业务需求决定是接受一个估算值,还是另想办法维护精确值。
第一种场景:只需要一个大概量级,比如后台列表页显示"共 1.2 亿条记录",上下差个几十万无所谓的。这种情况直接用估算值就行:
sql复制SHOW TABLE STATUS LIKE 'orders';
-- 看 rows 字段
-- 或者
SELECT table_rows FROM information_schema.tables WHERE table_name = 'orders';
这两个地方的 rows 是优化器估算的,不是精确值,但量级基本靠谱。也可以用 EXPLAIN SELECT COUNT(*) FROM orders; 看执行计划里的 rows 列。这种方式速度极快,但精度完全无法保证,别拿它做对账。
第二种场景:业务确实需要精确计数,但数据量又很大。常规做法是维护一张"计数器表"或汇总表。比如统计累计订单数,可以在每次插入订单后更新计数器表:
sql复制-- 计数器表
CREATE TABLE order_count (
total BIGINT NOT NULL DEFAULT 0
);
-- 插入订单后,在同一个事务里更新计数器
START TRANSACTION;
INSERT INTO orders (id, status) VALUES (1001, 'paid');
UPDATE order_count SET total = total + 1;
COMMIT;
因为更新和插入在同一个事务里,计数不会因为并发错乱。查询总订单数时直接 SELECT total FROM order_count,毫秒级返回。大型互联网系统里还常常把这种计数放到 Redis 里,每天定时把 Redis 计数落库。这一套方案实现不复杂,但能支撑的业务量比全表 count 大几个数量级。
第三种场景:主要查询都带了 WHERE 条件,那优化的重点就是让 WHERE 条件走索引。count(*) 的执行效率高度依赖扫描的行数,如果条件能命中索引,扫描的行数就少,自然就快了。这个点在实际排查时最常用,下面用案例说明。
4.3 一个排查count慢的真实案例
之前线上有个"按状态统计订单数"的接口,调用量挺高,某天突然开始超时。排查过程大概是这样的。
接口对应的 SQL 是:
sql复制SELECT COUNT(*) FROM orders WHERE status = 3;
status 字段当时没有建索引,订单表 4000 万行。执行计划一眼就能看到问题:type 是 ALL,rows 显示 4162 万,说明这是全表扫描。InnoDB 要一行行看 status 是不是 3,扫 4000 万行不慢才怪。
修复方案很简单,给 status 加上普通索引:
sql复制ALTER TABLE orders ADD INDEX idx_status (status);
加完索引再看执行计划,type 变成 ref,rows 降到 320 万左右,查询从 8 秒降到 0.6 秒。这就是索引对 count 的直观影响。
但注意,加索引不是万能的。如果统计条件查出来的结果集本身就有几千万行,比如 COUNT(*) WHERE status IN (1,2,3,4,5,6,7,8,9),那就算走索引,扫描行数还是几千万,依然快不起来。这种场景就得靠汇总表或计数器的思路去解决,不要死磕 SQL。
提示:凡是遇到 count 慢,先上 EXPLAIN 看 type 和 rows,再做决定。用索引解决的问题,优先用索引;索引解决不了的,再考虑换统计方案。
5. 面试必问与经典坑:count函数高频考点
5.1 面试官最爱的三个问题
Q1:count(*) 和 count(1) 哪个性能更好?
标准答法是:在 MySQL 的 InnoDB 引擎下,二者性能基本相同。count(*) 并不会把表里所有字段取出来数,优化器会选择最小索引进行扫描;count(1) 同样是扫描行数后累加。早期 MySQL 版本中,有人觉得 count(1) 更快,是因为当时 count(*) 的实现会展开所有列,但这个问题在现代版本已经不存在了。如果面试官继续追问,可以补充一句:我通常建议用 count(*),因为它是 SQL 标准用法,语义最清晰。
Q2:为什么 InnoDB 不缓存总行数,而 MyISAM 会?
原因核心是 MVCC。InnoDB 事务隔离要求不同事务看到不同版本的快照,如果直接缓存一个总行数,这个数字对正在运行的事务没有意义。MyISAM 不支持事务,表级锁也天然规避了并发写冲突,所以可以直接存一个精确行数。讲到这,还可以顺带提一句 InnoDB 的 count(*) 会通过扫描最小辅助索引来加速,这能体现你对实现机制有了解。
Q3:count(字段) 不统计 NULL,怎么解决?
首先要澄清,这是特性不是 bug。如果业务上需要统计该字段非空的记录数,直接用 COUNT(字段) 是对的。如果需要统计包括 NULL 在内的总数,就改用 COUNT(*) 或者 COUNT(主键)。不要试图用 COUNT(IFNULL(字段, 0)) 这种函数包裹来绕过 NULL,虽然能work,但会让索引失效的概率变大,性能得不偿失。
除了这三个经典问题,面试还喜欢问"count(*) 和 count(字段) 返回结果什么时候不同",答案就是字段有 NULL 的时候。这个区分要刻在脑子里。
5.2 几类业务经典坑
坑一:JOIN 之后 count 结果膨胀
之前有同事统计"已支付订单数",SQL 长这样:
sql复制SELECT COUNT(*)
FROM orders o
LEFT JOIN order_items i ON o.id = i.order_id
WHERE o.status = 'paid';
一个订单如果包含 3 个商品明细,JOIN 后这一单就会变成 3 行,COUNT(*) 统计的结果直接翻了 3 倍。这不是 count 的错,是 JOIN 的语义改变了行粒度。统计订单总数时应该这样写:
sql复制SELECT COUNT(DISTINCT o.id)
FROM orders o
LEFT JOIN order_items i ON o.id = i.order_id
WHERE o.status = 'paid';
或者干脆先聚合再 JOIN:
sql复制SELECT COUNT(*)
FROM orders o
WHERE o.status = 'paid'
AND EXISTS (SELECT 1 FROM order_items i WHERE i.order_id = o.id);
EXISTS 写法在大表场景下往往比 JOIN + DISTINCT 更高效,语义也更明确,"只要存在明细就算订单"。
坑二:分页接口每次都要精确 count
很多同事喜欢在分页查询时先 SELECT COUNT(*) 拿总条数,再查当前页数据。如果数据量小没问题,一旦几千万行,每次翻页 count 一次,数据库压力非常大。
优化的常见思路是:如果业务上不需要显示精确总页数,就改成"查前 N+1 条",比 N 多出来就说明还有下一页,直接隐藏"共 X 页"的展示即可。如果产品非要显示精确总数,再考虑用缓存或汇总表。
坑三:字符集和排序规则导致的去重不准
COUNT(DISTINCT name) 去重时,name 列如果用的是不区分大小写的排序规则(例如 utf8mb4_general_ci),那么 'Tom' 和 'tom' 会被视为同一条数据。大多数场景这不是问题,但做账号统计时如果不希望大小写合并,就要明确使用区分大小写的排序规则,或者对字段做二进制比较:
sql复制SELECT COUNT(DISTINCT BINARY name) FROM user;
这种细节平时不遇到根本不觉得是问题,遇到了能把人卡半天。写统计脚本之前先确认一下字段的 collation,能省去不少返工时间。
关于count,我最后想说的
count 函数我用了快十年,从最开始只会写 count(*) 数总数,到后来被报表数据坑过、被慢查询工单教育过,再到现在能快速判断一个统计需求该用哪种方案,最大的体会是:写 SQL 之前先想清楚"这个数字的表意粒度"。你要的数到底是"行数"、"非空字段数"还是"去重后的实体数",三者对应的写法完全不同。
如果问我现在的新人最缺什么,我觉得不是语法,而是对执行原理的敏感度。遇到 count 慢,先打开 EXPLAIN 看看是全表扫还是走索引;遇到统计结果和预期不符,先想想是不是 NULL 过滤了、是不是 JOIN 把行放大了。对一件事不确定的时候,用一条最简 SQL 快速验证,比盯着代码猜一整天强太多。
最后分享一个我一直在用的小习惯:所有涉及 count 的统计 SQL,我都会在本地准备一个几万行数据的测试库,把要上线的 SQL 先跑一遍,观察执行时间和返回行数是否符合直觉。这个小动作帮我挡掉过至少三次线上事故。也希望这篇文章能帮你少踩几个坑,让你在 Mysql count 上少走弯路。
