select count(*) 底层究竟做了什么?

阅读本文大概需要 6.6 分钟。

SELECT COUNT( * ) FROM t是个再常见不过的 SQL 需求了。在 MySQL 的使用规范中,我们一般使用事务引擎 InnoDB 作为(一般业务)表的存储引擎,在此前提下,COUNT( * )操作的时间复杂度为O(N),其中 N 为表的行数。

而MyISAM表中可以快速取到表的行数。这些实践经验的背后是怎样的机制,以及为什么需要/可以是这样,就是此文想要探讨的。

先来看一下概况:MySQL COUNT( * )在 2 种存储引擎中的部分问题:

下面就带着这些问题,以InnoDB存储引擎为主来进行讨论。

一、InnoDB 全表 COUNT( * )

主要问题:

执行过程是怎样的?

如何计算 count?影响 count 结果的因素有哪些?

count 值存在哪里?涉及的数据结构是怎样的?

为什么 InnoDB 只能通过扫表来实现 count( * )?(见本文最后的问题)

全表COUNT( * )作为 table scan 类型操作的一个 case,有什么风险?

COUNT(* )操作是否会像SELECT *一样可能读取大字段涉及的溢出页?

1. 执行框架 – 循环: 读取 + 计数

1.1 基本结论

全表扫描,一个循环解决问题。

循环内: 先读取一行,再决定该行是否计入 count。

循环内是一行一行进行计数处理的。

1.2 说明

简单SELELCT-SQL的执行框架,类比INSERT INTO … SELECT是同样的过程。

下面会逐步细化如何读取与计数 (count++) 。

2. 执行过程

引述: 执行过程部分,分为 4 个部分:

COUNT( * )前置流程: 从 Client 端发 SQL 语句,到 MySQL-Server端执行 SELECT 之前,为后面的一些阐述做一铺垫。

COUNT( * ) 流程: 简要给出代码层面的流程框架及 2 个核心步骤的重点调用栈部分。

读取一行: 可见性及 row_search_mvcc函数,介绍可见性如何影响 COUNT( * ) 结果。

计数一行: Evaluate_join_record与列是否为空,介绍计数过程如何影响 COUNT( * )结果。

如果读者希望直接看如何进行COUNT( * ),那么也可以忽略 (1),而直接跳到 (2) 开始看。

2.1 COUNT( * ) 前置流程回忆 – 从 Client 端发 SQL 到 sub_select 函数

为了使看到的调用过程不太突兀,我们还是先回忆一下如何执行到sub_select函数这来的:

MySQL-Client 端发送 SQL 语句,根据 MySQL 通信协议封包发送。

Mysql-Server端接收数据包,由协议解析出 command 类型 ( QUERY ) 及 SQL 语句 ( 字符串 ) 。

SQL 语句经过解析器解析输出为 JOIN类的对象,用于结构化地表达该 SQL 语句。

PS: 这里的JOIN结构,不仅仅是纯语法结构,而是已经进行了语义处理,粗略地说,汇总了表的列表 (table_list)、目标列的列表 (target_list)、WHERE条件、子查询等语法结构。

在全表COUNT( * )-case中,table_list = [表“t”(别名也是“t”)],target_list = [目标列对象(列名为“COUNT( * )”)],当然这里没有WHERE条件、子查询等结构。

JOIN对象有 2 个重要的方法: JOIN::optimize(), JOIN::exec(),分别用于进行查询语句的优化 和 查询语句的执行。

join->optimize(),优化阶段 (稍后myisam下全表count( * )操作会涉及这里的一点内容)。

join->exec(),执行阶段 ( 重点 ),包含了InnoDB下全表count( * )操作的执行流程。

join->exec() 经过若干调用,将调用到sub_select函数来执行简单 SQL,包括 COUNT( * ) 。

END of sub_select 。

2.2 COUNT( * ) 流程 ( 于 sub_select 函数中 )

上层的流程与代码是比较简单的,集中在sub_select函数中,其中 2 类函数分别对应于前面”执行框架”部分所述的 2 个步骤 – 读取、计数。先给出结论如下:

读取一行:从相对顶层的 sub_select 函数经过一番调用,最终所有分支将调用到 row_search_mvcc 函数中,该函数就是用于从 InnoDB 存储引擎所存储的B+-tree结构中读取一行到内存中的一个 buf (uchar * )中,待后续处理使用。

这里会涉及行锁的获取、MVCC 及行可见性的问题。当然对 于

SELECT COUNT( * ) 这类快照读而言,只会涉及 MVCC 及其可见性,而不涉及行锁。详情可跳至“可见性与 row_search_mvcc 函数”部分。

计数一行: 代码层面,将会在 evaluate_join_record函数中对所读取的行进行评估,看其是否应当计入 count中 ( 即是否要count++ )。

简单来说,COUNT(arg)本身为 MySQL 的函数操作,对于一行来说,若括号内的参数arg ( 某列或整行 )的值若不是 NULL,则count++,否则对该行不予计数。详情可跳至“ Evaluate_join_record 与列是否为空”部分。

这两个阶段对COUNT( * )结果的影响如下: (两层过滤)

SQL 层流程框架相关代码摘要如下:

1210enum_nested_loop_state

1211sub_select(JOIN *join, QEP_TAB *constqep_tab,boolend_of_records)

1212{

1213DBUG_ENTER("sub_select");

... ...// 此处省略1000字

1265while(rc == NESTED_LOOP_OK &&join->return_tab >= qep_tab_idx)

1266{

1267interror;

// 第一步,从存储引擎中获取一行;

1268if(in_first_read)

1269{

1270in_first_read=false;

// 第一步,首次读取,扫描第一个满足条件的记录;

// 初始化cursor,从”头”扫描到某个位置

// 类似: SELECT id FROM t LIMIT 1;

1271error= (*qep_tab->read_first_record)(qep_tab);

1272}

1273else

// 第一步,后续读取,在前次扫描的位置上继续遍历,找到一个满足条件的记录;

// 类似: SELECT id FROM t WHERE id > $last_id LIMIT 1;

1274error= info->read_record(info);

... ...// 此处省略1000字

// 第二步,处理刚刚取出的一行

1291rc= evaluate_join_record(join, qep_tab);

... ...// 此处省略1000字

1303DBUG_RETURN(rc);

1304}

Q: 代码层面,第一步骤(读取一行)有 2 个分支,为什么?

A:从InnoDB接口层面考虑,分为 “读第一行” 和 “读下一行”,是 2 个不同的执行过程,读第一行需要找到一个 (cursor) 位置并做一些初始化工作让后续的过程可递归。

正如我们如果用脚本/程序来进行逐行的扫表操作,实现上就会涉及下面 2 个 SQL:

// SELECT id FROM t LIMIT 1; OR SELECT MIN(id)-1 FROM t; ->$last_id

// SELECT id FROM t WHERE id >$last_idLIMIT 1;

具体涉及到此例的代码,SQL 层到存储引擎层的调用关系,读取阶段的调用栈如下:(供参考)

sub_select 函数中从 SQL 层到 InnoDB 层的函数调用关系:(同颜色、同缩进 表示同一层)

Ø  (*qep_tab->read_first_record) ()

| -- > join_read_first(tab)

| -- > tab->read_record.read_record=join_read_next;

| -- > table->file->ha_index_init()

| -- > handler::ha_index_init(uint idx, bool sorted)

| -- > ha_innobase::index_init()

| -- > table->file->ha_index_first()

| -- > handler::ha_index_first(uint idx, bool sorted)

| -- > ha_innobase::index_first()

| -- > ha_innobase::index_read()

| -- > row_search_mvcc()

初始化cursor并将其放到一个有效的初始位置上;

Ø  info->read_record (info)

| -- > join_read_next(info)

| -- > info->table->file->ha_index_next(info->record))

| -- > handler::ha_index_next(uchar * buf)

| -- > ha_innobase::index_next(uchar * buf)

| -- > general_fetch(buf, ROW_SEL_NEXT,0)

| -- > row_search_mvcc()

“向前”移动一次cursor;

我们可以看到,无论是哪一个分支的读取,最终都殊途同归于row_search_mvcc函数。

以上是对 LOOP 中的代码做一些简要的说明,下面来看row_search_mvcc与evaluate_join_record如何输出最终的count结果。

2.3 行可见性及 row_search_mvcc 函数

这里我们主要通过一组 case 和几个问题来看行可见性对 COUNT( * ) 的影响。

Q:对于SELECT COUNT( * ) FROM t或者SELECT MIN(id) FROM t操作,第一次的读行操作读到的是表 t 中 ( B+ 树最左叶节点 page 内 ) 的最小记录吗?(ha_index_first为何也调用row_search_mvcc来获取最小 key 值?)

A:不一定。即使是MIN ( id )也不一定就读取的是 id 最小的那一行,因为也同样有行可见性的问题,实际上index_read取到的是 当前事务内语句可见的最小 index 记录。这也反映了前面提到的join_read_first与join_read_next“殊途同归”到row_search_mvcc是理所应当的。

Q:针对图中最后一问,如果事务 X 是RU ( Read-Uncommitted )隔离级别,且C-Insert ( 100 )的完成是在X-count( * )执行过程中 ( 仅扫描到 5 或 10 这条记录 ) 完成的,那么X-count( * )在事务C-Insert ( 100 )完成后,能否在之后的读取过程中看到 100 这条记录呢?

A:MySQL 采取”读到什么就是什么”的策略,即X-count( * )在后面可以读到 100 这条记录。

2.4 evaluate_join_record 与列是否为空

Q:某一行如何计入 count?

A:两种情况会将所读的行计入 count:

1、如果 COUNT 函数中的参数是某列,则会判断所读行中该列定义是否Nullable以及该列的值是否为NULL;若两者均为是,则不会计入 count,否则将计入 count。

e.g. SELECT COUNT(col_name) FROM t

col_name可以是主键、唯一键、非唯一键、非索引字段

2、如果 COUNT 中带有 * ,则会判断这部分的整行是否为 NULL,如果判断参数为 NULL,则忽略该行,否则count++。

e.g-1. SELECT COUNT(*) FROM t

e.g-2. SELECT COUNT(B.*) FROM A LEFT JOIN B ON A.id = B.id

Q: 特别地,对于SELECT COUNT(id) FROM t,其中 id 字段是表 t 的主键,则如何?

A:效果上等价于COUNT( * )。因为无论是COUNT( * ),还是COUNT ( pk_col )都是因为有主键从而充分断定索取数据不为NULL,这类 COUNT 表达式可以用于获取当前可见的表行数。

Q: 用户层面对InnoDB COUNT( * )的优化操作问题

A:这个问题是业界熟悉的一个问题,扫描非空唯一键可得到表行数,但所涉及的字节数可能会少很多(在表的行长与主键、唯一键的长度相差较多时),相对的 IO 代价小很多。

相关调用栈参考如下:

参考一:

evaluate_join_record()

| -- > rc= (*qep_tab->next_select)(join, qep_tab+1,0);

| -- > end_send_group(...)

| -- > init_sum_functions(join->sum_funcs, join->sum_funcs_end[idx+1]))

| -- > (*func_ptr)->reset_and_add()

| -- > Item_sum::aggregator_clear()

| -- > Item_sum::aggregator_add()

| -- > update_sum_func(Item_sum **func_ptr)

| -- > (*func_ptr)->add()

| -- > Item_sum::aggregator_add()

参考二: (Item_sum::aggregator_add)

((Item_sum *) (*func_ptr))->aggregator_add()

| -- > (Item_sum *)this->aggr->add()

| -- > ((Aggregator_simple *) aggr)->item_sum->add()

| -- >if(! aggr->arg_is_null(false))

| ------ > ((Item_sum_count *)aggr->item_sum)->count++;

二、数据结构:

Q:count 值存储在哪个内存变量里?

A:SQL 解析后,存储于表达COUNT( * )这一项中,((Item_sum_count*)item_sum)->count

如下图所示回顾我们之前“COUNT( * )前置流程”部分提到的 JOIN 结构。

即 SQL 解析器为每个 SQL 语句进行结构化,将其放在一个JOIN对象 ( join ) 中来表达。在该对象中创建并填充了一个列表result_field_list用于存放结果列,列表中每个元素则是一个结果列的 (Item_result_field*) 对象 ( 指针 ) 。

在COUNT( * )-case中,结果列列表只包含一个元素,(Item_sum_count: public Item_result_field) 类型对象 (name = “COUNT( * )”),其中该类所特有的成员变量 count即为所求。

三、MyISAM 全表 COUNT( * )

由于MyISAM引擎并不常用于实际业务中,仅做简要描述如下:

MyISAM-COUNT( * ) 操作是 O(1) 时间复杂度的操作。

每张MyISAM表中存放了一个 meta 信息-count 值,在内存中与文件中各有一份,内存中的 count 变量值通过读取文件中的 count 值来进行初始化。

SELECT COUNT( * ) FROM t 会直接读取内存中的表 t 对应的 count 变量值。

内存中的 count 值与文件中的 count 值由写操作来进行更新,其一致性由表级锁来保证。

表级锁保证的写入串行化使得,同一时刻所有用户线程的读操作要么被锁,要么只会看到一种数据状态。

四、几个问题

Q:MyISAM与InnoDB 在 COUNT( * )操作的执行过程在哪里开始分道扬镳?

共性:共性存在于 SQL 层,即 SQL 解析之后的数据结构是一致的,count 变量都是存在于作为结果列的 Item_sum_count 类型对象中;返回给客户端的过程也类似 – 对该 count 变量进行赋值并经由 MySQL 通信协议返回给客户端。

区别:InnoDB 的 count 值计算是在 SQL 执行阶段进行的;而 MyISAM表本身在内存中有一份包含了表 row_count 值的 meta 信息,在 SQL 优化阶段通过存储引擎的标记给优化器一个 hint,表明该表所用的存储引擎保存了精确行数,可以直接获取到,无需再进入执行器。

Q:InnoDB 中为何无法向 MyISAM 一样维护住一个 row_count 变量?

A:从 MVCC 机制与行可见性问题中可得到原因,每个事务所看到的行可能是不一样的,其count( * )结果也可能是不同的;反过来看,则是MySQL-Server端无法在同一时刻对所有用户线程提供一个统一的读视图,也就无法提供一个统一的count值。

PS: 对于多个访问 MySQL 的用户线程( COUNT( * ) )而言,决定它们各自的结果的因素有几个:

一组事务执行前的数据状态(初始数据状态)。

有时间重叠的事务们的执行序列 (操作时序,事务理论表明 并发事务操作的可串行化是正确性的必要条件)。

事务们各自的隔离级别(每个操作的输入)。

其中 1、2 对于 Server 而言都是全局或者说可控的,只有 3 是每个用户线程中事务所独有的属性,这是 Server 端不可控的因素,因此 Server 端也就对每个COUNT( * )结果不可控了。

Q:InnoDB-COUNT( * )属table scan操作,是否会将现有Buffer Pool中其它用户线程所需热点页从LRU-list中挤占掉,从而其它用户线程还需从磁盘load一次,突然加重 IO 消耗,可能对现有请求造成阻塞?

A:MySQL 有这样的优化策略,将扫表操作所load的page放在LRU-list的oung/old的交界处 ( LRU 尾部约 3/8 处 )。这样用户线程所需的热点页仍然在LRU-list-young区域,而扫表操作不断 load 的页则会不断冲刷old区域的页,这部分的页本身就是被认为非热点的页,因此也相对符合逻辑。

PS: 个人认为还有一种类似的优化思路,是限定扫描操作所使用的Buffer Pool的大小为 O(1) 级别,但这样做需要付出额外的内存管理成本。

Q:InnoDB-COUNT( * )是否会像SELECT * FROM t那样读取存储大字段的溢出页(如果存在)?

A:否。因为InnoDB-COUNT( * )只需要数行数,而每一行的主键肯定不是NULL,因此只需要读主键索引页内的行数据,而无需读取额外的溢出页。

据说,帅的人已经将这个公众号设为星标

·END·

程序员的成长之路

路虽远,行则必至

微信ID:cxydczzl

往期精彩回顾

程序员接私活的7大平台利器

Java程序员的成长之路

白话TCP为什么需要进行三次握手

Java性能优化的50个细节(珍藏版)

设计电商平台优惠券系统

一个对话让你明白架构师是做什么的?

教你一招用 IDE 编程提升效率的骚操作!

送给程序员们的经典电子书大礼包

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 156,265评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,274评论 1 288
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,087评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,479评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,782评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,218评论 1 207
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,594评论 2 309
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,316评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,955评论 1 237
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,274评论 2 240
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,803评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,177评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,732评论 3 229
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,953评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,687评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,263评论 2 267
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,189评论 2 258

推荐阅读更多精彩内容