资讯动态

freehire 富化队列改造解析:按新鲜度领取、过滤关岗与 ENRICH_CONCURRENCY 有界并发

发布时间:2026/10/24 8:13:19 来源:尧图企业网站定制
后端前端搜索引擎网页爬虫数据工程AI 应用可观测性【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址https://gitcode.com/GitHub_Trending/fr/freehire点击查看免费下载本文以 freehire 仓库中的变更提案 proposal.md 为主线完整拆解enrichment_outbox富化队列从“串行、按插入 id 顺序、不区分岗位状态”的消费方式改造为“最新开放岗位优先 有界并发消费”的全过程。读者读完可以掌握该改造的动机、SQL 领取语义、并发与租约lease的正确配合方式、相关环境变量的真实默认值与边界行为以及如何在无数据库迁移的前提下安全上线和回滚。配套的设计决策与验收标准分别记录在同一变更目录的 design.md、tasks.md 与 ai-enrichment 规格 中。改造背景一个永远追不上的积压队列提案开篇给出的问题陈述非常具体也是整个改造的出发点enrichment_outbox中积压了约313k条待富化任务cmd/enrich严格串行消费每条任务约45 秒的 LLM 调用折算吞吐约1,900 条/天与此同时 ingest 仍在持续写入新岗位产出速度高于消费速度因此积压无限增长更糟的是领取claim按插入 id 排序而非按岗位新鲜度排序也从未排除已关闭的岗位——于是新鲜、仍然开放的岗位排在一条长长的“陈旧 已死”尾巴之后LLM 预算被花在用户永远看不到的空缺上。这三个问题分别对应三类修复排序语义、状态过滤、并发模型。提案在 “What Changes” 中把它们压缩成四条要点并在 “Capabilities” 中说明这不是新增能力而是对既有能力ai-enrichment的细化——具体表现为入队与领取排除关闭岗位、claim 按新鲜度排序、批处理命令以可配置并发消费整波任务。设计目标与非目标在动手改 SQL 之前变更先划定了边界见 design.md目标优先富化更新鲜、仍开放的岗位永不富化已关闭岗位通过有界并发把吞吐提升约 4 倍同时不破坏“每次运行中每条任务只被领取一次”的保证不引入任何新的数据库迁移。非目标同样重要避免范围蔓延不清理 outbox 中已经存在的关闭岗位残留claim 时跳过即可清理扫描被列为后续独立变更不引入每轮运行上限也不改变“一直消费到队列为空”的语义不做并发度自动调优也不针对 LLM 端点做自适应限流不把多条岗位拼进同一个 prompt。理解这些非目标才能正确解读后面“关闭岗位会残留、但无害”的取舍。改造一claim 按岗位新鲜度排序并排除关闭岗位SQL 的真实形态提案的表述是claim 关联jobs表并按posted_at DESC NULLS LAST, id DESC用户在信息流中看到的顺序排序。而仓库当前的实现比提案更进一步实际查询位于 enrichment.sqlWITH claimable AS ( SELECT o.id FROM enrichment_outbox o JOIN jobs j ON j.id o.job_id WHERE o.failed_at IS NULL AND (o.claimed_at IS NULL OR o.claimed_at now() - make_interval(secs sqlc.arg(lease_seconds)::int)) AND j.closed_at IS NULL AND j.duplicate_of IS NULL ORDER BY COALESCE(j.posted_at, j.created_at) DESC, j.id DESC FOR UPDATE OF o SKIP LOCKED LIMIT sqlc.arg(batch_size) ) UPDATE enrichment_outbox o SET claimed_at now() FROM claimable c WHERE o.id c.id RETURNING o.id, o.job_id, o.target_version;这段 SQL 里每一个子句都对应一条设计决策JOIN jobs排序依据与状态过滤都必须来自岗位表而 outbox 只保存job_id与target_version不复制岗位字段。o.failed_at IS NULL已进入死信dead-letter的任务不再被领取。claimed_at租约谓词claimed_at IS NULL或已早于now() - lease_seconds的任务才可领取。这同时充当崩溃回收器与跨运行/跨实例去重闸门。j.closed_at IS NULL关闭岗位不领取。j.duplicate_of IS NULL重复投递repost不是可检索的规范行富化它等于为一条不可见记录付费。ORDER BY COALESCE(j.posted_at, j.created_at) DESC, j.id DESC新鲜度排序。FOR UPDATE OF o SKIP LOCKED只锁 outbox 行并发 claim 各自拿到不相交的行。为什么用COALESCE(posted_at, created_at)而不是NULLS LAST这是设计文档中被反复强调、也最容易被“简化”掉的一处。提案写的是posted_at DESC NULLS LAST设计文档则明确改为COALESCE(posted_at, created_at)理由是数据分布posted_at是面向用户的新鲜度信号与信息流顺序一致但对某些来源为 NULL如 telegram、linksource 以及部分 ATS在纯NULLS LAST语义下这些无日期岗位会排在所有有日期岗位之后当 ingest 在数天的积压清空期间不断追加有日期的岗位时它们会被无限期饿死回退到created_atNOT NULL即写入时间后无日期岗位按“入库新鲜度”参与排序对有日期岗位仍然是“最新优先”对无日期岗位则是不被饿死的公平排序。设计文档还记录了两个被否决的替代方案不做 COALESCE 的NULLS LAST会导致上述饿死、以及在 outbox 上冗余一列posted_at需持续同步相较 join 无收益。为什么必须是FOR UPDATE OF o带 join 的FOR UPDATE在 PostgreSQL 中默认会锁定涉及的每一张表。如果直接写FOR UPDATEjobs行也会被锁住并发 claim 波次就会在热门岗位行上互相争用SKIP LOCKED的意义随之消失。限定OF o后只有 outbox 行被锁定SKIP LOCKED才能真正让并发消费者取到互不相交的任务。关于索引的一处事实校正提案与设计文档都提到复用jobs_posted_at_id_idx (posted_at DESC NULLS LAST, id DESC)并依赖 outbox 的UNIQUE (job_id, target_version)所提供的job_id索引。但按当前仓库的迁移快照情况已有变化阅读时需注意区分0116_drop_jobs_posted_at_id_idx.sql 已将该索引删除生产上 692 MB原因是 jobs 列表排序早已迁移到 Meilisearch而 0001_init.sql 中存在一个与本次 claim 排序完全同形的部分索引CREATE INDEX jobs_open_enrich_freshness_idx ON public.jobs (COALESCE(posted_at, created_at) DESC, id DESC) WHERE (closed_at IS NULL);从索引定义可以直接推断ORDER BY COALESCE(posted_at, created_at) DESC, id DESC加上closed_at IS NULL的过滤正是该部分索引的服务形态。此外 0093_jobs_similar_backfill_idx.sql 的注释也明确把这个索引称为“富化 claim 使用的、查找未完成工作且最新优先”的同一形状。同时设计文档承认新排序不再由enrichment_outbox_claimable_idx(id) WHERE failed_at IS NULL见 0001_init.sql服务大积压时可能出现排序或嵌套循环探测。其结论是该工作负载是LLM 延迟受限的最坏情况每波的全排序耗时不到它所要等待的“每岗位数秒 LLM 时间”的 2%因此按 YAGNI 不新增索引等生产EXPLAIN ANALYZE显示真正 DB-bound 时再议。改造二在入队侧就挡住关闭岗位排序只解决“谁先被处理”过滤才解决“该不该花预算”。变更同时修改了入队侧。当前 EnqueuePendingJobs 的实际门槛比提案更完整INSERT INTO enrichment_outbox (job_id, target_version) SELECT id, sqlc.arg(target_version)::int FROM jobs WHERE closed_at IS NULL AND duplicate_of IS NULL AND (enriched_at IS NULL OR enrichment_version sqlc.arg(target_version)::int) AND is_tech IS TRUE AND description ON CONFLICT (job_id, target_version) DO NOTHING;各条件的含义可由查询注释与相邻查询交叉确认条件作用closed_at IS NULL本次变更新增关闭岗位不入队duplicate_of IS NULL重复投递不入队与 claim 侧过滤对齐enriched_at IS NULL OR enrichment_version target_version仅未富化或版本落后的岗位入队支持版本升级重跑is_tech IS TRUE只对已确认技术类岗位花费 LLM 预算is_tech IS NULL未判定也被排除description 空描述无可提取内容ON CONFLICT ... DO NOTHING每个(job_id, target_version)至多一条天然幂等规格文件对这一行为的表述是强制性的入队 SHALL 只为开放岗位创建条目对已入队的(job_id, target_version)重复入队 SHALL NOT 产生重复行见 spec.md。提案同时明确指出ingest 的事务内入队UpsertJob路径保持不变因为刚写入或刚重开的岗位在构造上就是开放的。这条路径在仓库中对应 EnqueueJobEnrichment与岗位 upsert 处于同一事务其注释说明了对duplicate_of IS NULL、is_tech IS TRUE、非空描述的完整门槛调用点见 cmd/ingest/store.go其中if !deduped的守卫确保重复行不进入富化队列。值得强调的是提案坦白的一处“已知缝隙”已经躺在 outbox 里的关闭岗位行不会被领取但也不会被删除它们作为无害残留存在。设计文档把它明确列为非目标与未来清理扫描的候选。改造三以ENRICH_CONCURRENCY为界并发消费波次大小 并发度这是租约算术的结果提案的关键洞察不是“开 goroutine”而是把 claim 波次的大小绑定到并发度。设计文档给出的算术非常直观每条任务最慢的 LLM 调用约85 秒若波次等于并发度 N、池子也是 N则每条被领取的任务立刻开始处理其租约窗口 ≈ 一次最慢调用约 85 秒远小于LeaseSeconds300 秒若沿用BatchSize50而并发 4则尾部任务会“已租未跑”长达50/4 × 45s ≈ 560s超过 300 秒租约此时重叠的 cron 运行会把这些任务再次领取造成重复富化。这也是为什么ENRICH_BATCH_SIZE被移除、波次改由并发度决定——两个旋钮在语义上本就是同一个东西。规格对此的表述是命令 SHALL 以可配置的 worker 数并发处理每个 claim 波次并 SHALL 将波次大小设置为该并发度使条目在被处理前保持租约的时间远小于租约时长见 spec.md。运行时实现共享的outbox.RunPool当前代码中这一循环已被抽到共享包 internal/platform/outbox/runner.goRunner.Run只需把并发度同时传给BatchSize与Concurrencys, err : outbox.RunPool(ctx, r.Store, outbox.RunOptions{ // The wave is sized to the concurrency, then drained in parallel: each entry // starts processing at once, so its lease window stays ≈ one LLM call. BatchSize: opt.Concurrency, LeaseSeconds: opt.LeaseSeconds, Concurrency: opt.Concurrency, OnWave: func(cum outbox.Stats) { /* 每波心跳日志 */ }, }, rn.process)见 internal/ai/enrich/runner.go。波次执行体runPoolWave用带缓冲 channel 作为信号量保证同时在飞的处理数不超过并发度并在汇总统计时加锁func runPoolWaveC any { var mu sync.Mutex var wg sync.WaitGroup slots : make(chan struct{}, concurrency) for _, item : range batch { wg.Add(1) go func(it C) { defer wg.Done() slots - struct{}{} defer func() { -slots }() outcome : process(ctx, it) mu.Lock() tally(stats, outcome) mu.Unlock() }(item) } wg.Wait() }见 internal/platform/outbox/runner.go。一个容易被忽略的细节是Concurrency 1时该函数根本不启动 goroutine而是退回顺序循环以便需要严格顺序语义的调用方如邮件链接处理仍然获得真实的顺序保证——注释解释了为什么“大小 1 的信号量池”不能替代字面循环。单条任务的失败语义没有被并发破坏rn.process对每条领取项的处理路径保持不变runner.goStore.Job读取岗位若返回损坏页错误XX001立即以maxAttempts1死信因为不可读行重试永远失败rn.enrich调用 provider传输类错误进程内重试一次errUnparseableResponse则不重试同样的调用会复现同样结果结果先Sanitize()丢弃词表外枚举再Validate()校验失败视为确定性错误Store.Complete在同一事务中写入富化结果并删除 outbox 行见 cmd/enrich/store.go任一步失败都走failN记录不中断整轮运行。失败边界分为两类这与提案的“一条失败不影响整轮”直接相关也是当前实现的延伸postingAtFault判定的“岗位自身问题”按尝试次数封顶ENRICH_MAX_ATTEMPTS其余网关错误、超时、数据库不可达按条目在队列中的年龄封顶ENRICH_UPSTREAM_GRACE_DAYS。enrichment.sql 中的RecordEnrichmentFailure注释解释了原因租约到期即可重领队首条目在网关宕机时大约每小时消耗十二次尝试三次尝试只等于十五分钟而两次长时间网关故障曾把 172,875 条可富化岗位永久打成死信。dbStore的并发安全性设计文档专门用一节说明dbStore包装的是*pgxpool.Pool本身对并发使用安全Job、Complete、Fail各自从池中获取连接因此除了 runner 之外无需改动。唯一的例外是测试替身internal/ai/enrich/runner_test.go中的fakeStore与funcProvider必须加互斥锁才能通过-race——当前代码中确实能看到mu sync.Mutex字段与加锁的Complete/Fail/calls见 runner_test.go。这让“并发正确性”从注释承诺变成了可验证断言。配置项真实默认值与边界行为提案只给出一个新变量ENRICH_CONCURRENCY默认 4并删除ENRICH_BATCH_SIZE。当前实现位于 internal/platform/config/enrich.go可整理的完整参数如下环境变量结构体字段默认值含义与边界ENRICH_CONCURRENCYConcurrency4同时在飞的 LLM 调用数同时作为 claim 波次大小小于 1 会被压到 1ENRICH_LEASE_SECONDSLeaseSeconds300一次领取的租约时长过期后可被重新领取兼作崩溃回收ENRICH_MAX_ATTEMPTSMaxAttempts3岗位自身导致失败时的尝试上限ENRICH_UPSTREAM_GRACE_DAYSUpstreamGraceDays14非岗位自身失败时按条目队列年龄封顶的天数ENRICH_BATCH_SIZE—已移除波次大小改由并发度决定残留该变量会被忽略非正值为何要 floor 到 1代码注释写得很直接LIMIT 0会让 worker 变成静默空转cron 看起来健康实际一条都不富化而负值会把非法LIMIT传给 PostgreSQL。测试 enrich_test.go 对这一行为有明确断言默认值为concurrency4 / lease300 / max3、ENRICH_CONCURRENCY8被正确采纳、0与-3均被压到 1。同一测试还校验UpstreamGraceDays默认 14 天且必须显著长于 48 小时理由是它要覆盖多日网关故障。envInt的取值规则也值得注意未设置、空串、无法解析为整数三种情况都回退默认值而不是报错——这让一个写错格式的环境变量退化为“保守默认”而不是让 worker 直接起不来真正必须存在的 LLM 配置另由Require()快速失败。上线步骤、验证与回滚提案的 Impact 与设计文档的 Migration Plan 给出的落地顺序非常明确且没有数据库迁移改查询 → 生成代码修改 enrichment.sqlClaim Enqueue执行make sqlc重新生成internal/db。Makefile 中该目标通过容器运行固定版本的 sqlc无需本地安装。发布代码在 enrich worker 的环境中设置ENRICH_CONCURRENCY未设置即默认 4并从部署配置里删除ENRICH_BATCH_SIZE留着也会被忽略。无迁移、可回滚outbox 表结构未变回滚方式就是重新部署上一个二进制旧 worker 与新 worker 甚至可以针对同一队列并行运行。验证依据对应当前仓库中的测试领取顺序与关岗过滤TestEnrichmentClaimPriority包含“更新的posted_at先被领取”“无日期但刚入库的先于陈旧有日期岗位”“关闭岗位不入队”“已入队后关闭的岗位不被领取”四个子测试见 enrichment_integration_test.go租约与不相交同一文件的TestEnrichmentQueue用两次 claim 断言不会拿到同一 outbox id并发波次TestRunPool_RunsUpToConcurrencyItemsInParallel用阻塞 channel 证明并发度内的条目会同时进入处理见 outbox/runner_test.go并发统计与失败不中断TestRun_oneFailureDoesNotAbortBatch见 runner_test.go全量校验go test ./...、go test -race、go test -tagsintegration ./internal/db/。风险与取舍提案与设计文档列出的风险几乎都可以在当前代码里找到对应处置并发 4 触发 LLM 端点 429默认值保守可按环境调低失败调用已有“重试一次、再计一次失败”的既有行为瞬时 429 不会丢条目租约到期后下次运行重试。共享 outbox 包还预留了Deferred结果用于表达“对方要求降速”而不消耗条目尝试次数。关闭岗位残留成为 outbox cruft这是提案明确承认的“known seam”——claim 过滤会跳过它们因此无害清理留给后续变更。并发打乱日志顺序每条目仍打印自己的job… ok/FAILED in …s并保留每波心跳批次 worker 允许交错。无日期岗位被饿死由COALESCE(posted_at, created_at)解决。非正并发度LoadEnrich压到 1避免静默空转。claim 不再由enrichment_outbox_claimable_idx服务工作负载是 LLM 延迟受限最坏全排序开销占比不足 2%不新增索引以生产EXPLAIN ANALYZE为触发条件。连接池与并发不匹配Complete每条目从共享pgxpool开启短事务默认MaxConns max(4, numCPU)默认并发 4 足够若把ENRICH_CONCURRENCY调到远高于池大小写回会在pool.Begin上排队需要同步提高池大小。后续演进把“无害残留”真正清掉提案把关闭岗位残留列为非目标而当前代码已经补上了这一步Runner.Run在入队之前先调用Store.Reaprunner.go对应查询DeleteIneligibleEnrichmentOutboxenrichment.sql并通过常量reapLimit 20_000限制单轮删除量。查询注释记录了一次生产测量1,118,601 条条目中 486,178 条位于关闭岗位之后、216,283 条位于重复项之后、19 条无对应岗位仅 484,651 条可领取——57% 的队列是不可达残留却被深度指标当成待办工作。这次演进同样保持了原有取舍的一致性删除不是有损的EnqueuePendingJobs会在岗位重新符合条件时自动重建条目已死信行被刻意保留作为freehire_queue_dead_letters指标的证据单轮限量避免长事务持锁并且刻意不序列化“岗位重新符合条件”与删除之间的竞态因为窗口只有一条语句且下一轮入队会重新评估整个目录——最坏情况是一条岗位延迟一个 cron 周期。从“串行 1,900 条/天”到“波次等于并发度、租约窗口 ≈ 一次 LLM 调用”这套改造的价值不在于引入了多少新组件而在于把排序语义、状态过滤、并发度与租约这四件事对齐到同一个不变量上每一波被领取的任务都会立刻开始处理因此租约永远够用只有仍然开放、非重复、最新优先的岗位才会消耗 LLM 预算。这条不变量既写在了提案的四条变更里也落实在一条 SQL、一个共享池和一个默认值为 4 的环境变量中。赞分享后端前端搜索引擎网页爬虫数据工程AI 应用可观测性【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址https://gitcode.com/GitHub_Trending/fr/freehire点击查看免费下载相关推荐freehire enrichment 队列优化新鲜度优先认领与有界并发排空freehire enrichment 队列优化新鲜度优先认领与有界并发排空 本文以 freehire 仓库中 enrich priority concurr后端前端搜索引擎网页爬虫数据工程AI 应用可观测性freehire 富化队列提速实战按新鲜度优先出队、剔除已关闭职位与并发排空freehire 富化队列提速实战按新鲜度优先出队、剔除已关闭职位与并发排空 本文以 freehire 仓库中 2026 06 14 enrich prior后端前端搜索引擎网页爬虫数据工程AI 应用可观测性CouchDB JSON 结构参考大全数据库、文档、变更流、复制与请求对象的完整字段速查手册CouchDB JSON 结构参考大全数据库、文档、变更流、复制与请求对象的完整字段速查手册 导读 CouchDB 是一个以 HTTP/JSON API 为核后端前端搜索引擎网页爬虫数据工程AI 应用可观测性上一篇透明化设计拆解BrewUI 怎么做到「每次点击都等价于一条 brew 命令」下一篇发票、合同、病例全塞进去之前Paperless-ngx 备份与灾难恢复的完整复盘创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑