如何做到 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(原子交换)- Prepare:新建一个空的 rebuild 索引,配好索引设置。在线索引全程不碰,搜索照常服务旧索引。
- Push:从 Postgres 用 keyset 分页(
id > last_seen,避免扫描期间插入/乱序导致漏读)流式扫全表,只把在线职位写入 rebuild 索引,每批 2000 条;push 不等任务完成就继续下一批,让 Meilisearch 自动合并连续任务,把吞吐量拉满。 - 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防自叠),每轮做的事:
- Claim:从
search_outbox租约领取一批(wave),默认SEARCH_DRAIN_BATCH_SIZE;领取时联表jobs,已经关闭或已变成重复非规范副本的职位直接不再领取; - IndexBatch:把这批职位构建成文档,作为一个 Meili task整体 push 进在线
jobs索引——多少个 board 在这一窗口变了,都被折叠成一次 push; - Complete / Fail:push 成功后整批删除队列行;失败则逐条降级重试,超过
SEARCH_DRAIN_MAX_ATTEMPTS(默认 3)转死信(dead-letter); - 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_SIZE | 2500 | 单波(wave)领取条数 |
SEARCH_DRAIN_LEASE_SECONDS | 180 | 租约时长,崩溃后多久可被重新领取 |
SEARCH_DRAIN_MAX_ATTEMPTS | 3 | 超过则转死信 |
SEARCH_DRAIN_CALL_TIMEOUT_SECONDS | 600 | 单次 push 超时(保持宽松,见上文事故) |
SEARCH_DRAIN_MAX_PER_RUN | 0(不限) | 单轮 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 |
| 增量排水 worker | cmd/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 |
七、总结:三句话带走
- Meilisearch 的写入成本与批量大小无关——它每次都 re-merge 整个在线索引,所以"把碎片写入折叠成少量肥写入"是第一性原则;
- 事务性 outbox(search_outbox)让"写入 → 入队"原子化,单一 worker 按波排水,租约 + 重试 + 死信保证不丢不卡,且索引/删除两个队列谁也不饿死谁;
- 全量 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),仅供参考