count(1)、count(*) 与 count(列名) 的区别?

#中间件#面试八股

count(1)、count(*) 与 count(列名) 的区别?

结论:count(*) 与 count(1) 语义等价,都是统计行数,InnoDB 会挑一棵最小的索引来扫;count(列名) 统计该列非 NULL 的行数,需要判断每一行的值。性能差异不在 * 和 1,而在「扫哪棵索引、要不要回表、要不要判空」。

一、语义对照

写法统计对象是否跳过 NULL可用索引备注
count(*)行数不涉及任意索引,优化器选最小的SQL 标准写法,语义最清晰
count(1)行数不涉及同 count(*)常量表达式,不读取列值
count(id)主键非空行数否(主键非空)聚簇索引或二级索引通常等价于行数
count(col)col 非空的行数是col 上的索引无索引需回表读列
count(distinct col)去重后的非空值个数是col 上的索引需要排序或临时表

二、为什么 InnoDB 不缓存总行数

事务 A(已提交 100 行)        事务 B(未提交,插入 10 行)
        │                              │
        ▼                              ▼
   ┌─────────────────────────────────────────┐
   │ 同一张表在 MVCC 下有多个可见版本          │
   │ A 看到 100 行,B 看到 110 行              │
   │ 没有单一「总行数」可以缓存                │
   └─────────────────────────────────────────┘

MyISAM 没有 MVCC,可以把行数存在表头里,所以无 WHERE 的 count(*) 是 O(1)。InnoDB 只能实时扫,这也是互联网业务普遍另建计数表的原因。

三、执行路径的差异

-- 建一张有二级索引的表
CREATE TABLE t_log (
  id      BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  user_id BIGINT UNSIGNED NOT NULL,
  action  VARCHAR(32) NOT NULL,
  memo    VARCHAR(255) DEFAULT NULL,
  PRIMARY KEY (id),
  KEY idx_user (user_id)
) ENGINE=InnoDB;

EXPLAIN SELECT count(*) FROM t_log;
-- key: idx_user —— 优化器选了最小的二级索引,而不是聚簇索引

EXPLAIN SELECT count(1) FROM t_log;
-- 与 count(*) 完全一致的计划

EXPLAIN SELECT count(memo) FROM t_log;
-- type: index / ALL,需要读取 memo 判断是否为 NULL;
-- memo 无索引时只能在聚簇索引上逐行取值

三个关键点:

  1. count(*) 并不需要展开成所有列,它只关心「行存在」,因此可以走任何覆盖整表的索引。
  2. count(1) 里的 1 是常量,不产生列读取,MySQL 5.7 之后与 count(*) 的计划完全相同。
  3. count(col) 必须读 col。col 有索引时可扫索引(索引里含值),无索引时只能扫聚簇索引并跳过 NULL。

四、大表统计的三种替代方案

方案一:汇总表 + 事务内自增

CREATE TABLE t_log_counter (
  stat_date DATE NOT NULL,
  action    VARCHAR(32) NOT NULL,
  cnt       BIGINT UNSIGNED NOT NULL DEFAULT 0,
  PRIMARY KEY (stat_date, action)
) ENGINE=InnoDB;

-- 与业务写入放在同一事务
INSERT INTO t_log_counter (stat_date, action, cnt)
VALUES (CURDATE(), 'login', 1)
ON DUPLICATE KEY UPDATE cnt = cnt + 1;

方案二:接受估算值

-- 误差可能很大,只能用于展示量级
SELECT table_rows FROM information_schema.tables
WHERE table_schema = 'demo' AND table_name = 't_log';

方案三:近似统计

-- 只统计某个范围,用覆盖索引把小范围扫完
SELECT count(*) FROM t_log
WHERE user_id BETWEEN 10000 AND 20000;

五、一个常见误区

「count(1) 比 count(*) 快」是 MySQL 5.5 时代的老结论。当时的优化器对 count(*) 存在额外的列展开逻辑,如今两者生成同一份执行计划,甚至在 EXPLAIN 里连 key 都相同。真正的性能变量是:有没有可用的最小索引、索引是否覆盖、以及是否需要判断 NULL。

常见追问

  • count(*) 会扫描所有列吗? 不会。它只判断行是否存在,走最窄的索引即可。
  • count(col) 比 count(*) 慢多少? 取决于 col 有无索引。有索引时差距很小;无索引时要读整行,宽表上可能慢一个数量级。
  • count(1) 在联合主键表上更快吗? 不会,计划一样。
  • 为什么 count(*) 结果和 SHOW TABLE STATUS 对不上? 后者是采样估算值,误差可达百分之几十。
  • 分页时既要数据又要总数怎么办? 两害相权:要么缓存总数并用增量维护,要么只返回「是否有下一页」。
  • count(distinct col) 怎么优化? 在 col 上建索引可避免临时表;数据量极大时改用 HyperLogLog 类近似去重。

小结

count(*) 与 count(1) 是同一件事,选哪个只看团队规范;count(col) 因为要跳过 NULL,语义与代价都不同。InnoDB 无法缓存行数,大表统计真正该做的是把「实时全扫」换成「增量维护的计数」。

笔记加载中…