资讯动态

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

发布时间:2026/10/24 5:13:02 来源:尧图企业网站定制
后端前端搜索引擎网页爬虫数据工程AI 应用可观测性【免费下载链接】freehirefreehire — 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 的maxValuesPerFacet300上限countries有 129 个取值同样在阈值内。因此这两个 facet 的分发可以完整加载客户端搜索能覆盖全部选项。但公司不是有界词表文档给出的规模是约154 万个职位、分布在数千家公司上。Meilisearch 只会返回按字母序排列的前 300 个 facet 值于是公司列表变成了废数据——0-compromise-recruitment…、01-tech这类计数为 1 的条目占据全部名额热门雇主被彻底排除在外输入 “google” 什么也搜不到。改造前已经存在的两块基础设施决定了改造方向GET /api/v1/companies?q端点已经存在已经返回{slug, name, job_count}已经支持大小写不敏感的名称搜索与分页/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注释里用大写强调否则筛选后的分页总数与页面内容不一致sortrating的特例任意其他值包括空让CASE恒为 NULLORDER BY与原始版本逐字节一致。在 internal/api/handler/companies.go 中ListCompanies处理器体现了改造后的路由策略无筛选的目录列表走 Postgresjob_count DESC, name加 O(1) 的估算总数带q或任意 facet 的请求优先走 Meilisearch 公司索引相关度优先、支持拼写容错以job_count作为 tiebreaker任何 Meili 错误都回落到 Postgres 子串路径因此/companies不依赖 Meilisearch 可用性。sortrating则强制留在 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_slugstripe回落到既有的companyLabel(slug)人性化函数。不新增批量 slug→name 端点YAGNI。注册表侧FacetControl 与 companySearchweb/src/lib/facets.ts 中控制类型已扩展为四种type FacetControl pills | select | tokens | remote;FacetDef上新增了remote?: (query: string) PromiseFacetOption[]注释说明它是remote控制的服务端选项源即一个防抖查询函数公司、技能词典、城市地理编码。公司 facet 的条目现在是{ param: company_slug, label: Company, control: remote, excludable: true, placeholder: Search companies, remote: companySearch },而选项源本身把端点返回的行直接映射为 facet 选项export async function companySearch(query: string): PromiseFacetOption[] { 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。面板形态默认expandfalse时渲染为锚定输入框的浮动面板与CompanyPicker.svelte同一模式无论下方已经堆了多少 chip面板总是出现在正在输入的字段下方expandtrue时改为内联专供筛选弹窗自己的滚动容器。选项按钮用mousedownpreventDefault而不是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的调用返回 400unknown facet而不是静默无操作——注释解释了原因调用方按 key 读取计数已经把“缺失”解释为“Meili 的每 facet 上限丢掉了这个值”拼写错误会被长期隐藏。顺带一提性能量级该函数的注释记录了线上实测完整属性集在?work_moderemote下耗时 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 镜像而忘了跑 workerUI 看起来就像“彻底坏了”。迁移与部署顺序文档给出的迁移计划是一个严格有序的清单顺序本身就是知识交付迁移0025_companies_job_count.sql列 索引。在生产上通过psql手动应用initdb 只在卷首次初始化时运行部署完整的Go 镜像重建包含新的cmd/recount-companies二进制——不是只同步源码的部署同时部署 Web 镜像以获得新的 facet 控制手动运行一次cmd/recount-companies填充job_count加入每小时 cron 行flockdocker compose run --rm -T recount-companies与 compose 的 worker 服务验证/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_contributionsjob_count列在当前基线 migrations/0001_init.sql 中即已存在。阅读旧文档时按“语义”而非“路径”对照可以避免找不到文件。效果验证测试如何证明这些语义设计文档把验证拆成 RED/GREEN 步骤产物留在仓库里。internal/platform/db/companies_job_count_integration_test.go 用 testcontainers 起真实 Postgres//go:build integration直接断言 SQL 行为构造 Acme3 个在招 2 个已关闭、Globex1 个在招、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 DESCname 作为 tiebreak返回且q名称过滤仍然生效。运行方式需要 Dockergo test -tagsintegration ./internal/platform/db/前端侧的验证在任务清单里记录为cd web npm run checksvelte-check并以线上/api/v1/companies?qgoogle返回 Google3794 个职位作为数据通路验证——这条记录同时也说明了修复前后的差异修复前在侧边栏输入 “google” 是搜不到任何东西的。可复用的设计经验把这份设计文档抽干留下的是一组对“实体型 facet”普遍适用的判断区分有界词表与无界实体。skills238与countries129适合分发加载公司数千、且长尾全是噪声不适合。同一个 facet 机制不该被强行套在两类数据上。排序键决定可用性而不只是好看。按字母序返回的前 300 个值对“找工作”这个场景毫无意义按热度排序才让默认视图具备信息量。反范式计数 周期重算胜过触发器也胜过查询期聚合。前提是接受最终一致并把重算做成集合式、幂等IS DISTINCT FROM守卫、可自愈、被flock串行化的一次性任务。把“选项来源”与“筛选状态”解耦。状态仍以company_slug为键URL 同步、排除、chips、计数这些横切能力就完全不用改——这是本次改造风险最低的部分。删除无用计算也是性能优化。侧边栏不再读公司分发了就不要再请求它284ms 与 10ms 的差距说明“少算一个宽值 facet”是实打实的收益。部署顺序是方案的一部分。列默认 0 意味着“迁移 镜像 手动重算 cron”缺一不可任何一步跳过都会让 UI 看起来像功能坏了。沿着 设计文档、能力规格、前端规格 与 任务清单 对照阅读再回到 companies.sql、companies.go、facets.ts 与 RemoteSearchSelect.svelte可以看到一条从“facet 上限导致筛选器不可用”到“反范式计数 服务端 typeahead 部分索引”的完整工程链路——这条链路里的每一步取舍都能在代码注释与集成测试中找到依据。赞分享后端前端搜索引擎网页爬虫数据工程AI 应用可观测性【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址https://gitcode.com/GitHub_Trending/fr/freehire点击查看免费下载相关推荐freehire 搜索索引中的 curated collections从公司事实到 Meilisearch 可筛选 facet 的完整实现freehire 搜索索引中的 curated collections从公司事实到 Meilisearch 可筛选 facet 的完整实现 本文以 freeh后端前端搜索引擎网页爬虫数据工程AI 应用可观测性freehire 精选集合Curated Collections实现解析从公司级事实到职位搜索 Facetfreehire 精选集合Curated Collections实现解析从公司级事实到职位搜索 Facet freehire 的 job collecti后端前端搜索引擎网页爬虫数据工程AI 应用可观测性freehire 精选合集Collections实现指南从公司级标签到 Meilisearch 分面与 /collections 落地页freehire 精选合集Collections实现指南从公司级标签到 Meilisearch 分面与 /collections 落地页 本文以 free后端前端搜索引擎网页爬虫数据工程AI 应用可观测性上一篇为什么选择codecentric Helm Charts三大核心优势助力Kubernetes应用管理下一篇Chatterbox多语言TTS终极23种语言语音合成指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

免费获取报价 →
↑