MySQL之count函数
次访问
在 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 函数有不同的参数:
- count(主键id)
- count(1)
- count(字段)
- count(*)
以上几种表示之间的性能是有差异的。
对于1,InnoDB 引擎会遍历整张表,把每一行的 id
都取出来,返回给 Server 层,Server 层拿到 id
集合后,根据是否为空,按行累加;
对于2,InnoDB 引擎会遍历整张表,但是不取值,Server 层对于返回的每一行,放入数字“1”,根据是否为空,按行累加。
注:对于“不取值,Server 层对于返回的每一行”这样的描述可能会有歧义,实际情况可以理解为:每一行返回的内容带0个字段。
对于3:
- 如果字段定义为 not null,则一行行从记录里面读出这个字段,判断不能为 null,按行累加;
- 如果这个字段定义允许为 null,执行时,判断有可能是 null,不是 null,然后把值取出来,再判断一下,不是 null,才累加。
对于4:对于 count(*) 服务器内部对其进行了优化,并不取值。
所以对于这四种情况,如果要按照执行效率来说:count(字段) < count(主键 id) <count(1)≈ count(*)。
实际应用
从上面内容我们知道:
- 使用 MyISAM 进行 count 统计,可以直接读取磁盘值返回,但是不支持事务;
- 使用 InnoDB 进行 count 统计,会遍历全表,性能较低
实际的统计场景我们如何使用呢?
平台层没有提供两全其美的方法,我们只能自己在业务层保证。
InnoDB 存储引擎
采用这种存储引擎时,因为每进行一次count统计都很耗时,所以我们脑中第一个想法是:将其缓存起来。
缓存在 Redis
使用 redis 将表的行数存储起来。每添加一行,计数加1,每删除一行,计数减1,即先后的操作是:
- 更新表数据
- 更新 Redis
正常情况下,这样设计是没有问题的,就怕各种异常场景:
- 步骤2出问题。此时数据表中的数据已经更新,在执行步骤2 的过程中 Redis 重启,导致更新操作丢失, Redis 中数据与数据表中的数据不一致
- 如果我们现在在原来的基础上有一个额外的需求:查询总的记录数和最近的100条操作记录。
异常场景2,在 Redis 正常工作的场景下,也会造成逻辑上的不精确。
Time | SessionA | SessionB |
---|---|---|
T1 | ||
T2 | insert into t;//插入一行数据 | |
T3 | 读取 Redis 的总记录数; 查询最近100条记录; |
|
T4 | Redis 计数+1; |
在 T2 时刻,数据表中的数据已经多了一行,但是在 SessionB
读取到的 Redis 总记录数没有加1,数据会不一致。
如果把执行操作的先后顺序换一下呢:
- 更新 Redis
- 更新表数据
抛开 Redis 本身写入的异常不管,看看是否会发生其他的逻辑问题。
Time | SessionA | SessionB |
---|---|---|
T1 | ||
T2 | Redis 计数+1 | |
T3 | 读Redis计数; 查询最近 100 条记录 |
|
T4 | insert into t;//插入一行数据 | |
T5 |
T2时刻,SessionA 将总的记录数更新了,于是 SessionB 在 T3 时刻已经看到总数增加了,但是在查询最近 100 条记录时,还没有看到更新的操作,也会造成数据不一致。
总结将总记录数缓存到 Redis 中会出现的问题:
- 崩溃丢失(Redis 异常重启)
- 逻辑不精确(由于并发执行的操作造成的数据一致性问题)
缓存在数据库
缓存到数据库中又如何呢,我们把这个计数直接放到一张单独的数据库表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 的事务还没有提交,所以计数值和操作记录还是原来的,并没有数据不一致的现象产生。
(全文完)
参考资料
- 丁奇 《MySQL 45讲》
- 了解 select count(*), count(1) 和 count(field)