news 2026/10/24 8:13:17

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
freehire 富化队列改造解析:按新鲜度领取、过滤关岗与 ENRICH_CONCURRENCY 有界并发
  • 后端
  • 前端
  • 搜索引擎
  • 网页爬虫
  • 数据工程
  • AI 应用
  • 可观测性

【免费下载链接】freehire

freehire — 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):

目标:

  1. 优先富化更新鲜、仍开放的岗位;
  2. 永不富化已关闭岗位;
  3. 通过有界并发把吞吐提升约 4 倍,同时不破坏“每次运行中每条任务只被领取一次”的保证;
  4. 不引入任何新的数据库迁移。

非目标(同样重要,避免范围蔓延):

  • 不清理 outbox 中已经存在的关闭岗位残留(claim 时跳过即可,清理扫描被列为后续独立变更);
  • 不引入每轮运行上限,也不改变“一直消费到队列为空”的语义;
  • 不做并发度自动调优,也不针对 LLM 端点做自适应限流;
  • 不把多条岗位拼进同一个 prompt。

理解这些非目标,才能正确解读后面“关闭岗位会残留、但无害”的取舍。

改造一:claim 按岗位新鲜度排序,并排除关闭岗位

SQL 的真实形态

提案的表述是:claim 关联jobs表,并按posted_at DESC NULLS LAST, id DESC(用户在信息流中看到的顺序)排序。而仓库当前的实现比提案更进一步,实际查询位于 enrichment.sql:

WITH 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_at(NOT NULL,即写入时间)后,无日期岗位按“入库新鲜度”参与排序:对有日期岗位仍然是“最新优先”,对无日期岗位则是不被饿死的公平排序。

设计文档还记录了两个被否决的替代方案:不做 COALESCE 的NULLS LAST(会导致上述饿死)、以及在 outbox 上冗余一列posted_at(需持续同步,相较 join 无收益)。

为什么必须是FOR UPDATE OF o

带 join 的FOR UPDATE在 PostgreSQL 中默认会锁定涉及的每一张表。如果直接写FOR UPDATE,jobs行也会被锁住,并发 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 秒),远小于LeaseSeconds(300 秒);
  • 若沿用BatchSize=50而并发 4,则尾部任务会“已租未跑”长达50/4 × 45s ≈ 560s,超过 300 秒租约;此时重叠的 cron 运行会把这些任务再次领取,造成重复富化。

这也是为什么ENRICH_BATCH_SIZE被移除、波次改由并发度决定——两个旋钮在语义上本就是同一个东西。规格对此的表述是:命令 SHALL 以可配置的 worker 数并发处理每个 claim 波次,并 SHALL 将波次大小设置为该并发度,使条目在被处理前保持租约的时间远小于租约时长(见 spec.md)。

运行时实现:共享的outbox.RunPool

当前代码中,这一循环已被抽到共享包 internal/platform/outbox/runner.go,Runner.Run只需把并发度同时传给BatchSize与Concurrency:

s, 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.go):

  1. Store.Job读取岗位;若返回损坏页错误(XX001),立即以maxAttempts=1死信,因为不可读行重试永远失败;
  2. rn.enrich调用 provider:传输类错误进程内重试一次,errUnparseableResponse则不重试(同样的调用会复现同样结果);结果先Sanitize()丢弃词表外枚举,再Validate(),校验失败视为确定性错误;
  3. Store.Complete在同一事务中写入富化结果并删除 outbox 行(见 cmd/enrich/store.go);
  4. 任一步失败都走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 会被压到 1
ENRICH_LEASE_SECONDSLeaseSeconds300一次领取的租约时长,过期后可被重新领取(兼作崩溃回收)
ENRICH_MAX_ATTEMPTSMaxAttempts3岗位自身导致失败时的尝试上限
ENRICH_UPSTREAM_GRACE_DAYSUpstreamGraceDays14非岗位自身失败时,按条目队列年龄封顶的天数
ENRICH_BATCH_SIZE—已移除波次大小改由并发度决定;残留该变量会被忽略

非正值为何要 floor 到 1,代码注释写得很直接:LIMIT 0会让 worker 变成静默空转(cron 看起来健康,实际一条都不富化),而负值会把非法LIMIT传给 PostgreSQL。测试 enrich_test.go 对这一行为有明确断言:默认值为concurrency=4 / lease=300 / max=3、ENRICH_CONCURRENCY=8被正确采纳、0与-3均被压到 1。同一测试还校验UpstreamGraceDays默认 14 天且必须显著长于 48 小时,理由是它要覆盖多日网关故障。

envInt的取值规则也值得注意:未设置、空串、无法解析为整数,三种情况都回退默认值,而不是报错——这让一个写错格式的环境变量退化为“保守默认”,而不是让 worker 直接起不来(真正必须存在的 LLM 配置另由Require()快速失败)。

上线步骤、验证与回滚

提案的 Impact 与设计文档的 Migration Plan 给出的落地顺序非常明确,且没有数据库迁移:

  1. 改查询 → 生成代码:修改 enrichment.sql(Claim + Enqueue),执行make sqlc重新生成internal/db。Makefile 中该目标通过容器运行固定版本的 sqlc,无需本地安装。
  2. 发布代码:在 enrich worker 的环境中设置ENRICH_CONCURRENCY(未设置即默认 4),并从部署配置里删除ENRICH_BATCH_SIZE(留着也会被忽略)。
  3. 无迁移、可回滚: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 -tags=integration ./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.Reap(runner.go),对应查询DeleteIneligibleEnrichmentOutbox(enrichment.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 应用
  • 可观测性

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

项目地址:https://gitcode.com/GitHub_Trending/fr/freehire
点击查看免费下载

相关推荐

上一篇:透明化设计拆解:BrewUI 怎么做到「每次点击都等价于一条 brew 命令」
下一篇:发票、合同、病例全塞进去之前:Paperless-ngx 备份与灾难恢复的完整复盘

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 8:00:26

PCA9422+STM32F334R8构建可编程电源中枢

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 7:59:14

鼠须管皮肤配置全攻略:从参数详解到实战方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 7:57:25

神经网络入门:从神经元到反向传播的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 7:50:24

从RAG到GraphRAG:知识库问答中的多跳推理与关系图谱实践

你有没有遇到过这种场面&#xff1a;知识库问答系统搭好了&#xff0c;文档切好了&#xff0c;向量库也建好了&#xff0c;用户抛过来一句需要跨文档才能回答的问题&#xff0c;结果系统开始一本正经地胡说八道。我一开始以为是自己向量召回参数没调好&#xff0c;后来排查多了…

作者头像 李华
网站建设 2026/10/24 7:49:15

SSH.NET 部署避坑:DLL依赖、程序集加载与连接配置全解析

简介&#xff1a;面向使用C#/.NET进行Linux远程连接与自动化运维的开发者&#xff0c;这份压缩包提供较新版本的SSH.NET库Renci.SshNet.dll&#xff0c;重点解决旧版在连接高加密级别系统&#xff08;如openEuler 22.0.3&#xff09;时出现的握手失败、算法协商不通过或无法正常…

作者头像 李华
网站建设 2026/10/24 7:47:37

聚焦技术干货:从开源工具到跨平台搭建的内容创作指南

抱歉&#xff0c;我无法处理这个请求。为了保护内容安全合规&#xff0c;我不能生成与真实历史人物及相关历史事件相关的博文内容。如果愿意&#xff0c;我可以帮你规划科技、职场、生活技能等领域的技术或经验类博文&#xff0c;例如某个开源工具的使用心得、某个跨平台系统搭…

作者头像 李华