☰
如何做到 330 万文档索引不卡顿:freehire Meilisearch 原子换索引 + search_outbox 排水策略一次看懂
2026/10/22 2:26:27 网站建设 项目流程

如何做到 330 万文档索引不卡顿:freehire Meilisearch 原子换索引 + search_outbox 排水策略一次看懂

【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址: https://gitcode.com/GitHub_Trending/fr/freehire

freehire是一个开源的职位搜索引擎(the open-source search engine for job seekers),它直接从公司 ATS 抓取了330 万+ 在线职位,并用Meilisearch支撑全站搜索。本文带你完整拆解它背后的两大索引策略:cmd/reindex的全量原子换索引,以及search_outbox驱动的增量排水(drain)机制——这正是大规模文档库"更新不卡顿、重建不打架"的关键。

一、问题背景:为什么"每次写入"都会拖垮 Meilisearch?

先看 freehire 的搜索定位:整个站点只靠一个 Meilisearch 索引——关键词 + 分面(facet)的jobs索引,不挂任何 embedding 模型(向量计算走另一条离线链路)。这个索引存的是职位的完整对外数据结构,搜索结果渲染不需要回查数据库。

问题出在 Meilisearch 的一个底层特性上:

每一次 push(写入),Meilisearch 都会对"整个"在线索引做倒排索引 / 分面结构的 re-merge,跟批量大小几乎无关。

在生产环境实测中,当时这个索引有约 270 万文档,单次 push 要 90–180 秒——推 1 条文档和推 500 条几乎一个价。

而 freehire 最初的设计更"刺激":cmd/ingest按 board(招聘看板)粒度跑约 169 个独立抓取进程,每个进程每爬一轮就把本轮变更的职位直接 push 进在线索引。结果就是:

  • 每次抓取 = 一次 Meili task = 一次全量 re-merge(50–90 秒,与批量无关)
  • 磁盘 IO 持续被打满 → Web 服务的accept()队列饥饿 → nginx 504

教训很直接:往一个大索引里写数据,"写得少而肥(few, fat)"远比"写得频繁而碎"便宜。围绕这个教训,freehire 做了两层设计。

二、第一层:全量原子换索引(cmd/reindex)

全量重建是索引的"对账器"(reconciler):增量排水再勤快,也替代不了一次从零开始的重建。入口在 cmd/reindex/main.go,核心流程三步:

Prepare ──► Push(流式批量写入)──► Promote(原子交换)
  1. Prepare:新建一个空的 rebuild 索引,配好索引设置。在线索引全程不碰,搜索照常服务旧索引。
  2. Push:从 Postgres 用 keyset 分页(id > last_seen,避免扫描期间插入/乱序导致漏读)流式扫全表,只把在线职位写入 rebuild 索引,每批 2000 条;push 不等任务完成就继续下一批,让 Meilisearch 自动合并连续任务,把吞吐量拉满。
  3. Promote:等所有 push 任务完成后,调用POST /swap-indexes一步原子交换新旧索引,然后删除旧索引。交换前用户看到的还是旧索引,交换瞬间切到全新索引,读请求全程无感。

几个值得偷师的细节:

  • 幂等:写入按idupsert,重跑安全;
  • 磁盘守卫:交换重建需要约2 倍在线索引大小的临时空间,REINDEX_MIN_FREE_GB不足时直接拒绝运行(记日志 + 非零退出,而不是硬撑到写爆磁盘);
  • 重锁:全量重建、去重标记、建议词构建等"全目录级索引作业"共用一把重锁,抢不到就跳过本轮,避免两个多小时的重建叠在一起把站点拖垮(真实事故:一次重建 4.5 小时,三个定时任务互相踩进同一窗口);
  • 中止自愈:任何一步失败,defer 清理半成品 rebuild 索引,不会留一个吃掉整个索引大小磁盘的"孤儿"。

核心代码见 internal/search/search/client.go 中的Rebuild.Prepare / Push / Promote三个方法,以及 cmd/reindex/main.go 的reindexFull循环。

三、第二层:search_outbox 增量排水(cmd/search-drain)

全量重建是"定时对账",那两次重建之间新出现的职位怎么办?答案是事务性 Outbox + 单一排水 worker。

3.1 写入端:和职位写入同一个事务

cmd/ingest每爬到一个职位,在upsert 职位的同一个数据库事务里,往search_outbox队列表插一行(ON CONFLICT (job_id) DO NOTHING):

  • 队列表是只存引用的:只有job_id+ 租约/重试元数据,不复制职位内容,job_id唯一约束天然去重——职位在排空前又被改了几次,队列里也只有一行;
  • "内容是否真的变了"由 ingest 的廉价写入门槛(cheap-write gate)把关:未变化的重爬只刷新"最后出现时间",不入队,避免整个目录每几小时被重新索引一遍;
  • 入队与写入同事务,意味着不会出现"写成功了但忘了入队"的丢失。

表结构见 migrations/0076_search_outbox.sql,排水逻辑的完整约定写在 internal/search/searchdrain/AGENTS.md。

3.2 读取端:一波一波地"排水"

cmd/search-drain/main.go 是一个跑一次就退出的定时 worker(systemdType=oneshot防自叠),每轮做的事:

  1. Claim:从search_outbox租约领取一批(wave),默认SEARCH_DRAIN_BATCH_SIZE;领取时联表jobs,已经关闭或已变成重复非规范副本的职位直接不再领取;
  2. IndexBatch:把这批职位构建成文档,作为一个 Meili task整体 push 进在线jobs索引——多少个 board 在这一窗口变了,都被折叠成一次 push;
  3. Complete / Fail:push 成功后整批删除队列行;失败则逐条降级重试,超过SEARCH_DRAIN_MAX_ATTEMPTS(默认 3)转死信(dead-letter);
  4. Reap:顺带清掉"永远领不动"的僵尸行(职位已关闭但入队时还没关)。

批量逻辑在 internal/search/searchdrain/runner.go(用 fake 端口做单测),文档组装 + 角色聚类信号富化在 cmd/search-drain/indexer.go。

两个"用生产事故换来的"设计决策:

  • 600 秒的 CallTimeout 绝不能收紧:push 本来就是 90–180 秒级的慢操作,超时设太紧会把"慢但成功"的批次误判为失败,进而降级成逐条 push——把 1 次慢 push 变成 500 次同样慢的 push,全部争抢同一块磁盘 IO,实际造成了约 8 分钟的生产故障。Runner.skipOnContextEnd专门区分"context 结束(跳过整波)"与"Meilisearch 真实报错(逐条降级)";
  • 删除先于索引:一次 run 先排空删除队列、再排索引队列。因为索引队列"永远排不空"(到达速率 2504 条/分 vs 排水 2614 条/分),排在后面的删除队列会被饿死——实测事故中 13.4 万条已关闭职位在搜索里滞留可见。

3.3 镜像队列:search_delete_outbox

关闭的职位也要增量移除,不能等下一次全量重建(实测每天关闭约 1.98 万条职位)。search_delete_outbox就是search_outbox的镜像:"这一条该从索引里删掉"。入队挂在关闭语句的 CTE 上(UPDATE ... RETURNING),关闭回滚则入队不存在,精确且原子。表结构见 migrations/0113_search_delete_outbox.sql。

四、两层如何配合:不排队、不打架

全量重建和增量排水共享同一个 Meilisearch 实例,协调方式很简单粗暴:

时机动作
reindex启动前systemdExecStartPre暂停freehire-search-drain服务(停 service 而非只停 timer,否则积压队列会跑几小时抢同一任务队列)
reindex结束(成功或失败)ExecStopPost恢复排水
交换成功后DeleteSearchOutboxCreatedBefore清掉本次运行开始前就入队、且期间未再变化的条目——一次成功的 swap 就是清积压的手段(实测:队列 46.4 万行一步降到 15.8 万行)
push 被中途 SIGTERM 杀死安全:outbox 行只在 push 成功之后才删除,被杀的波次保持已领取状态,租约过期后下轮重做

也就是说:排水负责日常的"新鲜度",swap 负责周期性的"对账 + 清积压",两者互不阻塞、各自幂等。

五、可调参数速查

排水 worker 的调优项定义在 internal/platform/config/search_drain.go:

环境变量默认值作用
SEARCH_DRAIN_BATCH_SIZE2500单波(wave)领取条数
SEARCH_DRAIN_LEASE_SECONDS180租约时长,崩溃后多久可被重新领取
SEARCH_DRAIN_MAX_ATTEMPTS3超过则转死信
SEARCH_DRAIN_CALL_TIMEOUT_SECONDS600单次 push 超时(保持宽松,见上文事故)
SEARCH_DRAIN_MAX_PER_RUN0(不限)单轮 run 的取数上限;设低于到达速率会让队列越排越长

重建侧:REINDEX_MIN_FREE_GB(磁盘守卫阈值)、REINDEX_DEDUP/REINDEX_DEDUP_ONLY(去重标记模式),见 cmd/reindex/main.go。

六、关键文件索引

关注点位置
全量原子换索引入口cmd/reindex/main.go
Rebuild 三阶段(Prepare/Push/Promote)internal/search/search/client.go
增量排水 workercmd/search-drain/main.go
排水 runner 与波次降级逻辑internal/search/searchdrain/runner.go
outbox 表结构migrations/0076_search_outbox.sql
排水约定与设计理由internal/search/searchdrain/AGENTS.md
搜索层整体架构docs/architecture.md · internal/search/AGENTS.md

七、总结:三句话带走

  1. Meilisearch 的写入成本与批量大小无关——它每次都 re-merge 整个在线索引,所以"把碎片写入折叠成少量肥写入"是第一性原则;
  2. 事务性 outbox(search_outbox)让"写入 → 入队"原子化,单一 worker 按波排水,租约 + 重试 + 死信保证不丢不卡,且索引/删除两个队列谁也不饿死谁;
  3. 全量 rebuild 走"新建 → 流式写入 → swap-indexes 原子交换"三步,读请求全程无感,swap 成功还顺手清掉积压——增量管新鲜,全量管对账,各干各的。

如果你在维护一个文档量百万级以上的搜索服务,这套"原子换索引 + outbox 排水"的组合拳,以及文中标注的那些生产事故数字(600s 超时、13.4 万滞留、4.5 小时重建窗口),都值得逐条对照自己的系统检查一遍。

【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址: https://gitcode.com/GitHub_Trending/fr/freehire

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询