知也无涯

吾生也有涯,而知也无涯,能学一点算一点…

  • 这是一个系列文章,旨在了解如何使用Flex/Lex和Yacc/Bison进行词法和语法解析。这个系列,分成了几个部分,包括

    • flex的基本用法
    • 使用flex/bison实现一个简单的计算器
    • 实现一个带有条件判断与循环的计算程序

    了解这个系列需要一定的编译原理知识作为背景知识,了解程序如何从字符串先解析成Token,而后使用语法解析器生成解析树,最后执行该解析树。

    概述

    lex/flex可以按照“词法文件”的定义,将文本解析成单个的Token,然后通过执行“词法文件”中定义的Action来完成一些操作,一般,flex的输出会通过函数/变量将结果传递给yacc/bison进行进一步的语法解析。为了简化,本文将仅通过独立的“词法文件”完成一些操作,以了解flex的基础使用。

    这里完成的程序是一个简单的“count”程序,输入是一个文件,程序输出文件中包含的字符数、词语数、以及行数。

    安装flex并编写词法文件

    1. 安装lex: yum/apt-get install flex

    2. 编写如下词法文档:

    %{                                       //
            int characters = 0;              //    %{ ... }% 之间的部分是"Declarations"
            int words = 0;                   //    Declarations 部分声明的变量,是可以在全局使用的
            int lines = 0;                   //    例如,在该示例的main程序中,就通过extern声明的方式
    %}                                       //    使用了这些变量
    %%                                       //
    \n      {                                //    从这里开始是Translations阶段
                    ++lines;                 //    这里定了Token,以及遇到了Token之后
                    ++characters;            //    应该采取/执行什么,例如这里遇到了\n字符
            }                                //    则,将lines/characters变量都加1
    [ \t]+          characters += yyleng;    //
    [^ \t\n]+ {                              //    注释部分的文本需要删除,程序才能正常编译 
                    ++words;                 //    删除注释的vim命令:1,$s/\/\/.*$//g 
                    characters += yyleng;    //
            }                                //
                                             //
    %%

    直接使用如上代码的话,后面就会在gcc编译的时候遇到如下错误:

    $ lex zzx.l
    $ gcc lex.yy.c wc.c -o wc.out
    /tmp/cc1SPYm2.o:In function yylex':
    lex.yy.c:(.text+0x42f):undefined reference toyywrap'
    /tmp/cc1SPYm2.o:In function input':
    lex.yy.c:(.text+0xf73):undefined reference toyywrap'
    collect2: ld returned 1 exit status
    

    如果你也遇到了这个错误,不用担心,你并不孤单,在Stackoverflow上看到解决该失败的的答案一共有150点赞(up),就知道大家都一样了(参考@Stackoverflow)。因为默认的,lex生成的词法解析程序中,在最后是需要调用的yywrap函数的(关于yywrap),如果不打算提供该函数,则可以使用lex选项 %option noyywrap 禁用该调用。那么上面的代码就需要修改为:

    $ cat zzx.l 
    %{
            int characters = 0;
            int words = 0;
            int lines = 0;
    %}
    %option noyywrap
    %%
    \n      {
                    ++lines;
                    ++characters;
            }
    [ \t]+          characters += yyleng;
    [^ \t\n]+ {
                    ++words;
                    characters += yyleng;
            }
    
    %%

    编写入口函数并调用yylex

    词法文件需要使用工具flex将其编译生成一个c语言文件,然后再使用gcc将其编译成一个可执行文件。编译前,我们需要先编写一个简单的main函数. 再编写一个程序的入口函数(main),并调用yylex()就可以了。具体如下:

    $ cat wc.c
    #include <stdio.h>
    
    int yylex(void);
    
    int main(void)
    {
            extern int characters, words, lines;
    
            yylex();
            printf("%d characters, ", characters);
            printf("%d words, ", words);
            printf("%d lines\n", lines);
            return 0;
    }

    这里需要注意:在程序中,我们通过调用yylex()完成了实际的词法解析过程,并获得执行结果。这是一个非常简单的示例,实际过程比这要更加复杂,在词法文件中,每一次rule解析完成后,再起action部分,通常都会有return语句结束本次yylex调用,所以会是一个反复调用yylex的过程。

    编译并执行

    $ lex zzx.l    
    $ gcc lex.yy.c wc.c -o wc.out
    $ chmod +x wc.out
    $ cat s.txt
    this is a input file.
    this is a input file.
    $ ./wc.out < zzx.l
    404 characters, 36 words, 18 lines
    $ ./wc.out < s.txt
    44 characters, 10 words, 2 lines

    好了,至此,我们就完成一个词法解析的任务,因为这个任务不涉及任何语法(yyac)解析,所以比较适合初学者学习词法解析工具lex。

    补充关于Definitions

    为了再略微增强该示例的,这里对上面的示例又做了一个小调整,新增一行“Definitions”,有时候为了增强可读性,会对一些expression定义一个名称,如下,将\n定义为NL:

    %{                                        //
            int characters = 0;               //   %{ ... }% 之间的部分是"Declarations"
            int words = 0;                    //   Declarations 部分声明的变量,是可以在全局使用的
            int lines = 0;                    //   例如,在该示例的main程序中,就通过extern声明的方式
    %}                                        //   使用了这些变量
                                              //
    NL \n                                     //   这里新增了一行,这是一行 Definitions
                                              //   将\n用字母NL定义,所以下面的\n也就可以使用NL
                                              //   试想,如果表达式很复杂用这种方式,可读性会增强很多
    %%                                        //
    NL      {                                 //   从这里开始是Translations阶段
                    ++lines;                  //   这里定了Token,以及遇到了Token之后
                    ++characters;             //   应该采取/执行什么,例如这里遇到了\n字符
            }                                 //   则,将lines/characters变量都加1
    [ \t]+          characters += yyleng;     //
    [^ \t\n]+ {                               //   注释部分的文本需要删除,程序才能正常编译        
                    ++words;                  //   删除注释的vim命令:1,$s/\/\/.*$//g 
                    characters += yyleng;     //
            }                                 //
                                              //
    %%           

    自此,我们就了解一个词法解析文件的几个主要部分:Definitions、Declarations、rule(以及rule对应的Action)。

    参考资料:

    更多说明

    • Flex / Lex程序通常与Yacc/Bison一起使用,flex负责词法解析,bison则负责语法解析
    • flex与bison接口的函数,就是上面的调用 yylex()函数,该函数每次基于某个规则(rule)解析到一个新的Token的时候,则会执行对应的“Action”(也就是每个Token后面的代码)部分的代码。例如,上面的程序中会执行++lines++words代码。
    • 在rule action部分,我们看到使用了一个yyleng的“变量”用于获取当前被解析的原始字符串的长度。类似的“变量”有:yyleng、yytext、yyin等,完整的列表可以参考:Values Available To the User。另外,这些“变量”并不是真的变量,大部分都是一些“宏”,例如,yytext的真实定义可能是这样的:#define yytext (((struct yyguts_t*)yyscanner)->yytext_r)。了解这一点,有利于理解这些”变量”并不能在外部直接引用。
      • yyin yylex函数处理的字符串来源,默认情况是标准输入,在你的程序,例如可以定义为一个打开的文件描述符(在Posix中,一起都是文件)
      • yylength 用于记录,当前读取的Token的长度
      • yytext 用于记录当前读取的文本
    • 一般的,flex的“Action部分”,会包含一个return,例如如果遇到一个整数,可能会看到类似这样的代码:return INTEGER; 这时候,yylex()遇到一个对应的字符就会返回INTEGER
    • 在实践中,则是按照如下方式实现:
      • 在 yacc/bison的语法文件中定义Token,例如整数为 INTEGER,语法为 %token INTEGER
      • 使用yacc/bison命令生成对应的头文件,头文件则会包含关于 INTEGER的预定义:#define INTEGER 257
      • 只需要在flex词法文件中包含该头文件,就可以使用这里的预定义 INTEGER
      • 那么较为完整的代码看起来就是这样
    cat cal.y
    ...
    %token INTEGER
    ...
    cat cal.tab.h //这是bison生成的文件
    ...
    #define INTEGER 257
    ...
    cat cal.l
    ...
    [[:digit:]]+  { return INTEGER; }
    ...
    • 我们在考虑另一个问题:在lex的rule action可以使用本地的“变量”(其实是“宏”),也会通过return语句给yyparse()中调用yylex时,返回当前Token的类型。如果一个Token是一个[:digit:]+的时候,我们除了需要知道这个Token是一个整数之外,至少yyparse()还需要知道这个是一个什么整数,具体数值是多少,当然并不是所有的token都需要,一般identifier都是需要的。而,前面的yytext都是yylex本地的“变量”。这时候,通常会使用yylvalyylval是一个由yacc/bison定义的变量(默认是int类型),用于存储词法解析需要传递给yyparse的数据,在yacc/bison的语句处理的Action阶段,可以使用变量,以获得词法解析阶段的一些值。例如,一个Token是一个整数、字符串(并非keyword)的时候,我们会将对应的值存储在yylval中。所以,yylval通常会被定义为一个联合体(union类型),用于存储不同类型的值。

    关于这几个概念的更详细细致的解释可以参考最前面提到的“IBM的z/OS系统的文档中关于lex和yacc的介绍”(参考:Tutorial on using lex and yacc)。

  • SQL Server中的Schema、dbo

    ·

    MySQL已经流行了非常长时间,已经有一代数据库从业者最初就是从MySQL起接触数据库。再重新了解其他数据库的时候,虽然底层原理很多类似,但是上层实现、接口与使用上还是有非常大的不同。本文,就简单的介绍一下SQL Server中的Schema、dbo概念。

    参考阅读:

    Schema or dbo or Database?

    这是一个问题。也有很多人做了很多深入的讨论,关于这一点可以参考这篇文章,以及这篇文章后面的100个评论: Why Use Schemas?

    有几个观点吧:

    • 使用Schema的想法是”美好的”,但是实际的情况往往并不理想,甚至是还不如统一使用dbo。文章作者说,随着时间推移,他看到的实际情况是,使用了多个Schema之后,往往不同Schema中存储的内容并是逻辑上在一起的,而是由于各种历史原因,各个业务的数据表都会混杂在一起,这时候,由于修改应用的成本太高,大家也往往也不会去移动Schema下的表,去让他们在逻辑上更加合理。所以,基于此,作者并不赞同使用Schema,而是倾向于在需要的时候使用不同的Database去隔离逻辑上没什么关系的数据表。
    • 很多的第三方的应用,都是使用一个数据库、一个用户访问所有的数据表,因此Schema方式虽然很理想,但是现实世界并不常用。
    • 另一方观点是在于,在一个设计较好的数据库中,Schema可以很好的对不同的数据表进行分组,例如,用户相关的表,库存相关的表,店铺相关的表等。另外,在基于这样设计下,权限系统也会更加好管理。另外,否则可能会使用大量的前缀的方式(例如,user_开头代表用户,shop_开头代表店铺相关业务)来区分这些表,看起来并不整齐,整体的授权管理也会比较难一些。
    • 比较多人,认为虽然多Schema的设计,前期是比较理想的,但是随着业务和需求的变化,表的业务属性也会发生变化,按照多Schema设计来说,这时候是需要将表移动到合适的Schema下面,但是因为代码的SQL中都带有Schema,这就到这个”移动”的操作变得异常困难,所以也就会最终一定会破坏当初使用Schema的初衷。
    • 很多人认为,所有表放在dbo中,从业务角度来说,其实问题是最少的。
    • 有人提到,他这样使用schema,将所有的基础表都放在dbo中,但是某些特殊用户的表或者对象放在特定的schema中。例如,一些特定用途的视图、存储过程等。这样,更加便于权限管理。(个人注:这似乎是一种非常适合schema的场景)
    • (Mark Broadbent)提到,相比Oracle数据库的Schema,SQL Server权限系统设计更加面向的是Database,而不是Schema。用户通常都有多个Schema的访问权限。
    • Ben Thul提到,在大量存储过程的权限管理过程中,schema应该是非常有效的。这和前面的观点是有一定的相似的。Brent Ozar则认为,权限管理更多是通过用户角色组来管理,就可以了。Ben Thul也反驳到,当你已经有100个存储过程的时候,当写101个存储过程,如果没有schema,那么还是要单独管理这个存储过程的访问权限,这时候如果通过以schema为单位的权限管理,确实是高效的。
    • 有来曾经在MSFT PMs相关项目的员工(Clifford Dibble),回忆schema设计的初衷如下:
      • 在单个数据库中,提供一种命名空间隔离的方式
      • 在使用GRANT语句授权时,大大简化对一组对象进行授权管理
      • 微软公司内部”WinFS”团队需要该功能
      • 与ANSI/ISO SQL有更好的兼容性
      • SQL Server自己需要一个系统使用schema:sys
      • 让用户删除更加简单(有一些明确的客户需要)
      • 允许让角色组作为schema的owner
      • 允许多个用户都有某个特定的默认schema
    • vinod jain在2021年给出回答(是的,这个帖子是2010年发的,这个讨论延续了11年)有一定代表性,也是两种观点的一个中间形态:
      • 所有的核心表全部都放在dbo中
      • 其他的辅助功能表都独立放在不同的schema中,例如结构记录变更时用到的临时表和数据、导入导出时的中间表、用户日志表
  • 关于云数据库行业动态

    ·

    “云数据库行业动态”是一个每周五发布的行业发展动态的聚合,包括各个云数据库最新的工作进展、数据库版本发布、融资概况、行业案例等信息。

    该系列最早起源于2020年左右的工作周报。当时,需要每周/双周通过“周报”形式汇报最近的工作情况,周报的一部分就是“行业动态”,当时自己主要负责PolarDB产品体系管理、云数据库生态发展、云数据库品牌运营、文档建设等工作,所以,“行业动态”部分主要会关注行业主要的云原生数据进展情况,并以简报形式记录在公司内部的知识库中。

    2021年10月,从阿里云离开后,这个习惯一直保持了,并以公众号的形式对外发布汇总的结果。也因为对外了,所以,对于内容的要求也更高了一些,包括要求更加及时、结构化和系统等。于是就有了当前的每周云数据库行业动态。

    因为需要每周坚持,有时候还是挺困难的,再早期的时候也会花非常多的时间进行汇总和总结。最近,最近,通过了自动化的程序把汇总的工作都自动完成了,不过还是有些人工进行总结和润色。具体的程序参考:链接

    具体的,自动化程序使用php/curl模块自动化的获取文章内容更新,此外还使用了Google Cloud提供的Translation API服务对海外英文进行自动化的翻译。也曾尝试了ChatGPT做一些Summary,不过效果并不是很好,所以放弃了。

  • 在前面两篇文章,较为系统介绍了B-TreeB+-Tree的基础。本文则以InnoDB为例,来看看B+-Tree内部排序的实现。

    简单回顾B-Tree存储

    在前面B-Tree基础文章中,我们了解到在一个B-TreeB+-Tree的“节点”通常可以存放很多的“索引入口”。在数据库实践中,通常一个“节点”大小为16KB8KB。一个索引入口的结构一般是:\( (k_i,p_i,\alpha_i) \) 、 \( (k_i,p_i) \)、\( (k_i,\alpha_i) \),具体的,在叶子节点上通常为\( (k_i,\alpha_i) \),而非叶子节点则为: \( (k_i,p_i) \)。

    那么,在实际的设计中,一个“节点”可以存储多个“索引入口”,在非叶子节点,主要存储键值和到子节点的指针,即 \( (k_i,p_i) \);叶子节点中,则存储键值和相关数据,即\( (k_i,\alpha_i) \)。

    问题描述

    在很多的B+-Tree实现中,一个节点可以存储几十、甚至数百个的“索引入口”。而我们在前面看到的示意图中,看起来节点内部的数据似乎是按照键值排好序的,但在实际实现中,这里以InnoDBB+-Tree实现为例,问题要更复杂一些,主要考虑如下:

    • B+-Tree中的“数据”\( \alpha_i \)可能是变长的,并且内容很大,例如200~300个Bytes。例如,在InnoDB的Clustered Index中,表结构是 id int, nick varchar(32),realname varchar(32),birthday date,password char(32),email varchar(60),字符集是utf8mb4
    • 查询搜索是一方面,还需要更快的写入速度,从而提升数据库的事务吞吐量
    • 依旧需要节点内部非常便利的顺序查找,以满足搜索、范围扫描等需要

    以“堆”的形式存储

    为了满足变长、快速存储的要求,InnoDB页面内部使用了典型的“堆”的形式组织数据,这也是数据库数据存储实现的一般模式。即,每次数据写入时,将数据存储在堆的“顶端”(或者中间的空洞),如右图:

    • 对于最后写入的新记录\( (31,\alpha_5) \),不考虑排序,直接放在“堆顶端”
    • 每条记录的大小不同,按“堆”遍历时,可以根据每条记录的元信息,到一条记录的头部(即本记录的尾部)

    但在B+-Tree中遍历时,更多的是需要顺序遍历,例如键值搜索、范围查找等场景。那么,按“堆”存储的记录,就需要一种顺序遍历的方式。

    B+-Tree页内搜索实现概述

    逻辑实现说明

    一种直观的思维是这样,在页面的某个特殊区域,新建一个“数组”(记录指针或偏移),顺序排列所有的记录。每次需要搜索时,则在该数据中进行二分或插值搜索。这种存储模式的优势是,简单而直接,且易于维护。但对于关系型数据库来说,如果数据写入或更新频繁,那么这个数组的维护会有非常频繁的数据拷贝。

    InnoDB的实现(我后面要去看看Jim Gray的那本书,看看是否有描述这些细节)并没有使用这种直接的实现,而是做了一些变化:

    • 在每条被存储的记录Header部分,都存储了按排序顺序的下一条记录的位置(页内偏移量),所以InnoDB的页内记录可以看做是一个按键值大小排序的链表
    • 页面开辟了一片区域(Page Directory)存储“部分”页内记录的偏移,这些记录的选取与Skip List实现类似。即按排序顺序,并按一定的间隔取部分记录放到Page Directory中,并将Page Directory间隔两个记录之间在页内的所有记录称为一个SlotPage Directory中的这条记录则可以称为一个SlotSlot Header

    上右图是在GitHub上搜索到的由SmartKeyerror绘制的关于Page Directory的示意图[6],是比较准确且直观的。

    所以,在进行搜索时(InnoDB的实现):

    • 首先,在Page Directory中的各个Slot Header中做二分查找
    • 然后,再在Slot中进行线性搜索,即逐个向前比较

    InnoDB中,一个Slot有4~8个记录,所以,可以认为一个500条记录的页面,需要做的比较次数约为(考虑平均每个slot中6条记录):

    $$
    log_2(\frac{500}{6}) + \frac{6}{2} \approx 9.38
    $$

    在实际中,可能会有多次CPUCache Miss发生,所以实际的成本比看起来的9.38次要略微高一些,但作为粗略预估,这个计算也是合理的。

    InnoDB 物理存储实现说明

    厘清物理层的实现,还是稍微花了一些时间的。在Jeremy Cole 2013 Efficiently traversing InnoDB B+Trees with the page directory 中对于Page Directory实现页内搜索做了比较详细的说明,并且在讨论中也一些有意思的讨论。但,对于其中的几个问题做了一些验证,还是有一些更细致的补充说明。

    上面,SmartKeyerror绘制的关于Page Directory图在逻辑上是没有太大问题的,但就物理实现,则容易引起一些误解,故绘制了新的示意图如右下,以更准确的反映InnoDB物理存储的结构:

    • Page Directory就是一个存储页内偏移的“数组”,就是右图的深红色部分
    • 每个slot使用2 Bytes,存储的是对应记录的页内偏移

    相比于逻辑结构,物理结构上,需要注意的是:

    • “记录”在页内物理位置,并不是按键值大小排列,而通常是按照插入时间向下生长的
    • Page Directory存储在页面的底部,从高位地址向低位地址扩展;此外,SlotInnoDB中的逻辑概念

    更多关于Page Directory实现细节

    一般的了解了Page Directory的实现逻辑以及概述,基本上就可以了,也算是清楚了InnoDB页内搜索的实现。本小结为一个扩展阅读部分,描述更多关于slot结构、搜索、查找的细节。

    InnoDB的代码参考

    在了解Page Directory的过程中,还是阅读了部分InnoDB的代码,主要是innobase/page/page0page.c以及相关的代码。在该文件中,也有很多的注释可以阅读。这里列举部分函数,算是验证一下上述的部分理解。这里的示例代码来自版本:MySQL-4.1.7

    InnoDB 代码中的Page Directory

    page0page.c 中的注释

    这段注释在page0page.c文件中,是InnoDB最早、也是最为原始的关于page directoryslot的说明与介绍,非常建议阅读。

    另外,这段注释中最后:50 x 4 bytes = 200 bytes这部分估算应该是有错误的,应该是50 x 2 bytes = 100 bytes。在16KB的页面中,2 bytes存储偏移量是够了的,而实际实现中也是2 bytes

    #define PAGE_DIR_SLOT_SIZE  2

    当然,如果一定要“找补”,那么也可以把每条记录中用于存放slot中记录数量的4-bit也算进来的话,那么平均一个slot需要:

    $$ 2+\frac{6*4}{8} \approx 5 $$

    /*          THE INDEX PAGE
                ==============
    
    The index page consists of a page header which contains the page's
    id and other information. On top of it are the the index records
    in a heap linked into a one way linear list according to alphabetic order.
    
    Just below page end is an array of pointers which we call page directory,
    to about every sixth record in the list. The pointers are placed in
    the directory in the alphabetical order of the records pointed to,
    enabling us to make binary search using the array. Each slot n:o I
    in the directory points to a record, where a 4-bit field contains a count
    of those records which are in the linear list between pointer I and
    the pointer I - 1 in the directory, including the record
    pointed to by pointer I and not including the record pointed to by I - 1.
    We say that the record pointed to by slot I, or that slot I, owns
    these records. The count is always kept in the range 4 to 8, with
    the exception that it is 1 for the first slot, and 1--8 for the second slot.
    
    An essentially binary search can be performed in the list of index
    records, like we could do if we had pointer to every record in the
    page directory. The data structure is, however, more efficient when
    we are doing inserts, because most inserts are just pushed on a heap.
    Only every 8th insert requires block move in the directory pointer
    table, which itself is quite small. A record is deleted from the page
    by just taking it off the linear list and updating the number of owned
    records-field of the record which owns it, and updating the page directory,
    if necessary. A special case is the one when the record owns itself.
    Because the overhead of inserts is so small, we may also increase the
    page size from the projected default of 8 kB to 64 kB without too
    much loss of efficiency in inserts. Bigger page becomes actual
    when the disk transfer rate compared to seek and latency time rises.
    On the present system, the page size is set so that the page transfer
    time (3 ms) is 20 % of the disk random access time (15 ms).
    
    When the page is split, merged, or becomes full but contains deleted
    records, we have to reorganize the page.
    
    Assuming a page size of 8 kB, a typical index page of a secondary
    index contains 300 index entries, and the size of the page directory
    is 50 x 4 bytes = 200 bytes. */

    从这里也可以看出来,因为InnoDB的页内地址很多地方是用的2 Bytes的存储偏移量,所以InnoDB单个页面大小也受此限制,最大为 \( 2^{16} \text{Bytes} = 64 \text{KB} \)。(参考:innodb_page_size

    page_dir_get_nth_slot

    该函数输入是一个页面指针,返回该页面的第\( n \)个slot的指针。下右为InnoDB中的源码,下左则是代码部分对应的示意图:

    Gets pointer to nth directory slot. */
    UNIV_INLINE
    page_dir_slot_t* page_dir_get_nth_slot(
                /* out: pointer to dir slot */
        page_t* page,   /* in: index page */
        ulint   n)  /* in: position */
    {
        ut_ad(page_header_get_field(page, PAGE_N_DIR_SLOTS) > n);
        return(page + UNIV_PAGE_SIZE - PAGE_DIR
                        - (n + 1) * PAGE_DIR_SLOT_SIZE);
    }

    其他的优化

    在 G. Graefe 2010 Modern B-Tree Techniques 中概述了B-Tree中使用Interpolation Search优化Binary Search的选择问题。关于这个问题,G. Graefe在另一篇Survey性质的论文中,较为系统的描述使用插值优化的一些相关技术:B-tree indexes, interpolation search, and skew.[2]

    这篇论文[2]介绍了很多使用插值优化二分查找的考虑,但对于InnoDB似乎并不适用,主要原因是,在InnoDB中很多的Key都不是整数,且Key的类型会比较复杂,例如字符串、多字段组合等,并没有很简单的插值计算。此外,在当前的InnoDB实现中,各个Slot并没有存储实际的Key值,而是存储Key所在的页内偏移,即如果要进行Key比较,则一定要跳转到页面的其他位置,而这与论文中的描述想避免CPUCache Miss的理念是冲突的。

    一些遗留的问题:

    • 一个slotPAGE_DIR_SLOT_MAX_N_OWNED就是8,那么如果b-tree结构的相同key就超过8个,如何处理? (可能就直接截断就可以了,在搜索slot的时候需要注意处理这种情况)

    B-tree indexes, interpolation search, and skew.

    这是一篇研究B-Tree内部搜索性能的文章。B-Tree的最为主要的访问成本是从存储到内存,但作者认为,在某些场景下,需要关注CPU和内存处理的成本。这里的“某些场景”作者认为在变得更为重要,具体的,“某些场景”包括:

    • 现在的内存变得很大,页面命中率变得非常高,只有很少的页面需要从存储读取
    • 由于B-Tree的特性,页面读取的效率已经非常优秀了,已经是对数级的了

    此外,作者还注意到:硬件技术的发展上,更多的是带宽的提升,而不是延迟的提升,这包括两个方面(磁盘到内存、内存到CPU)。

    所以,作者把注意力放到了CPU处理效率的提升上。作者在论文中的预估是这样:在这个场景下,如果一个页面中有500个索引入口,那么页面内部的二分查找,就需要做:\( log_{2}500 \approx 18 \) 次比较操作以及对应的内存访问操作。

    这里做过了一些初步的测试,在当代的CPU中,通常都会有32KBL1 Cache ,故这里的部分事实已经不成立了。不过还是把这篇论文看完,看看作者的思考是什么。

    相比于二分查找,插值查找则每次从区间的某个相对位置开始进行搜索,这个相对位置则使用插值的方式获得,而不是简单的从中点开始(二分查找)。如果,键值模型恰好是一个线性模型的时候,这个时候线性插值的效率就会非常完美。而现实中,确实很多就是线性分布的。这篇文章的目的:The topic and purpose of this paper is to survey techniques that avoid the worst case of interpolation search. 看看有哪些技术,避免差值查找的糟糕情况发生。

    更细致的关于B-Tree和CPU Cache相关的讨论:[GL 01] G Graefe, P Larson: B-tree indexes and CPU caches. ICDE 2001, 349-358.

    一些研究的方向:

    • 最为直接的:使用一个类似的B-Tree结构,可能是二叉树、红黑树等
    • 为了让这部分索引能够更快速,能够让其存放到CPU缓存中,会考虑删除前缀或后缀
    • 此外,考虑利用B-Tree的多层索引,与CPU的多级缓存进行适配

    阅读这篇论文的收获是了解大家考虑问题的方法,大家的思考方式,以及对各种技术边界的拓展。例如,

    • 了解到大家对于程序实现的考虑,如何去思考处理器技术对于程序效率的影响,L1 Cache、L2 Cache以及指令Cache等如何影响CPU的效率。这些技术远看,其实并不算特别复杂,但是有了这些思考,对于计算机性能、架构的理解是不一样的。包括这里提到的一个论文中的观点:L1 Cache for Instruction 和 L2 Cache for Data 对于事务处理技术更为重要。这个观点就很有意思,当然,在当下,当然未必适用,但是这个思考方式可能是很重要的。如果对于这些技术非常了解,才有可能写出,诸如Redis、ClickHouse、DuckDB等这些性能非常极致的产品(大概是这样吧)。

    再比如这两篇论文中的一些计算,可以看到如何考虑程序的效率,包括CPU时间、存储时间等。

    在这里的一些考虑,看看他的计算和实现吧:在当时(这篇论文时候),一次\( \text{Page Access} \) 计为 \( 8ms \) ,传输时间为 \( 0.1ms \),如果页面中有 256 个索引入口。

    考虑一个计算指标( “utility” of a disk page )即:

    $$
    \frac{\text{counter of comparison}}{\text{page access latency} + \text{page transfer time}}
    $$

    那么,256个入口,一个典型的二分查找需要约:\( \log_2{256} \) 次比较,则:\( utility = \frac{8}{8+0.1} = 0.987 \);

    那么,如果页面大小增加到当前的八倍,那么需要的比较次数:\( \log_2{256*8} = 11 \),延迟依旧是 \( 8ms \) ,传输时间则为:\( 8*0.1 ms \),则:\( utility = \frac{11}{8+0.8} = 1.25 \)

    根据这里的计算,最优的页面大小,应该远远大于各个数据库系统中实际使用值。对于现代CPU来说,相比于处理指令,处理性能需要优先考虑cache miss以及pipeline stalls。在一些论文中对相关问题做了一些量化分析,有一些可以参考的结论,例如在事务处理时,最为重要的是L1的指令缓存和L2的数据缓存([ADH 99].)。所以实现的主要考虑包括:代码量(具体的某个功能模块)的多少、复杂度,以及L2 Data Cache Miss。

    页大小

    概述:对于非叶子节点(尤其是靠近根节点的),使用非常大的页面大小是有一定道理的,但叶子节点则不应该页面太大。非叶子节点的使用率是非常高的,并且数量相比于叶子节点要少很多;但叶子节点,则不同。当需要读取某个叶子节点时,通常只是为了读取某一条记录(典型的OLTP场景),为了一条记录而读取过大的页面,会导致内存利用率的降低,反而会导致整体的性能降低。

    在实践场景中,如果这个问题很关键或者有人对这个问题感兴趣,是可以做一些量化的分析的。把整个流程的时间、命中率、数据大小等相关的因素都考虑上,然后做一个最优化的分析。只是呢,B-Tree是一个比较古老的话题,大家对于这个数据结构的关注可能没有那么高了。

    分裂和合并的选择

    分裂与合并,不同的实现会有一些不同选择,包括:

    • 提前分裂,例如页面的占用率超过了一定的比率就进行分裂;结合插值算法考虑,如果页面里面的值,可以非常好的分裂为两个非常适合插值的区间,也可以考虑优先进行分裂。
    • 在某些实现中,删除的时候,从不触发合并,而是在某种“碎片整理”的时候才进行合并或分裂处理

    回退到二分查找

    在数据分布非常倾斜的情况,插值查找的效率可能会非常糟糕。需要考虑回退到二分查找。一些常见的策略是这样:

    • 例如,进行了3、4次差值查找还没有找到目标,那么可以回退到二分查找
    • 可以考虑根据第一、二次查找的效率做一些预估,预估效果差,则回退到二分查找
    • 在差值查找中,考虑使用二分查找做一些修正,最终后再退回二分查找

    正规化处理

    • key是字符串的时候,通常需要考虑先进行一定的正规化处理,以可以更好的进行大小比较。
    • 前缀删除。和一般的索引/搜索技术一样,如果有公共前缀可能会大大降低效率。

    回归差值

    在进行了一些插值计算后,可以考虑使用这些值进行回归计算。

    相关性分析

    可以对一个节点内的值,先进行一次回归分析,并考虑定期进行回归分析,从而判断插值搜索的效果。

    一个感受:在SSD推出之初,有很多人认为,B-Tree可能不再适用于数据库的存储(或其他场景的存储),可能要有更有的存储结构适配新的硬件(SSD)。但实际的情况是,在现在的几乎所有的存储硬件中,B-Tree依旧非常成功。这是因为,B-Tree所解决的问题本质并没有改变:内存访问的速度/成本远高于“磁盘”(HDD或者SDD、甚至S3等块存储)存储设备。这时候,B-Tree这种多路、平衡树结构依旧非常有效。(注:LSM-Tree 在很多模型下,也是非常适用的,这是另一个问题)

    名词说明

    • B+TreeB+-Tree 均指B+-Tree
    • 一般的,B-Tree指狭义的、经典的B-Tree,该结构主要参考:了解 InnoDB 的索引结构:B-Tree 基础
    • 有时候,B-Tree也会指代B+-Tree或其他B-Tree变种;这种用法也是非常广泛的,例如,在MySQL/InnoDB的文档中,仅使用B-Tree,而更为准确的说是B+-Tree
    • “节点”(Node)、“页面”(Page)、“数据块”、“块”(Block)等均指存储B-TreeB+-Tree的节点,即树结构的节点,内部可以存储多个键值。在不同的语境下,大家会用不同的词,例如通常在算法课程/书籍中,会更多的使用“节点”(Node);而在数据库实现中,则更喜欢用“页面”(Page)或“块”(Block);更为细节的,如果在内存中,则更多的时候用“页面”(Page),在磁盘上,则更多的“块”(Block)。各种情况下混用的也很多,并没有什么问题。
    • “记录”(record)、“行”(row)、tuple都是表示表中的一行记录,通常包含多个字段数据。
    • 索引入口(Entry)等是指完整的“键值对”,在叶子节点中,通常包括\( (k_i, a_i) \);在非叶子节点中可能的形式是:\( (k_i,p_i, a_i) \) 或 \( (k_i, p_i) \)
    • TAOCP 是 “The Art of Computer Programming”的缩写

    参考

    本文主要参考内容为:

  • InnoDB 的“记录”存储

    ·

    了解InnoDB的物理存储可以为后续进一步了解其日志存储打下基础。在前述的内容,较为详细的介绍了InnoDB内部的B+-Tree存储,本文在此基础上,继续介绍,一个B+-Tree的节点(经常称作“页面”)中,如何存储单条记录的。本文,仅介绍其一般实现,并不对InnoDB记录格式的各种类型、场景做详细介绍。

    这部分倒没有什么复杂的理论的,更多的是一些实践经验以及实际应用场景中各种情况的考虑与适配。

    最为直接的,则是按照“数据表”各个列的元数据顺序排列即可。而事实上,这也是InnoDB实现的基本原则。但在实践中,还有很多的问题要去解决:

    • 总是要考虑存储与读取效率:存储空间尽量不要有浪费,这样可以保持单个页面存储更多的数据,则有更高的磁盘和内存的利用率。例如,需要考虑Default值如何存储?NULL值如何存储?变长字段如何存储?超大的字段如何存储等。
    • 如何较为便捷的实现各种类型的DDL,例如新增一个字段、删除一个字段等

    一条记录分为两个部分,Record Header

    1. 准备测试数据

    这里新建表,并写入两条新的数据:

    DROP TABLE IF EXISTS t_r; 
    CREATE TABLE t_r (
      id int auto_increment primary key ,
      nick varchar(32),
      birthday date,
      password_hash char(32),
      email varchar(32)
    );
    
    INSERT INTO t_r VALUES 
      (1,"zzx","1989-02-11","a76e5fc27899ada11b4eafd0b1a7a4fc",'zzx@example.com'),
      (2,"yhq","2012-05-18","b6bdc8dcddeacece09d5697145d373d9",'yhq@example.com')
    ;

    (注:最好不要这样存储hash值)

    2. 找到数据页

    这需要一些背景知识,但并不是本文介绍的重点。在独立表空间(当前MySQL的默认行为)的情况下,:

    • 在数据目录下找到表数据文件:db.t_r.ibd
    • 查看该页面的第四个Page即为第一个数据页面,因为这里写入的数据较少,所以,一般就存储在该页面

    2.1 为什么是第四个页面

    如果对于这个问题感兴趣的话,可以参考 The basics of InnoDB space file layout 中关于“Per-table space files”的描述。可以看到,第四个页面即为INDEX: Root page of first index

    使用vim:%!xxd)查看时,每行16 Bytes,一个页面就是1024行,第四个页面,即从3073行开始,到4096行结束。

    2.2 数据页二进制数据

    $ view -b t_r.ibd
    3073 0000c000: 646e 9e73 0000 0003 ffff ffff ffff ffff  dn.s............
    3074 0000c010: 0000 0007 de81 961e 45bf 0000 0000 0000  ........E.......
    3075 0000c020: 0000 0000 01d0 0002 0114 8004 0000 0000  ................
    3076 0000c030: 00ce 0002 0001 0002 0000 0000 0000 0000  ................
    3077 0000c040: 0000 0000 0000 0000 030c 0000 01d0 0000  ................
    3078 0000c050: 0002 00f2 0000 01d0 0000 0002 0032 0100  .............2..
    3079 0000c060: 0200 1d69 6e66 696d 756d 0003 000b 0000  ...infimum......
    3080 0000c070: 7375 7072 656d 756d 0f03 0000 0010 004e  supremum.......N
    3081 0000c080: 8000 0001 0000 0090 f05b bb00 0001 3101  .........[....1.
    3082 0000c090: 107a 7a78 8f8a 4b61 3736 6535 6663 3237  .zzx..Ka76e5fc27
    3083 0000c0a0: 3839 3961 6461 3131 6234 6561 6664 3062  899ada11b4eafd0b
    3084 0000c0b0: 3161 3761 3466 637a 7a78 4065 7861 6d70  1a7a4fczzx@examp
    3085 0000c0c0: 6c65 2e63 6f6d 0f03 0000 0018 ffa2 8000  le.com..........
    3086 0000c0d0: 0002 0000 0090 f05b bb00 0001 3101 1d79  .......[....1..y
    3087 0000c0e0: 6871 8fb8 b262 3662 6463 3864 6364 6465  hq...b6bdc8dcdde
    3088 0000c0f0: 6163 6563 6530 3964 3536 3937 3134 3564  acece09d5697145d
    3089 0000c100: 3337 3364 3979 6871 4065 7861 6d70 6c65  373d9yhq@example
    3090 0000c110: 2e63 6f6d 0000 0000 0000 0000 0000 0000  .com............
    3091 0000c120: 0000 0000 0000 0000 0000 0000 0000 0000  ................
    3092 0000c130: 0000 0000 0000 0000 0000 0000 0000 0000  ................
         ......
    4094 0000ffd0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
    4095 0000ffe0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
    4096 0000fff0: 0000 0000 0070 0063 646e 9e73 de81 961e  .....p.cdn.s....

    3. InnoDB 用户“记录”格式概述

    关于该格式的描述,可以参考innobase/rem/rem0rec.c中的注释说明部分,有比较详细的说明。

    /*
    ...
    | length of the last non-null variable-length field of data:
      if the maximum length is 255, one byte; otherwise,
      0xxxxxxx (one byte, length=0..127), or 1exxxxxxxxxxxxxx (two bytes,
      length=128..16383, extern storage flag) |
    ...
    | length of first variable-length field of data |
    | SQL-null flags (1 bit per nullable field), padded to full bytes |
    | 4 bits used to delete mark a record, and mark a predefined
      minimum record in alphabetical order |
    | 4 bits giving the number of records owned by this record
      (this term is explained in page0page.h) |
    | 13 bits giving the order number of this record in the
      heap of the index page |
    | 3 bits record type: 000=conventional, 001=node pointer (inside B-tree),
      010=infimum, 011=supremum, 1xx=reserved |
    | two bytes giving a relative pointer to the next record in the page |
    ORIGIN of the record
    | first field of data |
    ...
    | last field of data |
    ...
    */

    4. InnoDB 页内的记录

    在一个页面内,InnoDB存储每条物理记录时,都会在记录中存储一个“指针”,该“指针”指向下一条记录的物理位置,并且在实现时,该“指针”是以当前记录偏移的方式存储的。

    所以,如果我们能够找到页面内的第一条记录,那么就可以依此顺序的遍历整个页面内的记录了。找到第一条记录大概有很多的方法,这里说说InnoDB的实现:infimumsupremum。简单来说,InnoDB会在页面中存储一个infimum记录,该记录可以认为是该页面的极小值,该记录中的“Next Record”即为实际的该页面最小值。

    4.1 系统记录(system record):infimum 和 supremum

    infimumsupremum是每个InnoDB页面内存储两条物理记录,分别代表“起始最小记录”和“结束最大记录”。所以如果使用上述的下一条记录“指针”遍历页内所有记录,则总是从infimum开始,以supremum结束,中间就是所有的用户记录。

    在页面找到infimumsupremum记录的方法有两种:

    • infimumsupremum在页面的偏移,总是99112
    • Page Directoryslot中查找:第一个slot总是指向infimum;最后一个slot总是指向supremum

    4.2 从Page Directory找到infimum

    在前文“B+-Tree的内部搜索:InnoDB的实现”,已经概述了slot 0中总是存储的infimum记录,则可以通过页面底部向前偏移PAGE_DIR(InnoDB中PAGE_DIR的值是8),然后找到slot 0对应的记录的偏移,即infimum的偏移,而因为一个slot存储的偏移地址都是2 bytes 。所以,在页面内的倒数第10、9个字节,即为infimum在页内的偏移地址,即0x0063

    4.3 “记录”指针

    这里有两点需要注意:

    1. 一般“记录”指针通常是指向记录的“the origin of the record”,即实际数据开始的地方。不包含“记录头”部分
    2. 一般“记录”指针会使用两种方式表示:(a) 页内偏移;(b) 相当于当前记录的偏移。不同的地方会不同,例如“记录头”中存储next record的指针则是“相对偏移”,而slot内存储的则是页内绝对偏移。

    理解上述两点,对于理解后续内容是很重要的,要不然,可能会算错地址。

    例如,上述的slot 0中记录的infimum的偏移0x0063就是绝对偏移;而infimum中存储的next record偏移,则是相对于infimum的偏移地址。例如,next record偏移是00 1d,那么意味着这条记录的偏移是0x0063+0x001d,即绝对偏移是:0x0080

    4.4 记录头 record header

    按照上述方法,这里已经找到第一条(按大小顺序)记录的“记录指针”,即0x0080。把记录的头部(record header)和记录本身数据从上述二进制数据单独取出来,如下:

                                       |<-record header->|
    3080 0000c070: ................... 0f03 0000 0010 004e  supremum.......N
                   |<---------- field data   ----------->|
    3081 0000c080: 8000 0001 0000 0090 f05b bb00 0001 3101  .........[....1.
                   ^
                   |--> record pointer/offset (the origin of the record)
    3082 0000c090: 107a 7a78 8f8a 4b61 3736 6535 6663 3237  .zzx..Ka76e5fc27
    3083 0000c0a0: 3839 3961 6461 3131 6234 6561 6664 3062  899ada11b4eafd0b
    3084 0000c0b0: 3161 3761 3466 637a 7a78 4065 7861 6d70  1a7a4fczzx@examp
    3085 0000c0c0: 6c65 2e63 6f6d ........................  le.com..........
                  |<-field data->|

    注:

    • 在获得了 record pointer后,是无法简单的、直接获得record header长度的,也无法简单的获取记录本身的长度的。而是需要根据元数据库、以及record header中的部分数据,计算而来。
    • record header 的长度获取,是比较复杂的。这里做一定的简化,描述如下:
      • record pointer向前(record header方向)移动,5个bytes是相对固定的,包含了
        • 2 bytes: next record
        • 3 bits: record type
        • 13 bits: the order number of this record
        • 4 bits: the number of records owned by this record(slot)
        • 4 bits: delete mark and mark a predefined minimum record
      • record pointer再向前的 nbytes则用于存储 nullfield,该存储模式为按标记位存储,且按bytes补齐:
        • 例如,这里一共有5fields,则需要1 bytes,作为标记位;如果某个fieldnull则对应的bit即标记位1
        • 所以,这里的 n=1
      • record pointer再向前的mbytes则用于存储变成列的实际长度。本示例中,共有两个变成列,故这里的 m = 2

    4.5 记录的解析Record Header

    这里以页面中的第一条记录为示例,继续解析该记录的头部信息。详情如下:

    (<------------- record header / hex ----------------->) (<----- data/hex ----->)
    0f03 0000             0010             004e             8000 0001 0000 0090 f05b 
    
    (hex)(<---------------  binary   -------------------->) (<----- data/hex ----->)
    0f03 0000000000000000 0000000000010000 0000000001001110 8000 0001 0000 0090 f05b
    | |  |<----->|<->|<-->|<---------->|   |<------------>|
    | |  |       |   |    |            |   |
    | |  |       |   |    |            |   |-->(2 bytes) next record
    | |  |       |   |    |            |
    | |  |       |   |    |            |-->(3 bits) record type: 000=conventional
    | |  |       |   |    |
    | |  |       |   |    |-->(13 bits) the order number of this record
    | |  |       |   |
    | |  |       |   |-->(4 bits) the number of records owned by this record(slot)
    | |  |       |
    | |  |       |-->(4 bits) delete mark and mark a predefined  minimum record
    | |  |
    | |  |-->(8 bits) null-flag (here ,no null field at all, 5 field, so 1 bytes )
    | |
    | |-->(1 byte) length of 1st variable field, so length is 03
    |
    |-->(1 byte) length of 2nd variable field, so length is 0f(15)
    

    结合记录头部信息,再结合“元数据信息”,则就可以解析出记录本身的信息了。

    5. 其他

    有很多人对这个问题已经有了非常深入到说明或研究,如下给出主要的参考链接:

    延伸讨论:

    • 各种数据类型的序列与反序列化,以及对应的效率考虑
    • B+TreeB+-Tree 均指B+-Tree
    • 一般的,B-Tree指狭义的、经典的B-Tree,该结构主要参考:了解 InnoDB 的索引结构:B-Tree 基础
    • 有时候,B-Tree也会指代B+-Tree或其他B-Tree变种;这种用法也是非常广泛的,例如,在MySQL/InnoDB的文档中,仅使用B-Tree,而更为准确的说是B+-Tree
    • “节点”(Node)、“页面”(Page)、“数据块”、“块”(Block)等均指存储B-TreeB+-Tree的节点,即树结构的节点,内部可以存储多个键值。在不同的语境下,大家会用不同的词,例如通常在算法课程/书籍中,会更多的使用“节点”(Node);而在数据库实现中,则更喜欢用“页面”(Page)或“块”(Block);更为细节的,如果在内存中,则更多的时候用“页面”(Page),在磁盘上,则更多的“块”(Block)。各种情况下混用的也很多,并没有什么问题。
    • “记录”(record)、“行”(row)、tuple都是表示表中的一行记录,通常包含多个字段数据。
    • 索引入口(Entry)等是指完整的“键值对”,在叶子节点中,通常包括\( (k_i, a_i) \);在非叶子节点中可能的形式是:\( (k_i,p_i, a_i) \) 或 \( (k_i, p_i) \)
    • TAOCP 是 “The Art of Computer Programming”的缩写

  • 学习的误区

    ·

    小时候,就被一直教育,要独立完成所有的寒假作业。不能有任何的“参考”,这个参考定义非常广,甚至查阅资料、字典、与同学讨论等等都算是抄袭。

    这就留下有一类永远都做不完的作业。例如,那时候作业有一类是填写歇后语,有一题是“猪八戒照镜子–”填写后面的内容。以现在的生活经验,这道题目当然简单,但对一个初中生,在不允许任何“参考”,所以知道坐在那里瞎想,现在想想,也是醉了。

    优点,我的数学训练比较好,所以初高中数学成绩都算不错,只是没有天赋,只能算是不错。

    生活的复杂和人类社会这么多的积累,单单靠这样的”空明的思考”之于很多知识的学习是非常不适合的。学习的正确方法,应该是先学习当前的人类已经掌握的知识,然后再去尝试探索边界,有时候完全独立的思考的目的并不是想出什么新想法,而更多的时候一种思维的锻炼。

    文科则通常更加辩证,没有简单的对错,也没有简单的唯一的答案,可以更有创造力一些。