在阅读本章节之前我假设您已经阅读了XDB-数据结构的描述并且对XDB的数据结构有了一个基础的认识,为了便于了解以下要描述的xdb生成过程,在此再重复阐述下xdb文件的结构分层,如下四层:
| Header 数据段 | Vector 索引数据段 | 地域信息数据段 | 二分索引数据段 |
| 256 Bytes | 512 KiB | 动态空间 | 动态空间 |
四层结构的作用分别如下:
1. Header 数据段:存储例如版本号,索引指针等头信息,固定空间。
2. Vector 索引数据段:二级加速索引,固定空间。
3. 地域信息数据段:IP归属地地域信息数据,不固定空间。
4. 二分索引数据段:二分查找依赖的一级索引数据,不固定空间。
一、几个重要约定
描述过程中我会给每个核心过程配上必要的伪代码,为了伪代码的简明我们先统一如下几个约定:
1、错误处理:全部的语言层面的 API 调用都忽略错误检测和处理,伪代码重点用于描述核心过程逻辑,错误处理细节可以阅读相关的 maker 代码。
2、字节顺序:除了 IP 解析得到的字节数组使用大端字节序,多字节的编解码全部采用小端字节序,除非特殊注明的地方。
3、数据类型:字面数据类型统一:uint16,uint32,int64 等等,u表示无符号,后面的数字就表示占据的位数,例如:uint32 表示 32 位的无符号整数。
二、初始化工作
初始化工作主要包括如下三个部分:
1、打开 IP 原始文件和目标xdb二进制文件:
// 原始 IP 文件的 IP 版本,v4 设置为 IPv4,v6 设置为 IPv6。// Version 的类描述请查看XDB-查询过程中的相关描述。varversion =IPv4;// 或者 IPv6;// 以只读模式打开 ipvx_source.txt 原始输入文件varsrcHandle =fopen("ipvx_source.txt file path","r")// 以读写模式打开 xdb 二进制文件目的地址,不存在自动创建 xdb 文件vardstHandle =fopen("ip2region_vx.xdb dst path","rw")// 备注:后续演示代码将直接使用上述三个全局变量
2、分配 header 数据段的空间,并写入已经确定的信息,整个 header 数据段的空间结构如下:
| 版本号 | 缓存策略 | 文件生成时间 | 索引起始地址 | 索引结束地址 | IP版本 | 指针字节数 | 剩余空间 |
| 2 Bytes | 2 Bytes | 4 Bytes | 4 Bytes | 4 Bytes | 2 Bytes | 2 Bytes | 236Bytes |
header 数据的初始化逻辑如下:
// 将 dstHandle seek 到文件开头dstHandle.seek(0,SEEK_SET)// header 信息 buffer 数组,并且全部初始化为 0varheader[256] = {0}// 从0索引处写入2个字节的版本号,此处固定为 2writeShort(header,0,VersionNo)// 从2索引处写入2个字节的的缓存策略,此处固定为 1,表示采用 vector 索引writeShort(header,2,indexPolicy)// 从4索引处写入4个字节的生成时间,获取当前系统时间戳writeInt(header,4,time.Unix())// 从8索引处写入4个字节的二分索引开始指针地址,一开始不确定,固定写入 0writeInt(header,8, 0)// 从12索引处写入4个字节的二分索引结束指针地址,一开始不确定,固定写入 0writeInt(header,12,0)// 以下字段是从 xdb 结构 3.0 后,也就是 IPv6 支持后的版本才有的字段// 从16索引处写入2个字节的 IP 版本号writeShort(header,16,version.id)// 从18索引处写入2个字节的 运行时指针字节数,RuntimePtrSize目前固定为4writeShort(header,16,uint16(RuntimePtrSize))// 写入整个 header 到 dstHandle 文件中dstHandle.Write(header)
3、加载ipvx_source.txt中的IP数据段并且对数据段进行必要的验证:
// 全部的 ip 数据段对象动态数组varSegment[] segments// 上一个 ip 段的引用varSegment last// 逐行读取 srcHandle 中的 ip 数据段fors := srcHandle.lines() {// 去掉两边可能存在的空白varl =trim(s)// 对读取的数据进行拆分// ps[0] -> 开始 IP// ps[1] -> 结束 IP// ps[2] -> 地域信息,不做任何处理varps = l.SplitN("|",3)// 解析开始和结束 IP,确保都是合法的 IP 地址varsip_bytes =parseIP(ps[0])vareip_bytes =parseIP(ps[1])// 开始 IP 应该 <= 结束 IPifipCompare(sip_bytes, eip_bytes) > 0 {returnerrorf("start ip(%s) should not be greater than end ip(%s)", ps[0], ps[1]) }// 从 xdb 3.0 开始地域信息允许为空,方便开发者自定义字段生成// 创建一个 Segment 结构体或者对象// 这里 seg 为指针或者引用varseg = &Segment{ StartIP: sip_bytes, EndIP: eip_bytes, Region: ps[2], }// 检查 IP 数据段的连续性// 也就是上一行的结束 IP + 1 需要等于这行的开始 IPiflast != nil {ifipCompare(ipAddOne(last.EndIP), seg.StartIP) != 0 {returnerrorf("discontinuous data segment: last.eip+1(%d) != seg.sip(%d, %s)", sip, eip, ps[0]) } }// 将得到的 IP 段对象加入到全局的 segments 动态数组中segments.add(seg)// 重置上一个 IP 段 last 为当前这个 Segment对象last = seg }
三、写入地域数据信息
完成了上面的初始化就可以开始写入地域数据了,为什么地域数据的写入是在这一步呢?vector 索引在第二层,但是他需要等到二分索引写入完成后才能确定,因为 vector 索引本身就是二分索引的索引,而二分索引在第四层,但是二分索引的结构本身需要对应 IP 段的地域信息的指针,所以我们需要先写入地域信息数据并且得到每个地域信息的指针,才能比较自然的完成不同层段的数据依赖映射。
地域信息的去重和写入逻辑如下:
// 地域信息指针 map,同时用来去重varregionPool[string]int64// 将 dstHandle seek 到数据段的开始位置// HeaderInfoLength 就是 header 段的长度 - 固定 256// VectorIndexLength 就是 vector 索引段的长度 - 固定 512KiBdstHandle.seek(HeaderInfoLength+VectorIndexLength,SEEK_SET)// 逐条写入初始化过程中加载的 segments 集合for_, seg :=rangesegments {// 先检测下对应的地域信息是否已经写入// 同时可以过滤已经写入了的重复地域信息ptr, has := regionPool[seg.Region]ifhas {// 地域信息已经写入了continue}// 使用 utf-8 编码地域信息字符串varregion =getUtf8Bytes(seg.Region)// 确保 region 字节数的整个长度不超过 65535// 也就是可以用两个字节存储其长度iflen(region) >0xFFFF{returnerrorf("too long region info `%s`: should be less than %d bytes", seg.Region,0xFFFF) }// 获取当前 dstHandle 的当前的文件指针pos = dstHandle.ftell()// 写入 region 到 dstHandledstHandle.write(region)// 记下当前地域信息写入时候的文件指针// 这里使用 uint32 转换文件指针,也就是之保留 32 位// 文件最大可以 4G,对于 ipv4 完全足够了regionPool[seg.Region] =uint32(pos) }
四、构建二分索引
上一步我们写入了地域数据并且存储对应的文件指针信息 regionPool,有了这个数据我们就可以构建第四层的二分索引了,这一步是整个生成过程最复杂的一个过程,这个过程还需要同步构建 vector 二级索引信息,需要对本身的 IP 数据段进行二次拆分,目的是为了使用一次固定的 IO 操作大幅度的减少二分索引的扫描范围,加速查询,详细的解释请阅读XDB-查询过程。
在生成二分索引的时候我们将利用 IP 的第一个和第二个字节来生成 vector 索引,所以要确保每个 IP 段的开始和结束 IP 的第一个和第二个字节一样,如果不一样就需要拆分成多个 IP 段,例如如下的一个 IP 数据段:
1.1.0.0|1.3.3.24|中国|广东|深圳|电信
第一个字节相同都是 1,但是第二个字节就不同了,所以这里需要拆分成如下三个 IP 段:
1.1.0.0|1.1.255.255|中国|广东|深圳|电信 1.2.0.0|1.2.255.255|中国|广东|深圳|电信 1.3.0.0|1.3.3.24|中国|广东|深圳|电信
经过拆分后的三个 IP 段的开始和结束 IP 的第一个和第二个字节都相等了,这样我们在构建二分索引的时候可以非常方便的构建 vector 二级索引。IP 数据段的二次拆分代码就不在此描述了,你可以用你自己的逻辑来实现,也可以参考默认的 maker 的实现。
二分索引项的空间结构如下:
| 开始 IP | 结束 IP | 数据长度 | 数据指针 |
| 4 / 16 Bytes | 4 / 16 Bytes | 2 Bytes | 4 Bytes |
二分索引整个写入逻辑如下:
// 二分索引数据段的开始和结束文件指针值varstartIndexPtr, endIndexPtr =int64(-1),int64(-1)// 二分索引缓冲 buffer,长度为 version.indexSize,IPv4 / IPv6 不一样,并且初始化为 0varindexBuff[version.indexSize] = {0}// 遍历每一个 segment 开始生成二分索引for_, seg :=rangesegments {// 获取当前地域信息的位置指针// 这个数据是在第四步的 “写入地域信息” 记录的ptr, has := regionPool[seg.Region]if!has {returnerrorf("missing ptr cache for region `%s`", seg.Region) }// 获取地域信息 utf-8 编码后的字节数varrLen =utf8Bytes(seg.Region)// 检测并且拆分当前数据段varsegList = seg.Split()// 遍历拆分后的数据段进行处理for_, s :=rangesegList {// 记录当前 dstHandle 文件指针位置pos = dstHandle.ftell()// 编码二分索引信息到 indexBuff// 从 0 索引处写入 开始IP 的字节writeInt(indexBuff,0, s.StartIP)// 从 len(s.StartIP) 索引处写入 结束IP 的字节writeInt(indexBuff, len(s.StartIP), s.EndIP)// buffer 中 IP地址 后的 offset 值varoffset = len(s.StartIP.length) + len(s.EndIP);// 写入 2 字节的地域信息长度writeInt(indexBuff, offset,uint16(rLen))// 写入 4 字节的地域信息位置指针writeInt(indexBuff, offset + 2, ptr)// 写入编码后的 indexBuff 到 dstHandle 文件dstHandle.write(indexBuff)// 更新当前 IP 段的 vector 索引信息// @Note: 查看下面的详细介绍setVectorIndex(s.StartIP,uint32(pos))// 检测并且更新二分索引的开始和结束指针endIndexPtr = posifstartIndexPtr == -1 { startIndexPtr = pos } } }
五,写入 vector 索引信息
二分索引写入完成后,我们就可以紧接着处理 vector 二级索引了,上面的二分索引构建过程中有个 setVectorIndex 就是更新内存中的 vector 索引的位置,vectorIndex 的空间结构如下,等同于一个256 x 256的二位数组:
| 0 | 1 | ... | 254 | 255 |
| 1 | ||||
| 2 | ||||
| ... | ||||
| 254 | ||||
| 255 |
vector 索引更新函数的实现如下:
funcsetVectorIndex(ip_bytesbyte[], ptruint32) {// 获取 ip 的第一个字节varil0 = ip_bytes[0] & 0xFF// 获取 ip 的第二个字节varil1 = ip_bytes[1] & 0xFF// 计算当前 ip 的 vector 索引的偏移量// 等同于二位数组的 vectorIndex[il0][il1]varidx = il0*VectorIndexCols*VectorIndexSize+ il1*VectorIndexSize// 从 idx 解码 4 个字节得到当前 ip 的 vector 索引信息varsPtr =le_getUint32(vectorIndex, idx)ifsPtr ==0{// 索引信息尚不存在 - 第一次更新// 从 idx 处写入 4 字节的ptr信息writeInt(vectorIndex, idx, ptr)// 从 idx + 4 处写入 4 字节的 ptr 信息writeInt(vectorIndex, idx +4, ptr +version.indexSize) }else{// vector 索引信息已经存在 - 更新// 从 idx + 4 处写入 4 字节的 ptr 信息writeInt(vectorIndex, idx +4, ptr +version.indexSize) } }
二分索引写入完成后,就可以直接写入vector索引到dstHandle文件中,具体逻辑如下:
// 将 dstHandle seek 到 vector 索引段开始位置dstHandle.seek(int64(HeaderInfoLength),SEEK_SET)// 写入 vectorIndex 缓冲到 dstHandledstHandle.write(vectorIndex)
六,记录二分索引位置信息
最后再把二分索引写入过程中记载的二分索引段的开始和结束的指针信息写入头信息中即可,具体实现如下:
// dstHandle seek 到 8 位置 - header 信息中存储索引首地址的位置dstHandle.seek(8,SEEK_SET)// 编码二分索引开始和结束地址到 indexBuffvarindexBuff[8]// 从 0 处写入4个字节的 startIndexPtrwriteInt(indexBuff,0,uint32(startIndexPtr))// 从 4 处写入4个字节的 endIndexPtrwriteInt(indexBuff,4,uint32(endIndexPtr))// 最后将 indexBuff 写入到 dstHandledstHandle.write(indexBuff[:8])
利用这里写入的二分索引的开始和结束位置信息也可以做完整的二分查找,也就是不基于 vector 索引的查找,查找过程自然会慢很多。
到此整个xdb二进制文件的生成过程就介绍完成了,详细的实现代码可以参考你熟悉的语言的 maker 实现。