ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

freehire 公司筛选器改造:用反范式 job_count 与远端 typeahead 取代 Meilisearch facet 上限

freehire 公司筛选器改造:用反范式 job_count 与远端 typeahead 取代 Meilisearch facet 上限
  • 后端
  • 前端
  • 搜索引擎
  • 网页爬虫
  • 数据工程
  • AI 应用
  • 可观测性

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

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

在 freehire 这样覆盖百万级职位的求职搜索引擎里,侧边栏的“Company”筛选器曾经是一个典型的能力边界问题:Meilisearch 的 facet 分发只能返回前 300 个值,而且是按字母序而不是按热度排序,导致热门雇主(例如google)在筛选器中根本搜不到。本文以 变更设计文档 为主线,结合仓库中的 SQL 查询、Go 后端处理器、定时 worker 与 Svelte 组件,完整拆解这套“反范式计数 + 服务端 typeahead”的改造方案:为什么放弃触发器、为什么放弃查询期count()、状态为什么仍以company_slug为键,以及部署时那些容易踩坑的顺序问题。

问题背景:facet 上限撞上 154 万职位

设计文档给出的起点非常具体:侧边栏的 Company 筛选器(PR#202 交付)是一个dynamic的 Meilisearch facet,与skills、countries共用同一套机制。这套机制对“有界词表”是有效的:

  • skills有 238 个不同取值,低于 Meilisearch 的maxValuesPerFacet=300上限;
  • countries有 129 个取值,同样在阈值内。

因此这两个 facet 的分发可以完整加载,客户端搜索能覆盖全部选项。但公司不是有界词表:文档给出的规模是约154 万个职位、分布在数千家公司上。Meilisearch 只会返回按字母序排列的前 300 个 facet 值,于是公司列表变成了废数据——0-compromise-recruitment…、01-tech这类计数为 1 的条目占据全部名额,热门雇主被彻底排除在外,输入 “google” 什么也搜不到。

改造前已经存在的两块基础设施,决定了改造方向:

  1. GET /api/v1/companies?q=端点已经存在,已经返回{slug, name, job_count},已经支持大小写不敏感的名称搜索与分页;
  2. /companies页面已经能渲染职位数徽标、搜索框、输入防抖和无限滚动。

也就是说,数据源与一个消费者都已经就位,缺陷只有两点:(a) 列表按名称排序;(b) 每次请求都要在 154 万行上做一次查询期的LEFT JOIN ... GROUP BY count()。整个方案因此不是“新建能力”,而是“换排序、换计数来源、换选项来源”。

目标与非目标:把范围钉死

文档明确列出了 Goals 与 Non-Goals,这套边界对后续所有技术取舍起到了约束作用。

目标

  • 侧边栏 Company 筛选器能按名称搜索整个目录,并按活跃度(职位数)倒序列出公司,展示真实名称与职位数;
  • 提供一个便宜、按热度排序的GET /api/v1/companies,同时被侧边栏 typeahead 与/companies页面复用;
  • 公司筛选仍然通过既有的company_slug搜索参数生效,因此 URL 同步、排除(exclusion)、chips、活跃筛选计数全部不变。

非目标

  • 不追求job_count的实时精确性(在重算周期内最终一致即可);
  • 不提供“上下文相关”的公司计数(计数是全局在招职位数,不随其他筛选变化);
  • 不改动skills/countries两个 facet——它们在阈值内且工作正常;
  • 不做任何 Meilisearch 重建索引(文档结构未变)。

这些“非目标”在后文的每个决策里都会出现:凡是与它们冲突的方案,都被明确排除。

决策一:反范式 job_count,由周期重算维护而非触发器

第一个核心决策是在companies表上新增反范式列:

ALTER TABLE companies ADD COLUMN IF NOT EXISTS job_count INT NOT NULL DEFAULT 0; CREATE INDEX IF NOT EXISTS companies_job_count_idx ON companies (job_count DESC);

并由一个新的 one-shot workercmd/recount-companies执行一条集合式UPDATE:对jobs WHERE closed_at IS NULL按company_slug做GROUP BY,把每家公司的在招职位数写回job_count,没有在招职位的公司置 0。该 worker 由宿主机 cron 每小时调度一次,用flock防止叠加,遵循仓库既有的 worker 模式(worker.Bootstrap、run-once、退出码),与cmd/backfill-derive同构。

文档给出了两条“为什么不是别的方案”的理由,值得逐条理解:

为什么不用 PostgreSQL 触发器:计数在入库时变,也在关闭时变(closed_at由 ingest 巡检与 liveness worker 写入),而 154 万行是被高频抓取的。触发器会给每次 upsert 增加写开销,并且任何绕过触发器的写入路径都会造成漂移。周期性全量重算是简单、可自愈、且远离热路径的选择。

为什么不在查询期计数:每次/companies请求、每次 typeahead 击键都要在 154 万职位上做一次临时GROUP BY,而这正是要消除的成本。

当前实现:重算范围已被扩大

在今天的仓库里,这个 worker 的职责比设计文档写作时更宽。cmd/recount-companies/main.go 的包注释说明它“一次集合式遍历重算每家公司的反范式状态——在招职位数,以及由在招职位派生的 facet 数组(regions/countries/domains/company_types/company_sizes)”。入口本身非常薄:

func main() { worker.Main(run) } func run() int { ctx, _, pool, cleanup, err := worker.Bootstrap(context.Background()) if err != nil { log.Printf("database: %v", err) return 1 } defer cleanup() mappingDomains, mappingIndustries := industrytag.DomainIndustryPairs() updated, err := db.New(pool).RefreshCompanyFacets(ctx, db.RefreshCompanyFacetsParams{ MappingDomains: mappingDomains, MappingIndustries: mappingIndustries, }) if err != nil { log.Printf("recount-companies: %v", err) return 1 } log.Printf("recount-companies done: companies updated=%d", updated) return 0 }

对应的 SQL 定义在 internal/platform/db/queries/companies.sql 中的RefreshCompanyFacets(:execrows)。它的实现要点对理解“为什么这条 UPDATE 便宜”很关键:

  • 用WITH oj AS MATERIALIZED (...)把在招职位集合物化固定,供后续八个聚合共用。注释明确指出:oj被所有八个聚合引用,因此必须MATERIALIZED,否则规划器可能把它内联、为每个聚合重新扫描一次在招职位集合;
  • 每个 facet 数组是独立、非相关的GROUP BY,避免一个数组的unnest行放大扭曲另一个数组的计数;
  • 每个数组都带稳定的ORDER BY,使下面的IS DISTINCT FROM守卫能确定性比较;
  • 逐列的IS DISTINCT FROM守卫让已经是最新状态的行不被重写,因此重复运行不会产生写入,返回的updated行数反映真实变更量;
  • 无在招职位的公司通过COALESCE归零/置空。

oj的过滤条件比设计文档更严格,也与搜索索引对齐:

SELECT company_slug, regions, countries, enrichment, work_mode, source FROM jobs WHERE closed_at IS NULL AND duplicate_of IS NULL AND company_slug <> '' AND NOT is_private AND description <> '' AND (category <> '' OR COALESCE(enrichment->>'category', '') NOT IN ('', 'other') OR is_tech IS TRUE)

duplicate_of IS NULL让同一角色簇只计一个规范职位,使公司计数与折叠后的/jobs、公司列表一致;is_private排除仅创建者可见的职位;后两条谓词与search.DescriptionMissing、search.CategoryUnresolved保持同步。注释还记录了一个真实的线上不一致:Stripe 在/companies显示 570 个在招职位,而其公司页只显示 444 个——原因就是计数用了比搜索索引更宽的表口径。

索引的演进:从全表索引到部分索引

设计文档写的是(job_count DESC)索引,而仓库后续的迁移把这条路走得更远。migrations/0023_companies_hiring_idx.sql 建立了部分索引:

CREATE INDEX IF NOT EXISTS companies_hiring_job_count_idx ON public.companies (job_count DESC, name) WHERE job_count > 0;

原因是列表读路径都带job_count > 0的“招聘中”范围约束:这会排除 YC 与 company-info 回填导入的约 9.2 万条无职位参考行,并让读操作只触碰约 13.4 万条招聘中的行,而不是完整的非部分索引。migrations/0042_drop_redundant_indexes.sql 随后删除了companies_job_count_idx,理由是ListCompanies、CountCompanies与重建索引的 keyset 扫描都限定job_count > 0,全部走部分索引。

这是一个值得记住的经验:反范式计数列本身不值钱,值钱的是“筛选范围与排序键被同一个部分索引覆盖”。

决策二:复用 companies 端点,只改排序与计数来源

第二个决策刻意做小:不新增端点,只改查询。

ListCompanies去掉LEFT JOIN/GROUP BY/count(),改为读取c.job_count,并按job_count DESC, name排序;q的 ILIKE 搜索与CountCompanies保持不变。由于GetCompany是SELECT *,job_count会自动流入db.Company,公司详情页无需改动。

当前 SQL 的形态(internal/platform/db/queries/companies.sql)保留了这一骨架,并增加了若干后续演进:

SELECT slug, name, job_count, tagline, industries, hq_country, collections, feedback_count, feedback_rating_avg, ai_interview_reports FROM companies WHERE job_count > 0 AND (sqlc.arg('search')::text = '' OR name ILIKE '%' || sqlc.arg('search') || '%' OR slug ILIKE '%' || sqlc.arg('search') || '%') ...各 facet 的数组重叠约束... ORDER BY CASE WHEN sqlc.arg('sort')::text = 'rating' THEN feedback_rating_avg END DESC NULLS LAST, job_count DESC, name LIMIT sqlc.arg('limit') OFFSET sqlc.arg('offset');

几个细节与设计文档直接对应:

  • 空查询短路:search = ''时 ILIKE 分支整体短路,同一条预编译语句同时服务“全量列表”与“名称搜索”;
  • slug 也参与匹配:名称搜索额外匹配 slug,所以ge-vernova这种带连字符的查询能找到名称为 “GE Vernova” 的公司;
  • CountCompanies必须保持相同 WHERE:注释里用大写强调,否则筛选后的分页总数与页面内容不一致;
  • sort=rating的特例:任意其他值(包括空)让CASE恒为 NULL,ORDER BY与原始版本逐字节一致。

在 internal/api/handler/companies.go 中,ListCompanies处理器体现了改造后的路由策略:无筛选的目录列表走 Postgres(job_count DESC, name加 O(1) 的估算总数),带q或任意 facet 的请求优先走 Meilisearch 公司索引(相关度优先、支持拼写容错,以job_count作为 tiebreaker),任何 Meili 错误都回落到 Postgres 子串路径,因此/companies不依赖 Meilisearch 可用性。sort=rating则强制留在 Postgres,因为评分当时还不是 Meili 可排序属性,路由过去会静默丢掉请求的排序。

总数的处理也值得单独说明:无筛选时用EstimateHiringCompanies()(estimate_hiring_companies()读取规划器估算,O(1)),因为对约 22.7 万招聘行做精确count(*)在线上是约 17 秒的冷缓存堆扫描(见 migrations/0034_estimate_hiring_companies.sql);只要带任何筛选,就退化为索引上的精确CountCompanies。处理器里还留下了一段真实 bug 的注释:此前两次isCompanyFilter调用被分别硬编码,第二处漏了industries,导致“只按行业筛选”的请求被当成未筛选,返回了目录级估算总数——因为参数是变长形式,这个错误是静默的。

决策三:前端新增control: 'remote',状态仍以 company_slug 为键

第三个决策是前端形态。方案是在 facet 注册表里新增一种控制类型,并实现RemoteSearchSelect.svelte:防抖请求api.listCompanies(q, limit, 0),渲染名称 + 计数,空查询时展示热门第一页,选中时调用既有的store.toggle('company_slug', slug)。

关键收益在于:因为状态仍以company_slug参数为键,filtersToParams/filtersFromParams、排除、chips、活跃筛选计数全部原样工作,只有“选项来源”从 Meili 分发变成了端点。

文档解释了两个设计动机:

  • 为什么是控制类型而不是专用组件:这是既有pills/select/tokens模式的自然延伸,让公司筛选留在注册表驱动的系统内。组件接收一个 fetch 函数,所以它并不硬编码公司,但公司是目前唯一消费者——在第二个远端 facet 出现之前不做进一步泛化。
  • 已选 chip 的标签:从结果中累积一个会话级的slug → name映射,使 chips 显示真实名称;对 URL 预选、尚未见过的 slug(例如?company_slug=stripe)回落到既有的companyLabel(slug)人性化函数。不新增批量 slug→name 端点(YAGNI)。

注册表侧:FacetControl 与 companySearch

web/src/lib/facets.ts 中,控制类型已扩展为四种:

type FacetControl = 'pills' | 'select' | 'tokens' | 'remote';

FacetDef上新增了remote?: (query: string) => Promise<FacetOption[]>,注释说明它是'remote'控制的服务端选项源,即一个防抖查询函数(公司、技能词典、城市地理编码)。公司 facet 的条目现在是:

{ param: 'company_slug', label: 'Company', control: 'remote', excludable: true, placeholder: 'Search companies', remote: companySearch },

而选项源本身把端点返回的行直接映射为 facet 选项:

export async function companySearch(query: string): Promise<FacetOption[]> { const { items } = await api.listCompanies(query, 20, 0); return items.map((c) => ({ value: c.slug, label: c.name, count: c.job_count })); }

注释再次点明了动机:Meili 把 facet 截断在 300 个值并按字母序返回,热门雇主永远不会浮现;空查询返回的是端点第一页,也就是最活跃的公司。

回退标签函数companyLabel保持原样,注释也诚实地承认其不完美:

function companyLabel(slug: string): string { return titleCase(slug.replace(/-/g, '_')); }

即group-ib会变成 “Group Ib”,但与其他 facet 一致——真正的 slug→name 查询需要逐公司请求,而这个 facet 刻意放弃了它。

组件侧:防抖、竞态与去重

web/src/lib/components/facets/RemoteSearchSelect.svelte 是这套方案的核心实现,注释密度很高,几乎逐条对应了实践中踩过的坑:

防抖与首屏:$effect在每次查询变化时清除上一个定时器,250ms 后再执行;挂载时以空查询触发一次,从而展示热门第一页。它还依赖ready标志:当候选列表(例如某个 facet 词典)仍在加载时,先清空结果并阻断防抖搜索;等ready翻转后再重新触发——否则一个比 250ms 防抖更快完成的加载会让查询卡在“竞态跑进空结果”的状态上。

竞态防护:一个单调递增的请求号gen,慢的旧查询不得覆盖新的查询:

async function run(q: string) { const mine = ++gen; loading = true; try { const opts = await search(q); if (mine !== gen) return; for (const o of opts) seen.set(o.value, o.label); results = dedupeByKey(opts, (o) => o.value, new Set()); } catch { if (mine === gen) results = []; } finally { if (mine === gen) loading = false; } }

重复键的真实事故:选项列表以value为键,重复键会被 Svelte 视为需要整体销毁重建——一个重复条目就会清空整个 picker。由于search是外部传入的 prop,返回内容不受组件控制,去重因此是组件自己的责任;它保留每个值的第一个(排名更好的那个)。这也是dedupeByKey被引入的原因。

标签记忆:seen是一个响应式SvelteMap,从每次渲染过的结果中累积 value→label。用响应式 Map 的意义在于:一个从 URL 回退标签渲染出来的 chip,会在后续搜索揭示真实名称时自动升级显示,无需拷贝再重新赋值。

chip 语义:selected是include与exclude的并集;chip 既可能是包含也可能是排除,点击统一走onToggle(可排除 facet 上是循环切换,否则是普通包含切换);已选项不再出现在候选列表中(它已经是一个 chip)。

面板形态:默认expand=false时渲染为锚定输入框的浮动面板(与CompanyPicker.svelte同一模式),无论下方已经堆了多少 chip,面板总是出现在正在输入的字段下方;expand=true时改为内联,专供筛选弹窗自己的滚动容器。选项按钮用mousedown+preventDefault而不是click,以保持焦点留在输入框、面板不关闭,从而支持连续多选。

web/src/lib/components/facets/FacetSection.svelte 中,路由逻辑只有一小段:

{:else if def.control === 'remote' && def.remote} <RemoteSearchSelect search={def.remote} include={st.include} exclude={st.exclude} placeholder={def.placeholder} {onToggle} fallbackLabel={(v) => dynamicLabel(def.param, v)} {expand} />

注意fallbackLabel绑定的是dynamicLabel(def.param, v),而dynamicLabel对company_slug会转到companyLabel——这正是“URL 预选的公司也能显示为可读 chip”的实现路径。

决策四:停止请求不再使用的 company_slug 分发

最后一个决策是一个纯收益的清理:company_slug仍保留在search.StringFacets中(?company_slug=过滤仍需要它),但从 facets 端点请求分发的属性集合中排除。此前每次侧边栏交互都在计算一个 300 桶、UI 已不再读取的分发。

当前实现见 internal/api/handler/facets.go:

// facetsNoDistribution are string facets we filter on but never request a value // distribution for. company_slug has thousands of values; the sidebar company // filter is a server-backed typeahead (GET /api/v1/companies, count-ordered), not // a facet distribution — and Meili would only return a capped, alphabetical slice // anyway. var facetsNoDistribution = map[string]bool{"company_slug": true}

facetAttributes()在遍历search.StringFacets时跳过该集合中的项,从而保证“新增 facet 自动被统计”这条单一来源原则不被破坏;同时requestedFacets对显式请求company_slug的调用返回 400(unknown facet),而不是静默无操作——注释解释了原因:调用方按 key 读取计数,已经把“缺失”解释为“Meili 的每 facet 上限丢掉了这个值”,拼写错误会被长期隐藏。

顺带一提性能量级:该函数的注释记录了线上实测,完整属性集在?work_mode=remote下耗时 284ms,而单属性只要 10ms——这也是facetAttributes(only ...string)支持按需收窄的原因。

风险与权衡:每一条都有对应的缓解措施

文档列出的风险并非泛泛而谈,每一条都指向具体机制:

风险缓解
两次重算之间计数陈旧每小时 cron 把漂移压小;计数只作为热度提示与排序键,不作为精确数字
154 万职位上的重算成本单次聚合 + 集合式UPDATE,不是逐行;远离热路径,且在flock下运行,不叠加
部署后job_count立即为空列默认 0,首次重算前列表看起来空/无序;缓解是迁移后立刻手动跑一次cmd/recount-companies
全局(非上下文)公司计数与其他 facet 的上下文计数不同有意为之并写入 spec;被替换的上下文路径本来就是坏的

其中第三条是最容易在发布日翻车的地方:迁移只建列,不填值。默认 0 意味着所有公司并列,ORDER BY job_count DESC, name退化成按名称排序,加上job_count > 0的范围约束甚至会得到空列表——如果只跑 Web 镜像而忘了跑 worker,UI 看起来就像“彻底坏了”。

迁移与部署顺序

文档给出的迁移计划是一个严格有序的清单,顺序本身就是知识:

  1. 交付迁移0025_companies_job_count.sql(列 + 索引)。在生产上通过psql手动应用(initdb 只在卷首次初始化时运行);
  2. 部署完整的Go 镜像重建(包含新的cmd/recount-companies二进制)——不是只同步源码的部署;同时部署 Web 镜像以获得新的 facet 控制;
  3. 手动运行一次cmd/recount-companies填充job_count;
  4. 加入每小时 cron 行(flock+docker compose run --rm -T recount-companies)与 compose 的 worker 服务;
  5. 验证:/api/v1/companies按计数排序;侧边栏 typeahead 能找到 “google”;/companies页面按热度排序。

回滚策略同样体现“增量”设计:改动是纯追加的(新列、新二进制、新的可选 cron)。回退 Web 镜像即恢复旧 facet;列可以安全保留在原地。

这里有一个需要读者注意的现实差异:设计文档写于该变更实施时,其中引用的路径是internal/db/queries/companies.sql、internal/handler/facets.go、migrations/0025_companies_job_count.sql;在当前仓库中,这些文件已分别位于internal/platform/db/queries/companies.sql、internal/api/handler/facets.go,且迁移编号已被后续变更占用(migrations/0025是link_contributions,job_count列在当前基线 migrations/0001_init.sql 中即已存在)。阅读旧文档时按“语义”而非“路径”对照,可以避免找不到文件。

效果验证:测试如何证明这些语义

设计文档把验证拆成 RED/GREEN 步骤,产物留在仓库里。internal/platform/db/companies_job_count_integration_test.go 用 testcontainers 起真实 Postgres(//go:build integration),直接断言 SQL 行为:

  • 构造 Acme(3 个在招 + 2 个已关闭)、Globex(1 个在招)、Empty Co(无职位);
  • 调用RefreshCompanyFacets后断言 Acme 为 3、Globex 为 1、Empty Co 为 0——“只计在招”与“无职位归零”两个语义同时被覆盖;
  • 再把 Acme 的三个在招职位全部关闭并重新重算,断言其job_count归零——对应 spec 中的 “Recompute zeroes a company whose jobs all closed” 场景;
  • 断言ListCompanies按job_count DESC(name 作为 tiebreak)返回,且q名称过滤仍然生效。

运行方式(需要 Docker):

go test -tags=integration ./internal/platform/db/

前端侧的验证在任务清单里记录为cd web && npm run check(svelte-check),并以线上/api/v1/companies?q=google返回 Google(3794 个职位)作为数据通路验证——这条记录同时也说明了修复前后的差异:修复前在侧边栏输入 “google” 是搜不到任何东西的。

可复用的设计经验

把这份设计文档抽干,留下的是一组对“实体型 facet”普遍适用的判断:

  1. 区分有界词表与无界实体。skills(238)与countries(129)适合分发加载;公司(数千、且长尾全是噪声)不适合。同一个 facet 机制不该被强行套在两类数据上。
  2. 排序键决定可用性,而不只是好看。按字母序返回的前 300 个值对“找工作”这个场景毫无意义;按热度排序才让默认视图具备信息量。
  3. 反范式计数 + 周期重算胜过触发器,也胜过查询期聚合。前提是接受最终一致,并把重算做成集合式、幂等(IS DISTINCT FROM守卫)、可自愈、被flock串行化的一次性任务。
  4. 把“选项来源”与“筛选状态”解耦。状态仍以company_slug为键,URL 同步、排除、chips、计数这些横切能力就完全不用改——这是本次改造风险最低的部分。
  5. 删除无用计算也是性能优化。侧边栏不再读公司分发了,就不要再请求它;284ms 与 10ms 的差距说明“少算一个宽值 facet”是实打实的收益。
  6. 部署顺序是方案的一部分。列默认 0 意味着“迁移 + 镜像 + 手动重算 + cron”缺一不可,任何一步跳过都会让 UI 看起来像功能坏了。

沿着 设计文档、能力规格、前端规格 与 任务清单 对照阅读,再回到 companies.sql、companies.go、facets.ts 与 RemoteSearchSelect.svelte,可以看到一条从“facet 上限导致筛选器不可用”到“反范式计数 + 服务端 typeahead + 部分索引”的完整工程链路——这条链路里的每一步取舍,都能在代码注释与集成测试中找到依据。

  • 后端
  • 前端
  • 搜索引擎
  • 网页爬虫
  • 数据工程
  • AI 应用
  • 可观测性

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

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

相关推荐

上一篇:为什么选择codecentric Helm Charts?三大核心优势助力Kubernetes应用管理
下一篇:Chatterbox多语言TTS:终极23种语言语音合成指南

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

RELATED READING

延伸阅读

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