上一篇【第13篇】生成索引文件:把内存里的索引安全落盘
下一篇【第15篇】读入索引文件:怎么把磁盘数据读回来
摘要
上篇我们把索引安全落盘的逻辑定了下来,但serializeDictionary和serializePostings还是两个空方法。这一篇就填它们。写磁盘不是把内存对象直接toString那么随意,你得设计一套自己的二进制协议:文件头怎么放 magic 和版本号、字段怎么排布、整数怎么存才省空间、以后想加字段老文件还能不能读。这些讲究合在一起,就是"文件格式设计"。设计得好,引擎能平滑长大;设计得烂,改一次格式全员返工。咱们用 VInt 变长编码当主角,把每字节都用到刀刃上。
一、文件头:先亮明身份和规矩
任何自定义二进制文件,第一件事是让读取方知道"你是什么、哪来的、按什么规矩解析"。这靠文件头(Header)。
最小必备的头字段:
- magic(魔数):几个固定字节,比如
0xDIYC(手写引擎的标记)。读取时先核对,不匹配就说明文件不是咱们写的,直接报错,别硬解析。 - 版本号(version):格式会变,记下当前版本。老读取器遇到新版本可以选择兼容或提示升级。
- 统计信息:词典里有多少词、倒排总共多少条。有了它,读取方能预先分配内存,免得边读边扩容。
- 偏移表:词典区、倒排区各自从哪开始。读取方靠它跳转。
【我们设计的索引文件头布局】 offset 0 magic(4字节) 0x44 0x49 0x59 0x43 ("DIYC") offset 4 version(4字节) 当前格式版本, 比如 1 offset 8 termCount(4字节) 词典词数 offset 12 postingCount(4字节) 倒排总条数 offset 16 dictOffset(4字节) 词典区起始 offset 20 postOffset(4字节) 倒排区起始 ... 后续是词典区、倒排区重点:magic 是文件的"身份证"。我见过有人没加 magic,结果拿一个旧日志文件当索引读,解析到一半才崩溃,排查极痛苦。先核对 magic,是自我保护的第一道防线。
二、字段布局:定长头 + 变长区
头用定长字段,简单可靠,读取方按固定偏移一个个读。头部之后才是真正的数据区,这里用变长编码才划算。
词典区里每个词长这样:词长度(变长) + 词内容 + 倒排链偏移(变长) + 倒排链长度(变长)。倒排区里每个 Posting 是:docId(变长) + tf(变长) + 位置列表(变长)。
【词典区一个条目的结构】 [词长度VInt][词 UTF-8 字节][倒排偏移VInt][倒排长度VInt] 如: 3 "耳机" 1024 12 【倒排区一个 Posting 的结构】 [docId VInt][tf VInt][posCount VInt][pos1][pos2]...把"倒排偏移"放在词典区,读取时先扫词典拿到每个词的倒排位置,再按需去倒排区取,正好呼应上篇"词典常驻、倒排按需"的读取策略。
三、VInt 变长整数:把空间榨干
倒排里全是整数(docId、tf、position)。如果用固定 4 字节 int,docId=1 也要占 4 字节,太浪费。VInt(变长整数)按"每字节 7 位存数据、最高位当continuation标志"来编码:值小的用 1 字节,值大的才多用几字节。
编码规则(和 Lucene 的 VInt 一致):
- 取整数最低 7 位,若还有更高位,把该字节最高位置 1(表示"后面还有"),否则置 0(“到此为止”);
- 右移 7 位,重复,直到没有剩余位。
importjava.io.*;/** VInt 变长整数读写,和 Lucene 规则一致 */publicclassVIntCodec{/** 写:值越小占字节越少 */publicstaticvoidwriteVInt(DataOutputout,intvalue)throwsIOException{while((value&~0x7F)!=0){// 还有高于7位的位out.writeByte((byte)((value&0x7F)|0x80));value>>>=7;}out.writeByte((byte)(value&0x7F));// 最后一段, 最高位0}/** 读:逐字节拼回整数 */publicstaticintreadVInt(DataInputin)throwsIOException{intb=in.readByte()&0xFF;if(b<0x80)returnb;// 单字节intvalue=b&0x7F;intshift=7;do{b=in.readByte()&0xFF;value|=(b&0x7F)<<shift;shift+=7;}while((b&0x80)!=0);returnvalue;}}算笔账:docId 平均几千,用 VInt 大多 2 字节,比固定 int 省一半;tf 通常很小,基本 1 字节。前面估算的 1.6 GB 倒排,引入 VInt + 差值编码(下篇读盘时讲)能再砍掉一大截。这也是工业引擎体积可控的底层秘诀。
【同一数字两种写法占用的字节】 数字 1 固定int: 4字节 VInt: 1字节 数字 300 固定int: 4字节 VInt: 2字节 数字 100000固定int: 4字节 VInt: 3字节 → 小数字越多, VInt 越省四、把序列化真正填上
现在回头填上篇的两个占位方法。先用ByteArrayOutputStream攒字节,再交给原子写入。
importjava.io.*;importjava.nio.charset.StandardCharsets;importjava.util.*;publicclassIndexSerializer{privatestaticfinalintMAGIC=0x44495943;// "DIYC"privatestaticfinalintVERSION=1;publicbyte[]serialize(Map<String,List<Posting>>dictionary)throwsIOException{ByteArrayOutputStreambos=newByteArrayOutputStream();DataOutputStreamout=newDataOutputStream(bos);// ---- 头 ----out.writeInt(MAGIC);out.writeInt(VERSION);out.writeInt(dictionary.size());// termCount// postingCount 先数一遍intpostingCount=dictionary.values().stream().mapToInt(List::size).sum();out.writeInt(postingCount);// 先写倒排区,记录每个词的偏移;再写词典区指向它们ByteArrayOutputStreampostBuf=newByteArrayOutputStream();DataOutputStreampostOut=newDataOutputStream(postBuf);// 词典区: 词 -> (偏移, 长度)List<int[]>offsets=newArrayList<>();for(Map.Entry<String,List<Posting>>e:dictionary.entrySet()){intstart=postBuf.size();for(Postingp:e.getValue()){VIntCodec.writeVInt(postOut,p.getDocId());VIntCodec.writeVInt(postOut,p.getTf());VIntCodec.writeVInt(postOut,p.getPositions().size());for(intpos:p.getPositions()){VIntCodec.writeVInt(postOut,pos);}}intlen=postBuf.size()-start;offsets.add(newint[]{start,len});}intdictOffset=8+16;// 头之后intpostOffset=dictOffset+estimateDictSize(dictionary,offsets);out.writeInt(dictOffset);out.writeInt(postOffset);// 词典区Iterator<int[]>it=offsets.iterator();for(Map.Entry<String,List<Posting>>e:dictionary.entrySet()){int[]off=it.next();byte[]term=e.getKey().getBytes(StandardCharsets.UTF_8);VIntCodec.writeVInt(out,term.length);out.write(term);VIntCodec.writeVInt(out,off[0]);VIntCodec.writeVInt(out,off[1]);}// 倒排区out.write(postBuf.toByteArray());out.flush();returnbos.toByteArray();}privateintestimateDictSize(Map<String,List<Posting>>d,List<int[]>offsets){intsize=0;Iterator<int[]>it=offsets.iterator();for(Stringterm:d.keySet()){int[]off=it.next();size+=1+term.getBytes(StandardCharsets.UTF_8).length+1+1;// 粗略size+=(off[1]>0?2:1);}returnsize;}}代码里关键点:先写倒排区并记下每个词的偏移/长度,再写词典区让每个词"指"向自己的倒排;头里写死 dictOffset 和 postOffset,读取方据此跳转。真实引擎会用更严谨的偏移计算,但思路完全一致。
五、向后兼容:给未来留个扩展位
格式迟早要改(比如想给 Posting 加个 payload 字段)。如果老读取器遇到新文件就崩,用户会骂街。做法是在头里留扩展位,新字段以"带标签的块"追加在文件尾,老读取器看不懂的标签直接跳过。
【带扩展块的格式,兼容老读取器】 [Header][词典区][倒排区][扩展块: 标签=0x01, 长度=8, 数据...][扩展块: ...] 老读取器: 只读 Header+词典+倒排, 遇到不认识的扩展块标签跳过 新读取器: 识别 0x01 块, 读出 payload 新功能设计原则就一条:老版本读新文件时,要么优雅降级、要么明确报错提示升级,绝不能静默解析出错误结果。这点 Lucene 用文件名的版本号(segments_N 里的 N)和 codec 版本玩得炉火纯青,我们手写版先记住这个意识。
六、协议设计的边界感
这一篇你其实在做一个微型"文件格式规范",和定义网络协议没本质区别。几个反复踩出来的经验:
- 头一定要定长且先校验 magic,别让错误文件悄悄混进来。
- 整数默认用 VInt,空间敏感处再上差值编码(下篇读盘会讲 docId 差分)。
- 任何"未来可能变"的东西,用扩展块承载,别硬编码在头里。
- 写之前先在纸上把字节布局画一遍,比写完再调偏移省十倍时间。
下一篇,我们把这套格式从磁盘读回来:用 RandomAccessFile 还是内存映射?词典全量加载还是懒加载?倒排链怎么按需读取?边界检查怎么做才不会越界崩掉。
八、VInt 的边角问题:负数与大数
VInt 看着简单,边角情况能坑人。第一是负数的处理:我们的 VInt 按"无符号 7 位"编码,docId 都是正数没事,但如果你哪天想存带符号的差值(比如后面讲的差分可能出现负数),就得改成 ZigZag 编码先把负数映射到正数再走 VInt,否则符号位一乱全错。
第二是大数位数:VInt 每个字节承载 7 位,4 字节 int 最多需要 5 个字节(32/7 向上取整)。这意味着超大的 docId 反而比固定 int 还占空间,所以 VInt 只对"小数字居多"的场景划算。倒排里的 docId、tf、position 恰好都是小数字聚集,这正是它大行其道的原因。
九、版本演进的真实代价
留扩展位听着轻松,真到要改格式时会发现:老读取器必须能读完新文件(哪怕忽略新字段),新读取器也必须能读老文件(降级)。这就逼你在写代码时把"版本分支"写进每一处解析逻辑。Lucene 用一个叫 codec 的版本号贯穿所有文件,读取时严格按文件的 codec 版本走对应解析路径,才不至于一团乱麻。
我们手写版只要记住一条底线:新格式发布时,老代码遇到不认识的扩展块必须跳过而非崩溃。哪怕你暂时只维护一个版本,把"跳过未知块"的逻辑预留好,未来升级能省掉一次全员返工。
十、协议设计的审美
好的二进制协议读起来像一首规整的诗:头定长、区有序、编码统一、扩展有度。烂的协议则处处特例、长度靠猜、版本靠祈祷。我越来越觉得,文件格式设计本质上是给未来的自己写情书,现在多写一行清晰的布局注释,三个月后 debug 时就能少掉一把头发。
本篇小结
文件格式设计是把内存索引翻译成字节流的协议。头里放 magic 验身份、版本号管兼容、统计信息助预分配、偏移表供跳转;数据区用 VInt 把小整数压到 1~2 字节,省下大量空间;再用带标签的扩展块给未来留余地,保证老读取器不崩。写好这套协议,下一篇我们学习把它原样读回内存。
上一篇【第13篇】生成索引文件:把内存里的索引安全落盘
下一篇【第15篇】读入索引文件:怎么把磁盘数据读回来