跳转到主要内容

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 算相关性排序、合并、高亮。

为什么需要它:第八章 findManyLIKE '%关键词%' 三个硬伤——

  1. 不走索引:前导 % 让 B+Tree 索引失效,逐行扫描,表一大就慢;
  2. 不能排序LIKE 只有「匹配 / 不匹配」,没有「这条比那条更相关」的概念,结果顺序只能靠 ORDER BY createTime 之类的硬规则;
  3. 没有中文分词:搜「智能手机」找不到只含「手机」的帖子,也找不到「智能设备」这种语义相关——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 keywordIK 中文分词两个 analyzer 的分工bool + should 标题加权 ×3 的搜索 DSLhighlight 高亮
  • 理解 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]——ElasticsearchServiceES_CLIENT 是内部实现,不对外暴露,业务代码只该认 SearchService 这一个入口。
  • ES_CLIENT 是个 string tokenexport const ES_CLIENT = 'ES_CLIENT',定义在 search.constants.ts),不是 class token——因为 @elastic/elasticsearchClient 是三方库的类,没法往上面挂 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,relationeq 还是 gte),这里抽出来变成 total: number,业务层不用判断。
  • highlightfields 写成 { 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'),不是「标签里包含某词」。如果设成 textterm'Nest' 会查不到(已经被分词器小写成 'nest' 存进倒排索引了)。新手常犯的错就是 termtext 字段查不到,根因就在这。

两个 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(第七步看)。
  • indexid 幂等:ES 的 PUT /post/_doc/:id 如果 :id 已存在就覆盖、不存在就新建。所以 createupdate 都调 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.shouldshould 是「或」——里面任一条件命中都算匹配。两个 match 分别查 title 和 content,意思是「标题或正文命中都行」。和 must(与,全要命中)、must_not(都不命中)、filter(与 must 同语义但不算分、可缓存)对比着记。
  • minimum_should_match: 1should 里至少要命中 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 给不了的东西。

这一章的成果

  1. 理解倒排索引原理、为什么 MySQL LIKE '%关键词%' 不行(不走索引、不能排序、不能中文分词)——补上了第八章 findMany 留的扣。
  2. 用 learnhub 真实的 SearchService 吃透四件事:mapping 的 text vs keyword(分词 vs 精确)、IK 两个 analyzer 的分工(索引 ik_max_word 宽进、查询 ik_smart 严出)、bool + should 标题加权 ×3(相关性排序)、highlight 高亮
  3. 理解 @Global SearchModule + ES_CLIENT string token + maxRetries: 0 非阻塞启动的写法(ES 不可用时应用照常起)。
  4. PostService 的真实代码里看清「MySQL→ES 异步双写」的同步钩子:indexPost fire-and-forget、放在事务外面——以及为什么这样设计(事务边界 + ES 是可重建副本)。

常见问题

  • 中文搜不准:没装 IK 或 IK 版本和 ES 不一致(必须完全一致)。用 _analyze 接口验证分词:POST /_analyze {"analyzer":"ik_max_word","text":"..."}
  • term 查 text 字段查不到:text 已被分词(小写、切词)存进倒排索引,term 拿原始词精确匹配自然查不到。精确字段(标签、状态、作者名)用 keyword 类型。
  • matchterm 区别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——配置中心与注册中心,微服务里管配置和服务发现的基础设施。