ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

freehire 架构深度解析:Go 单体 + 60 个 run-once Worker + 事务性 Outbox,330 万职位如何持续流转

freehire 架构深度解析:Go 单体 + 60 个 run-once Worker + 事务性 Outbox,330 万职位如何持续流转

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_outboxcmd/enrich新职位送 LLM 做 AI 富化(技能、职级、工作模式等分面)
semantic_outboxcmd/embed职位内容向量化,写入 pgvector 供"相似职位"查询
search_outboxcmd/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 的解法很有特色:

  1. 每个 worker 把本次运行结果写成 Prometheus textfile 文件(node_exporter 采集),freehire_worker_last_run_*三个指标回答"worker 还活着吗"——挂死的运行永远不会打上完成戳,所以"上次运行时间变老"一个规则同时覆盖"卡死"和"定时器停了"两种故障;
  2. cmd/queue-metrics定期巡检所有队列,发布队列深度、死信数、最老等待时长,回答"worker 跟得上吗"。文档里记录了真实案例:notify连续数周报告delivered=1 failed=0,但 114 万条匹配一直没发出去——只有"最老等待时长"这条曲线能提前暴露它;
  3. 全局暂停开关:往 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 架构最值得抄作业的,不是某个具体组件,而是三条原则:

  1. 用数据库当协调中枢——事务性 Outbox 把"消息可靠性"问题降维成"事务"问题,省掉一整层中间件运维;
  2. run-once worker 优于常驻守护——可重试、可审计、可暂停,故障模式少了一大类;
  3. 批量是免费的——把 169 次昂贵的小推送合并成几次大批量,性能问题往往不是靠更快的机器,而是靠"少做几次"。

如果你正在自建一个内容聚合或搜索引擎,这个开源职位搜索引擎的代码库值得认真读一读:每个决策的"为什么",都写在对应模块的AGENTS.md里。

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

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进