查询处理器
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, 否则不加。 最后返回累计值。
- count(*)、 count(主键id)和count(1) 都表示返回满足条件的结果集的总行数。
- count(字段) , 则表示返回满足条件的数据行里面, 参数“字段”不为NULL的总个数。
至于分析性能差别的时候, 你可以记住这么几个原则:
- server层要什么就给什么。
- InnoDB只给必要的值。
- 现在的优化器只优化了count(*)的语义为“取行数”, 其他“显而易见”的优化并没有做。
这是什么意思呢? 接下来, 我们就一个个地来看看。
- count(*)是例外, 并不会把全部字段取出来, 而是专门做了优化, 不取值。 count(*)肯定不是null, 按行累加。(不取值)
- 对于count(1)来说, InnoDB引擎遍历整张表, 但不取值。 server层对于返回的每一行, 放一个数字“1”进去, 判断是不可能为空的, 按行累加。单看这两个用法的差别的话, 你能对比出来, count(1)执行得要比count(主键id)快。 因为从引擎返回id会涉及到解析数据行, 以及拷贝字段值的操作。(不取值)
- 对于count(主键id)来说, InnoDB引擎会遍历整张表, 把每一行的id值都取出来, 返回给server层。 server层拿到id后, 判断是不可能为空的, 就按行累加。(取值,判断一次)
- 对于count(字段)来说:
看到这里, 你一定会说, 优化器就不能自己判断一下吗, 主键id肯定非空啊, 为什么不能按照count(*)来处理, 多么简单的优化啊。
当然, MySQL专门针对这个语句进行优化, 也不是不可以。 但是这种需要专门优化的情况太多了, 而且MySQL已经优化过count(*)了, 你直接使用这种用法就可以了。
所以结论是: 按照效率排序的话, count(字段)
小结:思考题
思考:我们用了事务来确保计数准确。 由于事务可以保证中间结果不被别的事务读到, 因此修改计数值和插入新记录的顺序是不影响逻辑结果的。 但是, 从并发系统性能的角度考虑, 你觉得在这个事务序列里, 应该先插入操作记录, 还是应该先更新计数表呢?
逻辑实现上是启动一个事务, 执行两个语句:
- insert into 数据表。
- update 计数表, 计数值加1。
从并发系统性能的角度考虑, 应该先插入操作记录, 再更新计数表。
因为更新计数表涉及到行锁的竞争, 先插入再更新能最大程度地减少事务之间的锁等待, 提升并发度。
注:该小节的讨论基于InnoDB引擎。