尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

MySQL中count(*)、count(1)和count(列名)的区别与性能解析

MySQL中count(*)、count(1)和count(列名)的区别与性能解析 1. 一个让候选人当场愣住的面试题先还原一下面试现场。面试官翻开简历看到候选人写了“熟悉 MySQL”随口问了一句说下count(1)、count(*)和count(列名)到底有什么区别很多候选人当场就愣住了。原因很现实——平时写 SQL 的时候count(*)用过count(1)也见过偶尔还会写count(id)但从来没认真想过它们之间有什么区别。脑子里模模糊糊有个印象好像count(1)比count(*)快又好像不是至于count(列名)只知道它好像不统计NULL但具体怎么回事又说不太清。这个问题的杀伤力不在于它难而在于它太基础了。基础到你觉得你肯定知道但真让你说又说不完整。本文就把这个问题彻底讲透。从count函数的语义出发结合 MySQL 的执行计划和实测数据把三者的区别、性能表现、NULL 值处理、面试回答思路一次讲清楚。即使你之前没系统研究过跟着本文走一遍下次再遇到这个问题也能从容应对。2. count 到底是什么先把基础概念对齐要搞清楚三者的区别不能直接背结论得先明白count这个函数在数据库里到底是怎么工作的。2.1 count 函数的语义count是一个聚合函数作用是统计“满足条件的行数”。它的返回值是一个数字表示查询结果集中符合要求的行数。在 SQL 标准中count有两种用法count(*)统计结果集的总行数包括值为NULL的行。count(expr)统计表达式expr不为NULL的行数。注意这里的expr可以是列名也可以是一个表达式或者常量。count(1)实际上属于count(expr)的一种特殊形式只不过这里的表达式是常量1。2.2 一个关键点NULL 值的处理理解count的关键在于搞清楚它对NULL值是什么态度。在 SQL 中NULL代表“未知”或“不存在”它不是空字符串也不是数字 0。任何普通表达式和NULL进行比较结果都是NULL。因为聚合函数在计算时通常会忽略值为NULL的输入。count(*)比较特殊它直接统计行数不会去管某一列是不是NULL也就是说即使有一行的所有列都是NULL这一行也会被count(*)算进去。而count(列名)不一样。它会遍历这一列的所有值凡是遇到NULL就跳过只统计非NULL值的个数。举个例子假设有一张表user只有一列name表里有 3 行数据name ----- 张三 NULL 李四count(*)的结果是 3因为表里确实有 3 行。count(name)的结果是 2因为有一行的name是NULL被跳过了。这个差异在面试中经常被问到也是实际开发中最容易踩坑的地方。2.3 三种写法的字面区别现在把三种写法放在一起看写法类型统计内容count(*)统计行数所有行包括 NULLcount(1)统计行数所有行包括 NULLcount(列名)统计非 NULL 值个数只统计该列不为 NULL 的行从字面语义上count(*)和count(1)是等价的它们都统计总行数。而count(列名)统计的是“非 NULL 值个数”语义完全不同。但是事情没这么简单。如果面试官只想知道这一步那这个问题就不值得单独拿出来问了。真正的重点是它们在 MySQL 里是怎么执行的性能有没有差别这正是下一节要讨论的内容。3. 核心区别拆解从语义到执行3.1 count(*) 和 count(1)语义等价执行基本一致先看一个很多文章都会提到的说法count(1)比count(*)快。这个说法在一些老的资料里出现过理由是count(*)会对所有列做一次“读取”而count(1)只判断常量 1不需要读取数据。但在现代数据库尤其是 MySQL 的 InnoDB 存储引擎中这种说法已经过时了。MySQL 的优化器非常聪明。对于count(*)和count(1)它会把它们都当作“统计行数”来处理。在执行计划层面两者几乎没有任何区别。也就是说count(*)并不会真的去“展开所有列”把每一列都读一遍然后数行数。它内部直接走行数统计的逻辑。可以做一个简单的验证。在 MySQL 中执行EXPLAIN SELECT COUNT(*) FROM user; EXPLAIN SELECT COUNT(1) FROM user;你会发现这两条 SQL 的执行计划是完全一样的用到的索引、扫描行数、访问类型都一致。结论在 MySQL 中count(*)和count(1)的执行效率基本一致不存在谁比谁更快的明显差异。那count(1)中这个1到底代表什么它只是一个常量表达式优化器在执行时不会真的对每一行都做“计算 1”这个操作它只是借这个写法表达“我要统计行数”。所以不要被这个 1 误导以为它和走索引有什么关系。3.2 count(列名) 和 count(*) 的本质区别count(列名)和count(*)的区别不仅体现在对NULL的处理上还体现在执行效率上。因为count(列名)需要判断该列的值是否为NULL所以它必须“读取”这一列的值。而count(*)不需要关心任何列的值在 InnoDB 中可以直接通过索引来统计行数。那么问题来了如果被统计的列没有索引count(列名)会怎么做它只能扫描整张表的所有数据行然后逐行取出这列的值做判断再累加计数。这个过程比count(*)要重得多。举个例子-- user 表的 name 列没有索引 SELECT COUNT(name) FROM user;如果name列没有索引这条 SQL 只能走全表扫描把每行的name值读出来判断非 NULL 才计数。而SELECT COUNT(*) FROM user;如果表上有任何一个索引哪怕是二级索引优化器会选一个最小的索引来做统计而不需要扫描全部数据行。因为 InnoDB 的聚簇索引主键索引叶子节点存的是整行数据二级索引叶子节点存的是索引列值和主键值扫描二级索引要比扫描聚簇索引快得多。这就引出了一个面试中常见的进阶问题为什么count(*)执行起来可能比count(主键列)更快答案的关键在于索引选择而不是函数本身的区别。这一点后面会详细展开。3.3 NULL 与“不可见”陷阱可能有人会想那为了避免NULL问题我在设计表时把所有字段都设为NOT NULL是不是count(列名)就和count(*)一样了逻辑上确实如此但要注意一点只要列被定义为NOT NULL执行count(列名)时优化器就不需要判断 NULL它的执行效率可能会与你用count(*)接近。但这也依赖具体的执行计划。如果这一列没有索引可用仍然可能变成全表扫描。所以更加稳妥的理解方式是count(*)统计行数不关心 NULL。count(列名)统计非 NULL 值个数天然会忽略 NULL。无论列有没有索引、是否允许 NULL语义都不会改变。4. 深入执行过程InnoDB 和 MyISAM 的差异很多面试者在解释count(*)性能时容易混淆存储引擎的区别。这里单独用一节来说清楚。4.1 MyISAM直接读行数在 MyISAM 存储引擎中表的行数是被单独维护的存在表的元数据里。所以执行count(*)时MyISAM 不需要真正扫描表而是直接返回这个预先维护好的行数。这也是为什么 MyISAM 的count(*)非常快。但要注意这只是 MyISAM 的行为。而且它的“快”是有条件的如果 SQL 中带了WHERE条件MyISAM 同样不能直接返回行数必须老老实实扫描。4.2 InnoDB没有缓存行数InnoDB 不一样。它为了保证事务的一致性尤其是多版本并发控制MVCC的支持不能缓存一个固定的行数。原因是不同事务在同一时刻看到的行数可能不同。举个例子事务 A 读表时表里有 100 行。此时事务 B 插入 1 行并提交。事务 A 再读时它应该看到多少行取决于事务 A 的隔离级别和快照时间。InnoDB 必须通过扫描索引来实时计算行数才能保证这种一致性。所以 InnoDB 执行count(*)时不能像 MyISAM 那样直接返回一个缓存值。这也是为什么大表的count(*)会慢因为它是真的在扫描索引。4.3 InnoDB 如何选择索引如果面试官继续追问InnoDB 扫描索引那扫的是哪个索引答案是优化器会选择一棵最小的索引树来扫描。InnoDB 的数据是存储在聚簇索引主键索引中的聚簇索引的叶子节点保存着整行数据。如果一个表没有主键InnoDB 会选择一个非空的唯一索引作为聚簇索引如果都没有InnoDB 会隐藏生成一个 rowid 作为聚簇索引。除了聚簇索引其他索引都叫二级索引。二级索引的叶子节点保存的是索引列的值和主键值不包含整行数据所以二级索引通常比聚簇索引小。对于count(*)这种只统计行数、不关心列值的操作优化器当然更倾向于扫描一棵更小的二级索引树。因为扫描的索引树越小需要读入的页越少IO 开销越低。所以在执行count(*)时如果表上有多个索引优化器会选一个最小的二级索引来扫描而不是拿主键索引来扫。这打破了一个常见的误解count(主键)一定比count(*)快。实际上可能恰恰相反。如果主键是bigint而某个二级索引是tinyint类型那么扫描二级索引的 IO 成本更低优化器可能选择count(*)走二级索引而count(主键)反而要走更大的聚簇索引或更宽的二级索引。所以不要背“count(主键) 最快”这种结论要理解优化器选索引的逻辑。4.4 没有索引怎么办如果表上没有索引比如一张只有几行数据的临时表那么count(*)、count(1)、count(列名)都可能走全表扫描。这时性能差异基本可以忽略真正的瓶颈在 IO 和扫描行数上。对于真正的生产环境表通常都会有主键索引或者业务索引所以count操作基本都能找到可用的索引。5. 实战验证对比执行计划与耗时理论说再多不如动手验证。下面我们用 MySQL 8.0 环境创建一张表插入大量数据用三种写法分别做实验。5.1 创建测试表CREATE DATABASE IF NOT EXISTS demo_count DEFAULT CHARSET utf8mb4; USE demo_count; DROP TABLE IF EXISTS t_user; CREATE TABLE t_user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, name VARCHAR(50) DEFAULT NULL COMMENT 姓名, age INT DEFAULT NULL COMMENT 年龄, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态, PRIMARY KEY (id), KEY idx_status (status) ) ENGINEInnoDB COMMENT用户表测试;这里给status建了一个二级索引。后面我们会看到这个索引对count(*)的性能影响很大。5.2 插入测试数据为了模拟真实场景我们用一个存储过程插入 100 万行数据。DELIMITER $$ DROP PROCEDURE IF EXISTS insert_test_data$$ CREATE PROCEDURE insert_test_data() BEGIN DECLARE i INT DEFAULT 1; SET autocommit 0; WHILE i 1000000 DO INSERT INTO t_user (name, age, status) VALUES ( CONCAT(user_, i), FLOOR(RAND() * 100), IF(i % 2 0, 1, 0) ); IF i % 1000 0 THEN COMMIT; END IF; SET i i 1; END WHILE; COMMIT; END$$ DELIMITER ; CALL insert_test_data();插入完成后可以查看一下表的总行数SELECT COUNT(*) FROM t_user;预期结果是 1000000。如果你的机器性能一般插入过程可能需要一些时间耐心等一下即可。5.3 三种写法执行计划对比先来看执行计划EXPLAIN SELECT COUNT(*) FROM t_user; EXPLAIN SELECT COUNT(1) FROM t_user; EXPLAIN SELECT COUNT(name) FROM t_user; EXPLAIN SELECT COUNT(id) FROM t_user; EXPLAIN SELECT COUNT(status) FROM t_user;重点看key列和rows列。在大部分情况下你会看到count(*)使用idx_status二级索引进行扫描。count(1)使用idx_status二级索引进行扫描。count(name)因为name列没有索引可能走全表扫描。count(id)如果只有主键索引可用会走主键索引如果表上有二级索引优化器也可能选二级索引。count(status)status有索引会走idx_status。从执行计划就能看出来是否有可用索引是影响count性能最核心的因素。5.4 耗时对比执行SELECT COUNT(*) FROM t_user; SELECT COUNT(1) FROM t_user; SELECT COUNT(name) FROM t_user; SELECT COUNT(id) FROM t_user; SELECT COUNT(status) FROM t_user;我的测试环境多次执行后耗时的分布大致如下SQL扫描对象耗时表现COUNT(*)二级索引快COUNT(1)二级索引快COUNT(name)全表name 无索引慢COUNT(id)二级索引或主键索引快COUNT(status)二级索引快这里要注意耗时表现受数据量、索引大小、缓存情况、机器配置影响很大不同环境会有差异。但有一个规律是通用的扫描的数据量越少速度越快。这也解释了为什么name列没有索引时count(name)最慢因为它真的要面对 100 万行数据的目标列值逐一判断。5.5 结果解读从上面的实验可以得到三个明确的结论count(*)和count(1)的执行计划几乎一致性能没有明显差异。count(列名)可能因为没有索引而全表扫描成为性能瓶颈。即便count(列名)有索引它仍然会忽略NULL在语义上和前两者不同。真正让count变快或变慢的不是写法本身而是优化器能不能找到一棵合适的索引树来扫描。6. 面试官真正想听到的回答面试时遇到这个问题不要只抛结论要展现出你对 SQL 执行过程和存储引擎的理解。这里给出一套可以直接用的回答思路。6.1 三步回答法第一步讲语义区别count(*)统计的是结果集总行数包括 NULL 值count(1)和它语义一致也是统计总行数包括 NULL 值count(列名)统计的是该列非 NULL 值的个数遇到 NULL 会跳过。第二步讲 MySQL 执行层面的区别在 MySQL InnoDB 中count(*)和count(1)执行计划基本相同优化器都会选一棵较小的二级索引树来扫描。而count(列名)需要读取该列的值做非空判断如果列没有索引通常要做全表扫描性能更差。第三步讲影响性能的关键因素影响count性能的主要因素是优化器选择的索引而不是函数写法本身。InnoDB 没有像 MyISAM 那样缓存行数所以必须实时扫描索引统计。能走二级索引就不要走全表扫描。6.2 标准回答示例如果面试时要把话说完整可以参考这样一段先说语义。count(*)就是统计行数不管某列是不是 NULL它都算进去。count(1)也是统计行数因为 1 是一个常量表达式优化器会把它等同于count(*)处理。count(列名)不一样它只统计这一列非 NULL 的行数所以如果列里有 NULL 值结果会比其他两种方式小。在执行效率上MySQL 的 InnoDB 引擎没有缓存行数count(*)需要扫描索引。优化器通常会选一棵最小的二级索引来扫描所以只要表上有合适的索引count(*)并不一定慢。count(列名)要看列上有没有索引如果没索引就要全表扫描性能最差。另外即使count(列名)对应的列有索引如果它的索引树比别的索引大扫描效率还是会受影响。所以我的结论是业务上要统计总行数优先用count(*)如果业务上要统计某列非空值个数再用count(列名)。6.3 面试官可能的追问面试官通常不会只问一个问题就结束。针对这个主题常见的追问包括count(1)是不是一定比count(*)快答在现代 MySQL 中基本不存在这种差距优化器会生成相近的执行计划。不要迷信“count(1) 更快”。count(主键)是不是一定比count(*)快答不一定。如果表上有更小的二级索引count(*)会选二级索引扫描反而可能比count(主键)快。如果要统计一张 1 亿行大表的行数怎么做最合适答生产环境下不要频繁精确执行count(*)。可以结合业务缓存行数、使用信息模式估算或者用单独的计数表记录。确实需要精确值时要接受扫描成本并配合覆盖索引优化。count(*)在 MyISAM 上快是因为什么答MyISAM 在元数据中直接保存了表的行数执行count(*)时不需要扫描数据直接返回。但 InnoDB 为了保证事务一致性不支持这种机制。这些问题都是围绕count展开的只要把原理吃透现场组织语言完全可以应对。7. 常见误区与排查清单7.1 常见误区围绕count的误区不少我列几个非常典型的误区一count(1)一定比count(*)快。正确理解在 MySQL 8.0 的 InnoDB 中两者执行计划基本一致性能无显著差异。误区二count(主键)一定比count(*)快。正确理解不一定优化器会选最小的索引树扫描count(*)反而更能利用二级索引。误区三count(列名)和count(*)结果一定一样。正确理解如果列里有NULL结果会不同。count(列名)忽略NULL。误区四表行数可以从information_schema.tables拿到精确值。正确理解这个值是估算值不是精确值不能替代count(*)。误区五count(*)会做全表扫描。正确理解大多数情况下优化器会选索引扫描除非表上没有索引或优化器判断全表扫描更合适。7.2 排查清单如果你在生产环境遇到count查询慢可以按下面的顺序排查排查项操作期望结果查看执行计划EXPLAIN SELECT COUNT(*) FROM table确认是否走索引检查可用索引SHOW INDEX FROM table找到合适的二级索引检查 WHERE 条件确认条件列是否有索引避免大范围回表分析表数据量查看扫描行数明确性能瓶颈确认存储引擎SHOW TABLE STATUS确认是 InnoDB考虑业务场景是否需要精确值大表可考虑估算方案7.3 如何避免踩坑日常开发中建议建立几个本能反应写count之前先想清楚业务要的是“总行数”还是“某列非空值个数”。如果只是统计总行数大胆用count(*)不要被网上过时的性能结论干扰。给高频查询的count条件列建合适的索引。大表统计行数要评估成本不要在前端触发接口里直接跑count(*)。8. 工程建议与生产环境实践面试之外真实工程里count的使用场景也更复杂。这里补充一些实战建议。8.1 统计行数优先使用 count(*)如果你只需要“表里有多少行”这个结果直接写count(*)。原因有三点语义清晰表达“统计行数”的意图。优化器对count(*)的优化处理很成熟会主动选择最优索引。在代码评审和后续维护中count(*)的意图最容易被理解。有些团队为了“性能”硬性规定用count(1)如果没有实测数据支撑这种规定反而容易误导新人。8.2 大表行数统计方案如果你的表已经到了百万、千万甚至亿级直接count(*)会消耗大量资源。这时要根据业务场景选择方案。方案一业务计数表单独建一张统计表在业务事务里更新计数。例如用户表每插入一行计数表的total_user字段就加 1。适合对精确度要求高的场景。当然要保证计数更新和业务操作在同一个事务里避免计数失真。方案二缓存将行数缓存在 Redis 中定期刷新。适合对实时性要求不高的展示场景比如后台管理系统首页的“累计用户数”。方案三估算值如果只是做展示可以用information_schema.tables里的估算值或者SHOW TABLE STATUS查看行数估算。但要知道它是估算不是精确值。方案四定期快照把精确计数作为一个定时任务在低峰期执行结果写入统计表。适合每日报表这类场景。我在实际项目中最常用的是“业务计数表 定时任务校准”的组合。定时任务在夜间低峰期执行一次精确count(*)对比并校正计数表里累积出来的误差。既保证了业务的实时性又避免了计数的长期漂移。8.3 count 与分页总数列表接口经常需要返回总条数来实现分页。常见的做法是// 伪代码 int total userMapper.countUser(query); ListUser list userMapper.pageUser(query, page, size);当数据量很大时countUser可能成为性能瓶颈。此时可以考虑只对必要的分页场景返回总数比如前几页之后不再查询总数。用“是否有下一页”替代“总页数”减少一次精确 count。对总数加缓存设置合理的过期时间。将分页列表和 count 分开优化给 count 查询建专门索引。8.4 覆盖索引对 count 的优化如果count查询带有WHERE条件比如SELECT COUNT(*) FROM order WHERE status 1;那么(status)上的索引可以帮助优化器减少扫描范围。更进一步如果查询条件能覆盖一个二级索引比如status索引包含所有需要的列InnoDB 就不需要回表效率更高。需要说明的是覆盖索引并不是让count变成 O(1)而是让单行扫描的成本更低。在数据量特别大时效果才会明显。8.5 不要在生产环境随意执行无谓的大 count有人会在排查问题时顺手在线上执行一条不带条件的count(*)结果把一个核心库的 IO 打满了。这种操作风险很高。如果确实需要知道线上大表的行数优先用下面这种安全的方式SELECT TABLE_ROWS FROM information_schema.tables WHERE TABLE_SCHEMA your_db AND TABLE_NAME t_user;虽然不精确但可以给一个量级参考。如果你要精确值建议在从库或者专门的统计环境执行不要在业务高峰期直接对主库跑大 SQL。9. 总结与延伸学习这篇文章围绕一个高频面试题展开核心要点可以归纳为三点count(*)和count(1)语义相同都是统计总行数在 MySQL InnoDB 中执行计划基本一致没有明显的性能差异。count(列名)只统计非NULL值的个数语义上与前两者有着本质区别。影响count性能的关键是索引选择和扫描行数而不是函数写法本身。InnoDB 没有缓存行数必须实时扫描索引因此合适的二级索引是提升count性能的关键。如果你在准备面试建议不只是背结论而是结合本文第 4 节的内容把 InnoDB 为什么要扫描索引、优化器如何选索引这两条主线理清楚。面试官问这个问题很多时候不是想听你背出两者的“速度对比”而是想考察你对数据库底层执行机制的理解程度。如果你想继续深入下一步可以学习这几个方向InnoDB 的索引结构B 树是如何存储数据的。覆盖索引与回表的概念。MySQL 优化器选择执行计划的整体流程。大表分页和统计的架构方案。这些问题在面试里也能串成一条线值得花时间研究。动手验证是最好的学习方式建议你在自己的测试库里建一张百万行表把本文的 SQL 都执行一遍亲眼看看执行计划和耗时差异印象会比单纯读文章深刻得多。如果本篇文章对你有帮助可以收藏备用下次面试或写 SQL 前翻出来看一眼相信能帮你少踩几个坑。
返回列表