1. 搜索服务概述:搜索服务在微服务架构中的定位、Elasticsearch核心概念、NestJS与ES集成方案对比

1.1 搜索服务在微服务架构中的定位

在微服务架构中,搜索服务通常作为数据查询的专用枢纽,承担着跨服务、跨数据源的全文检索与聚合分析职责。与传统的单体应用不同,微服务架构下的数据被分散在多个独立的服务数据库中,直接跨库 JOIN 查询既不现实,也会破坏服务自治原则。搜索服务通过事件驱动或 CDC(Change Data Capture)机制,将各业务域的数据同步至 Elasticsearch 集群,对外提供统一的搜索入口。

核心职责:
  • 数据汇聚:从订单、商品、用户等多个微服务同步结构化与非结构化数据。
  • 索引管理:维护索引映射(Mapping)、分词器(Analyzer)与分片策略。
  • 查询封装:对外暴露 RESTful / gRPC 接口,屏蔽 ES 查询 DSL 的复杂性。
  • 高可用保障:通过集群部署与读写分离,确保搜索链路的 SLA。

下图展示了搜索服务在微服务架构中的典型位置:

微服务架构中的搜索服务定位 商品微服务 Product Service 订单微服务 Order Service 用户微服务 User Service 更多服务 More Services 数据同步层(CDC / 消息队列 / 定时任务) Kafka / RabbitMQ / Debezium NestJS 搜索服务 索引管理 · 查询DSL封装 · 结果聚合 Elasticsearch 集群 索引分片 · 副本 · 近实时搜索 前端 / App 搜索请求 搜索服务 不直接操作业务DB 只与ES集群交互
架构建议:搜索服务应保持无状态,通过消息队列接收数据变更事件,避免与业务服务产生同步耦合。NestJS 的模块化与依赖注入特性非常适合构建这种事件驱动的搜索中间件。

1.2 Elasticsearch 核心概念

Elasticsearch 是一个基于 Lucene 的分布式搜索与分析引擎。理解以下核心概念是封装搜索服务的前提:

概念 说明 类比关系型数据库
索引(Index) 存储具有相似特征的文档集合,是搜索的最小逻辑单元 Database / Table
映射(Mapping) 定义索引中字段的数据类型、分词器、是否索引等规则 Schema / DDL
文档(Document) JSON 格式的可被索引的基本数据单元 Row
分片(Shard) 索引被水平拆分为多个分片,分布在集群的不同节点上 分区表
副本(Replica) 每个分片的冗余拷贝,用于高可用与查询负载均衡 主从复制
分词器(Analyzer) 将文本拆分为词项(Token)的组件,包含字符过滤器、分词器、Token 过滤器 全文索引的解析规则
查询 DSL 基于 JSON 的查询语言,支持全文搜索、聚合、过滤、排序等 SQL

下图展示了 Elasticsearch 索引与分片的物理结构:

Elasticsearch 索引与分片结构 索引 products 分片组(Shards + Replicas) 主分片 0 Node A 主分片 1 Node B 主分片 2 Node C 副本 0 Node B 副本 1 Node C 副本 2 Node A 主分片负责写入与索引 副本分片负责查询与容灾 副本不会与主分片在同一节点
映射设计陷阱:ES 的 Mapping 一旦创建,字段类型不可修改(只能新增字段)。在生产环境中,务必在索引创建前通过 NestJS 的 @nestjs/elasticsearch 模块预先定义 Mapping,避免后期重建索引带来的数据迁移成本。

1.3 NestJS 与 Elasticsearch 集成方案对比

NestJS 生态中集成 Elasticsearch 主要有三种方案,各有适用场景:

方案 底层库 封装程度 适用场景 维护成本
@nestjs/elasticsearch 官方 @elastic/elasticsearch 中等(提供 Module/Service 注入) 标准 CRUD、简单搜索、需要官方最新特性 低
@nestjs/bull + elasticsearch Bull 队列 + 官方客户端 高(异步写入、队列缓冲) 高并发写入、数据同步、CDC 场景 中
自定义 Repository 模式 官方客户端 + 自定义抽象层 高(完全控制 DSL 生成与映射管理) 复杂查询 DSL、多索引聚合、定制化搜索服务 高
本课程选择:我们将采用自定义 Repository 模式,在 @nestjs/elasticsearch 的基础上构建 SearchRepository 抽象层。这样既能利用 NestJS 的模块化注入,又能完全掌控查询 DSL 的生成逻辑,适合生产级搜索服务的定制需求。

三种方案的代码结构对比:

// 方案一:@nestjs/elasticsearch 直接注入
@Injectable()
export class ProductSearchService {
  constructor(
    @Inject(ELASTICSEARCH_CLIENT)
    private readonly esClient: ElasticsearchClient,
  ) {}

  async search(keyword: string) {
    return this.esClient.search({
      index: 'products',
      body: { query: { match: { name: keyword } } },
    });
  }
}

// 方案二:自定义 Repository 模式(本课程采用)
@Injectable()
export class ProductSearchRepository {
  constructor(
    @Inject(ELASTICSEARCH_CLIENT)
    private readonly esClient: ElasticsearchClient,
  ) {}

  async search(dto: SearchProductDto): Promise<SearchResult> {
    const queryBody = this.buildQueryDSL(dto);
    const response = await this.esClient.search({
      index: 'products',
      body: queryBody,
    });
    return this.mapResponse(response);
  }

  private buildQueryDSL(dto: SearchProductDto): Record<string, any> {
    // 封装复杂的 bool / multi_match / filter 逻辑
    return {
      query: {
        bool: {
          must: dto.keyword
            ? [{ multi_match: { query: dto.keyword, fields: ['name', 'description'] } }]
            : [{ match_all: {} }],
          filter: dto.categoryId
            ? [{ term: { categoryId: dto.categoryId } }]
            : [],
        },
      },
      sort: [{ price: { order: dto.sortOrder || 'asc' } }],
    };
  }

  private mapResponse(response: SearchResponse): SearchResult {
    return {
      total: response.hits.total.value,
      items: response.hits.hits.map((hit) => ({
        id: hit._id,
        score: hit._score,
        source: hit._source,
      })),
    };
  }
}
选型建议:如果团队刚接触 ES,可以先从 @nestjs/elasticsearch 起步;当搜索逻辑变得复杂(多索引联合、聚合分析、自定义评分)时,再逐步迁移到自定义 Repository 模式。本课程直接以最终形态——自定义 Repository 模式——作为教学基线,让你一步到位掌握生产级封装技巧。