ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

SQL优化:彻底搞懂count(*)、count(1)与count(列名)的区别与性能

SQL优化:彻底搞懂count(*)、count(1)与count(列名)的区别与性能 我做了八年SQL开发和数据库优化带过不少新人发现几乎每个来面试的候选人都会背“count(1)比count()快count(列名)不统计NULL”但一问到“为什么”“什么场景下用哪个”大多数人就卡壳了。尤其最近在排查慢SQL的时候发现很多线上事故的根源就是count函数用错了。今天不聊虚的直接把count(1)、count()和count(列名)这三兄弟扒干净讲清楚它们底层怎么执行、性能差在哪、实际项目里怎么选顺便把我踩过的坑也一并晾出来。1. 三条count语句的本质区别很多教程喜欢直接甩结论“count(*)最快、count(1)次之、count(列名)最慢”这话说得太粗暴了容易把人带沟里。要想真正记住区别得先回到count函数本身的设计逻辑上来。1.1 count(*) 到底统计了什么count()的语义是“统计结果集中所有行的数量”它完全不关心任何一列的数据内容也不管这一行里的字段是不是NULL。数据库在执行count()时会直接读取索引或者表中记录的个数逐行累加最后返回总数。这里有个非常关键的点count()不会把行的内容取出来做判断它纯粹是在数“行数”。哪怕这一行的所有字段都是NULL只要这一行存在count()就会算进去。所以在InnoDB引擎下count(*)是统计所有物理存在的记录行数这个数字是最接近“这张表到底有多少条数据”的答案。1.2 count(1) 和 count(*) 的深度对比先说结论count(1)在语义上等于count()统计的行数范围和结果完全一样都是包含NULL行的总行数。很多老程序员喜欢用count(1)理由是“1是个常量比要快”这个说法在早期的Oracle和SQL Server里有一定道理但在现代版本的MySQL、PostgreSQL、SQL Server中这个性能差异已经微乎其微几乎可以忽略不计。我自己实测过一张500万行的订单表在MySQL 8.0 InnoDB下分别执行count(1)和count()执行计划完全一致走的索引扫描路径也一样耗时差距在毫秒级别没有任何实际意义。所以现在的开发规范里我更倾向于统一用count()——因为它的语义最清晰阅读代码的人一眼就能明白“这是在统计总行数”而不是“统计常数1的个数”。另外有个细节count(1)里的“1”并不是什么魔法数字它只是代表“一个非NULL的常量表达式”。数据库在处理时会为每一行生成一个常量值1并计数本质上还是在数行。它不会去读取任何列的数据这一点和count(列名)有本质区别。1.3 count(列名) 的特殊之处count(列名)的语义就和前两个完全不同了它统计的是“该列中非NULL值的个数”。比如执行count(user_name)数据库会逐行读取user_name这一列如果发现值为NULL就跳过不计数只有当该列有值时计数才加1。这就意味着count(列名)的结果可能小于表的总行数。如果某张表的某个字段允许NULL并且实际数据里确实存在NULL那么count(列名)和count(*)的数量一定不一样。很多新人在这里栽跟头拿count(列名)当天数用结果业务报表对不上。再看一个极端例子如果某一列全为NULLcount(该列)返回的就是0而count(*)返回的是表的总行数。这种差异在数据清洗、空值排查时特别有用但如果你没意识到这个区别很容易得出完全错误的数据结论。2. 存储引擎和索引如何影响count的性能上面说的是语义区别下面聊性能。很多同学把count的性能问题归结于“用count(*)还是count(1)”这其实找错了方向。真正决定count快慢的是数据库的存储引擎和索引利用情况。2.1 MyISAM与InnoDB的count天壤之别老版本的MySQL里MyISAM引擎对count()的优化非常激进它会在表的元数据里直接保存当前表的行数执行count()时根本不需要扫描数据直接把这个缓存值取出来返回。所以在MyISAM表上哪怕有几千万行count(*)也是毫秒级返回。而InnoDB不支持这种“行数缓存”机制原因很复杂简单说就是InnoDB为了实现事务隔离和MVCC多版本并发控制同一个表在不同事务里看到的行数可能是不同的所以没法维护一个全局统一的计数器。这也是为什么InnoDB下count(*)必须实时扫描数据或索引来统计。这也是一个经典面试陷阱问你“为什么MyISAM的count(*)快InnoDB却慢”答不上来的话会显得对存储引擎理解不深。关键点就在MVCC这个我下面细说。2.2 为什么InnoDB不能直接缓存行数MVCC的全称是Multi-Version Concurrency Control即多版本并发控制。InnoDB在执行事务时不同事务的隔离级别下看到的快照数据可能不一样。比如事务A开启后事务B插入了一条新记录并提交事务A再去执行count(*)时如果按可重复读隔离级别A不应该看到B插入的这条数据。如果InnoDB像MyISAM那样在元数据里缓存一个总行数那么这个行数应该以哪个事务的快照为准A还是B根本没法统一回答。所以干脆放弃缓存每次count都按照当前事务的快照去扫描数据这样才能保证事务隔离性的正确。理解了这一点你就明白为什么InnoDB的大表count()那么慢——因为它真的要把符合条件的索引记录扫一遍才能得出总数。这也是为什么会有人在千万级大表上执行count()后直接卡死把数据库拖垮的原因。2.3 索引对count性能的决定性影响如果InnoDB表是一张没有任何二级索引、只有主键的表那么执行count(*)时InnoDB只能扫描主键聚簇索引。主键索引的叶子节点保存的是整行的全部数据扫描起来IO开销很大速度自然慢。但如果你给表添加了一个或多个二级索引非主键索引InnoDB在优化器允许的情况下会选择“最小的索引树”来扫描。什么是“最小的索引树”就是索引的键值长度最短的那棵B树。由于二级索引的叶子节点只保存索引列和主键字段不包含其他列的数据占用的空间更小扫描时读入的页更少IO开销更低速度更快。我用一张几百万行的表实测过给一个冗余的tinyint字段添加索引以后count(*)的耗时直接从原来的1.2秒降到了不到0.3秒。所以如果你需要频繁对某张表统计总行数而表中又没有合适的短索引可以考虑建一个只用于统计的短字段索引这对大表count性能的提升非常明显。3. 各数据库厂商下的表现差异以为理解了MySQL就万事大吉太天真了。count函数在不同数据库里的底层执行方式差异很大生产环境切换数据库时这些细微区别随时可能踩雷。3.1 MySQL下的实际执行计划分析在MySQL 8.0的环境下对同一张表分别执行三条SQL并查看执行计划EXPLAIN SELECT COUNT(*) FROM order_info; EXPLAIN SELECT COUNT(1) FROM order_info; EXPLAIN SELECT COUNT(pay_time) FROM order_info;实测下来count(*)和count(1)的执行计划完全一致type为indexkey为某个二级索引扫描行数也相同。而count(pay_time)的执行计划同样是index扫描但扫描时会逐行判断pay_time是否为NULL如果pay_time列允许NULL且存在NULL值实际返回的行数会小于扫描行数。还有一个常见误区很多人以为count()会选中“所有列”参与计算所以会很慢但优化器会自动选择成本最低的索引来做覆盖扫描并不会真的去逐一读取每一行所有列的数据。这也是为什么“count()慢”这个说法在现代MySQL里并不准确真正慢的是没有索引可用的大表全表扫描。3.2 Oracle和SQL Server的特例Oracle中count(*)和count(1)的性能差异同样是忽略不计的但count(列名)如果要判断NULLOracle读取数据块后还要额外做一次空值判断代价稍高。更值得关注的是Oracle用了Bitmap索引或者列式存储时count的方式会发生根本变化这种场景不建议把MySQL的经验直接套过来。SQL Server里有个有趣的细节它的执行计划对count()做了专门的优化会直接用最窄的非聚集索引来做流式计数甚至在某些情况下可以通过统计信息预估行数而不需要完全扫描。但如果你的表是堆表没有聚集索引count(列名)则必须做全表扫描性能和count()差了一个量级。所以说别把“count用哪个”当成一个固定的银弹问题必须结合当前数据库的类型、版本、引擎和索引设计来分析。曾经有个项目从MySQL 5.7迁移到PostgreSQL 14原本在MySQL上跑得好好的count(某列)在PG里优化器走了不同的索引路径耗时直接翻了三倍最后是调整了索引结构才把性能追回来。4. 实战场景与性能优化技巧理论讲透了下面直接上实战。毕竟要真正会用count函数就得知道在什么场景下选择哪一种以及遇到慢SQL时怎么优化。4.1 不同业务场景下count的选择建议如果你的业务逻辑是“只要总数不管某个列是否为空”那么无条件用count()这也是所有规范里优先级最高的写法。比如统计用户总量、订单总量、商品总量一律count()语义准确、可读性强、性能也不差。如果你的业务逻辑是“统计某个字段有值的人数”比如统计填写了手机号的用户数量、统计有支付记录的有效订单数那必须用count(指定列)。但这里有一个隐藏风险该列如果有NULL以外的“伪空值”比如空字符串count(列名)是会统计进去的。如果你想要的是“统计非空且非空字符串的值”那得写成count(列名)配合WHERE过滤或者用sum(case when 列名 is not null and 列名 ! then 1 else 0 end)。还有一种常见场景是“统计去重后的数量”。count(distinct 列名)和普通count(列名)的语义又不同它统计的是该列去重后的非NULL值的个数。注意distinct会把NULL视为一个值但count(distinct 列名)不统计NULL。这里特别容易混淆划重点记住。4.2 大表count(*)的三种优化策略几百万行的小表count(*)无所谓但如果表到了几千万甚至上亿行每次实时扫描索引都是沉重的负担。我总结了三类可行的大表count优化方案第一类是“定时快照近似值”。比如运营后台的Dashboard里展示的“总用户数”真的不需要每一秒都精确到最新值完全可以每十分钟统计一次把结果写入一张统计表前端直接查统计表。这样既保证了展示速度又不会在大表上频繁跑count。第二类是“汇总表触发器或事件”。每次插入、删除数据时使用事务同步维护一张计数表把count的开销平摊到每一次DML操作上。适合写入频率不高、但查询频率极高的表。比如订单状态表写入量不大就可以用触发器维护一个订单总数计数器。第三类是“利用信息库表或近似估算”。在MySQL中通过information_schema.tables里的table_rows字段来估算行数这个值不是精确值但很多场景够用了。注意这个数值在InnoDB里是抽样估算的误差可能达到百分之十几用于几十亿数据规模的粗略趋势展示可以但用于对账、报表精确统计则完全不行。4.3 count(1)和count(*)在慢SQL优化中的实战生产环境里最常见的慢SQL之一就是频繁对大表执行count()。我自己处理过一个真实案例一张日志表的count()查询在高峰期竟然需要2秒以上导致接口超时。我当时的排查步骤是第一步用explain查看执行计划发现走了全表扫描第二步查看表结构发现除了主键外没有任何二级索引第三步给表增加了一个最小的int类型的二级索引用于覆盖扫描把count(*)的执行计划从全表扫变成了index索引全扫第四步重新压测耗时降到了0.4秒左右。不能说这就是最优方案因为日志表的写入量非常大额外维护一个索引也增加了写入成本。所以后来我们又加了一层优化业务侧把“最近7天日志条数”改成“近似条数”使用缓存方案不再查询数据库实时统计。最终接口响应时间从2秒压到了100毫秒以内。5. 高频踩坑与面试细节速查最后这点内容是给那些正在准备面试或者刚接手旧项目的同学看的。count函数的坑隐蔽性强很多线上数据对不上的事故查到最后才发现是count用法的问题。5.1 空值统计与NULL处理的陷阱这里有一个极容易翻车的例子。假设有一张用户表user_info其中字段user_name允许NULL你需要统计“全部用户和填写了用户名的用户数量”写了下面两条SQLSELECT COUNT(*), COUNT(user_name) FROM user_info;如果你的数据里恰好有三行user_name为NULL最终返回的结果可能就是“10, 7”很多业务方看到这个结果直接蒙了认为数据库出bug了。根本原因就是count(user_name)不统计NULL这个坑我在公司至少讲过三遍。还有个小细节count(列名)判断的是NULL不是空字符串。如果user_name字段填充了一个空字符串这个行会被count(user_name)统计进去因为空字符串不是NULL。想要排除空字符串必须写成sum(case when user_name is not null and user_name ! then 1 else 0 end)。5.2 count(distinct 列名)的代价与优化思路count(distinct 列名)看起来好用它的底层代价却非常大要先对目标列进行排序或使用哈希去重然后再统计。数据量一大这条SQL的响应时间可能是普通count的十倍不止。我曾经在500万行上执行count(distinct user_id)整整跑了8秒多。如果业务确实需要高频统计去重后的数量一种优化方案是使用近似去重函数比如MySQL的APPROX_COUNT_DISTINCT8.0以上版本或者在大数据场景用HyperLogLog算法比如Redis的PFCOUNT命令。它们都能牺牲极小的精确度换来近乎实时的统计性能对报表场景来说性价比非常高。还有一种场景是只需要知道某个值是否存在比如“这个user_id是否出现在表里”那就别用count改用EXISTS它在找到第一条匹配记录后就会停止扫描性能远超count。5.3 面试高频追问与回答思路面试官问count的区别其实不只是在考察你记没记住结论更深层的意图是想看你对数据库底层实现机制的理解程度。总结几个高频追问第一“为什么MyISAM的count(*)不用扫描”前面已经说过答案是MyISAM在元数据里缓存了总行数执行时直接读取。考察点在于是否了解两种存储引擎的根本差异。第二“InnoDB的count(*)一定比count(1)慢吗”正确答案是不一定现代优化器会把两者编译成相同的执行计划。考察点在于是否迷信旧有经验。第三“count(id)用的是主键索引count(*)也用的主键吗”答案是优化器会选择最短的二级索引去扫如果没有合适的二级索引才扫主键索引。考察点在于是否真正理解索引选择机制。第四“为什么建议把count(*)写进规范而不是count(1)”参考答案是语义表达清晰、没有性能劣势、代码可读性好。考察点在于项目经验里是否有代码规范意识。6. 一句总结外加实操建议我在实际项目中见过太多因为count用错导致的数据事故所以给自己带的技术小组定了三条规矩第一统计总数一律用count()谁写count(1)谁去复查表结构第二统计有值数量必须明确字段语义先查该列是否存在NULL再写count(列名)第三大表count必须走优化方案线上代码里禁止裸查亿级大表的count()。最后再分享一个调优技巧如果你用的是InnoDB又确实需要在几千万行的表上频繁统计总数与其纠结count函数本身不如花点时间设计一个短字段索引或者改造成缓存预统计方案。我在实践里试过很多方法后者对接口响应速度的提升最为立竿见影。希望这篇内容能帮你把count函数彻底吃透下次再遇到相关SQL问题直接照着这些原则处理就行。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表