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:行格式过大

如果表中有TEXTBLOB等大字段,行格式可能是动态的,读取成本更高。

解决方案:确保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的估算行数

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

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 小时前
【口算王|01】HarmonyOS ArkTS 口算题生成实战:按年级、运算类型和难度生成可控题目
算法·harmonyos·arkts·随机生成·口算题
lzx_0021 小时前
C++11(一)
开发语言·c++·算法
老周聊架构1 小时前
Ontology:Palantir 架构真正的核心,不是数据库也不是知识图谱
数据库·架构·知识图谱
天衍四九-1 小时前
第一章:从 LLM 到 Agent —— DeepSeek Harness 入门
网络·数据库·人工智能·python
529宝宝起名网1 小时前
用 Python 实现名字寓意评分算法:基于 NLP 语义分析的名字内涵深度评估
python·算法·自然语言处理
kyrie_sakura1 小时前
MySQL数据库学习笔记2--系统函数(分组,单行,窗口函数)
数据库·学习·mysql
我不是程序员三三2 小时前
如何监控电脑|企业终端行为监控概念科普与落地方法论
数据库·电脑·php
带多刺的玫瑰2 小时前
Leecode#26刷题之删除有序数组中的重复项
数据结构·算法·leetcode
大牧师3 小时前
TypeORM 入门教程
后端·sql·mysql·orm·nest·typeorm