前言
SELECT COUNT 命令是我们日常开发中经常用到的 SQL 命令。
但是我们肯定遇到过这种情况,随着系统中的表的数量越来越多,SELECT COUNT 的效率越来越低。
今天,就带着大家来理一理 SELECT COUNT 的内部执行过程,以及日常开发中我们应该如何使用 SELECT COUNT。
按照以往的习惯,我们仍然带着问题去学习。
SELECT COUNT 提问
SELECT COUNT有几种用法?SELECT COUNT各种用法有什么不同?SELECT COUNT各种用法哪个效率更高?SELECT COUNT各种用法内部执行流程如何?- 为什么《阿里巴巴 Java 开发手册》建议使用
SELECT COUNT(*)?
count 介绍
初始 COUNT
官方文档 中对 COUNT 做如下解释:
COUNT(expr) [over_clause]:返回 SELECT 语句检索的行中 expr 的非 NULL 值数量的计数。结果是一个 BIGINT 值。如果没有匹配的行,则 COUNT()返回 0。COUNT(*):COUNT(*)和COUNT(expr)有所不同,它返回获取的行数的计数,无论它们是否包含 NULL 值。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 的行数。
COUNT(主键)
InnoDB 引擎会遍历整张表,把每一行的主键都取出来,返回给 server 层,server 层拿到主键后,判断不能为 null,按行累加。
COUNT(1)
InnoDB 引擎遍历整张表,但不取值,server 层对于返回的每一行,放一个数字“1”进去,判断不能为 null),按行累加。
从上面看,COUNT(1) 会比 COUNT(主键) 快的,因为从引擎返回 主键 会涉及解析数据行,以及拷贝字段值的操作。
COUNT(字段)
- 如果该字段定义为 not null 的话,那么 server 层一行行从记录中读出该字段,判断不能为 null,按行累加。
- 如果该字段定义为 null,那么在执行的时候,判断到有可能为 null,还要再把值取出来再判断一下,不是 null 才累加。
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 A | session B | session 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 中取出计数,然后去数据库中取出详细数据,那么就有可能出现下面两种情况:
- 查到的数据中有一条最新记录,但是 Redis 的计数中还没有加 1。
| 时刻 | session A | session B |
|---|---|---|
| T1 | ||
| T2 | 插入一行数据 R; | |
| T3 | 读 Redis 计数;查询最近记录; | |
| T4 | Redis 计数加 1; |
- 查到的数据中少了一条最新记录,但是 Redis 的计数中已经加 1。
| 时刻 | session A | session B |
|---|---|---|
| T1 | ||
| T2 | Redis 计数加 1; | |
| T3 | 读 Redis 计数;查询最近记录; | |
| T4 | 插入一行数据 R; |
这两种情况,都是逻辑不一致的。而在并发系统里面,我们是无法精确控制不同线程的执行时刻的,因此上述两种执行顺序都是有可能发生的。 所以,即使 Redis 正常工作,这个计数值还是逻辑上不准确的。
在数据库中保存计数
既然在 Redis 中保存计数会存在精度问题,那么把计数存在一张表 C 中是否可以。
首先我们应该了解过,MYSQL 是支持丢失数据恢复的问题,这样我们就解决了丢失的问题。
然后我们再解决一下精度的问题。
我们将上述 Redis 操作都换成 MYSQL 操作。
| 时刻 | session A | session B |
|---|---|---|
| T1 | ||
| T2 | 插入一行数据 R; | |
| T3 | 读表 C 中计数;查询最近记录; | |
| T4 | 表 C 计数加 1; |
我们来看下现在的执行结果。虽然会话 B 的读操作仍然是在 T3 执行的,但是因为这时候更新事务还没有提交,所以计数值加 1 这个操作对会话 B 还不可见。因此,此时逻辑是正确的。
因为读计数和读完整数据都在一个事务中,其他更新操作不影响该事务,所以用 MYSQL 存储计数可行。
总结
- MyISAM 表会存储数据总行数,InnoDB 表由于事务的原因,执行
COUNT()函数需要一行一行查。 - 在 InnoDB 引擎中,建议使用
COUNT(*)来查询表的总行数。 - 可以使用一张表来存储数据总行数,来提高查询效率,并且不会出现使用 Redis 存储总行数而存在的逻辑问题。
参考文档
- 12.20.1 Aggregate Function Descriptions
- 《MYSQL实战45讲》