招聘平台、企业内推系统、猎头工具,几乎都会涉及同一个功能模块:职位检索。用户输入一个关键词,系统需要在几万甚至几百万条职位数据中,快速返回与岗位名称、职责描述、技能要求相匹配的结果,还要支持城市、薪资区间、工作经验等多维度筛选。不少人以为这只是写一条SQL的LIKE查询就能搞定的事,但真到数据量上来之后,模糊查询的响应速度会直线下降,匹配的相关性也无法保证。这篇文章就用Node.js为主线,完整走一遍职位检索的实现过程。

一、为什么SQL的LIKE查询撑不起职位搜索
先看最直观的实现方式。假设职位存在MySQL里,用户搜索“前端开发”,最常见的写法是在SQL里拼接LIKE '%前端开发%'。这种写法在小数据量下没有任何问题,但它有两个致命缺陷。
第一是性能。以通配符开头的LIKE条件无法利用B+树索引,数据库只能对全表做逐行扫描。职位表如果有五百万条记录,每次搜索都要扫一遍全表,响应时间会从毫秒级劣化到秒级,高并发场景下数据库连接池很快就会被拖垮。
第二是相关性。LIKE只能判断字符串是否包含,无法区分“前端开发工程师”和“需要有前端开发经验的Java工程师”哪个更贴近用户意图,更不能按匹配程度排序。比如下面这段Node.js代码:
const mysql = require('mysql2/promise');
async function searchJobs(keyword) {
const conn = await mysql.createConnection({
host: 'localhost',
user: 'root',
password: 'yourpassword',
database: 'job_db'
});
// 通配符开头的LIKE无法走索引,全表扫描
const [rows] = await conn.execute(
'SELECT * FROM jobs WHERE title LIKE ? OR description LIKE ? LIMIT 20',
[`%${keyword}%`, `%${keyword}%`]
);
await conn.end();
return rows;
}这段代码在几千条数据时感觉不到问题,但它的查询计划注定是全表扫描。真正的职位检索需要的是“倒排索引”这种专门为搜索设计的数据结构:把每个词映射到包含它的文档列表,查询时先定位词,再合并文档,速度与数据量的关系远比关系型数据库温和。这也是Elasticsearch、Solr这类搜索引擎的底层基础。
二、用Elasticsearch搭建职位索引
Elasticsearch是目前做全文检索的主流选择,它天然支持中文分词插件(如IK Analyzer),并提供丰富的查询DSL。第一步是设计职位索引的Mapping,明确每个字段的类型和分词器。
const { Client } = require('@elastic/elasticsearch');
const client = new Client({ node: 'http://127.0.0.1:9200' });
async function createJobIndex() {
await client.indices.create({
index: 'jobs',
body: {
settings: {
analysis: {
analyzer: {
ik_max_word_synonym: {
type: 'custom',
tokenizer: 'ik_max_word'
}
}
}
},
mappings: {
properties: {
title: {
type: 'text',
analyzer: 'ik_max_word',
// 职位名称权重更高
boost: 3
},
company: { type: 'keyword' },
description: { type: 'text', analyzer: 'ik_max_word' },
city: { type: 'keyword' },
district: { type: 'keyword' },
salaryMin: { type: 'integer' },
salaryMax: { type: 'integer' },
experience: { type: 'keyword' },
education: { type: 'keyword' },
jobType: { type: 'keyword' },
publishTime: { type: 'date' },
skills: { type: 'keyword' }
}
}
}
});
console.log('索引创建成功');
}
createJobIndex().catch(console.error);这里有几个设计细节值得展开。职位名称title用text类型并配IK细粒度分词,因为用户搜索“高级前端”时,“高级”和“前端”都能被切出来参与匹配;而城市、学历这类枚举值用keyword类型,保证精确过滤不会被分词干扰。薪资拆成salaryMin和salaryMax两个整数字段,方便做区间过滤,比存“15-25K”这种字符串灵活得多。
索引建好后,需要把数据库中的职位数据同步进来。小规模数据可以直接全量拉取后用bulk接口批量写入;生产环境更推荐基于MySQL的binlog做增量同步,比如用canal监听变更后推送到消息队列,再由Node.js消费者写入Elasticsearch,保证两侧数据最终一致。
三、实现多条件组合查询的检索接口
索引就绪后,用Express封装一个检索API。这个接口要支持关键词搜索、城市过滤、薪资区间、工作经验筛选,并按相关度或发布时间排序,同时返回高亮结果和分页信息。
const express = require('express');
const app = express();
app.use(express.json());
app.get('/api/jobs/search', async (req, res) => {
const { keyword, city, salaryMin, salaryMax, experience, page = 1, size = 20 } = req.query;
const must = [];
const filter = [];
if (keyword) {
must.push({
multi_match: {
query: keyword,
fields: ['title^3', 'description', 'skills^2'],
type: 'best_fields',
fuzziness: 'AUTO'
}
});
} else {
must.push({ match_all: {} });
}
// 精确条件全部放filter,不参与打分且可利用缓存
if (city) filter.push({ term: { city } });
if (experience) filter.push({ term: { experience } });
if (salaryMin) filter.push({ range: { salaryMax: { gte: Number(salaryMin) } } });
if (salaryMax) filter.push({ range: { salaryMin: { lte: Number(salaryMax) } } });
const result = await client.search({
index: 'jobs',
from: (page - 1) * size,
size: Number(size),
body: {
query: {
bool: { must, filter }
},
highlight: {
pre_tags: ['<em>'],
post_tags: ['</em>'],
fields: { title: {}, description: { fragment_size: 100 } }
},
sort: keyword
? ['_score', { publishTime: { order: 'desc' } }]
: [{ publishTime: { order: 'desc' } }]
}
});
const hits = result.hits.hits.map(hit => ({
id: hit._id,
score: hit._score,
...hit._source,
highlight: hit.highlight
}));
res.json({
total: result.hits.total.value,
page: Number(page),
list: hits
});
});
app.listen(3000, () => console.log('检索服务已启动'));查询语句里有几处值得注意。关键词用了multi_match并给title加了三倍权重,因为职位名称命中的结果通常比正文命中更符合预期;fuzziness设为AUTO可以容忍“Jave”拼成“Java”这类错误,代价是略微增加查询耗时。城市、薪资、经验这些筛选条件统一放进filter子句,Elasticsearch对filter上下文的结果有缓存,且不影响相关度评分,这是提升复合查询性能的关键习惯。
薪资过滤的逻辑容易写错。用户期望“月薪20K以上”的职位,条件应该是职位的salaryMax大于等于20,而不是salaryMin,否则“15-30K”这种跨度大的岗位会被误排除。上面代码里两行range条件正是处理区间重叠的写法。
四、缓存与性能优化实践
搜索场景有个天然特点:热门关键词高度集中。“前端”、“销售”、“会计”这类词可能占据总搜索量的三成以上。给这类查询加一层缓存,收益非常明显。Node.js侧可以直接用内存LRU缓存,配合短过期时间兜底数据新鲜度。
const LRU = require('lru-cache');
const searchCache = new LRU({
max: 5000,
ttl: 1000 * 60 * 3 // 缓存3分钟
});
app.get('/api/jobs/search', async (req, res) => {
const cacheKey = JSON.stringify(req.query);
const cached = searchCache.get(cacheKey);
if (cached) {
return res.json(cached);
}
// ...执行真正的搜索逻辑
const payload = buildResult();
searchCache.set(cacheKey, payload);
res.json(payload);
});除了缓存,还有几个方向可以持续压榨性能。第一是分页策略,Elasticsearch的from + size深分页会带来显著开销,翻页超过一千条时应改用search_after游标方式。第二是只取必要字段,查询里显式指定_source包含的字段列表,减少网络传输和反序列化开销。第三是在Node.js进程层面做好连接复用,全局只创建一个Elasticsearch Client实例,避免每次请求都新建连接。
如果暂时不想引入Elasticsearch,中小规模数据也可以退一步用MeiliSearch,它对中文支持友好、部署极简,官方提供了meilisearch这个npm包,REST风格的调用方式和上面的实现思路基本一致,切换成本不高。方案的选择最终取决于数据量级、团队运维能力和对相关度排序的要求,理解了倒排索引和组合查询的设计思路之后,无论换哪种引擎,实现路径都是相通的。