MySQL查询执行(一):count执行慢

查询处理器


MySQL查询处理器是MySQL数据库服务器的组件,它负责执行SQL查询。查询处理器的主要任务是解析查询(把用户提交的SQL查询转换为可以被数据库引擎理解和执行的数据操作指令序列),生成查询计划,然后执行该计划。

SQL语句查询编译的步骤:

1)语法分析,建立查询分析树;

2)生成逻辑计划,将分析树转化为初始查询计划,并优化;(逻辑优化,生成逻辑执行计划)

3)生成物理计划,为逻辑计划中的每个操作符选择实现算法以及执行顺序;(物理优化,生成物理执行计划)

相关图如下:

注1:2)、3)步骤通常被称为查询优化器,包含:RBO、CBO策略。

注2:查询优化依赖元数据,如:关系的大小、属性数量及频率、索引、数据在磁盘的分布等。

在开发系统的时候, 你可能经常需要计算一个表的行数, 比如一个交易系统的所有变更记录总数。 这时候你可能会想, 一条select count(*) from t 语句不就解决了吗?

但是, 你会发现随着系统中记录数越来越多, 这条语句执行得也会越来越慢。 然后你可能就想了, MySQL怎么这么笨啊, 记个总数, 每次要查的时候直接读出来, 不就好了吗。

接下来,我们就来聊聊count(*)语句到底是怎样实现的, 以及MySQL为什么会这么实现。 然后, 我会再和你说说, 如果应用中有这种频繁变更并需要统计表行数的需求, 业务设计上可以怎么做。

count(*)的实现方式


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

1)MyISAM引擎把一个表的总行数存在了磁盘上, 因此执行count(*)的时候会直接返回这个数,效率很高。

2)而InnoDB引擎就麻烦了, 它执行count(*)的时候, 需要把数据一行一行地从引擎里面读出来, 然后累积计数。

注:这里讨论的是没有过滤条件的count(*), 如果加了where条件的话, MyISAM表也是不能返回得这么快的。

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

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

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

举例:假设表t中现在有10000条记录, 我们设计了三个用户并行的会话。

  • 会话A先启动事务并查询一次表的总行数;
  • 会话B启动事务, 插入一行记录后,查询表的总行数;
  • 会话C先启动一个单独的语句, 插入一行记录后, 查询表的总行数;

假设从上到下是按照时间顺序执行的, 同一行语句是在同一时刻执行的。

你会看到, 在最后一个时刻, 三个会话A、 B、 C会同时查询表t的总行数, 但拿到的结果却不同。

普通索引树比主键索引树小很多, 对于count(*)这样的操作, 遍历哪个索引树得到的结果逻辑上都是一样的。 因此, MySQL优化器会找到最小的那棵树来遍历。

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

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

答:不能。因为TABLE_ROWS值是估算得来的,且官方文档说误差可能达到40%到50%。

总结:

MyISAM表虽然count(*)很快, 但是不支持事务。

show table status命令虽然返回很快, 但是不准确。

InnoDB表直接count(*)会遍历全表, 虽然结果准确, 但会导致性能问题。

问3:count(*)这么慢,我该怎么办?

答:自己数。

自己计数有哪些方法呢?下面对几种常用方法逐一介绍。

用缓存系统保存计数(不推荐)

对于更新很频繁的库来说, 你可能会第一时间想到, 用缓存系统来支持。

你可以用一个Redis服务来保存这个表的总行数。 这个表每被插入一行Redis计数就加1, 每被删除一行Redis计数就减1。 这种方式下, 读和更新操作都很快,。

问1:这种计数方式存在什么问题吗?

答:缓存系统不仅存在丢失更新问题,还存在计数值逻辑上不精确。

先说丢失更新问题,如果刚刚在数据表中插入了一行, Redis中保存的值也加了1, 然后Redis异常重启了, 重启后你要从存储redis数据的地方把这个值读回来, 而刚刚加1的这个计数操作却丢失了。丢失更新解决方案:当Redis异常重启以后, 到数据库里面单独执行一次count(*)获取真实的行数, 再把这个值写回到Redis里就可以了。 异常重启毕竟不是经常出现的情况, 这一次全表扫描的成本, 还是可以接受的。

即使丢失更新问题可以被解决,亦或是Redis正常工作, 但这个值在逻辑上也是不精确的。不精确定义如下:

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

分别对上述两种情况进行举例说明:

1)情况一

上图中,会话A是一个插入交易记录的逻辑, 往数据表里插入一行R, 然后Redis计数加1; 会话B就是查询页面显示时需要的数据。

在上图的这个时序里, 在T3时刻会话B来查询的时候, 会显示出新插入的R这个记录, 但是Redis的计数还没加1。 这时候, 就会出现我们说的数据不一致。

2)情况二

你会发现, 这时候反过来了, 会话B在T3时刻查询的时候, Redis计数加了1了, 但还查不到新插入的R这一行, 也是数据不一致的情况。

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

注:Redis不支持分布式事务, 无法拿到精确一致的视图。所以Redis不能像MySQL一样使用事务解决计数不精确问题。

在数据库保存计数(推荐)

问:如果我们把这个计数直接放到数据库里单独的一张计数表C中, 又会怎么样呢?

答:不仅能解决崩溃丢失问题(InnoDB支持使用redo log+binlog解决崩溃丢失),还能解决计数不精确问题。

计数不精确解决思路:以子之矛攻子之盾。既然计数不精确是由于InnoDB引擎支持事务导致的,那么就利用事务特性解决该问题。

我们来看下现在的执行结果。 虽然会话B的读操作仍然是在T3执行的, 但是因为这时候更新事务还没有提交, 所以计数值加1这个操作对会话B还不可见。

因此, 会话B看到的结果里, 查计数值和“最近100条记录”看到的结果, 逻辑上就是一致的。

不同的count用法


思考:在select count(?) from t这样的查询语句里面, count(*)、 count(主键id)、 count(字段)和count(1)等不同用法的性能, 有哪些差别?

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

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

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

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

这是什么意思呢? 接下来, 我们就一个个地来看看。

  1. count(*)是例外, 并不会把全部字段取出来, 而是专门做了优化, 不取值。 count(*)肯定不是null, 按行累加。(不取值)
  2. 对于count(1)来说, InnoDB引擎遍历整张表, 但不取值。 server层对于返回的每一行, 放一个数字“1”进去, 判断是不可能为空的, 按行累加。单看这两个用法的差别的话, 你能对比出来, count(1)执行得要比count(主键id)快。 因为从引擎返回id会涉及到解析数据行, 以及拷贝字段值的操作。(不取值)
  3. 对于count(主键id)来说, InnoDB引擎会遍历整张表, 把每一行的id值都取出来, 返回给server层。 server层拿到id后, 判断是不可能为空的, 就按行累加。(取值,判断一次)
  4. 对于count(字段)来说:

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

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

所以结论是: 按照效率排序的话, count(字段)

小结:思考题


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

逻辑实现上是启动一个事务, 执行两个语句:

  1. insert into 数据表。
  2. update 计数表, 计数值加1。

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

因为更新计数表涉及到行锁的竞争, 先插入再更新能最大程度地减少事务之间的锁等待, 提升并发度。

注:该小节的讨论基于InnoDB引擎。

  • 5
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

数据库内核

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值