在开发系统的时候,可能需要需要计算一个表的行数这时候你可能会想,一条 select count(*) from t 语句不就解决了吗?
但是,会发现随着系统中记录数越来越多,这条语句执行得也会越来越慢。然后可能就想了,MySQL 怎么这么笨啊,记个总数,每次要查的时候直接读出来,不就好了吗
count(*) 的实现方式
首先要明确的是,在不同的 MySQL 引擎中,count(*) 有不同的实现方式。
-
MyISAM
引擎把一个表的总行数存在了磁盘上,因此执行 count(*) 的时候会直接返回这个数,效率很高; - 而 InnoDB 引擎就麻烦了,它执行 count(*) 的时候,需要把数据一行一行地从引擎里面读出来,然后累积计数。
这里需要注意的是,在这篇文章里讨论的是没有过滤条件的 count(*),如果加了 where 条件的话,
MyISAM
表也是不能返回得这么快的。
那
为什么 InnoDB 不跟 MyISAM 一样,也把数字存起来呢?
这是因为即使是在同一个时刻的多个查询,由于多版本并发控制
(MVCC)的原因
,InnoDB 表“应该返回多少行”也是不确定的。
假设表 t 中现在有 10000 条记录,我们设计了三个用户并行的会话。
- 会话 A 先启动事务并查询一次表的总行数;
- 会话 B 启动事务,插入一行后记录后,查询表的总行数;
- 会话 C 先启动一个单独的语句,插入一行记录后,查询表的总行数。
在最后一个时刻,三个会话 A、B、C 会同时查询表 t 的总行数,但拿到的结果却不同。这和 InnoDB 的事务设计有关系,
可重复读是它默认的隔离级别
,在代码上就是通过多版本并发控制,也就是
MVCC 来实现
的。每一行记录都要
判断自己是否对这个会话可见
,因此对于 count(*) 请求来说,InnoDB 只好
把数据一行一行地读出依次判断
,可见的行才能够用于计算“基于这个查询”的表的总行数。
当然,在执行 count(*) 操作的时候还是做了优化的:InnoDB 是索引组织表,主键索引树的叶子节点是数据,而普通索引树的叶子节点是主键值。所以,普通索引树比主键索引树小很多。对于 count(*) 这样的操作,遍历哪个索引树得到的结果逻辑上都是一样的。因此,MySQL 优化器会找到最小的那棵树来遍历。
在保证逻辑正确的前提下,尽量减少扫描的数据量,是数据库系统设计的通用法则之一。
到这里我们小结一下:
- MyISAM 表虽然 count(*) 很快,但是不支持事务;
- show table status 命令虽然返回很快,但是不准确(采用的是采样估算,官方说了误差40%-50%);
- InnoDB 表直接 count(*) 会遍历全表,虽然结果准确,但会导致性能问题。
接下来,我们讨论一下,看看自己计数有哪些方法,以及每种方法的优缺点有哪些。
用缓存系统保存计数
对于更新很频繁的库来说,可以用
缓存系统来支持
。
可以用一个
Redis 服务来保存这个表的总行数
。这个表每被插入一行 Redis 计数就加 1,每被删除一行 Redis 计数就减 1。这种方式下,
缓存系统可能会丢失更新
。
Redis 的数据不能永久地留在内存里,所以你会找一个地方把这个值定期地持久化存储起来。但即使这样,仍然可能丢失更新。试想如果刚刚在数据表中插入了一行,Redis 中保存的值也加了 1,然后 Redis 异常重启了,重启后你要从存储 redis 数据的地方把这个值读回来,而刚刚加 1 的这个计数操作却丢失了。
当然了,这还是有解的。比如,Redis 异常重启以后,到数据库里面单独执行一次 count(*) 获取真实的行数,再把这个值写回到 Redis 里就可以了。异常重启毕竟不是经常出现的情况,这一次全表扫描的成本,还是可以接受的。
但实际上,
将计数保存在缓存系统中的方式,还不只是丢失更新的问题。即使 Redis 正常工作,这个值还是逻辑上不精确的。
在数据库保存计数
根据上面的分析,用缓存系统保存计数有丢失数据和计数不精确的问题。那么,
如果我们把这个计数直接放到数据库里单独的一张计数表 C 中,又会怎么样呢?
首先,这解决了崩溃丢失的问题,InnoDB 是支持崩溃恢复不丢数据的。
接着我们看看改成了对计数表 C 的操作如何解决计数不精确的问题:利用“事务”这个特性,把问题解决掉。
虽然会话 B 的读操作仍然是在 T3 执行的,但是因为这时候更新事务还没有提交,所以计数值加 1 这个操作对会话 B 还不可见。因此,会话 B 看到的结果里, 查计数值和“最近 100 条记录”看到的结果,逻辑上就是一致的。
不同的 count 用法
在 select count(?) from t 这样的查询语句里面,count(*)、count(主键 id)、count(字段) 和 count(1) 等不同用法的性能,有哪些差别?
这里,首先要弄清楚 count() 的语义。count() 是一个聚合函数,对于返回的结果集,一行行地判断,如果 count 函数的参数不是 NULL,累计值就加 1,否则不加。最后返回累计值。
所以,count(*)、count(主键 id) 和 count(1) 都表示
返回满足条件的结果集的总行数
;而 count(字段),则表示返回满足条件的数据行里面,
参数“字段”不为 NULL 的总个数
。
而这些count()之间的性能区别,分析之前有三种原则:
-
server 层要什么就给什么;
-
InnoDB 只给必要的值;
-
现在的优化器只优化了 count(*) 的语义为“取行数”,其他“显而易见”的优化并没有做。
对于 count(主键 id) 来说
,InnoDB 引擎会遍历整张表,
把每一行的 id 值都取出来
,返回给 server 层。server 层拿到 id 后,
判断是不可能为空的
,就按行累加。
对于 count(1) 来说
,InnoDB 引擎遍历整张表,
但不取值
。server 层对于返回的每一行,
放一个数字“1”进去
,判断是不可能为空的,按行累加。
单看这两个用法的差别的话,你能对比出来,count(1) 执行得要比 count(主键 id) 快。因为从引擎返回 id 会涉及到
解析数据行
,以及拷贝字段值的操作。
对于 count(字段) 来说
:
-
如果这个“字段”是定义为
not null
的话,一行行地从
记录里面读出这个字段
,判断不能为 null,按行累加; -
如果这个“字段”定义允许为
null
,那么执行的时候,判断到有可能是 null,
还要把值取出来再判断一下
,不是 null 才累加。
也就是前面的第一条原则,
server 层要什么字段,InnoDB 就返回什么字段。
但是 count(*) 是例外
,并不会把全部字段取出来,而是专门做了优化,不取值。count(*) 肯定不是 null,按行累加。
有人会问,优化器就不能自己判断一下吗,主键 id 肯定非空啊,为什么不能按照 count(*) 来处理,多么简单的优化啊。
当然,MySQL 专门针对这个语句进行优化,也不是不可以。但是这种需要专门优化的情况太多了,而且 MySQL 已经优化过 count(*) 了,你直接使用这种用法就可以了。
所以结论是:按照效率排序的话,count(字段)<count(主键 id)<count(1)≈count(*),所以建议尽量使用 count(*)。