Scott's world.

MySQL-count(*)笔记

Word count: 2.1kReading time: 7 min
2020/01/11 Share

MySQL-count(*)笔记

在开发的时候我们可能会经常碰到计算一个表的行数的需求

这时候我们一般都会使用select count(*) from t

但是我们后面会发现随着记录数越来越多,这条语句执行的也会越来越慢,而为什么会出现这样的情况呢,很明显问题就出在count(*)

所以我们这次就来学习一下count(*)语句到底是如何实现,以及为什么MySQL会这么实现,说完了原理我们就再来说一下如果应用中有这种频繁变更并需要统计表行数的需求,业务设计上可以怎么实现

count(*)的实现方式

在不同的MySQL引擎中,count(*)有不同的实现方式。

  • MyISAM引擎是把一个表总行数存在了磁盘上,故执行该语句的时候直接返回行数,效率很高;
  • InnoDB引擎是需要把数据一行一行地从引擎里面读出来,然后累积计数;

但是这里如何加了where条件的话,MyISAM表业上不能返回得这么快的

为什么InnoDB不跟MyISAM一样,也把数字存起来呢?

因为即使是在同一时刻的多个查询,由于MVCC的原因,InnoDB表对于“应该返回多少行”也是不确定的。

我们知道RR是InnoDB的事务默认隔离级别,在代码上就是通过多版本并发控制,即MVCC实现的。每一行记录都要判断自己是否对于这个会话可见,因此对于count(*)请求来说,InnoDB只好把数据一行一行地读出依次判断,可见的行才能够用于计算“基于这个查询”的表的总行数。

count(*)的优化

实际上,MySQL在执行这个count(*)操作也是做了优化的。

我们知道InnoDB是索引组织表,主键索引树的叶子节点是数据,而普通索引树的叶子节点树主键值。所以,普通索引树比主键索引树小很多。对于count(*)这样的操作,遍历哪个索引树得到的结果逻辑上都是一样的。因此,MySQL优化器还是会找到最小的那棵树来遍历。

在保证逻辑正确的前提下,尽量减少扫描的数据量,树数据库系统设计的通用法则之一。

show table status

show table status这个命令输出结果中TABLE_ROWS也会显示这个表当前有多少行

但是我们在前面说到,索引统计的值是通过采样来估算的。实际上,TABLE_ROWS就是从这个采样估算得来的,因此它的误差很高,官方文档说可能达到40%到50%。所以,这个命令显示的行数也不能直接使用。

小结

  • MyISAM表虽然count(*)很快,但是不支持事务;
  • show table status命令虽然返回很快,但是不准确;
  • InnoDB表直接count(*)会遍历全表,虽然结果准确,但也会导致性能问题;

计数方法

当我们了解了这些原理,那么我们应该如何高效地计数呢,而计数的基本思路则是找一个地方,把操作记录表的行数存起来。

缓存系统计数

对于更新很频繁的库来说,我们一般都会想到缓存系统来支持。

而一般说到缓存便会想到用Redis来实现

所以当我们用一个Redis服务来保存这个表的总行数,虽然使用Redis可以使读和更新操作都很多,但这种方式会存在什么问题吗?

显然,会出现缓存系统可能会丢失更新

我们知道Redis的数据不可能永远留在内存中,所以我们还是会找一个地方把这个值定期地持久化存储起来。但是即使这样,仍然可能会丢失更新,因为Redis可能会异常重启,而重启后之前操作的便会丢失。

当然上述问题还是有解决办法的,比如异常重启后,到数据库里面单独执行一次count(*)获取真实的行数,再把它写回到Redis里面就可以。异常重启毕竟不会经常出现,这一次的全表扫描的成本还是可以接受的。

但实际上,将计数保存在缓存系统中的方式,还不只是丢失更新的问题。即使Redis正常工作,这个值还是逻辑上不精确的。

比如下面两种情况

在并发系统里面,我们是无法精确控制不同线程的执行时刻的,因为存在图中的这种操作序列,所以,我们说即使Redis正常工作,这个计数值还是逻辑上不精确的。

数据库保存计数

根据上面的分析,用缓存系统保存计数会出现丢失数据和计数不精确的问题。

那么如果我们把这个计数直接放到数据库单独的一张计数表C中,会发生什么呢?

  • 首先,解决了崩溃丢失的问题,InnoDB支持崩溃恢复不丢数据;
  • 再来看看计数不精确的问题,由于InnoDB要支持事务,从而导致InnoDB表不能把count(*)直接存起来,然后查询的时候直接返回形成的,所以利用事务也能很好地解决这一个问题

不同count()用法

说到count()用法不只有count(*),也还有count(主键id),count(字段)count(1)等不同用法。

而这些用法的性能差异也是大不相同的,所以让我们来分析一下。

首先我们要弄清楚count()的语义。count()是一个聚合函数,对于返回的结果集,一行行地判断,如果count函数的参数不是NULL,累计值就加1,否则不加。最后返回累计值。

  • count(*)、count(主键id)和count(1) 都表示返回满足条件的结果集的总行数;

  • count(字段),则表示返回满足条件的数据行里面,参数“字段”不为NULL的总个数。

而分析性能差异的时候,我们需要记录这几个原则

  • server层要什么就给什么;
  • InnoDB只给必要的值;
  • 目前优化器只优化了count(*)的语义为“取行数”,其他并没有做太多优化
  1. 对于count(主键id)来说

    InnoDB引擎会遍历整张表,把每一行的id值都取出来,返回给server层。server层拿到id后,判断是不可能为空的,就按行累加。

  2. 对于count(1)来说

    InnoDB引擎遍历整张表,但不取值。server层对于返回的每一行,放一个数字“1”进去,判断是不可能为空的,按行累加。

  3. 对于count(字段)来说

    • 如果这个“字段”是定义为not null的话,一行行地从记录里面读出这个字段,判断不能为null,按行累加;

    • 如果这个“字段”定义允许为null,那么执行的时候,判断到有可能是null,还要把值取出来再判断一下,不是null才累加。

  4. 对于count(*)来说

    并不会把全部字段取出来,而是专门做了优化,不取值。count(*)肯定不是null,按行累加。

所以结论是:按照效率排序的话,count(字段)<count(主键id)<count(1)≈count(*),所以我建议你,尽量使用count(*)。

问题

在刚刚讨论的方案中,我们用了事务来确保计数准确。由于事务可以保证中间结果不被别的事务读到,因此修改计数值和插入新记录的顺序是不影响逻辑结果的。但是,从并发系统性能的角度考虑,你觉得在这个事务序列里,应该先插入操作记录,还是应该先更新计数表呢

其实这个问题比较简单,因为更新计数表涉及到行锁的竞争,先插入再更新能最大程度地减少了事务之间的锁等待,提升了并发度。

从并发系统性能的角度考虑,应该先插入操作记录,再更新计数表。

CATALOG
  1. 1. MySQL-count(*)笔记
    1. 1.1. count(*)的实现方式
      1. 1.1.1. count(*)的优化
      2. 1.1.2. show table status
      3. 1.1.3. 小结
    2. 1.2. 计数方法
      1. 1.2.1. 缓存系统计数
      2. 1.2.2. 数据库保存计数
    3. 1.3. 不同count()用法
    4. 1.4. 问题