在 MySQL 中,计数的实现与具体的存储引擎有关:

  • MyISAM 存储引擎将 count 的总行数存储到磁盘上,因此在执行 count 的时候,会直接返回这个数。
  • InnoDB 存储引擎在执行 count 时候,会使用全表扫描的方式,将数据一行一行地从引擎里面读出来,累积计数。

这篇文章还是主要关注 InnoDB 的实现。

InnoDB 的实现

为什么不存储count结果到磁盘

InnoDB 不采用与 MyISAM 的方法是因为其有 MVCC 的机制,每一行都会有多个版本,所以即使是在同一时刻做出的查询,返回的结果也有可能截然不同。

SessionA SessionB SessionC
begin
select count(*) from t;
insert into t;(插入一行)
begin;
insert into t;(插入一行)
select count(*) from t; select count(*) from t; select count(*) from t;

三个会话得到的值完全不同。

A:10000

B:10002

C:10001

详细不展开讲,可以参见隔离级别的介绍,简单来说就是:因为 RR 隔离级别是InnoDB默认的事务隔离级别

如何全盘扫描

从前面的索引介绍可以知道,InnoDB 是索引组织表,主键索引树的叶子节点是数据,而普通索引树的叶子节点是主键值。

所以,普通索引树会比主键索引树小很多。对于 count 操作,遍历普通索引树和主键索引树的结果是一样的,所以:如果存在普通索引树,则 MySQL 的优化器一定会选择最小的那棵普通索引树进行遍历。

count 的性能问题

count 是一个聚合函数。

对于返回的结果集,一行行地判断,如果 count 函数的参数不是 null,累计值就加1,反之则不加,最终返回累计值。

但是 count 函数有不同的参数:

  1. count(主键id)
  2. count(1)
  3. count(字段)
  4. count(*)

以上几种表示之间的性能是有差异的。

对于1,InnoDB 引擎会遍历整张表,把每一行的 id 都取出来,返回给 Server 层,Server 层拿到 id 集合后,根据是否为空,按行累加;

对于2,InnoDB 引擎会遍历整张表,但是不取值,Server 层对于返回的每一行,放入数字“1”,根据是否为空,按行累加。

注:对于“不取值,Server 层对于返回的每一行”这样的描述可能会有歧义,实际情况可以理解为:每一行返回的内容带0个字段。

对于3:

  1. 如果字段定义为 not null,则一行行从记录里面读出这个字段,判断不能为 null,按行累加;
  2. 如果这个字段定义允许为 null,执行时,判断有可能是 null,不是 null,然后把值取出来,再判断一下,不是 null,才累加。

对于4:对于 count(*) 服务器内部对其进行了优化,并不取值。

所以对于这四种情况,如果要按照执行效率来说:count(字段) < count(主键 id) <count(1)≈ count(*)。

实际应用

从上面内容我们知道:

  1. 使用 MyISAM 进行 count 统计,可以直接读取磁盘值返回,但是不支持事务;
  2. 使用 InnoDB 进行 count 统计,会遍历全表,性能较低

实际的统计场景我们如何使用呢?

平台层没有提供两全其美的方法,我们只能自己在业务层保证。

InnoDB 存储引擎

采用这种存储引擎时,因为每进行一次count统计都很耗时,所以我们脑中第一个想法是:将其缓存起来。

缓存在 Redis

使用 redis 将表的行数存储起来。每添加一行,计数加1,每删除一行,计数减1,即先后的操作是:

  1. 更新表数据
  2. 更新 Redis

正常情况下,这样设计是没有问题的,就怕各种异常场景:

  1. 步骤2出问题。此时数据表中的数据已经更新,在执行步骤2 的过程中 Redis 重启,导致更新操作丢失, Redis 中数据与数据表中的数据不一致
  2. 如果我们现在在原来的基础上有一个额外的需求:查询总的记录数最近的100条操作记录

异常场景2,在 Redis 正常工作的场景下,也会造成逻辑上的不精确。

Time SessionA SessionB
T1
T2 insert into t;//插入一行数据
T3 读取 Redis 的总记录数;
查询最近100条记录;
T4 Redis 计数+1;

在 T2 时刻,数据表中的数据已经多了一行,但是在 SessionB 读取到的 Redis 总记录数没有加1,数据会不一致

如果把执行操作的先后顺序换一下呢:

  1. 更新 Redis
  2. 更新表数据

抛开 Redis 本身写入的异常不管,看看是否会发生其他的逻辑问题。

Time SessionA SessionB
T1
T2 Redis 计数+1
T3 读Redis计数;
查询最近 100 条记录
T4 insert into t;//插入一行数据
T5

T2时刻,SessionA 将总的记录数更新了,于是 SessionB 在 T3 时刻已经看到总数增加了,但是在查询最近 100 条记录时,还没有看到更新的操作,也会造成数据不一致

总结将总记录数缓存到 Redis 中会出现的问题:

  1. 崩溃丢失(Redis 异常重启)
  2. 逻辑不精确(由于并发执行的操作造成的数据一致性问题)

缓存在数据库

缓存到数据库中又如何呢,我们把这个计数直接放到一张单独的数据库表C中又如何?

针对问题1,InnoDB 具有 crash-safe 的特性,这个可以完美解决。

问题2其实核心的问题还是更新 Redis 与更新 MySQL 数据表不是一个完整的事务。

那么,如果把这两步操作放到一个事务中会如何呢?

Time SessionA SessionB
T1
T2 begin;
更新表C中计数值
T3 begin;
读取表C中的计数值;
查询最近100条记录;
commit;
T4 插入一行数据 R;
commit

从上表中可以看出:

T3时刻,SessionB 在读取表 C 中的计数时,由于 SessionA 的事务还没有提交,所以计数值和操作记录还是原来的,并没有数据不一致的现象产生。

(全文完)

参考资料

  1. 丁奇 《MySQL 45讲》
  2. 了解 select count(*), count(1) 和 count(field)