十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SQL中count(1)、count(*)与count(列名)的区别及性能优化

SQL中count(1)、count(*)与count(列名)的区别及性能优化 年初我帮团队复盘一个慢SQL问题优化完发现执行计划里count(1)被优化器和count(*)处理成了完全一样的东西。但到了count(列名)情况突然不一样了。群里当时吵了一轮有人说 count(1) 比 count(*) 快有人说 count(列名) 最快还有人因为线上统计数字对不上最后查出来就是 count 写错了列。这篇文章我想把这三者的区别一次说清楚优化器内部怎么处理、NULL 值怎么影响结果、真实的数据扫描差异、以及线上踩坑问题怎么定位。适合所有写 SQL 的开发、DBA也适合正准备面试的人。1. count(1) 和 count(*) 先说道说道它俩凭什么被当成一回事很多初学者看到count(1)会以为这是“数第一列”看到count(*)以为会先展开所有列所以网上大量说法是“count(1) 不读列因此更快”。这个说法在绝大多数数据库里都不成立而且是目前流传最广的一个误解。1.1 优化器眼里它俩长得一模一样先看一段最普通的 SQLSELECT COUNT(*) FROM orders; SELECT COUNT(1) FROM orders;在 MySQL 8.0 中分别执行EXPLAIN你会发现两条语句的type、key、rows、Extra完全一致。再进一步开启优化器跟踪可以看到 MySQL 内部在解析阶段就把COUNT(1)重写为COUNT(*)。换句话说1这个常量根本没有参与任何真正意义上的“取值”只是占了个位置。SQL Server 和 Oracle 也一样。SQL Server 中COUNT(1)和COUNT(*)会被编译成相同的执行计划扫描一个索引统计行数。Oracle 的COUNT(1)也会在优化器层被归一化处理。这也是为什么你在真正资深的 DBA 嘴里听不到“count(1) 比 count(*) 快”这个结论。它俩的差异在绝大多数数据库引擎里都不存在。提示如果哪天你测出来 count(1) 和 count(*) 耗时有明显差异优先检查是不是缓存干扰、索引选择变化、或者 SQL 语句复杂到已经触发不同执行计划而不是把它简单归因于“写法不同”。1.2 “count(1) 更快”这个说法是从哪来的这个误解来源很杂但我个人推测和两个历史背景有关。第一Oracle 早期版本中某些优化模式下对SELECT COUNT(1) FROM t这种写法偶发会走更合适的路径而COUNT(*)在某些复杂查询里会被解析成“检索所有列”性能反而不稳定。这个细节被很多人当成普适经验抄走了。第二早期很多 ORM 框架自动生成 SQL 时喜欢用COUNT(1)替代COUNT(*)原因是某些数据库的 SQL 解析器对*的处理有额外开销常量1不需要解析列列表。框架层这么用慢慢就演变成了“count(1) 性能更优”的口口相传。但从现在的 MySQL 8.0、PostgreSQL、SQL Server 2019、Oracle 19c 来看这个性能差基本不存在。真正应该关心的从来不是1还是*而是这张表你要统计什么数据、走了什么索引、有没有 WHERE 条件。2. count(列名) 才是真正的异类它数的是非空值如果说 count(1) 和 count(*) 是一对双胞胎那count(列名)就是那个同父异母的兄弟——长得像但语义上根本不是一回事。2.1 NULL 直接决定结果长短COUNT(列名)统计的是这一列中非 NULL 值的行数。也就是说如果某一行的该列是 NULL这行不会被计入。看个最直观的例子CREATE TABLE t_user ( id INT PRIMARY KEY, name VARCHAR(50), phone VARCHAR(20) ); INSERT INTO t_user (id, name, phone) VALUES (1, 张三, 13800000000), (2, 李四, NULL), (3, 王五, 13900000000);然后执行SELECT COUNT(*), COUNT(1), COUNT(phone) FROM t_user;结果表达式结果COUNT(*)3COUNT(1)3COUNT(phone)2两张只差一个phone为 NULLcount(phone)就少了 1。很多开发第一次看到这个结果都愣一下说明平时写 count 时根本没想过可空性。2.2 聚合函数对 NULL 的“无视规则”不只是 count几乎所有聚合函数都有类似行为。SUM(列名)遇到 NULL 会当 0 处理AVG(列名)会忽略 NULL 行再求平均COUNT(DISTINCT 列名)同样会忽略 NULL。这是 SQL 标准里的默认语义。所以如果业务上希望“统计所有行中该列有效值有几个”用 count(列名) 完全没问题。但如果希望“统计表里一共有多少行”却因为某些行的列值是 NULL 导致结果对不上那就是 SQL 语义和业务口径不匹配了。还有一个更隐蔽的坑COUNT(列名)配合GROUP BY时如果分组列本身也有 NULL那么这个组在结果中通常不会出现。比如SELECT dept_id, COUNT(emp_id) FROM t_emp GROUP BY dept_id当dept_id为 NULL 时这一组在大多数数据库里会被直接忽略不在结果集中展示。这又是一个容易在报表里造成数据缺失的点。2.3 它和 count(distinct 列名) 的区别也别搞混COUNT(DISTINCT 列名)在 count(列名) 基础上还要做去重开销更大含义也不同。还是上面那张表SELECT COUNT(DISTINCT phone) FROM t_user;结果是 2因为有两个非空且不同的 phone。如果列里出现重复值比如两条记录都填了同一个手机号COUNT(phone)会返回 2COUNT(DISTINCT phone)只会返回 1。这俩一个数“有效值数量”一个数“不同有效值数量”别在指标口径上混用。3. 实测数据说话100万、500万行下不同写法的真实差距有段时间我在帮朋友调一张订单报表顺手做了个针对 count 的测试。这里把过程和结果分享出来作为判断参考。3.1 我的测试环境与用例设计测试环境MySQL 8.0.28InnoDB 引擎单表结构模拟订单表约 500 万行数据主键order_idBIGINTorder_statusTINYINT其中约 12% 为 NULL二级索引idx_status(order_status)测试 SQLSELECT COUNT(*) FROM orders; SELECT COUNT(1) FROM orders; SELECT COUNT(order_id) FROM orders; SELECT COUNT(order_status) FROM orders;每个 SQL 执行前都清空缓存连续执行 5 次取中间值避免偶然抖动。3.2 三种写法耗时与执行计划实测耗时大致如下SQL耗时约执行计划扫描COUNT(*)0.72s全扫描二级索引 idx_statusCOUNT(1)0.72s全扫描二级索引 idx_statusCOUNT(order_id)0.75s全扫描主键索引/聚簇索引COUNT(order_status)1.10s全扫描二级索引 idx_status注意到一个反直觉的点count(order_status)明明扫描的是和count(*)相同的二级索引但耗时明显更高。原因是这一列允许 NULL引擎在读每一行索引记录时还需要额外判断对应字段是否为 NULL这个判断在 500 万行级别上会被放大。再看执行计划type都是indexkey可能是主键或二级索引。InnoDB 下count(*)不指定 WHERE 时优化器会优先选择一个最小的辅助索引来扫描因为辅助索引比聚簇索引小一次扫描的页更少IO 成本更低。而count(order_id)如果 order_id 恰好是主键优化器可能选择聚簇索引扫描数据页更多耗时反而略高。3.3 为什么 InnoDB 下 count(*) 也不是“免费午餐”很多从 MySQL 5.5 时代过来的开发者习惯把 count(*) 当成 O(1) 操作因为在 MyISAM 引擎里表总行数直接存在表的元数据中不带 WHERE 的COUNT(*)秒回。但 InnoDB 不是这样。InnoDB 要支持事务和 MVCC不同事务看到的数据版本不同所以它无法像 MyISAM 那样缓存一个全局行数。即使没有任何 WHERECOUNT(*)也必须真实扫描索引逐行统计当前事务可见的行。这也是为什么千万级甚至亿级表上做SELECT COUNT(*) FROM t会明显卡顿。它不是一个取元数据动作而是一次真实的索引全扫描。如果你在做的是大表实时计数建议趁早换方案而不是纠结改写成 count(1)。4. 一次由 count(列名) 引发的线上统计偏差排查记录理论说多了容易飘我讲一个自己踩过的线上 bug。那次问题不大但排查链路很有代表性希望帮你避开同类坑。4.1 场景描述月度报表数量对不上某天运营反馈后台“本月已完成订单数”和财务手动导出的数据差了几百单。后台报表使用的 SQL 很长我最后定位到核心计数语句长这样SELECT COUNT(pay_time) FROM order_info WHERE order_status COMPLETED AND pay_time 2024-10-01 AND pay_time 2024-11-01;逻辑看起来没毛病统计已完成订单里支付时间在 10 月的订单数。财务导出的口径也是已完成订单。但关键在于COUNT(pay_time)如果某笔已完成订单的pay_time是 NULL这一行根本不会被计入。你猜业务上会不会出现已完成订单但没有支付时间的记录会。比如线下补录订单、历史数据迁移、异常状态的兼容处理都可能导致order_statusCOMPLETED而pay_time为空。运营看到的结果是“少了几百单”本质是COUNT(pay_time)把 NULL 值全部排除了。4.2 完整排查链路从 SQL 到数据再到业务第一步我先确认计数差异是否稳定。让运营筛出几个特定订单编号发现这些订单在后台详情页能查到状态确实是已完成但支付时间展示为空。第二步我单独跑了一条 SQLSELECT COUNT(*) , COUNT(pay_time) FROM order_info WHERE order_status COMPLETED AND pay_time 2024-10-01 AND pay_time 2024-11-01;两个值果然对不上这就坐实了是 NULL 影响。第三步去查为什么这些订单没有 pay_time。看完数据迁移脚本发现早期系统从老库迁移时一部分已完成订单没迁移支付时间字段补脚本时也没做数据校验。这属于历史遗留数据质量坑。4.3 根因与修复NULL 导致的统计口径问题修复 SQL 很简单把COUNT(pay_time)改成COUNT(*)或者COUNT(1)先保证行数和业务口径一致SELECT COUNT(*) FROM order_info WHERE order_status COMPLETED AND pay_time 2024-10-01 AND pay_time 2024-11-01;如果业务展示层确实只需要展示“有支付时间的订单数”那我建议在字段语义上当区分处理比如接口返回两个指标总完成订单数、有支付时间的订单数避免指标口径来回切换。另外补了一条数据订正任务把历史 NULL 的 pay_time 按补单记录回填但回填前先和财务确认了规则。4.4 这类问题怎么从源头避免在这之前我以为 count(列名) 的 NULL 语义是“人尽皆知”的基础知识但实际统计报表里踩到的人不在少数。根源在于写报表 SQL 的人默认“业务上不可能出现 NULL”但数据库里字段是否可空和业务上是否允许为空经常不是一回事。之后我给自己定了几条规矩统计行数一律用COUNT(*)或COUNT(1)不要用业务字段确实需要统计“某字段有值的行数”时在 SQL 注释里显式写明“忽略 NULL 行”定期跑数据质量校验重点检查状态为终态但关键时间字段为 NULL 的记录DDL 阶段就该把“此字段是否允许为 NULL”想清楚默认允许 NULL 是最省事但最容易埋坑的选择5. 实际项目里到底该怎么选给出几条能直接用的判断标准写 SQL 没有银弹但 count 的选型逻辑其实很清晰。5.1 选型判断流程我建议按这个顺序决策如果目标是“查总行数”不管有没有 WHERE直接写COUNT(*)。不要写 count(主键)不要写 count(常数列)更不要写 count(可空列)如果目标是“查某列非 NULL 行数”写COUNT(列名)并且在代码评审时让人一眼看懂你的意图如果目标是“查某列不重复的非空值数量”写COUNT(DISTINCT 列名)如果目标是“查行数且性能优先”优先考虑有没有更小的二级索引让优化器选必要时显式给提示或重建更紧凑的索引关于优化器选索引的问题补一个实践细节当一张表存在多个索引时InnoDB 的COUNT(*)会选择扫描“最小”的辅助索引而不是一定选主键。所以如果你发现 count 扫描的 key 不是预期索引也可以用FORCE INDEX测试但通常没必要干预优化器决策在大多数情况下是合理的。5.2 大数据量下 count 的优化思路一旦表到了千万行以上任何形式的COUNT(*)都可能变成慢查询。这时候应该想的是能不能不做实时精确计数。常见方案有几个对历史表按时间分区后统计时只扫描需要的分区用 Redis 维护计数写入时同步累加适合详情页展示那种“不必绝对准”的场景用独立计数表在一个事务里同步更新业务数据和计数器对允许近似统计的场景利用EXPLAIN估算行数或使用统计信息采样其中计数表方案我最常用。它在事务里对计数器行加锁更新读取时直接查计数表能在很大程度避免大表的全索引扫描。代价是需要维护额外逻辑但比每次都扫几百万行索引靠谱多了。5.3 面试回答模板与常见追问如果面试问“count(1)、count(*) 和 count(列名) 的区别”可以按这个思路回答先说语义count(*) 和 count(1) 都是统计满足条件的总行数不关心具体列值count(列名) 只统计该列非空值的行数。再说性能在 MySQL InnoDB 中count() 和 count(1) 执行计划基本一致没有明显的性能差异。count(列名) 如果列可空会额外做 NULL 判断可能更慢如果列是非空且有合适的二级索引在某些场景下也可能比 count() 快因为辅助索引更小扫描页更少。最后补充引擎差异MyISAM 可以直接返回不带 WHERE 的 count() 结果InnoDB 则必须扫描索引。回到 Oracle 和 SQL Server同样建议把统计行数统一写成 count()。面试官如果追问“为什么 count() 和 count(1) 没差异”可以从优化器重写、执行计划一致、索引扫描成本相同这几个角度答。如果追问“count(列名) 什么时候比 count() 快”可以答“列非空、有二级索引且二级索引比聚簇索引小的时候”。最后再分享一个小经验写 SQL 时代码评审阶段看到COUNT(1)我不会拦但看到COUNT(业务字段)而不是COUNT(*)的时候我会要求写的人说明理由因为这里藏着太多统计口径问题。真正的线上事故往往不是 1 和*谁快谁慢而是你把 count 写在了哪一列上。
返回列表