大家好,我是小耶,写功课只是为了我踩过的坑,你们别再踩了!
COUNT(*)和COUNT(1)哪个更快?
这个问题在技术论坛上吵了十几年。我见过最极端的讨论,有人为了争这个在帖子里盖了200多楼。答案从“一样快”到“COUNT(1)更快”到“COUNT(*)更快”反复横跳。
2026年了,这个问题还值得吵吗?
不值得。因为答案早就有了——看执行计划。
但更值得问的问题是:如果你还在纠结这两个,说明你根本不理解COUNT真正的性能瓶颈在哪。
今天从执行计划、存储引擎、索引选择、优化器行为四个层面,把COUNT这件事彻底讲透。
一、COUNT的语义:5种写法的完整拆解
很多人的问题从一开始就问错了。他们问的是“性能”,但连语义都没搞清楚。
COUNT是聚合函数,计算一个SELECT结果集中的行数。但不同写法对“行”的定义完全不同:
COUNT(star)
计算所有行数
统计的是结果集中的每一行,不管列值是什么
写法:
COUNT(*)NULL处理:计入所有行
COUNT(常量)
计算所有行数(1是常量,每行都有值)
与
COUNT(*)在语义上等价写法:
COUNT(1)、COUNT(42)、COUNT('hello')NULL处理:计入所有行
COUNT(列名)
计算指定列中非NULL值的行数
只统计该列不为NULL的行
写法:
COUNT(column_name)NULL处理:只计入非NULL的行
COUNT(DISTINCT 列名)
计算指定列中非NULL值的去重后个数
写法:
COUNT(DISTINCT column_name)NULL处理:不计入NULL
COUNT(star) OVER()
窗口函数版本,计算当前窗口内的行数
写法:
COUNT(*) OVER(PARTITION BY ...)NULL处理:计入所有行
| 写法 | 含义 | 是否统计NULL行 |
|---|---|---|
COUNT(*) |
所有行数 | ✅ 是 |
COUNT(1) |
所有行数 | ✅ 是 |
COUNT(col) |
某列非NULL行数 | ❌ 否 |
COUNT(DISTINCT col) |
某列去重非NULL值个数 | ❌ 否 |
COUNT(*) OVER() |
窗口内行数 | ✅ 是 |
关键结论:COUNT(*)和COUNT(1)在语义上完全等价——都是统计所有行数。它们在MySQL 8.0中的执行计划也完全一样。
二、COUNT的存储引擎原理:为什么InnoDB没有“总行数”?
这个问题是所有COUNT性能问题的根源。
MyISAM引擎:存储表的总行数,COUNT(*)直接返回,O(1)。
InnoDB引擎:不存储总行数,COUNT(*)需要实时扫描数据。因为InnoDB支持MVCC(多版本并发控制),不同事务在同一时刻看到的数据行数可能不同,无法缓存一个“总行数”。
所以在InnoDB里,COUNT永远是一个实时计算操作。
那InnoDB怎么执行COUNT的?
没有WHERE条件时:优化器会选择最小的索引(二级索引通常比聚簇索引小)进行扫描,快速估算行数,可能返回近似值。对于
COUNT(*),优化器直接读取索引的元数据,不扫描数据行,所以很快——但这是近似值,不是精确值,且两者在8.0里执行路径相同。有WHERE条件时:必须根据WHERE条件扫描对应的索引或数据,精确计数。
精确COUNT永远需要扫描行数据:所谓的“快”只是相对于全表扫描而言,本质仍然是扫描操作。
三、执行计划分析:不同COUNT写法的真实差异
在1000万行数据的测试表上(有主键id,有二级索引status,status列有20%的NULL值):
场景1:无WHERE条件
| 写法 | 执行计划 | 扫描对象 | 优化器行为 |
|---|---|---|---|
COUNT(*) |
Select tables optimized away |
无实际扫描 | 直接从统计信息读取估算行数 |
COUNT(1) |
Select tables optimized away |
无实际扫描 | 同上,与COUNT(*)完全相同 |
COUNT(id) |
Select tables optimized away |
无实际扫描 | 同上,主键索引统计信息 |
COUNT(status) |
全索引扫描 | status二级索引 | 实际扫描,统计非NULL行数 |
关键区别:
COUNT(*)、COUNT(1)、COUNT(主键)在无WHERE条件时都走Select tables optimized away,直接从索引统计信息读取,几乎没有性能差异。COUNT(status)必须实际扫描status索引,统计非NULL的行数,需要扫描整个二级索引,是全量精确COUNT中最慢的写法之一(如果该列有大量NULL值,仍需扫描完整索引统计非NULL行数,无法跳过NULL)。
场景2:有WHERE条件
| 写法 | 执行计划 | 扫描对象 | 关键差异 |
|---|---|---|---|
COUNT(*) WHERE status=1 |
range |
status二级索引 | 只扫描status=1的行,无需回表 |
COUNT(1) WHERE status=1 |
range |
status二级索引 | 同上,优化器等价改写为COUNT(*) |
COUNT(id) WHERE status=1 |
range |
status二级索引+回表 | 需要回表读取id |
COUNT(status) WHERE status=1 |
range |
status二级索引 | 与COUNT(*)相同 |
有WHERE条件时,COUNT(*)和COUNT(1)的执行计划完全相同。 COUNT(列)如果列有索引,性能可能接近;如果列没有索引,则走全表扫描。
COUNT(列名)和COUNT(*)的核心差异在于语义:前者统计非NULL的行数,后者统计所有行数。如果列上有大量NULL值,两者结果可能差很多——但性能差异取决于列是否有索引,而非写法本身。
四、优化器视角:MySQL到底是怎么选索引的?
对于COUNT(*),MySQL优化器的决策逻辑是:
有WHERE条件:选择WHERE条件中最优的索引进行扫描
无WHERE条件:在候选索引中选择最小的索引(索引树页数最少的)进行扫描
为什么选最小的索引?因为COUNT(*)只需要统计行数,不需要回表读取完整行数据。二级索引比聚簇索引小得多——二级索引只包含索引列+主键值,聚簇索引包含所有列。
重要结论:COUNT(*)不会扫描聚簇索引(除非没有二级索引),因为二级索引的扫描成本更低。这也是为什么COUNT(*)在InnoDB中不是“全表扫描”——它是“全二级索引扫描”。
五、COUNT慢的5个真正原因
原因1:无索引的WHERE条件
这是最常见的场景:
-- status没有索引
SELECT COUNT(*) FROM orders WHERE status = 'PAID';
这会导致全表扫描(扫描聚簇索引),即使加了COUNT(*)也快不起来。
✅ 解决方案:在WHERE条件列上建索引。
原因2:需要精确计数的超大表
一张1亿行的表,任何精确COUNT都需要扫描大量数据。即使走二级索引,也要扫描上亿行索引数据。
✅ 解决方案:考虑用计数表(单独维护计数器)或Redis缓存。
原因3:WHERE条件中使用了函数或隐式类型转换
-- 错误写法:对索引列用了函数
SELECT COUNT(*) FROM orders WHERE DATE(create_time) = '2026-09-01';
-- 错误写法:隐式类型转换(create_time是字符串)
SELECT COUNT(*) FROM orders WHERE create_time = 20260901;
这些写法都会导致索引失效,走全表扫描。
✅ 解决方案:确保WHERE条件中的列是原始列,类型匹配。
原因4:COUNT(DISTINCT)的排序开销
SELECT COUNT(DISTINCT user_id) FROM orders;
COUNT(DISTINCT)内部需要去重,通常需要排序或哈希操作,对大数据集来说很重。
✅ 解决方案:如果业务对“精确去重”要求不高,考虑使用APPROX_COUNT_DISTINCT()(MySQL 8.0.17+支持)或HyperLogLog。
原因5:行格式过大
如果表中有TEXT、BLOB等大字段,行格式可能是动态的,读取成本更高。
✅ 解决方案:确保COUNT只走二级索引,避免回表。
六、4种COUNT优化方案
方案1:用小二级索引替代主键索引
当表没有WHERE条件时,MySQL选择最小的索引扫描。
如果一张表有主键(聚簇索引)和多个二级索引,优化器会选择叶子节点数量最少的索引。
-- 给一个占用空间小的列建二级索引
CREATE INDEX idx_small ON orders(status);
-- COUNT(*) 会优先扫描 idx_small 而不是主键
方案2:用计数表(Counter Table)
适合频繁计数的业务场景。
CREATE TABLE order_stats (
stat_date DATE PRIMARY KEY,
total_orders INT,
paid_orders INT
);
每次插入/更新订单时,同步更新计数表。查询COUNT时直接读计数表,O(1)。
方案3:用EXPLAIN的估算行数
如果业务允许“近似值”,直接看EXPLAIN的rows字段。
EXPLAIN SELECT * FROM orders WHERE status = 'PAID';
-- rows列显示估算行数
EXPLAIN FORMAT=TREE提供了更详细的估算信息,包括每次操作的代价。
方案4:用分区表降低扫描范围
如果表按时间RANGE分区,COUNT只扫描相关分区。
-- 按月分区后 SELECT COUNT(*) FROM orders WHERE create_time >= '2026-09-01'; -- 只扫描9月分区
当然,分区表有额外的维护成本,需要权衡。
七、总结
COUNT(*) vs COUNT(1)的争论可以停了。它们在MySQL 8.0中执行计划完全相同。
真正值得关注的问题只有三个:
WHERE条件能不能用到索引? → 不能就建索引
业务能不能接受近似值? → 能就用
EXPLAIN估算能不能用计数表缓存? → 能就单独维护
把精力放在这三个问题上,比争论COUNT(*)和COUNT(1)哪个快有意义100倍。
小耶在手,SQL 不愁
还有什么想了解的,欢迎留言!小耶一定知无不言言无不尽……我们下次见~