Nest 使用笔记
第二十一章:Elasticsearch——全文检索、中文分词与 MySQL→ES 双写同步
打开 learnhub 的 SearchService,在真实代码里讲透倒排索引为什么比 MySQL LIKE 快、IK 两个 analyzer 的分工、bool+should 标题加权 ×3 的搜索 DSL,以及 PostService 里「MySQL 是真相源、ES 是可重建副本」的 fire-and-forget 双写同步钩子。
- Nest
- Elasticsearch
- 全文检索
第八章讲 findMany 的时候留了一个扣:列表里用 LIKE '%关键词%' 做模糊查是因为它是 MySQL 内建的最简方案,真要做海量全文检索要走这一章的 Elasticsearch。这一章打开 learnhub 真正在用的 SearchService——不是给你一个「封装 client 调一下 index/search」的空壳,而是逐方法讲透:倒排索引为什么比 LIKE 快、IK 两个分词器的分工、bool + should 标题加权 ×3 的搜索 DSL,以及 PostService 里「写完 MySQL 异步双写 ES」的同步钩子。
先搞懂:Elasticsearch 是什么 / 为什么需要 / 企业级怎么用
Elasticsearch 是什么:分布式全文检索引擎,核心数据结构是倒排索引(inverted index)。MySQL 是「文档 → 找词」(拿到一行再去匹配关键字串),ES 反过来,预先建好「词 → 文档 id 列表」:写入时分词,把每个词指向包含它的文档 id;查询时也分词,对每个词 O(1) 拿到文档列表,再按 BM25 算相关性排序、合并、高亮。
为什么需要它:第八章 findMany 的 LIKE '%关键词%' 三个硬伤——
- 不走索引:前导
%让 B+Tree 索引失效,逐行扫描,表一大就慢; - 不能排序:
LIKE只有「匹配 / 不匹配」,没有「这条比那条更相关」的概念,结果顺序只能靠ORDER BY createTime之类的硬规则; - 没有中文分词:搜「智能手机」找不到只含「手机」的帖子,也找不到「智能设备」这种语义相关——
LIKE是子串匹配,不懂「词」。
ES 一次性解决:倒排索引按词 O(1) 检索、BM25 按词频和文档长度算相关性、highlight 把命中词包进 <em>、analyzer 把中文切成有意义的词。
企业级怎么用(下面每一条,这一章都会在 learnhub 里真做一遍):
- MySQL 是真相源,ES 是可重建的搜索副本——写操作先落 MySQL,再异步同步 ES;ES 丢了能从 MySQL 全量重建,绝不能拿 ES 当主库(没有事务、没有强一致);
- 中文场景装 IK 分词插件,索引和查询用两个不同的 analyzer(下面第四步讲为什么);
- ES 是 JVM 进程,吃内存——
ES_JAVA_OPTS堆内存给机器内存一半、别超 32G(指针压缩边界); - 启动期 ES 不可用不能拖垮 Nest 启动——learnhub 用
maxRetries: 0+onModuleInit里 try/catch,下面第二步讲。
这一章你会做出什么
- 打开 learnhub 的
src/modules/search/search.service.ts,逐方法讲清楚onModuleInit/indexPost/removePost/search为什么这么写。 - 在真实代码里吃透:mapping 的 text vs keyword、IK 中文分词两个 analyzer 的分工、bool + should 标题加权 ×3 的搜索 DSL、highlight 高亮。
- 理解 PostService 里「MySQL→ES 异步双写」的同步钩子:为什么
indexPost是 fire-and-forget、为什么放在事务外面。
前置:learnhub 的 ES 在跑(cd learnhub/docker && docker compose up elasticsearch),第八章的 PostService 已经看过。
第一步:用 Docker 起 ES + 装 IK 中文分词
learnhub 的 docker-compose.yml 里 ES 服务长这样:
# learnhub/docker/docker-compose.yml
elasticsearch:
image: elasticsearch:8.11.1
container_name: learnhub-elasticsearch
environment:
discovery.type: single-node # 单节点学习模式
xpack.security.enabled: "false" # 学习关掉 xpack 安全认证,走 HTTP 无账号
ES_JAVA_OPTS: "-Xms512m -Xmx512m" # 堆内存 512m(学习够用,生产建议 2g+ 且 lock 内存)
ports:
- "9200:9200"
volumes:
- es-data:/usr/share/elasticsearch/data
几件事:discovery.type=single-node 让 ES 单节点跑起来不报集群发现错误;xpack.security.enabled: "false" 是学习用的——8.x 默认开安全认证、要账密,关掉走纯 HTTP,配置里 ES_USERNAME / ES_PASSWORD 留空;ES_JAVA_OPTS 设 JVM 堆,学习 512m 够,生产给机器内存一半(且 lock 内存防 swap)。
ES 自带的 standard 分词器对中文是逐字切——「智能手机」切成「智」「能」「手」「机」四个字,检索质量极差。中文场景必须装 IK 分词插件,而且 IK 版本必须和 ES 版本完全一致(8.11.1 的 ES 就装 8.11.1 的 IK),否则 ES 起不来:
docker exec learnhub-elasticsearch ./bin/elasticsearch-plugin install --batch \
https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-8.11.1.zip
docker restart learnhub-elasticsearch
验证分词效果:
curl 'http://localhost:9200/_analyze?pretty' -H 'Content-Type: application/json' \
-d '{"analyzer":"ik_max_word","text":"智能手机开发"}'
# { "tokens": [ { "token": "智能手机" }, { "token": "智能" }, { "token": "手机" }, ... ] }
看到「智能手机」「智能」「手机」这种成词切分,而不是单字,就说明 IK 装好了。
第二步:全局 SearchModule + ES_CLIENT token——非阻塞启动
新手封装 ES 客户端十有八九是这样的(别学这个):
// 反面教材:硬编码 node + 阻塞启动(别学这个)
@Global()
@Module({
providers: [
{ provide: 'ES_CLIENT', useFactory: () => new Client({ node: 'http://localhost:9200' }) },
],
})
两个问题:node 硬编码(换环境改代码)、ping 失败会抛(ES 没起来时整个 Nest 应用启动失败)。learnhub 的真实写法是 useFactory + ConfigService,配置走 env,而且特意调成「ES 缺席时快速失败、不阻塞启动」:
// learnhub/src/modules/search/search.module.ts
@Global()
@Module({
imports: [ConfigModule],
providers: [
{
provide: ES_CLIENT,
inject: [ConfigService],
useFactory: (config: ConfigService) => {
const username = config.get<string>('elasticsearch.username');
const password = config.get<string>('elasticsearch.password');
return new Client({
node: config.get<string>('elasticsearch.node') || 'http://127.0.0.1:9200',
auth: username && password ? { username, password } : undefined,
// ES 缺席时快速失败(默认 maxRetries:3 会带退避重试 ~7s,拖慢启动/拖垮 e2e)
maxRetries: 0,
requestTimeout: 3000,
});
},
},
ElasticsearchService,
SearchService,
],
exports: [SearchService],
})
export class SearchModule {}
几个关键点:
@Global():SearchModule 在AppModule.imports里只 import 一次,任意模块就能直接注入SearchService,不用每个模块再imports: [SearchModule]。和 learnhub 的RedisModule一个套路(第四章讲过全局模块的取舍)。注意只exports: [SearchService]——ElasticsearchService和ES_CLIENT是内部实现,不对外暴露,业务代码只该认SearchService这一个入口。ES_CLIENT是个 string token(export const ES_CLIENT = 'ES_CLIENT',定义在search.constants.ts),不是 class token——因为@elastic/elasticsearch的Client是三方库的类,没法往上面挂 Nest 装饰器,用 string token 是惯例。注入时写@Inject(ES_CLIENT)(下面第三步ElasticsearchService里见)。maxRetries: 0+requestTimeout: 3000:这是踩过坑的选择。Client默认maxRetries: 3,每次重试带指数退避,ES 没起来时一次请求能卡 ~7 秒——本地跑 e2e、CI 起容器时这点延迟能把整套测试拖崩。设成 0 + 3 秒超时,ES 不在就快速失败,应用继续 boot。
注意:这个模块不在 onModuleInit 里强制 ping ES——learnhub 的设计是 ES 不可用时应用照样能起,只是搜索功能不可用(发帖、列表、详情照常)。下面 SearchService.onModuleInit 里也对应 try/catch 吞掉建索引的异常。
第三步:ElasticsearchService——对官方 Client 的薄封装
官方 @elastic/elasticsearch 8.x 的 API 参数和响应有些坑(hits.total 在 8.x 默认是 { value, relation } 对象而不是数字、highlight 的 fields 写法啰嗦、删不存在的文档抛 404),learnhub 把这些差异收敛进 ElasticsearchService,让业务层 SearchService 只关心「建索引、写文档、搜、删」,不直接碰 client:
// learnhub/src/modules/search/elasticsearch.service.ts
@Injectable()
export class ElasticsearchService {
private readonly logger = new Logger(ElasticsearchService.name);
constructor(@Inject(ES_CLIENT) private readonly client: Client) {}
/** 索引不存在则创建(带 mapping) */
async ensureIndex(index: string, mappings: Record<string, unknown>): Promise<void> {
const exists = await this.client.indices.exists({ index });
if (exists) return;
await this.client.indices.create({ index, mappings });
this.logger.log(`已创建索引 ${index}`);
}
/** 写 / 覆盖一条文档(按 id 幂等) */
async index<T>(index: string, id: string | number, document: T): Promise<void> {
await this.client.index({ index, id: String(id), document, refresh: false });
}
/** 删一条(不存在的会抛 404,这里吞掉) */
async delete(index: string, id: string | number): Promise<void> {
try {
await this.client.delete({ index, id: String(id) });
} catch (e) {
this.logger.debug(`删除 ${index}/${id} 忽略:${(e as Error).message}`);
}
}
async search<T = any>(
index: string,
query: Record<string, unknown>,
opts: { from?: number; size?: number; highlight?: string[] } = {},
): Promise<{ total: number; rows: Array<{ id: string; score: number; source: T; highlight?: Record<string, string[]> }> }> {
const result = await this.client.search({
index,
query,
from: opts.from ?? 0,
size: opts.size ?? 10,
highlight: opts.highlight
? { fields: Object.fromEntries(opts.highlight.map((f) => [f, {}])) }
: undefined,
});
const hits = (result as any).hits ?? {};
// 8.x total 默认是 { value, relation }
const total = typeof hits.total === 'number' ? hits.total : hits.total?.value ?? 0;
const rows = (hits.hits ?? []).map((h: any) => ({
id: h._id,
score: h._score,
source: h._source as T,
highlight: h.highlight,
}));
return { total, rows };
}
}
几个值得讲的点:
index()用id: String(id)——帖子在 MySQL 里id是 number,ES 的文档_id要字符串,这里转一道。refresh: false是默认值,意思是写完不强制刷新索引(强制refresh: true会让每次写都生成一个新 Lucene segment,性能差),生产用「写完异步刷、搜索近实时可见(默认 1 秒)」就够。delete()吞 404——删帖时 MySQL 那条已经没了,ES 里那条可能因为之前同步失败压根不存在,再调delete会抛 404。这里try/catch吞掉,让PostService.remove不用关心这种幂等细节。search()把 8.x 的hits.total归一化成数字:8.x 默认total是{ value: 42, relation: "eq" }(因为结果可能超过 10000,relation表eq还是gte),这里抽出来变成total: number,业务层不用判断。highlight的fields写成{ fields: { title: {}, content: {} } }——每个要高亮的字段值是个空对象(用默认配置),用Object.fromEntries让上层只传['title', 'content']数组就行。
这一层存在的意义就是「把 ES 客户端的版本差异、参数啰嗦、异常吞掉逻辑集中到一个文件」,业务层的 SearchService 因此能写得非常干净——下面看 SearchService。
第四步:SearchService——索引 mapping 与中文分词两个 analyzer 的分工
这是这一章的核心。SearchService 干三件事:启动时确保索引存在、帖子落库后同步进 ES、提供搜索。先看它认的「文档」长什么样,以及索引的 mapping:
// learnhub/src/modules/search/search.service.ts
/** 写入 ES 的帖子文档(扁平化、为检索优化) */
export interface PostSearchDoc {
id: number;
title: string;
content: string;
authorName?: string;
viewCount: number;
tags: string[];
createTime: string;
}
/** 帖子索引的 mapping:title/content 用 text(分词,全文检索),其余精确 */
const POST_MAPPINGS = {
properties: {
title: { type: 'text', analyzer: 'ik_max_word', search_analyzer: 'ik_smart' },
content: { type: 'text', analyzer: 'ik_max_word', search_analyzer: 'ik_smart' },
authorName: { type: 'keyword' },
viewCount: { type: 'integer' },
tags: { type: 'keyword' },
createTime: { type: 'date' },
},
};
这里每一行都有讲究:
PostSearchDoc 是「扁平文档」,不是 Post 实体。这是有意的设计——Post 是 TypeORM 实体,带 author / tags / comments / favoritedBy 这些关系对象,直接塞进 ES 既浪费(ES 不需要 comments),又制造模块循环依赖(search 模块 import post 模块拿实体)。learnhub 的做法是 search 模块只认 PostSearchDoc 这个扁平 interface,由 PostService 负责把 Post 拍平成文档(第七步看 indexPost 私有方法)——依赖方向是 post → search 单向,不循环。
text vs keyword 是新手最常踩的坑:
text:会分词,用于全文检索(title/content)。写入时分词进倒排索引,查询时match也分词。keyword:不分词,整个值当一个 token,用于精确匹配 / 聚合 / 排序(authorName/tags)。查询用term。
tags 为什么是 keyword 而不是 text?因为按标签筛选是「精确等于」(tag = 'Nest'),不是「标签里包含某词」。如果设成 text,term 查 'Nest' 会查不到(已经被分词器小写成 'nest' 存进倒排索引了)。新手常犯的错就是 term 查 text 字段查不到,根因就在这。
两个 analyzer 的分工是中文检索的关键:
analyzer: 'ik_max_word'(索引时用):细粒度切分,尽量多切词。「智能手机开发」切成「智能手机 / 智能 / 手机 / 开发」——倒排索引里多建几个词,查询时任何一个命中都能找到这条文档,高召回。search_analyzer: 'ik_smart'(查询时用):粗粒度切分,切得准。「智能手机开发」切成「智能手机 / 开发」——查询词少、意图准,少噪声。
思考:为什么索引时细、查询时粗?——索引时多切词是为了「让用户的查询更容易命中」:用户可能搜「手机」也可能搜「智能手机」,索引里两个词都有,都能命中这条文档。查询时少切词是为了「别把用户的查询拆散」:用户搜「智能手机开发」是想要这个整体,如果查询时也用 ik_max_word 切成「智能 / 手机 / 开发」,那「智能设备开发」这种无关文档也会因为「开发」命中被捞回来,结果噪声大。索引宽进、查询严出,是中文检索的常用配置。
createTime: { type: 'date' }:ES 的 date 类型默认支持 ISO 8601 字符串(post.createTime.toISOString()),不用额外配 format。支持按时间范围查询(range: { createTime: { gte: '...' } })和排序。
第五步:SearchService——onModuleInit / indexPost / removePost
把 SearchService 的方法分开看。先看启动和写入:
// learnhub/src/modules/search/search.service.ts
@Injectable()
export class SearchService implements OnModuleInit {
private readonly logger = new Logger(SearchService.name);
constructor(private readonly es: ElasticsearchService) {}
async onModuleInit(): Promise<void> {
// 非阻塞:ES 没起就跳过建索引,搜索功能暂不可用,但不影响应用启动
try {
await this.es.ensureIndex(POST_INDEX, POST_MAPPINGS);
} catch (e) {
this.logger.warn(`ES 不可用,全文检索暂关闭:${(e as Error).message}`);
}
}
/** 同步一条帖子进 ES(PostService 写库后调用) */
indexPost(doc: PostSearchDoc): Promise<void> {
return this.es.index(POST_INDEX, doc.id, doc);
}
/** 删帖时同步删除 ES 文档 */
removePost(id: number): Promise<void> {
return this.es.delete(POST_INDEX, id);
}
// search 方法见下一步
}
几个真实项目才会注意的点:
implements OnModuleInit:Nest 的生命周期钩子,所有 provider 初始化完后、HTTP 监听前调用。onModuleInit里调ensureIndex确保应用起来时post索引已经存在(不存在就按POST_MAPPINGS建)。好处是部署时不用人记得手动curl PUT /post建索引;ensureIndex内部先indices.exists判断,已存在直接 return,不会重复建,开销可忽略。try/catch吞异常:和第二步maxRetries: 0配套——ES 没起来时ensureIndex会快速失败,这里 catch 住只打 warn,应用照样启动。搜索接口被调到时会真的失败(es.search抛错),但发帖、列表、详情这些主流程完全不受影响。这是「ES 是可选的搜索副本」这个定位的代码体现。indexPost不是 async:注意方法签名是indexPost(doc): Promise<void>而不是async indexPost(...)——它直接return this.es.index(...),把 Promise 透传给调用方。调用方(PostService.indexPost)拿到这个 Promise 后.catch(() => undefined),实现 fire-and-forget(第七步看)。index按id幂等:ES 的PUT /post/_doc/:id如果:id已存在就覆盖、不存在就新建。所以create和update都调indexPost——create 是新建、update 是覆盖,同一个方法搞定。
POST_INDEX = 'post' 是个常量(在 search.constants.ts 里),意思是 ES 里这张「表」叫 post。和 MySQL 的 post 表同名是巧合(两边命名空间独立),不是必须。
第六步:SearchService——search 方法,bool + should 标题加权 ×3
这是这一章最值得逐行读的方法。search 接一个关键词、分页参数,返回带高亮的结果:
// learnhub/src/modules/search/search.service.ts
async search(q: string, page = 1, size = 10): Promise<PostSearchResult> {
const keyword = (q || '').trim();
if (!keyword) return { total: 0, rows: [] };
const query = {
bool: {
should: [
{ match: { title: { query: keyword, boost: 3 } } },
{ match: { content: keyword } },
],
minimum_should_match: 1,
},
};
return this.es.search<PostSearchDoc>(POST_INDEX, query, {
from: (page - 1) * size,
size,
highlight: ['title', 'content'],
});
}
把这个 DSL 翻译成人话:「在 title 或 content 里匹配关键词(title 命中算 3 分、content 命中算 1 分),至少命中一个;按分数从高到低返回,title 和 content 里命中的词用 <em> 标签包起来」。逐条拆:
bool.should:should是「或」——里面任一条件命中都算匹配。两个match分别查 title 和 content,意思是「标题或正文命中都行」。和must(与,全要命中)、must_not(都不命中)、filter(与 must 同语义但不算分、可缓存)对比着记。minimum_should_match: 1:should里至少要命中 1 条。只有两个match时这其实是默认值,显式写出来是为了可读性——业务改复杂时(比如加tags的 should),这个值的语义才会真的起作用。match: { title: { query: keyword, boost: 3 } }:match会对query分词(用字段配置的search_analyzer,也就是ik_smart),然后挨个词去 title 的倒排索引里查。boost: 3是标题加权 ×3——同样的词命中标题比命中正文得分高 3 倍。背后的逻辑:标题里就含关键词的文档,通常比正文里顺带提到的更相关。match: { content: keyword }:正文用默认 boost(1)。这里content: keyword是简写,等价于content: { query: keyword },用默认 analyzer 分词后查。from/size:分页。from = (page - 1) * size是偏移量、size是每页条数,和 MySQL 的LIMIT from, size一个意思。但 ES 的from越大越慢(要从每个分片取from + size条再合并排序),所以 ES 分页一般不超过 10000(深分页改用search_after游标)。highlight: ['title', 'content']:让 ES 在返回结果里附一份highlight字段,把命中的词包进<em>标签。前端拿到<em>智能</em>手机开发这种字符串直接渲染,命中词加粗。
if (!keyword) return { total: 0, rows: [] } 这行容易被忽略但很重要——空关键词直接返回空,不让 ES 跑 match_all 扫全量。如果不挡这个,用户传空查询时某些版本的 ES 会把空 match 解释成「匹配所有」,返回全索引数据,性能灾难。
最终相关性分数 = title 命中分 × 3 + content 命中分,再经 BM25(词频、文档长度归一化)算出来。ES 自动按这个分数降序返回,这就是 LIKE 给不了你的「按相关性排序」。
注意:ES 的相关性打分是「文档级」的,不是「业务级」——它知道「这条比那条相关」,但不知道「置顶帖该排在最前」。learnhub 现在的 search 没融合 pinned(置顶)字段,生产要做「置顶帖永远排第一 + 其余按相关性」会改用 function_score 包一层,给 pinned: true 的文档额外加一个常量分。这是后续优化方向,本章先看基础形态。
第七步:PostService——MySQL→ES 的同步钩子(fire-and-forget)
ES 不是真相源,真相在 MySQL。所以 learnhub 的同步模式是:PostService 写完 MySQL 后,异步双写一份到 ES。看 PostService 里三个写方法:
// learnhub/src/modules/post/post.service.ts
async create(dto: CreatePostDto, userId: number): Promise<Post> {
const post = await this.dataSource.transaction(async (manager) => {
const post = manager.create(Post, {
title: dto.title, content: dto.content,
author: { id: userId } as any,
});
if (dto.tagIds?.length) {
post.tags = await manager.findBy(Tag, { id: In(dto.tagIds) });
}
return manager.save(post);
});
this.amqp.publish('post.published', { postId: post.id, title: post.title, authorId: userId });
// ES 补充:双写一份到 ES 供全文检索(fire-and-forget)
this.indexPost(post);
return post;
}
async update(id: number, dto: UpdatePostDto, userId: number): Promise<Post> {
const post = await this.assertAuthor(id, userId);
Object.assign(post, { title: dto.title ?? post.title, content: dto.content ?? post.content });
if (dto.tagIds) post.tags = await this.tagRepo.findBy({ id: In(dto.tagIds) });
const saved = await this.postRepo.save(post);
// ES 补充:更新后同步(同 id 覆盖即更新)
this.indexPost(saved);
return saved;
}
async remove(id: number, userId: number): Promise<void> {
await this.assertAuthor(id, userId);
await this.postRepo.delete(id);
// ES 补充:删帖同步删除 ES 文档(失败不影响主流程)
this.searchService.removePost(id).catch(() => undefined);
}
注意三个同步调用都在 dataSource.transaction(...) 外面,并且都是 fire-and-forget。看 indexPost 这个私有方法是怎么实现 fire-and-forget 的:
// learnhub/src/modules/post/post.service.ts
/** 把帖子拍平成文档同步进 ES(fire-and-forget,失败只忽略) */
private indexPost(post: Post): void {
this.searchService
.indexPost({
id: post.id,
title: post.title,
content: post.content,
authorName: post.author?.username,
viewCount: post.viewCount ?? 0,
tags: (post.tags ?? []).map((t) => t.name),
createTime: post.createTime?.toISOString(),
})
.catch(() => undefined);
}
返回类型是 void(不是 Promise<void>),方法内部 this.searchService.indexPost(...).catch(() => undefined)——调了但不等它完成、不向外抛错。这就是「fire-and-forget」。
思考:为什么 indexPost 放在事务外面、且 fire-and-forget?——回到第八章讲过的事务边界原则:事务只该包住「必须一起成功或一起失败」的库操作。帖子创建的原子范围是「插 post + 绑 tags」——这两步必须在 MySQL 里原子完成。ES 同步是「派生数据」,不属于帖子创建的原子范围:如果 ES 暂时不可用就把已经成功的帖子创建也回滚,等于让 ES 的可用性拖垮主业务,事务边界画错了。同理,ES 写失败也不能让发帖接口返回 500——帖子已经在 MySQL 里了,搜索索引稍后重建就行。所以 .catch(() => undefined) 吞掉异常,发帖照样成功。MySQL 是真相源、ES 是可重建的搜索副本——这条原则在代码里就体现为这一行 fire-and-forget。
注意:fire-and-forget 意味着 MySQL 和 ES 之间存在短暂的最终一致性窗口——刚发完帖立刻搜,可能搜不到(ES 还没刷盘、或同步还没完成)。这对搜索场景是可接受的(用户不会发完帖 1 秒内就去搜),但绝不能把这个模式用在真相数据上——账单、订单这些必须强一致的写,绝不能 fire-and-forget 到一个派生存储。
indexPost 里把 Post 实体拍平成 PostSearchDoc 这一步也值得看一眼——authorName: post.author?.username 只取作者名(不存整个 author 对象)、tags: post.tags.map(t => t.name) 只存标签名数组。这是为检索优化的扁平结构:ES 不需要 comments、不需要 favoritedBy,只存搜索和展示列表要用的字段。create 之后 post.author 可能是个 stub(只有 id 没有 username),所以用可选链 ?.——这种 create 后 authorName 缺失的情况,等用户 update 这条帖子时会被补全(update 走 assertAuthor,author 已加载)。
ES 数据丢了怎么重建:因为 MySQL 是真相源,ES 整个索引丢了能从 MySQL 全量重建——写一个一次性脚本,postRepo.find() 把所有帖子拉出来,挨个调 searchService.indexPost(...)。learnhub 的 SearchService 已经提供了 indexPost,重建脚本只是个循环调用。这就是「可重建的搜索副本」的字面意义——丢得起,所以敢 fire-and-forget。
第八步:搜索接口——GET /api/v1/posts/search
Service 写完,Controller 里就一行委托:
// learnhub/src/modules/post/post.controller.ts
@Public()
@Get('search')
@ApiOperation({ summary: '全文检索帖子(ES,公开)' })
search(
@Query('q') q: string,
@Query('page') page = 1,
@Query('size') size = 10,
) {
// ES 补充:标题+正文全文检索,标题加权+高亮
// 注意:必须声明在 @Get(':id') 之前,否则 'search' 会被 :id 匹配
return this.postService.search(q ?? '', Number(page), Number(size));
}
两个真实项目才会注意的点(第八章讲过,这里再强调):
@Public():搜索是公开接口,不需要登录。learnhub 全局守卫默认要登录,@Public()标记的接口跳过守卫(机制第十一章细讲)。搜索做成公开是因为:让没登录的用户也能搜,降低使用门槛,符合内容平台的常规设计。- 路由顺序:
@Get('search')必须写在@Get(':id')前面。Nest 路由按声明顺序匹配,:id是动态参数会吃掉search这个字面量——写反了,访问/api/v1/posts/search会进detail,拿"search"去Number()变NaN,查不到返回 404。这种错误非常难发现。
PostService 里 search 方法也是个一行委托:
// learnhub/src/modules/post/post.service.ts
/** 全文检索(标题 + 正文,标题加权 + 高亮) */
search(q: string, page: number, size: number): Promise<PostSearchResult> {
return this.searchService.search(q, page, size);
}
PostService 注入 SearchService(在构造函数里),自身不实现搜索逻辑——它只是 SearchService 的一个壳,让 Controller 不直接依赖 SearchService(保持「Controller 只认 PostService」的分层惯例)。
第九步:跑起来
cd learnhub
docker compose -f docker/docker-compose.yml up -d elasticsearch # 起 ES
docker exec learnhub-elasticsearch ./bin/elasticsearch-plugin install --batch \
https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-8.11.1.zip
docker restart learnhub-elasticsearch # 装 IK 后重启
npm run start:dev # 起 Nest
发帖、改帖都会异步同步进 ES(应用起来时 onModuleInit 已经建好 post 索引)。验证搜索:
# 1. 发两条帖子(登录拿 token 见第八章)
curl -X POST http://localhost:3000/api/v1/posts \
-H "Content-Type: application/json" -H "Authorization: Bearer eyJ..." \
-d '{"title":"Nest 全栈开发实战","content":"这一篇讲 Nest+TypeORM+ES..."}'
curl -X POST http://localhost:3000/api/v1/posts \
-H "Content-Type: application/json" -H "Authorization: Bearer eyJ..." \
-d '{"title":"前端工程师进阶","content":"React 性能优化和工程化..."}'
# 2. 搜「Nest」(标题命中,加权 ×3,排前面)
curl 'http://localhost:3000/api/v1/posts/search?q=Nest'
# {"total":1,"rows":[{"id":"1","score":1.74,"source":{...},"highlight":{"title":["<em>Nest</em> 全栈开发实战"]}}]}
# 3. 直连 ES 看分词和高亮细节
curl 'http://localhost:9200/post/_search?pretty' -H 'Content-Type: application/json' \
-d '{"query":{"match":{"title":"全栈开发"}},"highlight":{"fields":{"title":{}}}}'
返回里能看到 highlight.title 把命中的词包在 <em> 里、score 按相关性排过序——这就是 LIKE 给不了的东西。
这一章的成果
- 理解倒排索引原理、为什么 MySQL
LIKE '%关键词%'不行(不走索引、不能排序、不能中文分词)——补上了第八章findMany留的扣。 - 用 learnhub 真实的
SearchService吃透四件事:mapping 的 text vs keyword(分词 vs 精确)、IK 两个 analyzer 的分工(索引ik_max_word宽进、查询ik_smart严出)、bool + should 标题加权 ×3(相关性排序)、highlight 高亮。 - 理解
@Global SearchModule+ES_CLIENTstring token +maxRetries: 0非阻塞启动的写法(ES 不可用时应用照常起)。 - 在
PostService的真实代码里看清「MySQL→ES 异步双写」的同步钩子:indexPostfire-and-forget、放在事务外面——以及为什么这样设计(事务边界 + ES 是可重建副本)。
常见问题
- 中文搜不准:没装 IK 或 IK 版本和 ES 不一致(必须完全一致)。用
_analyze接口验证分词:POST /_analyze {"analyzer":"ik_max_word","text":"..."}。 term查 text 字段查不到:text 已被分词(小写、切词)存进倒排索引,term拿原始词精确匹配自然查不到。精确字段(标签、状态、作者名)用keyword类型。match和term区别:match会分词(用于 text 全文检索)、term不分词(用于 keyword 精确匹配)。新手别对 text 字段用term。- ES 启动慢/占内存:JVM 堆给机器内存一半、不超 32G;学习用 512m 够。
docker compose up后 ES 起来要 30 秒左右,看 healthcheck 通过再启动 Nest。 - ES 没起来 Nest 也起不来:检查
maxRetries是不是被改大了——learnhub 特意设成maxRetries: 0+requestTimeout: 3000,就是为了让 ES 缺席时 Nest 快速启动。 - MySQL 和 ES 数据不一致怎么办:这是「最终一致」的设计,短暂窗口可接受。完全不一致(比如 ES 挂了一段时间漏了很多同步)就从 MySQL 全量重建索引——
postRepo.find()循环调searchService.indexPost(...)。
下一章讲 Etcd / Nacos——配置中心与注册中心,微服务里管配置和服务发现的基础设施。