SELECTCOUNT(*)FROMt是一个很常见的SQL需求。在MySQL的使用规范中,我们一般使用事务引擎InnoDB作为(一般业务)表的存储引擎。在此前提下,COUNT(*)操作的时间复杂度为O(N),其中N为表的行数。在MyISAM表中,可以快速获取表的行数。这些实践经验的背后是怎样的机制,为什么需要/可以这样,是本文想要探讨的。先看一下概况:MySQLCOUNT(*)在两种存储引擎中的一些问题:带着这些问题,接下来主要讨论InnoDB存储引擎。1.InnoDB全表COUNT(*)主要问题:执行过程是怎样的?如何计算计数?影响计数结果的因素有哪些?计数值存在于何处?涉及到什么数据结构?为什么InnoDB只能通过扫描表来实现count(*)?(见本文中的问题***)使用全表COUNT(*)作为表扫描类型操作的情况有什么风险?COUNT(*)操作会读取和SELECT*一样大的数据吗?溢出页涉及的领域?1、执行框架——循环:读取+计数1.1基本结论全表扫描,一个循环解决问题。循环中:先读取一行,然后判断该行是否包含在count中。在循环中,逐行进行计数。1.2讲解简单SELECT-SQL的执行框架,类似于INSERTINTO...SELECT是同一个过程。下面将逐步详细介绍如何读取和计数(count++)。2.执行过程引用:执行过程部分分为4个部分:COUNT(*)预处理:在客户端发送SQL语句到MySQL-Server执行SELECT之前,为下面的一些做铺垫解释。COUNT(*)流程:简单给出代码层面的流程框架和2个核心步骤的关键调用栈部分。读取行:可见性和row_search_mvcc函数描述了可见性如何影响COUNT(*)结果。统计一行:Evaluate_join_record和列是否为空,介绍统计过程如何影响COUNT(*)结果。如果读者想直接看COUNT(*)是如何执行的,那么可以忽略(1),直接跳到(2)开始阅读。2.1COUNT(*)预处理回顾——客户端发送SQL到sub_select函数为了让调用过程看起来不那么突兀,我们先回顾一下sub_select函数是如何执行的:1.MySQL-Client发送SQL语句根据MySQL通信协议在数据包中。2.Mysql-Server收到数据包,通过协议解析出命令类型(QUERY)和SQL语句(string)。3、SQL语句被解析器解析后输出为JOIN类的一个对象,用于结构化地表达SQL语句。PS:这里的JOIN结构不只是单纯的语法结构,而是经过语义处理的。粗略地说,它概括了表列表(table_list)、目标列列表(target_list)、WHERE条件、子查询等语法结构。全表COUNT(*)-case,table_list=[表"t"(别名也是"t")],target_list=[目标列对象(列名"COUNT(*)")],当然有没有条件、子查询等WHERE构造。4.JOIN对象有两个重要的方法:JOIN::optimize()、JOIN::exec(),分别用来优化和执行查询语句。join->optimize(),优化阶段(myisam下的全表count(*)操作后面会稍微涉及到这里的内容)。join->exec(),执行阶段(重点),包括InnoDB下全表count(*)操作的执行流程。5.join->exec()多次调用后,会调用sub_select函数执行简单的SQL,包括COUNT(*)。6.子选择结束。2.2COUNT(*)过程(在sub_select函数中)上层过程和代码比较简单,集中在sub_select函数中,其中两类函数分别对应前面“执行框架”部分描述的两个步骤——阅读,数数。先给出如下结论:1.读取一行:从比较顶层的sub_select函数经过一些调用,所有的分支最终都会被调用到row_search_mvcc函数中,用于从InnoDB中存储的B+树结构中读取存储引擎将一行读入内存中的一个buf(uchar*)中进行后续处理。2.这里会涉及到行锁的获取、MVCC和行可见性的问题。当然,像SELECTCOUNT(*)这样的快照读,只涉及MVCC及其可见性,不涉及行锁。跳至“可见性和row_search_mvcc函数”部分了解详细信息。3.Countarow:在代码层面,会在evaluate_join_record函数中对读取到的行进行求值,看是否应该计入count(即是否count++)。简单来说,COUNT(arg)本身就是MySQL的一个函数操作。对于一行,如果括号中的参数arg(一列或整行)的值不为NULL,则count++,否则不统计该行。有关详细信息,请跳至“Evaluate_join_record且列为空”部分。这两个阶段对COUNT(*)结果的影响如下:(两层过滤)SQL层流程框架相关代码总结如下:Q:在代码层面,***步(连续阅读)有2个分支,为什么?A:从InnoDB接口来看,分为“读***行”和“读下一行”,是两个不同的执行过程。读取***行需要找到一个(游标)位置,为后续做一些初始化工作,这个过程可以递归。就像我们使用脚本/程序逐行扫描表一样,实现会涉及到如下两条SQL://SELECTidFROMtLIMIT1;ORSELECTMIN(id)-1FROMt;->$last_id//SELECTidFROMtWHEREid>$last_idLIMIT1;到本例代码为止,SQL层到存储引擎层的调用关系,以及读取阶段的调用栈如下:(供参考)我们可以看到,无论读取哪个分支,都会最终通过不同的路径返回到row_search_mvcc函数。以上是LOOP中代码的简单说明。看看row_search_mvcc和evaluate_join_record是如何输出最终计数结果的。2.3行可见性和row_search_mvcc函数这里主要通过一组案例和几个问题来看一下行可见性对COUNT(*)的影响。Q:对于SELECTCOUNT(*)FROMt或SELECTMIN(id)FROMt操作,第一行读操作是否读取表t中的最小记录(在B+树的最左边的叶子节点页中)?(为什么ha_index_first也会调用row_search_mvcc获取最小键值?)A:不一定。即使是MIN(id)也不一定读取到id最小的行,因为同样存在行可见性的问题。实际上,index_read取的是当前事务中语句可见的最小索引记录。这也反映出前面提到的join_read_first和join_read_next对row_search_mvcc“同门”是理所当然的事情。Q:图中最后一个问题,如果事务X是RU(Read-Uncommitted)隔离级别,并且在X-count(*)执行期间完成了C-Insert(100)(只扫描到5或10条这条记录)完成了,那么事务C-Insert(100)完成后,X-count(*)在后续的读取过程中能看到100条这条记录吗?A:MySQL采用“readtoWhatiswhat”策略,即X-count(*)以后可以读到100条记录。2.4Evaluate_join_record和列是否为空问:某行如何统计?A:这两种情况,读取的行都会被统计:1.如果COUNT函数中的参数是某列,则判断读取的行该列定义是否为Nullable,该列的值是否为无效的;如果两者都是,则不计入,否则计入。例如SELECTCOUNT(col_name)FROMtcol_name可以是主键、唯一键、非唯一键或非索引字段2.如果COUNT中有*,则判断这部分整行是否无效的。如果判断参数为NULL,则忽略该行,否则count++。例如-1。从te.g-2中选择计数(*)。SELECTCOUNT(B.*)FROMALEFTJOINBONA.id=B.idQ:特别是对于SELECTCOUNT(id)FROMtwhereid字段是表t的主键,那又怎样?A:它在效果上等同于COUNT(*)。因为不管是COUNT(*)还是COUNT(pk_col),因为主键的关系,足以判断请求的数据不为NULL。这种COUNT表达式可以用来获取当前可见的表行数。Q:InnoDBCOUNT(*)的用户级优化操作问题A:这个问题是业界比较熟悉的问题。扫描一个非空的唯一键可以得到表的行数,但是涉及到的字节数可能会少很多(在表中行的长度与主键和唯一键的长度相差很大时),相对IO成本要小得多。相关调用栈参考如下:2、数据结构:Q:计数值存放在哪个内存变量中?A:SQL解析后存放在表达式COUNT(*),((Item_sum_count*)item_sum)->count如下图,回顾一下我们前面提到的JOIN结构“COUNT(*)预处理”部分。也就是说,SQL解析器构造每个SQL语句并将其表达在JOIN对象(join)中。在这个对象中创建并填充一个列表result_field_list来存储结果列,列表中的每个元素都是一个结果列的(Item_result_field*)对象(指针)。在COUNT(*)-case中,结果列列表只包含一个元素,(Item_sum_count:publicItem_result_field)类型对象(name=“COUNT(*)”),其中该类的唯一成员变量count就是请求的一个.3、MyISAM全表COUNT(*)由于MyISAM引擎在实际业务中并不常用,这里只简单说明如下:MyISAM-COUNT(*)操作是一个时间复杂度为O(1)的操作。每一张MyISAM表都存储一个metainformation-count值,在内存中各有一份,在文件中各有一份。通过读取文件中的计数值来初始化内存中的计数变量值。SELECTCOUNT(*)FROMt会直接读取内存中表t对应的count变量的值。内存中的计数值和文件中的计数值是通过写操作来更新的,它们的一致性是通过表级锁来保证的。表级锁保证的写序列化使得所有用户线程在同一时刻的读操作要么被锁定,要么只能看到一个数据状态。4.几个问题Q:MyISAM和InnoDB在COUNT(*)操作的执行过程中有什么不同?共性:共性存在于SQL层,即SQL解析后的数据结构是一致的,count变量存在于result列的Item_sum_count类型对象中;返回给客户端的过程类似——给count变量赋值,通过MySQL通信协议返回给客户端。区别:InnoDB的计数值计算是在SQL执行阶段进行的;而MyISAM表本身在内存中有一条元信息,其中包含该表的row_count值。在SQL优化阶段,通过存储引擎的标记给优化器一个提示,表明该表使用的存储引擎保存了准确的行数,无需进入执行器就可以直接获取。Q:为什么InnoDB不能像MyISAM那样维护一个row_count变量?A:原因可以从MVCC机制和行可见性问题得到。每个事务看到的行可能不同,count(*)的结果也可能不同;另一方面,MySQL-Server端无法同时为所有用户线程提供统一的读视图,因此无法提供统一的计数值。PS:对于访问MySQL的多个用户线程(COUNT(*)),有几个因素决定了它们各自的结果:一组事务执行前的数据状态(初始数据状态)。时间重叠的事务的执行顺序(操作时序,事务理论表明并发事务操作的可串行化是正确性的必要条件)。每个事务的隔离级别(每个操作的输入)。其中1和2是服务端全局或可控的,只有3是每个用户线程中每个事务的唯一属性,是服务端的不可控因素,所以服务端也控制了每个COUNT(*)结果失控了。Q:InnoDB-COUNT(*)是表扫描操作。现有BufferPool中其他用户线程需要的热页会不会被挤出LRU-list,让其他用户线程需要从磁盘加载一次,突然增加IOConsumption,可能阻塞现有请求?A:MySQL有这样一个优化策略,就是把tablescan操作加载的page放在LRU-list的oung/old交界处(LRU末尾的3/8左右)。这样,用户线程需要的热点页面还在LRU-list-young区,扫描表操作不断加载的页面会继续刷新old区的页面。这些页面被认为是非热点页面,因此它们相对合乎逻辑。PS:个人认为有类似的优化思路,就是将scan操作使用的BufferPool的大小限制在O(1)级别,但这需要额外的内存管理成本。问:InnoDB-COUNT(*)会像SELECT*FROMt那样读取存储大字段(如果有的话)的溢出页吗?A:不需要。因为InnoDB-COUNT(*)只需要统计行数,而每一行的主键肯定不为NULL,所以只需要读取主键索引页中的行数据,不需要额外读取溢出页面。blog.didiyun.com/index.php/2019/01/08/mysql-count/
