COUNT慢不是因为用了*,是这5个原因——1000万行数据实测+执行计划深度解析

大家好,我是小耶,写功课只是为了我踩过的坑,你们别再踩了!

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优化器的决策逻辑是:

  1. 有WHERE条件:选择WHERE条件中最优的索引进行扫描

  2. 无WHERE条件 :在候选索引中选择最小的索引(索引树页数最少的)进行扫描

为什么选最小的索引?因为COUNT(*)只需要统计行数,不需要回表读取完整行数据。二级索引比聚簇索引小得多------二级索引只包含索引列+主键值,聚簇索引包含所有列。

重要结论 :COUNT(*)不会扫描聚簇索引(除非没有二级索引),因为二级索引的扫描成本更低。这也是为什么COUNT(*)在InnoDB中不是"全表扫描"------它是"全二级索引扫描"。

五、COUNT慢的5个真正原因

原因1:无索引的WHERE条件

这是最常见的场景:

bash 复制代码
-- status没有索引
SELECT COUNT(*) FROM orders WHERE status = 'PAID';

这会导致全表扫描 (扫描聚簇索引),即使加了COUNT(*)也快不起来。

✅ 解决方案:在WHERE条件列上建索引。

原因2:需要精确计数的超大表

一张1亿行的表,任何精确COUNT都需要扫描大量数据。即使走二级索引,也要扫描上亿行索引数据。

✅ 解决方案 :考虑用计数表 (单独维护计数器)或Redis缓存。

原因3:WHERE条件中使用了函数或隐式类型转换

bash 复制代码
-- 错误写法:对索引列用了函数
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)的排序开销

bash 复制代码
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选择最小的索引扫描。

如果一张表有主键(聚簇索引)和多个二级索引,优化器会选择叶子节点数量最少的索引。

bash 复制代码
-- 给一个占用空间小的列建二级索引
CREATE INDEX idx_small ON orders(status);
-- COUNT(*) 会优先扫描 idx_small 而不是主键

方案2:用计数表(Counter Table)

适合频繁计数的业务场景。

bash 复制代码
CREATE TABLE order_stats (
    stat_date DATE PRIMARY KEY,
    total_orders INT,
    paid_orders INT
);

每次插入/更新订单时,同步更新计数表。查询COUNT时直接读计数表,O(1)。

方案3:用EXPLAIN的估算行数

如果业务允许"近似值",直接看EXPLAIN的rows字段。

bash 复制代码
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中执行计划完全相同。

真正值得关注的问题只有三个:

  1. WHERE条件能不能用到索引? → 不能就建索引

  2. 业务能不能接受近似值? → 能就用EXPLAIN估算

  3. 能不能用计数表缓存? → 能就单独维护

把精力放在这三个问题上,比争论COUNT(*)和COUNT(1)哪个快有意义100倍。

小耶在手,SQL 不愁

还有什么想了解的,欢迎留言!小耶一定知无不言言无不尽......我们下次见~

相关推荐
冰暮流星1 分钟前
mysql之索引结构
数据库·mysql
闲云自留地2 分钟前
从结绳记事到 MySQL:数据库原理 + MySQL 基础一篇通
数据库·mysql
逃逸线LOF7 分钟前
Redis快速入门
数据库·redis·缓存
就叫年华吧丶8 分钟前
长文档点目录定位总是不准?一个 content-visibility + 平滑滚动引发的连环坑(Vue3 实战)
前端·javascript·算法·vue
FPGA信号处理15 分钟前
【信号检测与估计】第四节课:非高斯噪声下的 BLUE、极大似然与 EM 算法
人工智能·算法·机器学习
施嘉伟20 分钟前
Oracle 在线重定义卡了一个多小时:查不到阻塞会话
数据库·oracle
꯭自꯭闭꯭21 分钟前
达梦守护集群手工切换及故障切换
linux·运维·服务器·数据库
j7~24 分钟前
【Redis初阶】(篇二)《一文吃透 Redis:特性、应用场景、版本演进与安装配置全解析》
数据库·redis·缓存·redis的特性·redis的主要应用场景·redis重要文件及其作用·安装并启动redis
.道阻且长.41 分钟前
C++ 11:可变参数模板
前端·c++·算法
Elastic 中国社区官方博客1 小时前
使用 NVIDIA cuVS 在 Elasticsearch 中实现 GPU 加速的向量索引:在 10 分钟内处理 1.38 亿个向量
大数据·数据库·elasticsearch·搜索引擎·全文检索·安全威胁分析