【DIY系列:自己动手写分布式搜索引擎】第13篇:生成索引文件:把内存里的索引安全落盘 上一篇【第12篇】构建索引二词典与倒排链的数据结构设计下一篇【第14篇】索引文件格式设计二进制协议的艺术摘要前两篇你建的倒排索引全活在内存里程序一关就灰飞烟灭而且 1.6 GB 的内存占用也告诉咱稍大点的语料根本装不下。必须落盘。落盘听着简单真正动手坑不少存文本还是二进制词典和倒排要不要分开写写到一半程序崩了怎么办索引会不会写花本文先把这几个工程正确性问题讲明白把写入的原子性和 .lock 锁机制给你安排上让你写出的索引经得起断电和并发的考验。一、为什么必须落盘内存版的两个死穴内存版倒排有两个绕不开的死穴。死穴一易失。JVM 一停HashMap 里那 1.6 GB 索引烟消云散下次还得花时间重建。对搜索引擎来说建索引是重活绝不希望每次启动都重来一遍。死穴二装不下。内存是有限的倒排链动辄上 GB。你总不能为了搜点东西先买台 256G 内存的机器。落盘之后索引变成磁盘上的文件重启即读、容量随硬盘走这两个问题同时解决。代价是多了序列化写、反序列化读两道工序但这是值得的。【内存版 vs 落盘版】 内存版: 构建快, 查询快, 但 易失 容量受限 程序关 → 索引没 落盘版: 构建稍慢(多一道写), 查询靠读盘, 但 持久 海量 程序关 → 文件还在, 下次直接读二、文本格式还是二进制格式落盘第一问文件里到底写什么形式。文本格式比如 JSON 或者一行一词。好处是人能直接看懂、调试方便、跨语言通用。坏处是体积大、读写要解析慢。二进制格式。把整数直接按 4 字节写进去字符串带个长度前缀。好处是紧凑、读写是纯字节拷贝快。坏处是不直观得对着格式文档才能看懂。【同一份倒排两种写法对比】 文本(JSON): {term:耳机,docs:[1,2,4]} → 几十字节, 人能读, 解析慢 二进制: 0x03 耳 机 0x03 0x01 0x02 0x04 → 十来字节, 机器快, 人看不懂手写版建议开发调试阶段可以用文本格式快速验证数据对不对正式落盘用二进制。下一篇我们专门设计二进制格式这一篇先把怎么写才安全的逻辑定下来。三、词典文件和倒排文件分开存还是揉一起一个直觉陷阱是把词典 倒排揉进一个大文件。别急先想清楚它们被读取的姿势不一样。词典经常被整体加载到内存词不多几十 MB 装得下用于给一个词查它有没有、倒排链在文件哪。倒排链则常常按需读取某个词可能命中十万文档全读进内存不划算。读取模式不同自然该分开存。【推荐的物理布局词典与倒排各一个文件】 dictionary.dat 词典文件 ┌──────────────────────────┐ │ 词项 该词倒排链的偏移量 │ ← 小, 常驻内存 └──────────────────────────┘ postings.dat 倒排文件 ┌──────────────────────────┐ │ 所有词的倒排链连着写到底 │ ← 大, 按需读取 └──────────────────────────┘ 词典里记: 耳机 → 在 postings.dat 的偏移 1024, 长度 12词典里每个词记一条指针它的倒排链在 postings.dat 的起始偏移和长度。查词时先在词典定位再只把那条倒排链读出来省得搬动整个大文件。这是搜索引擎落盘的标准套路。四、原子性写到一半崩了怎么办最要命的场景你正在写索引写到 50% 机器断电。文件半新不旧下次读直接解析失败整个索引报废。这叫写操作不具备原子性。工程上的标准解法是写临时文件 原子改名先把新索引写到index.dat.tmp这种临时文件全部写完后做一次原子重命名index.dat.tmp→index.dat旧文件要么被覆盖要么保留绝不会出现写了一半的状态。操作系统的 rename 在同一文件系统下基本是原子的这就保证了外界要么看到完整的旧索引、要么看到完整的新索引没有中间态。importjava.io.*;importjava.nio.file.*;publicclassAtomicWriter{publicvoidwriteAtomically(Filetarget,byte[]data)throwsIOException{FiletmpnewFile(target.getPath().tmp);// 1. 先写临时文件try(OutputStreamoutnewFileOutputStream(tmp)){out.write(data);out.flush();}// 2. 原子改名覆盖同一目录下基本原子Files.move(tmp.toPath(),target.toPath(),StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}}重点永远别直接往最终文件上边写边覆盖。先写 tmp 再 rename是保护索引不被写花的最低成本手段也是 Lucene 每次提交段文件的做法。五、并发与 .lock别让两个写者打架另一个现实问题两个进程同时往同一份索引写或者一个在写、另一个在读数据就乱套了。文件系统层面得有个门禁。做法很简单在索引目录放一个.lock文件当作门锁。谁要写先试着创建这个锁文件用File.createNewFile()的原子性创建成功说明抢到锁写完再删。别人来发现锁已在就排队或报错。importjava.io.*;publicclassIndexLock{privatefinalFilelockFile;publicIndexLock(Filedir){this.lockFilenewFile(dir,.lock);}/** 抢锁抢不到抛异常 */publicvoidacquire()throwsIOException{booleangotlockFile.createNewFile();// 原子存在则 falseif(!got){thrownewIOException(索引目录被占用另一个写者持有 .lock);}// JVM 退出时自动清理锁防死锁lockFile.deleteOnExit();}publicvoidrelease(){lockFile.delete();}}这样设计后单个目录同一时刻只有一个写者读者可以放心地读已提交完成的文件配合上一节的原子改名读者永远看不到半截文件。【带锁与原子改名的写入时序】 写者A: acquire(.lock) → 写 .tmp → rename 成正式文件 → release(.lock) 写者B: acquire(.lock) 阻塞/失败 (锁被A占着) 读者C: 只读已 rename 完成的正式文件, 永远看不到 .tmp六、把落盘串一遍一个迷你 IndexWriter把上面几节拼起来一个负责任的最小 IndexWriter 长这样细节先简化二进制格式留到下篇publicclassMiniIndexWriter{privatefinalFiledir;privatefinalIndexLocklock;publicMiniIndexWriter(Filedir){this.dirdir;this.dir.mkdirs();this.locknewIndexLock(dir);}/** 把内存索引字典序列化落盘 */publicvoidcommit(MapString,ListPostingdictionary)throwsIOException{lock.acquire();try{byte[]dictBytesserializeDictionary(dictionary);// 下篇细讲byte[]postBytesserializePostings(dictionary);newAtomicWriter().writeAtomically(newFile(dir,dictionary.dat),dictBytes);newAtomicWriter().writeAtomically(newFile(dir,postings.dat),postBytes);}finally{lock.release();}}privatebyte[]serializeDictionary(MapString,ListPostingd){/* 下篇 */returnnewbyte[0];}privatebyte[]serializePostings(MapString,ListPostingd){/* 下篇 */returnnewbyte[0];}}注意锁用try/finally包住保证哪怕序列化抛异常锁也会释放不会把目录永久锁死。这个细节我当年漏写过一次结果测试机上的索引目录再也没法写入排查半天才发现是异常路径没释放锁。七、落盘这一关你学到了什么到这索引的持久化与安全性骨架立起来了为什么落盘易失容量、文本还是二进制开发用文本、正式用二进制、词典和倒排分开存读取模式不同、原子改名防写花、.lock 防并发打架。这些都是工业引擎每天在做的平凡但要命的事。下一篇我们把serializeDictionary/serializePostings这两个占位方法真正填满设计一套自己的二进制索引文件格式把 magic、版本号、变长编码这些讲究都讲透。八、断电这场戏原子改名到底保了什么上一节说的写 tmp 再 rename看着像个小技巧但它保的是搜索引擎最怕的事索引损坏。我讲个真事早年一个同事没用原子改名直接往最终文件上覆盖写结果上线当晚机房短暂断电索引文件写了一半。第二天服务起来读索引直接解析失败整个搜索不可用只能连夜用原始文档重建索引几千万文档重建了快两个小时。原子改名的精妙在于操作系统保证 rename 要么完整发生、要么不发生不存在写了一半的中间态。所以读者永远看到的是上一个完整的旧文件或者改名成功后完整的全新文件绝不会看到半截。这比任何先写长度再写内容的自定义协议都靠谱因为它是文件系统级别的保证。九、和数据库 WAL 的异曲同工做过数据库的读者会发现这里的套路和 WAL写前日志一个思路先把要提交的内容写到一个临时/日志位置确认完整后再切换指针让新内容生效。Lucene 的段提交、MySQL 的 redo log、甚至 Git 的 objects底层都是不可变 原子切换的哲学。理解了这个你再去学任何存储系统都会觉得眼熟。十、一个常被忽略的细节锁的释放时机回头看我们的IndexLock锁用try/finally包住并且在 JVM 退出时deleteOnExit。为什么这么小心因为若只在正常路径release()一旦序列化过程抛了异常又没进 finally锁文件就永远留着下次启动直接被自己挡在门外索引目录变成上了锁的空房间。我踩过这个坑排查半天才发现是异常分支漏了释放。所以凡是加锁务必保证任何路径都能解开这是分布式系统之外的单机铁律。本篇小结落盘解决内存版的易失和容量两道死穴。文本格式好调试、二进制格式省空间且快词典与倒排分开存匹配它们不同的读取模式。写入安全靠两招先写临时文件再原子改名防写花用目录级 .lock 防并发写者打架。这些平凡手段是工业引擎的底线。下一篇我们把占位的两个序列化方法填上亲手设计二进制索引文件格式。上一篇【第12篇】构建索引二词典与倒排链的数据结构设计下一篇【第14篇】索引文件格式设计二进制协议的艺术