跳至主要內容

count() 计数的几种方式

AruNi_Lu数据库MySQL约 2255 字大约 8 分钟

本文内容

1. count() 的实现方式

在 MySQL 中,不同的存储引擎对 count() 有不同的实现方式:

  • MyISAM 把表的总行数保存在了磁盘上,因此执行 count() 时可以直接返回,性能很高;
  • InnoDB 在执行 count() 时需要把数据一行一行地从引擎里读取出来,然后在 server 层计数统计

注意:当加了 where 条件的 count() 统计时,MyISAM 也是需要把数据读取出来判断统计的。

由于 MyISAM 不支持事务、并发度较低,现在使用的 MySQL 一般都是 InnoDB 存储引擎,所以 表的行数越多,在使用 count() 统计数量时,执行的效率就越低

2. 为什么 InnoDB 不保存总行数?

那 InnoDB 为什么不把总行数也保存起来呢?这其实跟 InnoDB 的事务有关,其事务的默认隔离级别是可重复读(RR),底层是通过 MVCCopen in new window 来实现的。在 RR 隔离级别下,一致性视图在事务启动时就创建了,整个事务期间都会复用这个视图,因此在统计行数时,需要 判断每一行是否对该事务可见,因此只能一行一行的读取出来进行判断,才能 正确统计出 “基于该事务” 的表总行数

例如,下面这三个会话,在各自的事务中获取到的总行数是不同的,都是基于自己事务的总行数,这才是正确的。而如果都从磁盘读取出来,那对于该事务而言,就破坏了 RR 带来的保证了。

image-20240131111557439

注意,会话 C 没有手动开启事务,插入一行后自动提交了,对随后开启事务的会话 B 是可见的。

3. 如何手动实现计数?

既然 InnoDB 在行数量较多时会有性能问题,那如果我们 需要经常显示(查询)总行数,应该怎么办呢?那就是只能 自己来手动计数 了。

下面来看看常用的几个计数方式。

3.1 用缓存保存计数

对于查询很频繁的数据来说,最适合采用缓存来存储,比如可以用 Redis 来保存表的总行数,表中插入一行数据计数器就 +1,删除一行就 -1。Redis 的读写操作是很快的,因此可以很好的实现该功能。

功能虽然实现了,但又带来了两个新问题:

  • Redis 是不保证持久性的,如果插入一行数据后,Redis 执行了 +1 操作,但还未刷盘就宕机了,那这个 +1 的计数操作就丢失了。不过这个问题可以 在 Redis 异常重启后,重新从 MySQL 中执行一次 count() 获取真实的总行数,写回 Redis 即可

  • 引入了 Redis 这个新系统后,就会使得 系统整体的复杂性变高了。在插入或删除时,需要操作 MySQL 和 Redis,在并发情况下可能会出现 MySQL 和 Redis 数据不一致的情况。例如下面这种情况,会话 B 查询出来的计数就与数据库实际行数不一致了:

    image-20240131115444973

由于操作了两个不同的系统,所以这种数据不一致的情况是无法彻底避免的,也就是和数据库和缓存的一致性问题,是不能保证强一致性的。

3.2 用数据库保存计数

使用缓存保存计数的两个问题,在数据库中都能完美解决,我们看可以 把计数单独放在一张计数表中

在解决数据丢失问题上,由于 InnoDB 具有 crash safe 能力,是可以 保证数据不丢失的

在解决数据不一致的问题上,虽然还是有两步操作(插入数据、计数表 +1),但这是在一个系统中,而且 InnoDB 是支持事务的,所以 可以利用事务这一点,来解决数据不一致的这个问题

我们将这两步操作放在一个事务中执行,例如下面这两个事务:

image-20240131120350692

虽然两个会话并发交错的执行,但是由于会话 A 的事务在 T3 时刻并未提交,所以 其 +1 操作对会话 B 是不可见的,因此会话 B 查询到的计数值对于它这个事务来说是一致的

注意:会话 B 读取计数表时采用的是快照读,不会被会话 A 的更新操作锁住。

4. 如何用好 count()

其实,当表中的行数不是太多、并且查询总行数也没那么频繁时,并不需要额外手动计数,也可以直接使用 count() 统计。不过如何使用好 count() 也是有讲究的,下面就来看看几种常见的用法。

首先要清楚 count() 的语义,这很重要,将有助于它用法的理解。count() 其实是一个聚合函数,它会对返回的结果集一条一条的判断,如果 count 函数的参数(括号里面的内容)不是 NULL,累计值就 +1,最后返回累计值。

这个结果集具体是什么,跟 count 函数的参数有关,count 需要什么,就返回什么结果

4.1 count(主键)

当 count 函数的参数是主键时,InnoDB 引擎层会遍历整张表,取出每一行的主键值(这就是结果集),返回给 server 层。server 层拿到主键值后,判断是否为 NULL,由于主键不可能为 NULL,所以可以直接累加

4.2 count(字段)

由于 count 函数判断的标准为结果是否为 NULL,所以 count(字段) 需要分为两种情况:

  • 字段定义为 not null,引擎层读取出该字段的结果集后,server 层直接按行累加即可
  • 字段定义允许为 null,那么 server 层获取结果集后,还要把该字段值取出,判断是否为 null,不是才累加

4.3 count(1)

对于 count(1) 来说,引擎层也会遍历整张表,但是不用取值,server 层对于返回的每一行,由于数字 1 不可能为 null,所以直接按行累加即可

4.4 count(*)

count(*) 并不会把所有的表字段都取出来,而是做了专门的优化,与 count(1) 类似,不取值,由于 * 不是 null,所以直接按行累加即可

那 MySQL 为什么不对 count(主键) 也进行优化呢?主键肯定也不为 null,理论上是可以,但需要优化的情况太多了,因此 MySQL 只优化一个 count(*),直接使用这个就行了。

所以按照 执行效率 来排序的话:count(字段) < count(主键) < count(1) ≈ count(*)

因为 字段和主键需要从引擎层读取,会涉及到解析数据行、拷贝字段值,而 字段又需要在 server 层额外判断是否为 null,不能直接按行累加

所以在实际开发中,直接使用 count(*) 就好了

其实,MySQL 除了对 count(*) 做了不取数据的优化外,还有另一个优化:由于 InnoDB 索引即数据,主键索引中会保存所有的字段数据,而普通索引的叶子节点只会保存索引列和主键,所以 普通索引比主键索引小很多,而对于 count(*) 这类操作来说,遍历哪个索引得到的结果都是一样的,所以 MySQL 优化器 会选择最小的索引树来遍历,在保证逻辑正确的前提下,尽量减少扫描的数据量

同理,count(主键) 也会走最小的索引树,因为能从中获取到主键。

所以 count(字段) 为什么是效率最低的计数,还有一个原因,就是 如果这个字段没有建索引,那就只能选主键索引遍历了,而不能遍历较小的索引树

5. 参考文章

  • 《MySQL 实战 45 讲》
上次编辑于: