在现实工作中,我们经常会遇见统计的场景,例如统计订单的数量、统计数据表中有多少条数据、统计用户的数量,等等。遇到这些场景,你可能会不屑地说:“这还不简单吗?使用一个 count(*) 就可以解决!”

但比较尴尬的是,你经常会发现,当需要统计的数据越来越多的时候,使用 count(*) 会变得越来越慢。这是为什么呢?下面我们就一起来聊聊 count(*) 的底层原理。

1. count(*) 的原理

在 MySQL 中,count(*) 的作用理论上是统计相关数据的行数,不过数据过多的时候统计速度很慢。这个时候我们很容易就能想到,我们单独用一个字段来记录行数就可以完美地解决这个问题。这是可以的,事实上 MySQL 中的 MyISAM 存储引擎中就存有这样的数据。具体如下:

  1. mysql> select table_name,table_rows from tables where TABLE_SCHEMA='test01' AND table_name = 'city';
  2. +------------+------------+
  3. | table_name | table_rows |
  4. +------------+------------+
  5. | city | 3 |
  6. +------------+------------+
  7. 1 row in set (0.00 sec)

这种方法的查询速度虽然很快,但是有局限性。

  • 局限性一:就是当只需要统计某部分数据的时候仍然需要一行一行地进行统计,因为硬盘中只存放总行数这一个数据,没有进行筛选,这个统计的过程仍然很慢。
  • 局限性二:这种方法只适用于 MyISAM 这种不支持事务的存储引擎,因为支持事务的存储引擎在不同的场景下查询出来的数据是不一样的。具体如下(我们以 InnoDB 为例):

a. 在会话 a 中开启一个事务,查询 city 表中的数据:

  1. mysql> begin;
  2. Query OK, 0 rows affected (0.00 sec)
  3. mysql> select count(*) from city;
  4. +----------+
  5. | count(*) |
  6. +----------+
  7. | 0 |
  8. +----------+
  9. 1 row in set (0.01 sec)

b. 在会话 b 中新增一条数据:

  1. mysql> insert into city (name, fid) values ('松江区', 1);
  2. Query OK, 1 row affected (0.00 sec)

c. 在会话 c 中开启事务,插入一条数据:

  1. mysql> begin;
  2. Query OK, 0 rows affected (0.00 sec)
  3. mysql> insert into city (name, fid) values ('奉贤区', 1);
  4. Query OK, 1 row affected (0.00 sec)

最后,在这三个会话中分别查询出总条数:

  1. -- 会话a, 查询出0条数据
  2. mysql> select count(*) from city;
  3. +----------+
  4. | count(*) |
  5. +----------+
  6. | 0 |
  7. +----------+
  8. 1 row in set (0.03 sec)
  9. -- 会话b, 查询出1条数据
  10. mysql> select count(*) from city;
  11. +----------+
  12. | count(*) |
  13. +----------+
  14. | 1 |
  15. +----------+
  16. 1 row in set (0.00 sec)
  17. -- 会话c,查询出2条数据
  18. mysql> select count(*) from city;
  19. +----------+
  20. | count(*) |
  21. +----------+
  22. | 2 |
  23. +----------+
  24. 1 row in set (0.00 sec)

这时,我们可以看到,同一时刻,不同的场景下,查询出来的结果不同(这种情况是跟 MySQL 自带的事务隔离机制密切相关,具体可以了解事务的隔离机制)。所以,InnoDB 存储引擎在使用 count(*) 时只能一行一行地去统计。

这样看上去可能会觉得 count(*) 会很慢,但其实 MySQL 将 count(*) 做了一定的优化。MySQL 存储引擎查询数据的时候通常采用 B+ 树索引,当使用 count(*) 时,在保证统计数据正确的前提下,会选择遍历最小的那棵树(也就是读取最小的数据)来获得相应的数据,这样就能够有效地提高统计的速度(注:这里提高的统计速度是有限的)。

使用缓存计数

说到这里,你就很有可能产生一个疑问:我们为什么不能使用缓存来存放数据库总数呢?这样既可以提高查询速度又可以减少数据库性能消耗。

下面我们一起探讨一下使用缓存记录数据库总数是否可行。

这里以 Redis(缓存数据库)为例,我们通常的方案是在数据库中执行一下 count(*) ,然后将对应的数值保存在 Redis 中,之后每添加一条数据增加 1,减少一条数据减去 1。这看上去可行,但实际场景我们往往会遇见以下几个问题。

  • Redis 中的数据会丢失

在 Redis 中保存的数据一般都有一个有效的时间,一旦过了有效期该条数据就会自动删除,这个时候就不得不做数据持久化(详情请参考 Redis 数据持久化策略),但是 Redis 数据持久化也不能保证数据的百分百正确。假设在添加数据的时候,恰巧此时 Redis 异常宕机,导致此次添加数据并没有在 Redis 中成功累加,进而就会导致 Redis 中的数据不精确,所以每一次 Redis 异常重启之后都得在数据库中重新使用 count(*) 获取数据。

  • 计数逻辑不精确

上一条中我们已经提前说到了逻辑不精确的一个场景,除此之外还有一些不精确的场景,例如很难做到修改数据库记录和 Redis 记录同步。具体如下:

时间 会话A 会话B
T1 — 插入一条记录 mysql> insert into city (name, fid) values (‘嘉定区’, 1); Query OK, 1 row affected (0.01 sec)
T2 在Redis中读取数据
T3 修改Redis中的记录

在 T1 时刻,我们在数据库会话 A 中添加一条数据;T2 时刻在 Redis 会话 B 中查询该记录;T3 时刻修改 Redis 缓存数据库中的记录。很显然在 T2 时刻查询结果是不精确的,所以使用 Redis 缓存是不能够完全代替 count(*)

2. count(*)、count(主键)、count(字段) 和 count(1) 该选谁

了解完查询之后,就有朋友不由自主地会说:查询的时候,count(*)、count(主键)、count(字段) 和 count(1) 该选谁?

乍看这几种形式,你很可能会觉得一样,甚至有时候用起来也感觉不到有什么很大的不同。那我们到底应该选择谁呢?下面我们就一起来探讨一下。

首先我们来了解一下 count。count 其实是 MySQL 数据库中的一个聚合函数,它的主要作用是通过判断其中指定的字段是否为 null 来计数;如果其中字段为 null 就跳过,不为 null 就加 1,最后返回累加的总数,所以如果 count(字段) 中的字段是一个不为 null 的字段,那么 count(*)、count(主键)、count(字段) 和 count(1) 最终的结果是相同的。

对于 count(1) 和 count(*) 来说,count(1) 和 count(*) 的主要作用是每一条记录无论是否为 null,都会累加 1,唯一不同的是 count(*) 经过了 MySQL 的优化,性能要比 count(1) 好。

对于 count(主键) 来说,它是将指定数据中所有的主键 ID 拿出来,如果不为 null, 则计数累加 1。

除此之外,还有几条需要注意的点。

  • count(主键) 和 count(1) 谁的性能更强劲?

从表面上面看,count(主键) 和 count(1) 其实也是没什么太大的区别的,但是底层逻辑不同。count(主键)是将需要累加的数据的主键 ID 先查询出来,然后将数据返回到 SQL 的执行器中(具体参考前面《一条 SQL 的周期》),再进行判断且累加。而 count(1) 则是首先查询符合条件的数据,不取值,每一行数据都会累加1。

相比之下,count(1) 要比 count(主键) 的性能更强。

  • count(字段) 的两种情况

count(字段) 中的字段有两种情况,分别是允许为空(not null)和不允许为空。如果该字段不允许为 null 时,则直接取出该条记录的判断是否为 null 即可;如果该字段允许为 null 时,判断该字段有可能为 null 时(该字段的值为空格时),则会取出该字段的值再进行判断是否为 null 。

综上,我们可以得出 count 的各种形式的性能结果:count(*) > count(1) > count(主键) > count(字段)

3. 总结

在 MySQL 中,统计数据一般情况下会使用 count() 函数。它在不同的存储引擎中的结果是不一样的,主要原因是在支持事务的存储引擎中不同的情况得到的结果不同。如果使用 Redis 作为存储计数结果的对象,是不能够保证计数的精确性的,所以不能使用 Redis 等缓存数据库作为计数结果的存储数据的对象。而不同的 count 使用形式,其计数的结果可能是不一样的,性能也有一些差别。

我们在数据库的实际应用过程中,一般数据量不是特别大的情况下,我们尽量选择使用 count(*) 来统计,因为它是经过算法优化的,性能比其他的 count 形式都要优秀。但是在处理大量的数据统计的情况下,不建议直接使用count(*),因为它虽然经过优化,还是需要一条一条地统计,数据太大仍然很慢,这个时候我们可以考虑更换性能更加优秀的数据库。