SELECT COUNT() 详解

2,164 阅读11分钟

前言

SELECT COUNT 命令是我们日常开发中经常用到的 SQL 命令。

但是我们肯定遇到过这种情况,随着系统中的表的数量越来越多,SELECT COUNT 的效率越来越低。

今天,就带着大家来理一理 SELECT COUNT 的内部执行过程,以及日常开发中我们应该如何使用 SELECT COUNT

按照以往的习惯,我们仍然带着问题去学习。

SELECT COUNT 提问

  1. SELECT COUNT 有几种用法?
  2. SELECT COUNT 各种用法有什么不同?
  3. SELECT COUNT 各种用法哪个效率更高?
  4. SELECT COUNT 各种用法内部执行流程如何?
  5. 为什么《阿里巴巴 Java 开发手册》建议使用 SELECT COUNT(*)

count 介绍

初始 COUNT

官方文档 中对 COUNT 做如下解释:

  1. COUNT(expr) [over_clause]:返回 SELECT 语句检索的行中 expr 的非 NULL 值数量的计数。结果是一个 BIGINT 值。如果没有匹配的行,则 COUNT()返回 0。
  2. COUNT(*)COUNT(*)COUNT(expr) 有所不同,它返回获取的行数的计数,无论它们是否包含 NULL 值。
  3. COUNT(DISTINCT expr,[expr...]):返回具有不同非 NULL 的 expr 值的行数的计数。如果没有匹配的行,则 COUNT(DISTINCT)返回 0。

问题解答

我们通过逐个解答上述的问题,来深入理解 COUNT 函数。

几种用法

从 MYSQL 官方文档中,我们可以发现有下面四种用法:COUNT(expr)COUNT(*)COUNT(1)COUNT(DISTINCT expr)

各个用法不同点

返回结果

从文档中对不同用法的定义可以看出,不同的用法返回的结果不同:

  • COUNT(expr):返回检索的行中 expr 字段不为 NULL 的行数计数。
  • COUNT(*):返回所有的行数计数,会包含值为 NULL 的行。
  • COUNT(1):返回结果同 COUNT(*)
  • COUNT(DISTINCT expr):返回检索行中的 不同的 非 NULL 的 expr 的行数的计数。如果没有 distinct,则返回检索行中非 NULL 的 expr 的行数的计数。

效率

效率其实涉及 MYSQL 内部执行过程,内部优化,在下面部分我们会详细介绍,这个部分不做过多说明,只说一下官方文档中的关于效率部分的介绍。

MyISAM

对于 MyISAM 引擎,如果 SELECT 检索整张表,不是具体某一列,也没有 WHERE 子句,COUNT(*) 将会被优化的非常快速的返回,因为 MyISAM 存储引擎存储了整个表的准确的行数,并且可以非常快速的访问。并且仅当第一列定义为非空时,COUNT(1) 才受相同优化约束。

InnoDB

对于 InnoDB 这样的事务存储引擎,因为可能同时发生多个事务,每个事务都有可能影响行数,因此是无法存储准确的行数的。

InnoDB 通过遍历最小的可用二级索引索引来处理 SELECT COUNT(*) 语句,如果不存在二级索引,InnoDB 将通过扫描聚簇索引来处理 SELECT COUNT(*) 语句。

InnoDB 以相同的方式处理 SELECT COUNT(*)SELECT COUNT(1) 操作,没有性能差异。

效率

从上面来看,MyISAM 存储引擎 SELECT COUNT(*) 效率最高,直接返回全表数据。

首先我们需要弄清 COUNT() 的语义,COUNT() 是一个聚合函数,对于返回的结果集,一行行的判断,如果 COUNT() 函数的参数不是 NULL,累计值就加 1,否则不加,最后返回累计值。

所以 COUNT(*)COUNT(1)COUNT(主键) 都表示返回满足条件的结果集的总行数。而 COUNT(字段) 表示返回满足条件的数据行里面,参数“字段” 不为 NULL 的行数。

  1. COUNT(主键)

InnoDB 引擎会遍历整张表,把每一行的主键都取出来,返回给 server 层,server 层拿到主键后,判断不能为 null,按行累加。

  1. COUNT(1)

InnoDB 引擎遍历整张表,但不取值,server 层对于返回的每一行,放一个数字“1”进去,判断不能为 null),按行累加。

从上面看,COUNT(1) 会比 COUNT(主键) 快的,因为从引擎返回 主键 会涉及解析数据行,以及拷贝字段值的操作。

  1. COUNT(字段)
  • 如果该字段定义为 not null 的话,那么 server 层一行行从记录中读出该字段,判断不能为 null,按行累加。
  • 如果该字段定义为 null,那么在执行的时候,判断到有可能为 null,还要再把值取出来再判断一下,不是 null 才累加。
  1. COUNT(*)

MYSQL 对 COUNT(*) 进行了优化,并不会把全部字段取出来,因为肯定不为 NULL。

InnoDB 引擎 SELECT COUNT(*)SELECT COUNT(1) 没有性能差异。但是 MYSQL 会对 SELECT COUNT(*) 内部进行优化,因此还是 SELECT COUNT(*) 效率更好些。

因此,按照效率排序的话,上述排序为 COUNT(字段) < COUNT(主键) < COUNT(1)COUNT(*)

阿里巴巴开发手册

《阿里巴巴开发手册》中关于 COUNT 使用是如下定义的:

【强制】不要使用 count(列名)或 count(常量)来替代 count(*),count(*)是 SQL92 定义的 标准统计行数的语法,跟数据库无关,跟 NULL 和非 NULL 无关。 说明:count(*)会统计值为 NULL 的行,而 count(列名)不会统计此列为 NULL 值的行。

注:SQL92,是数据库的一个 ANSI/ISO 标准。它定义了一种语言(SQL)以及数据库的行为(事务、隔离级别等)。

原因也很简单,因为 COUNT(*) 是标准语法,MYSQL 对 COUNT(*) 做了很多优化,所以《阿里巴巴开发手册》建议使用 COUNT(*)

COUNT 详解

首先我们知道了 MyISAM 存储引擎会将表的总行数记录到磁盘上,因此执行 COUNT(*) 的时候会直接返回这个数,效率非常高。

然而因为在事务支持、并发能力以及数据安全等方面,InnoDB 都优于 MyISAM,我们日常的业务开发中一般都使用 InnoDB 引擎。

因为事务的原因,InnoDB 引擎在执行 COUNT(*) 的时候,就需要把数据一行一行的从引擎里面读出来,然后累计计数。

这里需要注意的是,我们讨论的是没有过滤条件的 COUNT(*),如果加了 where 条件的话,MyISAM 表也是不可能返回的这么快的。

InnoDB 不将 count 存储的原因

那是因为即使在同一个时刻的多个查询,由于 MVCC(多版本并发控制)的原因,InnoDB 表应该返回多少行是不同的。

我们来做个简单的例子来测试一下。

首先我么创建表 t。

CREATE TABLE `t` (
  `id` int(11) auto_increment,
  `a` int(11) DEFAULT NULL,
  `b` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `a` (`a`),
  KEY `b` (`b`)
) ENGINE=InnoDB;

然后我们使用存储过程来向表 t 中插入 10000 条数据。

delimiter ;;
create procedure idata()
begin
  declare i int;
  set i=1;
  while(i<=10000)do
    insert into t (`a`,`b`) values(i, i);
    set i=i+1;
  end while;
end;;
delimiter ;
call idata();

然后我们开启三个会话,三个会话的执行流程如下:

session Asession Bsession C
begin;
select count(*) from t;
insert into t (a,b) values(10001, 10001);
begin;
insert into t (a,b) values(10002, 10002);
select count(*) from t;(返回 10000)select count(*) from t;(返回 10002)select count(*) from t;(返回 10001)

从上面的例子中我们可以看到,在最后一个时刻,三个会话 A,B,C 虽然同时查询表 t 的总行数,但是得到的结果却是完全不同。

这是因为 InnoDB 中的事务的默认隔离级别是可重复读,通过 MVCC 实现。因此每一行记录都要判断自己是否对这个会话可见,也就是说,对于 COUNT(*) 请求,InnoDB 只好把数据一行一行地读出依次判断,可见的行才是能够用于计算“基于这个查询”的表的总行数。

InnoDB COUNT(*) 优化

既然 InnoDB 存储引擎需要扫描全表才能得到当前会话可见的表行数。那么 MYSQL 有没有对于 COUNT(*) 命令进行优化呢?

答案是 是的。

数据库系统设计的通用法则之一就是在保证逻辑正确的前提下,尽量减少扫描的数据量。

那 MYSQL 是怎么做的呢?

我们知道,InnoDB 是索引组织表,主键索引树的叶子节点是数据,而普通索引树的叶子节点是主键值。因此,普通索引树比主键索引树小很多。对于 COUNT(*) 这样的操作,遍历哪个索引树得到的结果逻辑上都是一样的,因此,MYSQL 优化器会找到最小的那棵树来遍历。

问题延伸

我们刚才介绍了 InnoDB 引擎执行 COUNT(*) 语句的方法,即 MYSQL 优化器会选择一个最小的索引树来查询表的总行数。

但是当表的数据量上去的时候,索引树在变大,COUNT(*) 速度也会降下来。那么假设我们我们现在有一个点赞功能,对应的是一个点赞的表,这个表的特点就是更新的频繁,且数据量大。

那么频繁的查库肯定就会特别慢,我们应该怎么解决这个问题呢?

使用 Redis 保存计数

对于更新特别频繁的数据库,我们第一时间想到的方法可能就是使用 Redis 来存储数据。

我们用 Redis 来存储表的总行数。这个表每被插入一行 Redis 计数就加 1,每被删除一行 Redis 计数就减 1。这种情况下,读和更新操作都很快,那么这么做有什么问题吗?

我们最容易想到的就是缓存丢失的问题。

Redis 会存在异常重启的情况,而我们的数据都保存在内存中,Redis 服务器重启内存中的数据会丢失。你可能会想到把 Redis 中的数据保存起来。重启之后再保存回去。那么假设这个时候我们执行了 +1 操作呢?那么这个 +1 就会丢失。我们可能也会觉得很简单,Redis 重启之后去数据库重新读一遍数据就好了,毕竟 Redis 重启也不是经常遇到,这个成本完全额可以接受。

然而,使用 Redis 来保存数据库计数,数据丢失问题还是小问题,可以解决,严重的问题是存储的这个值可能是不准确的。

比如说,我们想看这个页面点赞的人的详细信息。我们的操作可能是先去 Redis 中取出计数,然后去数据库中取出详细数据,那么就有可能出现下面两种情况:

  1. 查到的数据中有一条最新记录,但是 Redis 的计数中还没有加 1。
时刻session Asession B
T1
T2插入一行数据 R;
T3读 Redis 计数;查询最近记录;
T4Redis 计数加 1;
  1. 查到的数据中少了一条最新记录,但是 Redis 的计数中已经加 1。
时刻session Asession B
T1
T2Redis 计数加 1;
T3读 Redis 计数;查询最近记录;
T4插入一行数据 R;

这两种情况,都是逻辑不一致的。而在并发系统里面,我们是无法精确控制不同线程的执行时刻的,因此上述两种执行顺序都是有可能发生的。 所以,即使 Redis 正常工作,这个计数值还是逻辑上不准确的。

在数据库中保存计数

既然在 Redis 中保存计数会存在精度问题,那么把计数存在一张表 C 中是否可以。

首先我们应该了解过,MYSQL 是支持丢失数据恢复的问题,这样我们就解决了丢失的问题。

然后我们再解决一下精度的问题。

我们将上述 Redis 操作都换成 MYSQL 操作。

时刻session Asession B
T1
T2插入一行数据 R;
T3读表 C 中计数;查询最近记录;
T4表 C 计数加 1;

我们来看下现在的执行结果。虽然会话 B 的读操作仍然是在 T3 执行的,但是因为这时候更新事务还没有提交,所以计数值加 1 这个操作对会话 B 还不可见。因此,此时逻辑是正确的。

因为读计数和读完整数据都在一个事务中,其他更新操作不影响该事务,所以用 MYSQL 存储计数可行。

总结

  • MyISAM 表会存储数据总行数,InnoDB 表由于事务的原因,执行 COUNT() 函数需要一行一行查。
  • 在 InnoDB 引擎中,建议使用 COUNT(*) 来查询表的总行数。
  • 可以使用一张表来存储数据总行数,来提高查询效率,并且不会出现使用 Redis 存储总行数而存在的逻辑问题。

参考文档