索引底层是如何实现的以及详细工作过程剖析

        现在很多中间件系统的存储模块都会多少用到索引。由于网上大多对索引的介绍都是比较浅的逻辑视图描述,很少有真正从底层的索引存储以及其工作过程的详细描述的文章。所以本文重点在后者。重新梳理一遍,作为自己的知识总结,同时也分享给广大网友参阅。

        如果一个没有索引或索引结构的文件,你要从中拿到你想要的数据内容,那么你不得不遍历整个文件。即从文件的起始位置地址开始依次顺序读取文件内容,直到目标内容出现为止。如下图所示。我们需要寻找“天”这个字附近的相关内容。相关代码,我将以伪代码展示。

        伪代码如下:

File file = new File("C:\test.text");//打开文件
byte[] temp=new byte[10];//数据缓冲
while(file.read(temp)){//循环遍历整个文件
    String  content=new String(temp,"UTF-8");//将字节数据转换为字符串
    if(content.contain("天")){//判断是否出现目标信息
 	   //已经找到目标内容
    }
}

        显然这样的查找方式效率非常低,每次检索目标数据,都需要从头到尾遍历文件。现在我们常用的数据库系统,如果也是像上面那样查询目标数据,那么就没法玩下去了,肯定慢的要死。那么数据库系统又是如何做到快速检索数据的呢?答案显然是通过索引,这个大家都明白。可真正能理解索引底层是如何工作的,加快数据检索效率的过程恐怕不多。网上大部分资料基本都是简单的通过几个索引结构的图给我们展示原理。但是我这篇文章并不打算复制粘贴这些东西。而是要从磁盘上真实的数据分布结构上深入说明,让大家以后能有个真实直观理解索引的机制。

        我们研发人员最常接触到的是关系数据库里的索引,比如B+tree的聚集索引和非聚集索引以及哈希索引等。不过我这里并不打算详细介绍数据库上的索引,因为本身的复杂度可能需要研究非常透彻才能说清楚。我这里只关注底层索引的一般工作原理,使用一个简单的示例简明扼要的叙述清楚就行。

        如下图,假设有一张表格数据。模拟数据库系统进行阐述。表有两个列,其中主键是int类型,课程名称是vachar类型。

主键ID(id)

课程名称(name)

1

数学

2

计算机科学

3

图形学

4

英语

        那么对于这样一张表格的数据,我们如何存储到磁盘中呢?如下图所示,我们可以这样设计底层的数据存储结构。红色部分表示实际的表对应的文件的数据磁盘存储结构,里面的内容代表实际存储的信息。比如有存储长度的,存储表格字段值的。背景深灰黑色代表磁盘的磁道。其他的都是为了理解知识而画的解释说明。比如每个信息单元占用的实际存储空间(红色上一层)。这里我们定义各个信息单元占用空间大小如下。

        列长度:整数类型,2字节。

        id列:整数类型,4字节。

        name列:变长字符串类型,不固定字节。

        表格中的数据最终能够存储到磁盘上,就是逻辑上紧密排列存储的。只是会多出一些额外的字节用来记录字段(ID和Name)的实际存储长度的行头部数据。当然这些数据实际存储在磁盘哪个位置是不确定的,完全是由操作系统决定的。不理解的同学可以看我的另外一篇文章《文件随机或顺序读写原理深入浅出》。

        刚开始,我们没有使用索引结构存储,而是按照一行一行的方式排列存储的。所以目前的情况,如果我们要找出ID为3的行数据,我们需要顺序遍历整个文件才行。在此之前我们先解释下本文的一些概念词汇的含义。

        地址/偏移地址/逻辑地址/位置。都是指文件数据位置的偏移地址,从0开始的,以文件的大小字节为结尾。不是磁盘的物理地址,因为物理地址只有操作系统知道和使用。

        实现逻辑的伪代码如下所示。

public Map getRowDataById1(int id){
    Map<String,Object> row;//目标行数据接收对象
    RandomAccessFile randomFile =new RandomAccessFile("C:\test.data", "r");//打开文件
    long position=0;//当前处理行的起始地址
    while(true){//顺序处理每一行,直到目标行ID为3的行将返回方法。
        row=getRowDataByPosition(randomFile,position)
        int id=(int)row.get("id");
        if(id==3) {//命中到了目标行ID值
            return row;
        }else{
            position=(long)row.get("position");//获得下一行的起始地址
        }
    }
}
//获取表格中的行数据,通过起始偏移地址
private Map getRowDataByPosition(RandomAccessFile randomFile,long position){
    Map<String,Object> row=new HashMap<>();//目标行数据接收对象
    randomFile.seek(position);//指针定位到行数据开始位置
    byte[] headBt = new byte[4];//接收行头部数据缓冲
    randomFile.read(headBt);//读取第行头部数据
    int idLength=getPartBytes(headBt ,0,1);//得到ID列长度
    int nameLength=getPartBytes(headBt ,2,3);//得到名称列长度
    randomFile.seek(2+2);//文件读取指针移动到ID列数据的起始位置
    byte[] btid = new byte[idLength];//接受行ID列的数据
    randomFile.read(btid );//读取行ID列的值
    int id=Integer.valueof(btid);//类型转换得到行ID列的值
    byte[] btname = new byte[nameLength];//名称列的值数据缓存
    randomFile.read(btname);//读取行name列的值
    String name=new String(btname,"UTF-8");//类型转换得到行name列的值
    row.put("id",id);
    row.put("name",name);
    int rowLength=2+2+idLength+nameLength;//得到当前处理行的数据总长度。
    position=position+rowLength;//得到下一行起始位置
    row.put("position",position);//额外增加一个下一行起始偏移地址参数
    return row;
}
private byte[] getPartBytes(byte[],int start,int end){};//获取字节数组中从开始和结束位置的数组部分数据

        通过以上代码,我们可以看到,查找任意一个目标ID的行数据,都需要从头开始遍历表数据文件。那么算法的时间复杂度是O(n)。也许有些读者可能会提出疑问。为什么不直接定位到每一行中的ID列,而要多次读取一个行中的头部数据区?原因很简单,因为name列是可变长的字段,无法通过简单的倍数计算,快速定位到下一行地址。接下来我们将通过添加索引方式,看看索引是如何加快上述查询进程的。

      非聚集B树索引

        这是我们数据库系统非常常见的索引(一般是B+tree索引),也叫普通索引。非聚集指一个独立于数据文件的索引文件。树的每个叶子节点不包含整行的数据,仅带有行起始地址值。如下图所示,一个简易的树形索引逻辑视图。

        一般网上介绍B树也就到此为止,而本文的重点不在这里,而是要更加深入到磁盘底层的存储结构。如下图所示是树和叶子节点映射成磁盘物理存储的结构示图。当然文件视图依然是逻辑上的连续位置,实际存储到磁盘上的哪个位置,完全由操作系统决定。图中左右子节点/后继节点/地址等信息单元都使用8字节的长整数存储。

        这里的索引是独立于上面表格数据文件而单独的一个基于ID列的索引文件。用户要查找表格中的数据,需要先通过查询索引文件,然后再直接定位到表格的数据文件的行起始地址,得到最终所需的行数据。索引的建立过程,不是本文的范围。其伪代码如下所示。

public Map getRowDataById2(int id){
    RandomAccessFile randomFile =new RandomAccessFile("C:\test.index", "r");//打开表格的ID列索引文件
    long position=0;//B+树根节点起始偏移地址
    Long address=lookForObjectNode2(randomFile,id,position);
    if(address==null) return row;
    RandomAccessFile randomFile =new RandomAccessFile("C:\test.data", "r");//打开表格的数据文件
    return getRowDataByPosition(randomFile,position)
}
//递归在B+树种寻找目标值节点的行数据偏移地址。
//id:目标主键值。position:当前处理节点的起始地址
private Long lookForObjectNode2(RandomAccessFile randomFile,int id,long position){
    randomFile.seek(position);//指针定位到当前处理的节点
    byte[] nodeData= new byte[21];//接收节点数据
    randomFile.read(nodeData);//读取节点数据
    byte[] nodeType=getPartBytes(nodeData,0,0);//获取节点“类型”的数据
    int type=Integer.valueof(nodeType);//类型转换得到节点的“类型”
    byte[] nodeValue=getPartBytes(nodeData,1,4);//获取节点“值”的数据
    int dataValue=Integer.valueof(nodeValue);//类型转换得到节点的“值”
    if(nodeType==0){//非叶子节点
        if(id>=dataValue) {//目标值大于等于节点值,则进入右子节点寻找
           byte[] nodeRightaddress=getPartBytes(nodeData,13,20);//获取节点右子节点地址
           long rightValue=Long.valueof(nodeRightaddress);//得到节点的右子节点值
           lookForObjectNode2(randomFile,id,rightValue);
        }else{//目标值小于节点值,则进入左子节点寻找
           byte[] nodeleftaddress=getPartBytes(nodeData,5,11);//获取节点左子节点地址
           long leftValue=Long.valueof(nodeleftaddress);//得到节点的左子节点值
           lookForObjectNode2(randomFile,id,leftValue);
        }
    }else{//叶子节点
        if(id==dataValue){//找到了目标值,返回其表数据文件中的偏移地址。
            byte[] addressBt=getPartBytes(nodeData,13,20);//获取节点“地址”的数据
            long address=Long.valueof(addressBt);//类型转换得到节点的“地址”
            return address;
        }else{
            return null;
        }
    }
}

        以上通过一个简单的索引案例以及伪代码给大家展示了索引使用的过程。这个方式明显会比没有索引的方式查找效率更高。其算法时间复杂度为O(logN)。这也是数据库常用的B+树索引原理,当然实际上数据库的实现比我这个要复杂些。这里我只展示最基本的原理,整个代码过程还有很大优化空间。不纠结这么多,接下来我们要认识下聚集的B树索引的原理。

      聚集B树索引

        这是我们数据库系统非常常见的索引(一般是B+tree索引),也叫主键索引。聚集指表格本身的数据是以索引方式组织存储在磁盘文件中的,没有独立的索引文件。树的每个叶子节点包含了整行的数据。如下图所示,一个简易的树形索引逻辑视图和磁盘文件存储视图。

        对于这样的索引,我们可以使用如下伪代码加以描述。同样是查找ID等于3的行数据作为目标。

public Map getRowDataById3(int id){
    RandomAccessFile randomFile =new RandomAccessFile("C:\test.data", "r");//打开表格的数据文件
    long position=0;//B+树根节点起始偏移地址
    Long address=lookForObjectNode(randomFile,id,position);
}
//递归在B+树种寻找目标值节点的行数据偏移地址。
//id:目标主键值。position:当前处理节点的起始地址
private Map lookForObjectNode3(RandomAccessFile randomFile,int id,long position){
    Map<String,Object> row=new HashMap<>();//目标行数据接收对象
    randomFile.seek(position);//指针定位到当前处理的节点
    byte[] nodeData= new byte[21];//接收节点数据,因为叶子节点大小不固定,这里设置的大小够接收前面几个关键位的数据就可以了。
    randomFile.read(nodeData);//读取节点数据
    byte[] nodeType=getPartBytes(nodeData,0,0);//获取节点“类型”的数据
    int type=Integer.valueof(nodeType);//类型转换得到节点的“类型”
    byte[] nodeValue=getPartBytes(nodeData,1,4);//获取节点“值”的数据
    int dataValue=Integer.valueof(nodeValue);//类型转换得到节点的“值”
    if(nodeType==0){//非叶子节点
        if(id>=dataValue) {//目标值大于等于节点值,则进入右子节点寻找
           byte[] nodeRightaddress=getPartBytes(nodeData,13,20);//获取节点右子节点地址
           long rightValue=Long.valueof(nodeRightaddress);//得到节点的右子节点值
           lookForObjectNode3(randomFile,id,rightValue);
        }else{//目标值小于节点值,则进入左子节点寻找
           byte[] nodeleftaddress=getPartBytes(nodeData,5,11);//获取节点左子节点地址
           long leftValue=Long.valueof(nodeleftaddress);//得到节点的左子节点值
           lookForObjectNode3(randomFile,id,leftValue);
        }
    }else{//叶子节点
        if(id==dataValue){//找到了目标值,返回其叶子节点中的行数据。
            byte[] col2Bt=getPartBytes(nodeData,15,16);//获取节点“列2长度”的数据
            long col2Length=Long.valueof(col2Bt);//类型转换得到节点的“值”
            long nameStartposition=position+1+4+8+2+2+1;//计算出当前节点的Name列数据起始地址;
            randomFile.seek(position);//指针定位到Name列数据起始地址
            byte[] nameData= new byte[col2Length];//接收节点name列的数据
    	 randomFile.read(nameData);//读取节点数据
            String name=new String(btname,"UTF-8");//类型转换得到行name列的值
            row.put("id",id);
  		 row.put("name",name);
            return row;
        }else{
            return null;
        }
    }
}

      哈希索引

        接下来,我们还要介绍哈希索引基本原理。如下表格所示,我们要为上面表格数据建立一个哈希索引。哈希的key就是ID列,value就是一个指向表格数据文件的逻辑偏移地址。这样我们只需要把使用目标ID值就能快速获得其文件数据的偏移地址了。

Key(ID)

Value(偏移地址)

1

0

2

14

3

37

4

54

        这个哈希结构的索引,对应到其索引文件和数据文件的视图如下。哈希索引也是单独的文件,和表格的数据文件是分开的。如果数据量小的话,我们可以把整个哈希索引一次性读入内存中,使用编程语言的HashMap对象装入即可。

        下面我就使用伪代码详细解释下如何使用哈希索引高效查询目标数据。

public Map getRowDataById(int id){
    Map<Integer,Long> indexmap=initHashIndex()
    long position=indexmap.get(id);//获取目标ID的行数据偏移地址
    RandomAccessFile randomFile =new RandomAccessFile("C:\test.data", "r");//打开表格的数据文件
    return getRowDataByPosition(randomFile,position);
}
// 初始化哈希索引到内存
private Map initHashIndex(){
    Map<Integer,Long> indexmap=new HashMap<>();//目标行数据接收对象
    File file = new File("C:\hash.index");
    byte[] temp=new byte[12];//一次读取一个Entry项
    while(file.read(temp)){
        byte[] keyBt=getPartBytes(temp,0,3);//获取key的数据
        byte[] valueBt=getPartBytes(temp,4,11);//获取value的数据
	     Integer key=Integer.valueof(keyBt);//类型转换得到key的值
        Long value=Long.valueof(valueBt);//类型转换得到value的值
        indexmap.put(key,value);
    }
    return indexmap;
}

        通过上上述伪代码,展示了使用索引的详细过程,我想大家应该都明白了整个原理过程。哈希索引的算法时间复杂度是O(1),比上面的树形索引效率还要高。但是缺点就是需要一次性全部加载到内存才行,显然这种方式不适合数据量太大的场景。所以mysql种innodb引擎是不支持的。只有memerry引擎才支持,因为它是内存数据库的实现。

        实际上为了提高索引的过程,很多存储系统都会将尽可能多的将索引数据先加载到内存中,降低磁盘IO,以便提高检索效率。比如以下几种方式。

        1、部分加载索引数据到内存

因为上面展示的树形索引,如果数据量太多,不可能全部加载到内存。所以我们只展示了直接随机读取磁盘文件方式来检索。这样必然会存在大量的IO操作,从而也会降低检索效率。不过为了提高效率我们可以把树形索引的一部分数据加载到内存中来,并通过建立一些辅助数据结构(数组、Map,Tree等)加快检索过程。这里我就不详细说了。

        2、使用稀疏索引

        前面说的索引,都是对ID进行全部值的索引。还有一种索引是只对部分ID值进行建立索引,这样就可以大大降低索引的数据量,从而使得索引可以全部装载到内存中。不过缺点就是,不一定能直接命中目标查询值,可能还需要去数据文件中往前或往后遍历部分数据才能得到最终的查询目标数据。kafka的存储系统就是使用了这种索引方式。

        3、多级索引

        有些数据量非常大的存储系统,还会建立多级索引的父子结构。越往上层,索引数据量越小,也越容易被全量装载到内存中。从而使系统性能更优。如下图所示。

  • 4
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值