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

原创2026-10-23 18:27:391,266 阅读
文章标签:后端前端搜索引擎网页爬虫数据工程AI 应用可观测性

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

在 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 worker cmd/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 + 部分索引”的完整工程链路——这条链路里的每一步取舍,都能在代码注释与集成测试中找到依据。

登录后查看全文
freehire