freehire 公司筛选器改造:用反范式 job_count 与远端 typeahead 取代 Meilisearch facet 上限
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” 什么也搜不到。
改造前已经存在的两块基础设施,决定了改造方向:
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 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 看起来就像“彻底坏了”。
迁移与部署顺序
文档给出的迁移计划是一个严格有序的清单,顺序本身就是知识:
- 交付迁移
0025_companies_job_count.sql(列 + 索引)。在生产上通过psql手动应用(initdb 只在卷首次初始化时运行); - 部署完整的 Go 镜像重建(包含新的
cmd/recount-companies二进制)——不是只同步源码的部署;同时部署 Web 镜像以获得新的 facet 控制; - 手动运行一次
cmd/recount-companies填充job_count; - 加入每小时 cron 行(
flock+docker 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_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”普遍适用的判断:
- 区分有界词表与无界实体。
skills(238)与countries(129)适合分发加载;公司(数千、且长尾全是噪声)不适合。同一个 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 + 部分索引”的完整工程链路——这条链路里的每一步取舍,都能在代码注释与集成测试中找到依据。