首页 / 资讯中心 / 文章详情

freehire 索引重建的磁盘安全设计与一次性语义索引:从事故复盘到上线配置

freehire 索引重建的磁盘安全设计与一次性语义索引:从事故复盘到上线配置 ★ FEATURED ARTICLE
【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址https://gitcode.com/GitHub_Trending/fr/freehire点击查看免费下载本文以 freehire 仓库中的设计文档docs/superpowers/specs/2026-07-24-reindex-disk-safety-disposable-semantic-design.md为主线拆解一次反复因磁盘写满导致的线上事故是如何被结构性修复的一方面给 Meilisearch 的 swap-rebuild 加开工前磁盘闸门 失败后清场另一方面把派生的语义索引降级为可随时丢弃、从 Postgres 就地重建的一次性资产。读完后你应能独立配置MEILI_DATA_DIR与REINDEX_MIN_FREE_GB、理解Prepare/Push/Promote/Cleanup四个阶段各自的磁盘足迹并判断在当前代码形态下哪些设计仍生效、哪些已被后续变更取代。事故背景为什么一次 reindex 能拖垮整个应用设计文档记录的现场是 host2/dev/sda1301G反复出现 100% 磁盘占用Postgres 无法写入crash-loop或在临时文件上抛出SQLSTATE 53100 No space left、embed worker 崩溃重启、后台 worker 全部停摆。事故在 2026-06-17、06-25、07-03、07-23/24 反复出现每次都靠人工清理修好从未结构化解决。根因不是索引本身大小而是重建策略与磁盘容量的结构性错配freehire 常驻两个大型 Meili 索引jobsfacet/关键词约 50G与jobs_semantichybrid 向量约 45G。全量重建采用 fresh-index-plus-atomic-swap先构建一份完整副本jobs_rebuild/jobs_semantic_rebuild再占 45–50G再原子切换。磁盘本就紧张时构建过程把盘填满在 swap 之前中断留下孤儿 rebuild 索引。Rebuild.Prepare只在下一次运行时才会清掉上次遗留的 rebuild 索引——于是孤儿索引一直占着约 50G07-23 事故中滞留 13 小时以上直到人工删除。也就是说一次失败的重建不仅当下没收益还会以半成品索引的形式持续占用空间把下一次重建也连带拖死。两个使结构性修复成立的事实设计文档明确列出两条前提它们决定了后续所有方案取舍第一jobs_semantic是可丢弃的派生索引。它的向量持久化在 Postgres 的jobs.semantic_embeddingmigration 0021随每夜 pg_dump 备份可以从 Postgres 重建而无需重新 embedding零 TEI/GPU 成本。短期丢失是可接受的它只支撑/similar与 CV 推荐不承载主搜索hybrid 主搜索当时尚未启用。第二embed worker 本来就是原地写jobs_semanticupsert无 swap。因此原地批量回灌不需要发明新的原子性故事——它与既有写入模型完全一致。这两点合起来说明了设计的关键判断并非所有索引都需要原子切换。只有被在线搜索直接读取的 facet 索引才需要 swap派生索引可以承受重建期间结果不完整。目标与非目标设计文档把边界划得很清楚这直接反映为代码里守卫的放置位置。目标全量 reindex永远不会填满磁盘不再出现 disk-full-from-reindex 事故不遗留任何孤儿 rebuild 索引jobs_semantic的重建在 CPU不重新 embed和磁盘不做 2 倍副本两方面都便宜因而可以被视为一次性资产——需要时丢盘释放约 45G再按需重建在磁盘进入危险区之前发出预警。非目标明确排除避免方案膨胀扩容磁盘或迁移其他租户——属于基础设施决策改变 facet 索引的 swap-rebuild 策略——在线搜索索引需要原子切换且没有 Postgres 捷径在 SPA 中启用 hybrid 搜索——独立后续工作。组件一从 Postgres 原地回灌语义索引设计文档要求把reindex --semantic --from-pg改为原地重建jobs_semantic不产生 rebuild 副本、不做 swap先确保一个全新的空jobs_semantic存在则 drop再以语义设置userProvidedembedder创建。先删后建意味着旧副本的空间在新填充开始前就释放峰值磁盘占用约为1 倍索引大小永远不是 2 倍。以批处理方式流式读取所有开放、规范canonical且带有持久化向量的职位复用semanticDocsFromPG把文档含向量直接 upsert 进在线索引。全程不调用 TEI。因为索引是新建的不存在需要删除的陈旧文档——已关闭与非规范的职位只是从不推送与 reindex feed 已有的splitJobs过滤一致。填充期间/similar与推荐返回部分或空结果这对一次性索引是可接受的也正是 embed worker 早已采用的原地、非原子模型。设计文档还固化了一条决策--from-pg永远是原地模式基于 swap 的NewSemanticRebuildFromPG路径被移除——从 PG 出发再走 swap 没有任何收益而它恰恰是磁盘压力的来源。NewSemanticRebuildTEI embedding 的 swap 重建保留给确实需要从零重新 embed的场景并由组件二的守卫覆盖。当前仓库状态该路径已被整体删除阅读仓库时需要建立一个重要的时间线认知本文档的组件一描述的是 2026-07-24 的设计而后续变更openspec/changes/drop-hybrid-search-pgvector-similar/proposal.md已经彻底移除了jobs_semantic索引及其全部构建与查询代码包括cmd/reindex --semantic以及--from-pg/--posted-within标志、semanticSettings、EnsureSemanticIndex、NewSemanticRebuild、IndexSemanticJobsFromPG等。同文件记录的理由是jobs_semantic在 embed 入队层被is_techTRUE门控文档集比关键词索引更小且不同而/similar可以改为 pgvector 预计算表jobs.similar_job_ids查询。因此今天在 cmd/reindex/main.go 中run()只保留 facet 重建路径并在注释中明确写出语义路径已随该变更消失见streamOpenDocs的文档注释。阅读设计文档时应把组件一理解为当时用于解除磁盘压力的关键一步其结论派生索引可丢弃、可原地重建被后续架构进一步贯彻而不是当前可直接执行的命令。组件二开工前磁盘闸门 失败后延迟清理对必须 swap 的 facet 重建以及任何 TEI swap 重建设计文档给出两道防线。磁盘闸门guard在Rebuild.Prepare之前用syscall.Statfs测量 Meili 数据目录所在文件系统的可用空间低于配置下限就以清晰日志 非零退出中止。新增配置环境变量默认值作用MEILI_DATA_DIR/var/lib/freehire/meili闸门测量可用空间的文件系统路径必须是 Meilisearch 实际存放索引的卷REINDEX_MIN_FREE_GB70允许开始 swap-rebuild 所需的最小可用空间GiB≤0表示关闭守卫配置解析位于 internal/platform/config/reindex.gofunc LoadReindex() Reindex { r : Reindex{ MeiliDataDir: env(MEILI_DATA_DIR, /var/lib/freehire/meili), MinFreeGB: envInt(REINDEX_MIN_FREE_GB, 70), Dedup: envBool(REINDEX_DEDUP, false), DedupOnly: envBool(REINDEX_DEDUP_ONLY, false), } // A negative floor would make the guards free floor comparison always false, // silently disabling it in a way that reads like a real threshold. Clamp to 0, the // explicit guard off value. if r.MinFreeGB 0 { r.MinFreeGB 0 } return r }注意负值一律夹到0否则free floor恒为假守卫会被看起来像真阈值的方式静默关闭。这是一个典型的配置健壮性细节由 internal/platform/config/reindex_test.go 覆盖。为什么不按 Meili 自报的索引大小自适应文档给出的实测结论是/indexes/uid/stats的rawDocumentDbSize在磁盘占用上低估约 8 倍jobs_semantic报 6G实际约 51G自适应阈值不可靠。statfs 下限简单、诚实、可由运维调参。守卫本体实现在 cmd/reindex/diskguard.go几处工程细节值得展开func guardDisk(dir string, minFreeGB int, probe func(string) (uint64, error)) error { if minFreeGB 0 { return nil } free, err : probe(dir) if err ! nil { // Fail open: best-effort safety net, not a correctness gate. log.Printf(reindex: disk-guard skipped — cannot measure free space on %s: %v, dir, err) return nil } required : uint64(minFreeGB) 30 if free required { return fmt.Errorf(refusing rebuild — free %dGiB on %s is below the %dGiB floor (REINDEX_MIN_FREE_GB); a swap-rebuild would risk filling the disk, free30, dir, minFreeGB) } return nil }探针注入probe func(string) (uint64, error)使阈值逻辑无需真实文件系统即可单元测试参见 cmd/reindex/diskguard_test.go。测量失败即放行fail open数据目录在 CI/dev 上可能不存在若此处阻塞会让所有非生产环境的 reindex 全部失败。代价是生产上误配MEILI_DATA_DIR会静默关闭守卫因此代码里会大声打日志——磁盘告警是最后的兜底。用Bavail而非Bfreefunc statfsFree(dir string) (uint64, error) { var st syscall.Statfs_t if err : syscall.Statfs(dir, st); err ! nil { return 0, err } // Bavail (not Bfree) is the space usable by non-root writers. return st.Bavail * uint64(st.Bsize), nil }Bavail才是非 root 写入者真正可用的空间也就是 Meilisearch 实际能拿到的量用Bfree会把 root 预留块算进去让守卫偏乐观。调用点位置同样重要。在 cmd/reindex/main.go 中守卫被放在重复标记重算与全表扫描之前// Guard free disk for the swap rebuild up front — BEFORE the expensive recompute — // so a disk refusal is a true no-op (no prod writes, no full-catalogue scans). if err : guardDisk(rcfg.MeiliDataDir, rcfg.MinFreeGB, statfsFree); err ! nil { log.Printf(reindex: %v, err) return 1 }一次拒绝应当是真正的空操作不写生产、不做全目录扫描、不建任何索引。文档明确要求断言中止发生在创建任何索引之前这正是单元测试要锁定的行为。触发时的语义也已在文档中定义打印reindex: refusing — free %dG required %dG (REINDEX_MIN_FREE_GB); skipping to avoid disk-full并return 1。搜索保持原样增量 ingest 继续维持 facet 索引新鲜——全量 reindex 是对账器reconciler不是新鲜度路径。这一点是理解拒绝是安全行为的关键宁可索引一时陈旧也不要整站宕机。延迟清理defer-cleanup第一道防线防的是别开始第二道防的是开始了但失败了别留垃圾。rebuilder接口因此新增Cleanuptype rebuilder interface { Prepare(ctx context.Context) error Push(ctx context.Context, docs []search.JobDocument) error Promote(ctx context.Context) error // Cleanup drops a half-built rebuild index. reindexFull defers it so a run that // aborts before Promotes swap-and-drop does not leave an orphan index eating disk. Cleanup(ctx context.Context) error }reindexFull用promoted标志控制 defer 是否执行清理cmd/reindex/main.gopromoted : false defer func() { if promoted { return } cctx, cancel : context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) defer cancel() if err : b.Cleanup(cctx); err ! nil { log.Printf(reindex: cleanup aborted rebuild index (best-effort): %v, err) } }()三个设计要点只在未成功Promote时清理。成功路径的Promote自己会在 swap 后 drop 旧索引再清理一次是多余的。context.WithoutCancel(ctx)如果中止的原因正是父 context 被取消用原 ctx 发请求会立刻失败清理根本到不了 Meilisearch。这里派生一个不可取消、带 30 秒超时的上下文保证清理请求能发出去。尽力而为不掩盖原始错误清理失败只记日志。文档明确要求这一点——安全网不能把真正的失败原因吞掉。Cleanup的实现在 internal/search/search/client.go对索引不存在是容忍的幂等func (r *Rebuild) Cleanup(ctx context.Context) error { return r.c.dropIndex(ctx, r.rebuildUID) }对应的单元测试在 cmd/reindex/main_test.go 中用fakeRebuilder断言两条互斥行为Push中途失败时Cleanup必须被调用成功Promote后Cleanup必须不被调用。swap-rebuild 的四阶段与磁盘足迹理解守卫为何必须以2 倍索引为基准需要看 internal/search/search/client.go 中Rebuild的实现阶段行为磁盘影响Prepare确保 live 索引存在drop 上一次遗留的 rebuild 索引新建空jobs_rebuild并应用设置清掉孤儿创建空索引Push每批用UpdateDocumentsWithContext入队即返回不逐批等待交给 Meilisearch 自动合批索引持续增长至约 1 倍Promote依次awaitTask等待全部入队任务POST /swap-indexes原子交换drop 现在承载旧数据的 rebuild 索引交换瞬间存在两份之后回落Cleanup直接 drop rebuild 索引容忍不存在释放半成品Prepare中先 drop 再建的注释值得抄录理解// Discard any leftover rebuild index from an aborted prior run, then create it // fresh so the build always starts from empty. if err : r.c.dropIndex(ctx, r.rebuildUID); err ! nil { return err }这正是文档指出的问题根源清理依赖下一次运行的Prepare。在新代码里它退化为兜底第一责任交给了defer Cleanup。另外swapIndexes直接用 HTTP 调POST /swap-indexes而不是 SDK注释说明 pinned 的meilisearch-go总会序列化一个rename字段而引擎 v1.13 会拒绝它。这类版本约束是运维排障时容易踩的坑。已知局限文档诚实标注——在真正 100% 满盘时清理用的 MeiliindexDeletion任务可能无法立即调度串行任务队列。所以Cleanup是针对非磁盘原因中止的安全网真正防止盘满的是守卫。组件三运维侧磁盘告警文档规划的第三个组件在 freehire 主仓库之外freehire-opsfreehire-disk-alert.serviceoneshot/opt/freehire/bin/disk-alert.sh.timer每 15 分钟沿用provision/host2/systemd/下freehire-pg-backup的模式脚本检查df /使用率 ≥85% 时发出告警未定细节投递通道——优先复用项目 Telegram bottoken/chat 来自/opt/freehire/.env否则走既有 SES 邮件路径待 ops 侧落地时定稿。因为该 systemd 单元与脚本不在本仓库中本文不对其内容做超出文档的推断。这里只强调它的定位它不是可选项而是守卫 fail-open 的必要补偿——一旦MEILI_DATA_DIR配错导致守卫静默跳过告警是唯一还能提前发现问题的机制。运维推进顺序文档把上线拆成有先后的三步而不是一次性铺开先上线组件一与组件二并部署验证原地--from-pg能正确且省盘地重建jobs_semantic然后可选在需要腾出余量时在生产上 dropjobs_semantic释放约 45G82% → 约 67%在 hybrid/similar 真正需要前用已验证的原地路径按需重建。文档明确写不急——07-24 清理孤儿后磁盘约 82%。这个顺序的用意是先让重建是安全且便宜成为已证实的事实再去做依赖该事实的删索引操作。测试策略与代码落点文档列出四条测试要求目前仓库中的对应实现设计要求仓库落点守卫单测低于下限中止、达到或超过继续、0 关闭、探针失败放行cmd/reindex/diskguard_test.godefer-cleanup 单测Promote 前失败要清理、成功后不清理cmd/reindex/main_test.gofakeRebuilder原地 from-PG 单测确保新索引、只推开放职位向量、无 swap该路径已随drop-hybrid-search-pgvector-similar移除无现存实现运维冒烟disk-alert.sh在 host2 上以 dry-run 阈值验证不在本仓库守卫测试中错误信息必须同时包含实际可用量与阈值这一断言尤其值得保留——运维排障时最需要的就是这两个数字同时出现在一行日志里。当前代码形态下的补充配置与并发约束由于文档只覆盖磁盘安全而今天的cmd/reindex还多了若干与之交互的运行期约束这里补充说明便于实际排班时避免踩坑。去重标记已与重建解耦Reindex结构体除磁盘守卫外还有两个开关internal/platform/config/reindex.go默认值与直觉相反REINDEX_DEDUP默认false是否在重建前跑三趟重复标记重算角色簇、聚合器抑制、模糊折叠。源码注释记录了原因聚合器抑制单独在 306 个批次上实测约 23 小时而 unit 超时是 12 小时于是运行在去重中途被取消从未到达重建——三天零次成功 reindex。把它们拆开重建恢复自己的节奏标记用更稀有的REINDEX_DEDUP1单独刷新。REINDEX_DEDUP_ONLY默认 false只跑标记就退出。纯 Postgres 操作不碰 Meilisearch、不需要磁盘守卫、不需要暂停 search-drain因此可以跑得更频繁。两者同时设置时DedupOnly优先并在任何 Meilisearch 相关工作之前短路返回。这与磁盘安全直接相关只有真正要建索引的路径才需要 70G 余量纯标记刷新不受守卫制约。目录级互斥锁cmd/reindex/main.go 在进入任何目录级工作时抢占worker.HoldHeavyIndexLock拿不到就打印另一个目录级索引任务正在运行并return 0跳过而非等待。注释记录的现实原因一次重建在 2026-09-15 耗时 4.5 小时而计划时间是按3 小时排的于是 06:45、16:30、19:30 三个任务互相重叠某天下午两个并发把站点打挂。等待会让 oneshot 单元挂起数小时、看起来像卡死所以选择跳过——所有任务都是幂等的很快又会到期。批量与心跳reindexBatchSize 2000facet 索引去掉模型 embedder 后每批往返成为吞吐瓶颈因此从 500 上调技能向量在本地计算、只增加 payload 字节而不增加往返。progressInterval 60s心跳用于区分慢和卡住——总量停止增长即为卡住。重建成功后还会 best-effort 清理search_outbox中在本次运行开始前入队的过期条目失败只记日志因为重建本身已经成功。这部分的完整设计见openspec/changes/reindex-search-outbox-purge/design.md。风险与接受度文档对风险的表述非常克制值得原样保留其判断逻辑守卫可能拒绝 facet 重建直到磁盘扩容可用约 53G而索引约 50G 加开销。这是有意为之拒绝是安全的填满磁盘是事故。期间增量索引继续维持 facet 索引新鲜。原地语义重建会在填充期间降级/similar与推荐索引不完整。可接受——索引是一次性的且从 PG 重建很快。把这两条放在一起看就是整套设计的哲学在索引短暂不完整和整站写不进去之间永远选前者。快速核对清单上线或排障时按以下顺序核对MEILI_DATA_DIR是否指向 Meilisearch 真正存放索引的卷配错会让守卫静默失效日志中会出现disk-guard skipped。df -h /的可用空间是否高于REINDEX_MIN_FREE_GB默认 70低于时 reindex 会以退出码 1 拒绝这是预期行为。若曾在紧张磁盘上跑过旧版本检查是否残留jobs_rebuild索引——新版本会在Prepare时清、在失败时Cleanup但历史孤儿需人工确认释放。需要刷新重复标记时用REINDEX_DEDUP_ONLY1单独跑不要塞进重建窗口。确认 15 分钟粒度的磁盘告警已就位作为守卫 fail-open 的兜底。通过阅读 cmd/reindex/diskguard.go、internal/platform/config/reindex.go、cmd/reindex/main.go 与 internal/search/search/client.go可以完整复现这套先测量、再拒绝、失败必清场的防事故链路而设计文档中关于一次性语义索引的推理则解释了为什么本项目敢于把一个数十 GB 的索引当作可随时丢弃的缓存。赞分享【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址https://gitcode.com/GitHub_Trending/fr/freehire点击查看免费下载相关推荐SPTAG 快速上手指南内存索引构建、SPANN 磁盘索引与在线服务实战SPTAG 快速上手指南内存索引构建、SPANN 磁盘索引与在线服务实战 本文以 SPTAG 官方快速入门文档 docs/GettingStart.md h搜索引擎后端Flutter 官方站点 flutter.dev 宕机事故复盘一次 DNS 配置变更引发的 15 小时 40 分钟故障Flutter 官方站点 flutter.dev 宕机事故复盘一次 DNS 配置变更引发的 15 小时 40 分钟故障 2023 年 8 月 26 日Flu跨平台移动开发前端UI组件OpenHarmony图形学Faiss 分布式磁盘索引流水线从 500M 向量分布式 k-means 到 1T 级 OnDisk IVF 索引的完整构建Faiss 分布式磁盘索引流水线从 500M 向量分布式 k means 到 1T 级 OnDisk IVF 索引的完整构建 本系列文章聚焦 Faiss 仓库机器学习上一篇深度解析 BlueHammer 核心ServerMpUpdateEngineSignature 符号链接滥用与命名空间陷阱下一篇15秒多镜头分镜实战awesome-seedance-2-prompts 时间轴提示词设计完全教程创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
阅读完成 · 觉得有帮助?
咨询建站