数据库

count(*) vs count(1) 如何选?

转载:count(*) vs count(1) 如何选?

在实际开发工作中,难免会使用到 MySQL 的 count() 聚合函数进行统计操作,对于count(1)、count(*)、count(常量)、count(主键)、count(非主键)、count(distinct(字段)) 等多个函数,很多开发人员因为缺乏原理性的了解,往往会比较困惑选择哪一种,特别是在加 where 条件时,更加担心会不会造成性能问题,今天我们就来聊聊这些 count() 函数背后的实现原理以及执行效率。

申明:本文基于 MySQL 8.0.30,数据库引擎为 InnoDB 引擎 和 MyISAM 引擎;MySQL 8.0.30 默认使用 InnoDB 数据库引擎 。

一、前言 


count(expr) 是 MySQL 的一个聚合函数,函数接收

一个 expr 表达式,expr 表达式可以是 NULL、

可以是列名,可以是常量,可以是其他任意表达式都可以,

它有 2个重要的作用:统计某个列值的数量以及统计

表行数。在统计列数量时只统计列值不为 NULL 的

总数,统计表总行数时不做限制。

为了更好地展开本文的讲解,需要先创建 user 和 person 两张表,user 表使用 InnoDB 引擎,person 表使用 MyISAM 引擎,表创建完成后,查看它们在磁盘上的文件信息对比,具体对比如下截图:

从上述截图可以看出:使用 MyISAM 引擎的 person 表,表定义,数据,索引是分三个文件存储,文件说明如下:

使用 InnoDB 引擎的 user 表,表定义,数据,索引都存放在一个文件中,文件说明如下:

从上面表的创建可以发现 InnoDB 引擎 和 MyISAM 引擎在表文件上的差异性,接下来会围绕这两种数据库引擎来分析各种 count(expr) 操作。

二、count(*) 


对于 MyISAM 引擎,会把表的总行数存在了磁盘上(存放在 information_schema 库中的 PARTITIONS 表中),在不加 where 条件时,执行 count(*) 时会直接返回这个总数,因此效率很高,但是在加 where 限定语句的时候 MySQL 需要对全表进行检索从而得出 count 的总数。

而 InnoDB 引擎并没有像 MyISAM 那样把表的总行数存储在磁盘,而是在执行 count(* )时,在不加 where 限定语句时,MySQL Server 层需要把数据从引擎里面读出来,然后逐行累加得出总数;如果加了 where 限定语句,需要根据 where 条件从引擎里面筛选出数据,然后累加得出总数

下图展示了 MyISAM 引擎磁盘存储数据总行数

为了更好地理解在两种引擎中执行 count(*) 的差异,我们比较了 count(*) 在 user 表和 person 表中的执行计划,结果如下图:

从上述 explain 执行计划截图可以看出:

 使用 InnoDB 引擎的 user 表的执行计划显示:rows = 2(全表只有 2 行数据),说明需要扫描全表,Extra 里面的内容是 "Using index",说明该 count( )操作使用了索引,key,说明是使用了 age字段索引,此处使用 age字段索引而未使用主键索引,理由会在下文分析。


使用 MyISAM 引擎的 person 表的执行计划显示: rows = NULL,说明不需要扫描表,Extra 里面的内容是 "Select tables optimized away",它的意思是:MyISAM 表以单独的行数存储总数,执行 count 查询时,MySQL 不需要查看任何表行数据,而是将预先计算的行数立即返回。

那么,为什么 InnoDB 引擎不能像 MyISAM 引擎一样,把表的数据总数存起来,而是需要扫描全表呢?

这是因为 InnoDB 引擎需要支持事务,默认的隔离级别是 Repeatable Read(可重复读,指的是一个事务执行过程无法看到其它事务未提交的数据),而可重复读又是通过多版本并发控制(MVCC)来实现的,MVCC 更直白的表述是:一行记录在不同的事务中表现的结果值是不一样的,呈现出一行记录多种版本数据。关于 MVCC 可以参照下面的图来理解:

为了更好地说明 InnoDB 引擎为什么不把数据总行数存磁盘,这里通过一个案例进行解析:假设有两个事务 sessionA 和 sessionB,sessionA 先启动一个事务,然后 count(*) user表统计总行数;sessionB 启动事务,先执行一次 count(*) user 表,然后新增一行记录,再 count(*) user表统计总行数;整个执行顺序流以及截图如下:

通过运行结果截图,我们可以看出,sessionB 新增一条记录后,在未 commit 提交事务前,sessionA 的 count(*) 操作并没有把这条数据统计进去,符合可重复读隔离级别的要求,假如 InnoDB 也像 MyISAM 一样把行的总数存在磁盘上,那么 sessionA 和 sessionB 的 count(*) 结果值相同,也就是说 sessionA 和 sessionB 最后一次 count(*) 的结果值都是 3,这显然就违背了可重复读隔离级别的要求。

所以,通过该案例的分析也刚好验证了上述 explain count(*) 的执行计划,InnoDB引擎需要全表扫描。

有人说,在使用 InnoDB 引擎的 user表上执行 “show table status” 指令,结果中的 ROWS 字段就是表的总行数,快捷方便,效率高。那么,可行吗?

方法是否有效,还是得用事实说话,我们可以执行 “show table status” 指令,执行顺序流和结果截图如下:

通过运行结果截图可以看出:sessionA 的 count(*) 结果和 “show table status” 指令结果中的 ROWS 字段值相等,但是 sessionB 中两个值就不一样,因此说,通过 “show table status” 来统计 InnoDB 表的总行数,结果值是不准确的。

按照 MySQL 官方的说法:”show table status” 命令结果值中 ROWS 字段显示的行数误差率在 40% ~ 50%。

show table status 查询的是 MySQL 服务自带的 information_schema 库中的 TABLES 表,详情参考TABLES 官方文档 :

https://docs.oracle.com/cd/E17952_01/mysql-8.0-en/information-schema-partitions-table.html

需要说明的是:在上文 explain 执行计划中可以发现,尽管 InnoDB 引擎的 count(*) user 表操作需要扫描全表,但是 MySQL 还是有做过优化处理,使用了索引,具体优化如下:

因为 InnoDB 引擎采用的是聚簇索引机制,主键索引的叶子节点存放了数据,而普通索引的叶子节点存放的是主键值。因此,不管遍历哪一棵索引树,count( * )的结果都是一致的,所以,MySQL 优化器会找到最小的那棵索引树进行遍历,这样尽管扫描的行数没有减少,但是针对每行记录获取的数据量减少了,因此性能就提升了。因此上文explain 执行计划使用 age 字段索引而非主键索引。

有了上述对 count(*) 的讲解,我们分析和理解其他几种 count(exprexpr) 操作就会轻松很多。

三、count(1) 


对于 MyISAM 引擎,count(1) 和 count(*) 的 逻辑是一样的。

对于 InnoDB 引擎,按照官方文档,count(1) 和 count(*) 的处理方式一样,无性能差别,官方文档截图如下:

四、count(常量) 


count(常量)的执行逻辑和 count(1) 的逻辑是一样的,比如:count(5)、count(‘abc’)、count(‘.’),具体结果参照下图:

五、count(主键) 


对于 MyISAM 引擎,不加 where 条件时,直接返回的表中保存的数据总行数值;加 where 条件时,需要走主键索引筛选出值后再统计;

对于 InnoDB 引擎,不加 where 条件时,count(id) 和 count(*) 的处理方式一样,MySQL优化器会选择最小树索引age 索引进行遍历统计;加 where 条件时,需要走主键索引筛选出值后再统计;

实例截图如下:

六、count(非主键) 


count(非主键字段) 操作有些特殊,我们先看一张实例截图:

如上述截图:使用 InnoDB 引擎的 user 表最开始有 3 条数据,然后新增一条 age= NULL 的记录,各种 count(expr) 的结果为:

使用 MyISAM 引擎的 person 表最开始有 3 条数据,然后新增一条 name=NULL 的记录,各种 count(expr) 的结果为:

从结果可以发现,不管是使用了InnoDB 引擎的 user 表,还是使用了 MyISAM引擎的 person 表,通过 count(非主键)方式进行统计时,表的总行数就会比其他几种 count(expr)方式少 1 条,为什么呢?

这是因为:count(非主键) 只统计非主键字段值不为NULL的总数。

另外,count(非主键)中非主键字段是否增加了索引对统计性能影响也是很大,具体如下:

InnoDB 引擎的user表:

MyISAM引擎的 person表:

通过上述截图可以总结出:count(非主键)不管在 InnoDB 引擎 还是在 MyISAM引擎中执行,非主键这个字段是否添加了索引直接影响了count() 统计扫描表的行数,从而影响统计的性能。

七、count(distinct(字段)) 

count(distinct(字段)) 其实是 count(字段) + distinct 的结果集,统计字段不为NULL,并且在字段值重复的情况下只统计一次,下面给出一张实例截图:

八、源码简析 


从上面的理论分析后,我们再从源码角度看下 MySQL 是如何实现 count(expr) 的,MySQL的源码是 C++实现的。count() 函数的实现源码是 item_sum.cc 文件,在 server 端维护了一个 count 变量来记录 count(expr) 的结果值,源码截图如下

接着看下 arg_is_null() 函数的源码实现,截图如下:

其中 item[i] 表示的就是 count(expr) 函数中 expr 表达式的内容,可以通过调试看下 item[i] 的值是什么。

count(*) 调试结果如下:

上述截图可以看出,当expr=* 时,在MySQL源码中会把*的值当做 longlong 类型的0 处理,因此 count(*) 就转换成 count(0) 了。

count(1) 调试结果如下:

在MySQL源码中会把count(1)中的expr=1直接当做 longlong 类型的1 处理,解释了 count(1),也就很好理解 count(数字常量)这些统计操作了。

count(字段名) 调试结果如下:

通过截图可以看到,当 expr=字段名,对应的类型是Item_field,代表一个字段。

温馨提示:因为历史原因,C++中的 longint相当于Java中的int,longlong其实相当于Java中的long。

最后将InnoDB count(expr)整个过程笼统地归纳为下图:

九、总结 


count(expr)函数的参数 expr可以是任意的表达式,该函数用于统计在符合搜索条件的记录总数;

count(expr)函数执行效率从低到高排序为:count(非主键字段) < count(主键) < count(1) ≈ count(*) ;

对于 count(1) 和 count(*) ,效率相当,建议尽量使用 count(*),因为 MySQL 优化器会选择最小的索引树进行统计,把优化的问题交给 MySQL 优化器去解决就可以了;

count(列字段) 只统计不为 NULL 的总行数,比如,count(name),当name字段值为NUll时,就不会被count;而其他的count,它返回检索到的行数,无论它们是否包含 NULL值;

count(NULL) 总是返回 0;

count(expr) 聚合函数在统计时,带与不带 where 条件实现原理具有差异性,性能也存在差异性;

在生产中,对于InnoDB 引擎,如果对数据总量不要求特别精确,可以使用 “show table status” 方式获取总行数;

因为 MySQL一直在快速发展,所以不同的版本实现可能存在差异,所以在研究技术的时候一定要注意版本差异;

生产如何选择建议:

在生产环境中,应尽量避免 count(expr)这种耗时操作,如果一定要进行统计,可以根据统计数据的精确度来区分采用什么方式统计;也可以在专业 DBA 的指导下进行,或者通过 explain 执行计划宏观看下count(expr) 操作会扫描多少数据行,如果对性能影响比较大,可以选择在离线库或者只读库中进行;