freehire 架构深度解析:Go 单体 + 60 个 run-once Worker + 事务性 Outbox,330 万职位如何持续流转
【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址: https://gitcode.com/GitHub_Trending/fr/freehire
freehire 是一个开源职位搜索引擎,它直接从 92 个 ATS 招聘平台爬取 330 万+ 条在线 IT 职位,全部开源可自托管。这篇文章拆解 freehire 架构背后的三个关键工程决策:Go 单体、run-once worker 模式与事务性 Outbox——看看一个 330 万规模的职位搜索引擎,是如何让数据 7×24 持续流转而不积压的。
先看全景:一次搜索请求背后发生了什么
freehire 的产品形态很简单:按地区、工作形式、专业方向、职级做分面过滤,每条结果都直链到公司自己的招聘页,没有中介转帖。
而支撑这个界面的,是 docs/architecture.md 里描述的一整套数据管道。官方架构图(SVG)展示了完整链路:
用一句话概括:数据进来靠爬虫,数据流转靠 Postgres 里的事务性队列,数据出去靠 Meilisearch。下面逐个拆解。
为什么是 Go 单体:少一个进程,就少一种故障
很多读者听到"单体"会联想到"落后",但 freehire 的选择恰恰相反——它把复杂度压到了最少的地方:
- 只有一台常驻 HTTP 服务:
cmd/server基于 Fiber 框架,承载全部 API,包括应用内 AI 助手的 LLM 调用; - 唯一的例外是
cmd/mail-ingest(收件监听守护进程)。除此之外没有任何常驻后台运行时,没有消息队列守护进程; - 存储与搜索外置:PostgreSQL(含 pgvector)是唯一的事实源,Meilisearch 负责分面全文检索,Redis 只用于限流与实时推送。
技术选型清单见 README.md 的 Stack 一节。单体的好处在这里体现得很直接:所有写路径共享同一套代码、同一个数据库连接约定,进程之间不需要"互相通信",只需要"共享数据库"——这为下一节的核心模式铺平了路。
60+ 个 run-once Worker:没有守护进程的世界如何运转
打开 cmd/ 目录,你会看到 100 多个可执行入口:爬虫(ingest、harvest-boards)、队列消费者(enrich、embed、search-drain)、全量重建(reindex)、通知三件套(notify、remind、nudge)、以及各种一次性数据修补脚本(backfill-*)。
它们全部遵循同一个生命周期:由 cron/systemd timer 启动 → 读环境变量DATABASE_URL连接数据库 → 跑完一轮 → 退出,失败时以非零状态码结束。这种 run-once-and-exit 模式带来几个意外的好处:
| 特性 | 说明 |
|---|---|
| 不堆叠 | 靠 systemdType=oneshot防止上一轮没跑完时新一轮又启动,无需文件锁 |
| 易排查 | 进程活着就有工作,退出了就该有日志;没有"常驻进程假死"这种玄学问题 |
| 统一骨架 | 所有 worker 共用 internal/platform/worker/ 里的 Bootstrap 初始化、进度心跳、Sentry 异常捕获和统一的退出码约定(0=干净,1=有失败项,让 cron 告警生效) |
以爬取为例:cmd/ingest/main.go 每次只负责一个招聘平台(provider 作为参数传入),从boards目录表里读取该平台的所有待爬看板,逐一看板抓取、归一化、按UNIQUE (source, external_id)幂等去重后 upsert。这意味着每个平台可以按自己的节奏独立排期——某个慢平台卡住,绝不会拖累其他平台的更新。
💡 这种"单进程单职责 + 数据库协调"的模式,本质上是用磁盘上的表代替了内存中的队列。Postgres 天然提供了事务、唯一约束和并发控制,等于免费获得了一个可靠的消息系统。
事务性 Outbox:330 万职位流转的核心
这是整篇文章最重要的一节。问题场景是这样的:每次爬取都会产生成千上万条职位的写入,其中一部分需要触发 LLM 富化、一部分需要向量嵌入、一部分需要更新搜索索引。如果每条写入都"顺便"调一次下游,下游挂了就丢数据;如果引入 Kafka 之类的中间件,运维成本陡增。
freehire 的答案是事务性 Outbox(发件箱模式):在同一个数据库事务里,把"写入职位"和"往队列表插一行"一起提交。事务成功,两边同时存在;事务回滚,两边同时消失——不存在"职位写进去了但没进队列"的中间状态。
Postgres 里有三张这样的队列表:
| 队列表 | 消费 Worker | 职责 |
|---|---|---|
enrichment_outbox | cmd/enrich | 新职位送 LLM 做 AI 富化(技能、职级、工作模式等分面) |
semantic_outbox | cmd/embed | 职位内容向量化,写入 pgvector 供"相似职位"查询 |
search_outbox | cmd/search-drain | 内容变化的职位推送到 Meilisearch 活索引 |
以 migrations/0076_search_outbox.sql 为例,这张表只有一个job_id主引用加上认领/重试记账字段(attempts、claimed_at、failed_at),并用UNIQUE (job_id)保证同一职位不会重复入队。文件头部的注释还记录了一段真实的事故复盘:早期设计让 ~169 个独立 worker 进程各自直接推送 Meilisearch,而 Meilisearch 每次推送都要对整个百万级倒排索引做一次 re-merge(实测单次 50–180 秒),直接打满了磁盘 IO 拖垮了网站。改为所有写入统一走 Outbox 后,cmd/search-drain把一批变更合并成一次推送——"很多次小而贵的合并,变成少数几次大而廉价的合并"。
一个精妙的细节:爬取发现职位内容没变时,走"廉价路径"只刷新last_seen_at时间戳,不入队。否则整个目录每几小时就得整体重建一次索引。
搜索链路:结果页如何做到"不查数据库"
Outbox 把数据送进 Meilisearch 之后,查询侧的设计同样值得学习:
- 索引里存的是完整职位:
search.JobDocument直接内嵌了职位的公开 JSON 结构(jobview.Job),所以搜索结果命中即可渲染,不需要回 Postgres 取数据; - 唯一的例外是"幽灵职位"信号(Ghost Job Signal)——需要查库做 best-effort 补充,查询失败就隐藏徽标,绝不拖垮搜索本身;
- 深分页直接拒绝:
offset + limit上限 10000;空查询默认按最新发布排序(无关键词时"相关性"没有意义)。
活索引的"保真"靠 cmd/reindex 全量重建:从 Postgres 从零构建新索引后原子切换,在线读永远不受重建影响。相关细节见 internal/search/AGENTS.md。
可观测性与运维:让"静默的失败"无处藏身
run-once worker 没有 HTTP 端口可抓指标,freehire 的解法很有特色:
- 每个 worker 把本次运行结果写成 Prometheus textfile 文件(node_exporter 采集),
freehire_worker_last_run_*三个指标回答"worker 还活着吗"——挂死的运行永远不会打上完成戳,所以"上次运行时间变老"一个规则同时覆盖"卡死"和"定时器停了"两种故障; cmd/queue-metrics定期巡检所有队列,发布队列深度、死信数、最老等待时长,回答"worker 跟得上吗"。文档里记录了真实案例:notify连续数周报告delivered=1 failed=0,但 114 万条匹配一直没发出去——只有"最老等待时长"这条曲线能提前暴露它;- 全局暂停开关:往 Redis 写一个
freehire:pause:all键,就能让整个 cron 舰队拒绝启动新任务;开关本身故障时"fail open",绝不让限流设施成为目录停止更新的原因。
这些约定完整写在 internal/platform/worker/AGENTS.md,是学习 run-once worker 运维实践的好材料。
职位如何"死":生命周期设计
一个职位只要closed_at为空就算在线。关闭是软状态,系统从不硬删除职位——每笔关闭都记录closed_reason(哪个机制触发),猜测性关闭因此可以整批回滚。四种独立机制各管一段:
- 爬取清扫:某平台 48 小时未再出现的职位被关闭(且只限本次真正爬到的公司范围);
- 流式源主动下线:增量 feed 显式发出的删除事件;
- 存活探测:对无人回访的孤儿链接做纯启发式 URL 探测,连续两次"已过期"才关闭;
- 时效规则:Telegram 这类发帖型来源 45 天强制关闭(帖子会活得比职位久)。
详见 docs/agents/job-lifecycle.md。
延伸阅读:按模块深入
| 你想了解 | 从哪里读起 |
|---|---|
| 系统全景与数据流 | docs/architecture.md |
| Worker 骨架与退出码约定 | internal/platform/worker/ |
| Outbox 表结构(真实事故复盘在注释里) | migrations/0076_search_outbox.sql |
| 爬取管道与看板目录 | internal/ingest/pipeline/ |
| 搜索索引与增量排水 | internal/search/ |
| 功能清单(做了什么,而非怎么做) | docs/features.md |
总结:这套架构给普通开发者的启示
freehire 架构最值得抄作业的,不是某个具体组件,而是三条原则:
- 用数据库当协调中枢——事务性 Outbox 把"消息可靠性"问题降维成"事务"问题,省掉一整层中间件运维;
- run-once worker 优于常驻守护——可重试、可审计、可暂停,故障模式少了一大类;
- 批量是免费的——把 169 次昂贵的小推送合并成几次大批量,性能问题往往不是靠更快的机器,而是靠"少做几次"。
如果你正在自建一个内容聚合或搜索引擎,这个开源职位搜索引擎的代码库值得认真读一读:每个决策的"为什么",都写在对应模块的AGENTS.md里。
【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址: https://gitcode.com/GitHub_Trending/fr/freehire
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考