Boo's Blog

Stay foolish, Stay hungry

本文是基于 极客时间——MySQL 实战 45 讲 整理的学习笔记,仅供学习参考,请勿用于商业用途,如若侵权,请联系并删除。

课程重点:

  • 了解 count(*) 的实现方式
  • 了解常用的统计计数的方案以及各自的优劣势
  • 知道 count(*)count(id)count(1) 这几者的差别

count(*) 的实现方式

首先要明确的是,在不同的 MySQL 引擎中,count(*) 有不同的实现方式。

  • MyISAM 引擎把一个表的总行数存在了磁盘上,因此执行 count(*) 的时候会直接返回这个数,效率很高
  • 而 InnoDB 引擎就麻烦了,它执行 count(*) 的时候,需要把数据一行一行地从引擎里面读出来,然后累积计数

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

这是因为即使是在同一个时刻的多个查询,由于多版本并发控制(MVCC)的原因,InnoDB 表“应该返回多少行”也是不确定的。

InnoDB 是索引组织表,主键索引树的叶子节点是数据,而普通索引树的叶子节点是主键值。

所以,普通索引树比主键索引树小很多。对于 count() 这样的操作,遍历哪个索引树得到的结果逻辑上都是一样的。因此,MySQL 优化器会找到最小的那棵树来遍历。*在保证逻辑正确的前提下,尽量减少扫描的数据量,是数据库系统设计的通用法则之一**。

如果你用过 show table status 命令的话,就会发现这个命令的输出结果里面也有一个 TABLE_ROWS 用于显示这个表当前有多少行,这个命令执行挺快的,那这个 TABLE_ROWS 能代替 count(*) 吗?

答案是不可以的。

TABLE_ROWS 就是从这个采样估算得来的,因此它也很不准。有多不准呢,官方文档说误差可能达到 40% 到 50%。所以,show table status 命令显示的行数也不能直接使用。

小结一下:

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

回到文章开头的问题,如果你现在有一个页面经常要显示交易系统的操作记录总数,到底应该怎么办呢?答案是,我们只能自己计数。

计数思路:找一个地方,把操作记录表的行数存起来。

用缓存系统保存计数

对于更新很频繁的库来说,第一时间想到的就是缓存了。

可以使用 Redis 将表的总行数保存下来,这个表每被插入一行 Redis 计数就加 1,每被删除一行 Redis 计数就减 1。

这种方式下,读和更新操作都很快,可是,这是否就是完美的解决方案呢?

不是的,因为缓存可能会丢失。Redis 的数据不能永久地留在内存里,Redis 可能会异常重启。

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

这里说的不精确是这么定义的:

  1. 一种是,查到的 100 行结果里面有最新插入记录,而 Redis 的计数里还没加 1
  2. 另一种是,查到的 100 行结果里没有最新插入的记录,而 Redis 的计数里已经加了 1

在数据库保存计数

如果把这个计数直接放到数据库里单独的一张计数表中,就可以很好地解决上面的两个问题。

只是这个方案相比上一个方案,在查询速度上面,前者更快一些。

不同的 count 用法

经常会遇到一个问题:在 select count(?) from t 这样的查询语句里面,count(*)、count(主键 id)、count(字段) 和 count(1) 等不同用法的性能,有哪些差别?

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

所以,count(*)、count(主键 id) 和 count(1) 都表示返回满足条件的结果集的总行数;而 count(字段),则表示返回满足条件的数据行里面,参数“字段”不为 NULL 的总个数。

至于分析性能差别的时候,你可以记住这么几个原则:

  • server 层要什么就给什么
  • InnoDB 只给必要的值
  • 现在的优化器只优化了 count(*) 的语义为“取行数”,其他“显而易见”的优化并没有做

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

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

单看这两个用法的差别的话,你能对比出来,count(1) 执行得要比 count(主键 id) 快。因为从引擎返回 id 会涉及到解析数据行,以及拷贝字段值的操作。

对于 count(字段) 来说

  1. 如果这个“字段”是定义为 not null 的话,一行行地从记录里面读出这个字段,判断不能为 null,按行累加;
  2. 如果这个“字段”定义允许为 null,那么执行的时候,判断到有可能是 null,还要把值取出来再判断一下,不是 null 才累加。

也就是前面的第一条原则,server 层要什么字段,InnoDB 就返回什么字段。

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

看到这里,你一定会说,优化器就不能自己判断一下吗,主键 id 肯定非空啊,为什么不能按照 count(*) 来处理,多么简单的优化啊。

当然,MySQL 专门针对这个语句进行优化,也不是不可以。但是这种需要专门优化的情况太多了,而且 MySQL 已经优化过 count(*) 了,你直接使用这种用法就可以了。

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

总结

  • 按照效率排序的话,count(字段)<count(主键 id)<count(1)≈count(),建议使用 count()

评论