接手过一个商品搜索项目,MySQL单表刚过百万,LIKE '%关键词%'的查询已经能把接口拖到两秒开外。后来把搜索独立出来放到ElasticSearch里,同样的关键词,返回结果在百毫秒级,还顺手解决了拼音搜索、多字段权重、聚合统计这些数据库很难优雅实现的需求。这篇指南记录的是我在NestJS项目里从零接入ElasticSearch的完整过程,环境准备、索引设计、查询DSL、数据同步、上线调优都有,适合刚接触搜索组件、想在Nest项目里快速落地ElasticSearch的后端开发者参考。
1. 为什么业务一复杂,MySQL的LIKE查询就顶不住了
1.1 一个很典型的搜索痛点场景
假设你维护一个商品系统,早期数据量不大,搜索功能就是一条 SQL:
SELECT * FROM products WHERE name LIKE '%手机%';数据量到几十万条时,这条语句的表现就开始变得难看。LIKE '%关键词%'这种写法有一个天然问题:百分号在最前面,MySQL 的 B+ 树索引根本帮不上忙,只能走全表扫描。每个请求都要把所有商品名称翻一遍,CPU 和 IO 的压力全堆在数据库上。
更麻烦的是排序和匹配质量。用户搜"手机壳",你真的想把所有名称里带"手机"和"壳"的数据都返回吗?按什么顺序返回?MySQL 的ORDER BY只能按销量、价格、时间这类结构化字段,没法按"和搜索词的相关程度"排序。想实现"完全匹配的排前面、部分匹配的排后面"这种效果,SQL 得写出一大串CASE WHEN,又丑又难维护。
中文场景还有一个隐藏痛点:分词。"小米手机"这个短语,用户可能输入"小米"也可能输入"小米手机",甚至输入"小 米 手 机"(带空格)。MySQL 的 LIKE 只能做连续的字符串匹配,对于"今天买什么手机好"这种自然语言,数据库无从下手。而 ElasticSearch 这类搜索引擎天生就是为处理自然语言查询设计的,分词、同义词、拼音、模糊纠错都是它的常规能力。
1.2 倒排索引:搜索引擎和数据库查询的分水岭
要理解 ElasticSearch 为什么快,得先弄明白它和关系型数据库的根本差异。MySQL 用的是正向索引:一行数据对应一个记录,搜索时需要遍历所有行,逐行判断是否包含关键词。而 ElasticSearch 用的是倒排索引:它把文档拆成一个个词条,建立"词条 -> 文档列表"的映射。
我用一个生活化的例子来解释。想象你有一堆书籍,MySQL 的查询方式是"逐本翻开,看某一页里有没有要找的词";ElasticSearch 的方式是"提前把所有书做成一个关键词目录,目录上写着'苹果出现过在第1本、第7本、第23本'"。查的时候直接翻目录,完全不用碰正文。这也是为什么倒排索引在搜索场景下能快到毫秒级。
理解了这层原理,你就明白为什么 ElasticSearch 适合做搜索、MySQL 适合做事务存储,两者是互补关系而非替代关系。NestJS 项目里最常见的架构就是:MySQL 负责业务数据落地,ElasticSearch 负责搜索和分析,两边通过同步机制保持数据一致。下面正式开始接入。
2. 环境准备与依赖选型:官方模块还是原生客户端
2.1 版本兼容性是最容易被忽略的第一道坎
在动手写代码前,先确认三样东西的版本:Node.js 版本、NestJS 版本、ElasticSearch 服务端版本。这三者的兼容性直接影响后面是否会出现玄学报错。
以我当前的实践经验为例,Node.js 推荐 18 及以上,NestJS 推荐 10 及以上,ElasticSearch 服务端推荐 8.x。对应的 npm 包版本是@nestjs/elasticsearch的 8.x 和 elasticsearch 客户端的 8.x。如果你用的是 NestJS 9 或更早版本,也能跑,但建议还是把 NestJS 升级到当前主流版本,因为@nestjs/elasticsearch最近的版本对依赖注入和模块生命周期的处理更完善。
很多新手容易在这里踩坑:本地 ElasticSearch 是 7.x,却装了 8.x 的客户端,一请求就报Content-Type或version不兼容错误。我的建议是,客户端的次版本号尽量与服务端保持一致,或者至少保证主版本一致。你可以在启动项目后先做一个最简单的ping测试确认连通性,再开始写业务代码。
2.2 两种接入方式的取舍
NestJS 接入 ElasticSearch 有两条路:
第一种是使用官方封装模块。安装@nestjs/elasticsearch后,直接在AppModule里ElasticsearchModule.register(),然后通过构造函数注入ElasticsearchService就能用。优点是省事,官方把连接池管理、请求重试、日志集成都处理好了,而且能享受 NestJS 的依赖注入特性。
第二种是使用原生elasticsearch客户端,自己创建一个 Provider 来管理连接。优点是灵活性更高,你可以完全控制连接的创建和销毁逻辑,做一些官方模块不支持的高级定制;缺点是需要自己处理生命周期、错误重试、模块全局共享这些琐碎问题。
个人建议:如果你的项目只是一般业务搜索,没有特别特殊的连接管理需求,直接用官方模块。我在生产环境里用官方模块跑了很久,没有遇到过需要退回原生客户端的情况。只有当你需要在同一个 Nest 应用里连接多个不同的 ElasticSearch 集群、且每个集群的认证方式不同的时候,才值得考虑自己封装 Provider。
2.3 模块注册和环境变量管理
安装依赖:
npm install @nestjs/elasticsearch elasticsearch在AppModule中注册模块。生产环境我强烈建议用异步注册方式,把连接配置放在环境变量里,避免把地址和密码写死在代码中:
import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { ElasticsearchModule } from '@nestjs/elasticsearch'; @Module({ imports: [ ConfigModule.forRoot(), ElasticsearchModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (configService: ConfigService) => ({ node: configService.get('ELASTICSEARCH_NODE'), auth: { username: configService.get('ELASTICSEARCH_USERNAME'), password: configService.get('ELASTICSEARCH_PASSWORD'), }, maxRetries: 3, requestTimeout: 10000, }), }), ], }) export class AppModule {}对应的.env示例:
ELASTICSEARCH_NODE=http://localhost:9200 ELASTICSEARCH_USERNAME=elastic ELASTICSEARCH_PASSWORD=your_password这里有几个容易被忽略的点。maxRetries代表请求失败时的重试次数,默认值是 2,对搜索这种读多写少的场景,设为 3 到 5 能显著降低网络抖动带来的失败率。requestTimeout是单个请求的超时时间,如果你们某些聚合查询特别重,可以单独调大,但全局 10 秒已经够用。还有一点,如果 ElasticSearch 服务端没开安全认证,可以省略auth配置,不过生产环境我建议还是开启认证,至少用 ElasticSearch 自带的elastic用户加上强密码。
注册完模块,你就可以在任意 Service 中注入ElasticsearchService了:
import { Injectable } from '@nestjs/common'; import { ElasticsearchService } from '@nestjs/elasticsearch'; @Injectable() export class SearchService { constructor(private readonly elasticsearchService: ElasticsearchService) {} async checkHealth(): Promise<boolean> { try { const res = await this.elasticsearchService.ping(); return res; } catch (error) { return false; } } }先写一个ping健康检查,确认整条链路是通的,再往下走。
3. 第一个可运行的 Demo:索引、写入、搜索一次打通
3.1 索引映射设计:别让ES替你瞎猜字段类型
ElasticSearch 的"索引"相当于 MySQL 的一张表,但它的字段类型设计比 MySQL 更讲究。最基础的原则是:凡是需要参与全文搜索的字段用text,凡是需要精确匹配、过滤、排序、聚合的字段用keyword。
很多新手上来就PUT /products一把梭,让 ElasticSearch 自己根据第一条数据猜测字段类型。这种动态映射(dynamic mapping)在开发环境很方便,但生产环境是隐患。举例说明:商品名称name字段,如果第一条数据是"Apple iPhone 15",ES 会把它映射为text,这没问题;但如果某个字段的值是123456,ES 会判断成long类型,后续你想对它做全文搜索,就会报类型错误,而且 mapping 一旦建立,普通方式根本改不了。
正确做法是手动定义 mapping。以商品搜索为例,我通常会这样设计:
await this.elasticsearchService.indices.create({ index: 'products', mappings: { properties: { id: { type: 'keyword' }, name: { type: 'text', analyzer: 'ik_max_word' }, category: { type: 'keyword' }, brand: { type: 'keyword' }, price: { type: 'float' }, salesCount: { type: 'integer' }, createdAt: { type: 'date' }, description: { type: 'text', analyzer: 'ik_max_word' }, }, }, });ik_max_word是常见的中文分词器配置,需要提前在 ElasticSearch 服务端安装对应插件。如果没有装分词插件,可以用默认的standard分词器,但中文搜索效果会比较差,经常把整句话当成一个词或按单字切分。
设计 mapping 时有一个原则:不要过度设计。如果某个字段只有内部系统看,不需要给用户搜索,就用keyword或干脆关闭索引"index": false,这样可以省不少存储和内存。
3.2 文档写入:单个写入和批量写入
向 ElasticSearch 写入一条文档,最简单的做法是indexAPI:
await this.elasticsearchService.index({ index: 'products', id: 'product_123', document: { id: 'product_123', name: '小米手机', category: '数码', brand: '小米', price: 1999, salesCount: 2300, createdAt: new Date().toISOString(), description: '一款性价比很高的智能手机', }, });注意在 8.x 客户端中,数据字段名是document,而在 7.x 中叫body。如果你看到别人的代码用的body,有可能对方是 7.x 客户端,迁移到 8.x 时这里会踩坑。
批量写入用bulkAPI,性能远比循环调用index好。批量写入的标准套路是把每条文档操作和数据交替组成扁平数组:
const operations = products.flatMap((doc) => [ { index: { _index: 'products', _id: doc.id } }, doc, ]); await this.elasticsearchService.bulk({ operations });我实测过,单条循环写入 1 万条数据大约需要几十秒,用 bulk 一次写入 5000 条只需 1 到 2 秒。批量大小建议控制在 5000 到 10000 条,或者以数据总大小 5MB 到 15MB 为界,太大反而会触发 ES 服务端的批量限制报错。
3.3 跑通第一个搜索接口
数据写进去之后,下一步就是让用户搜得到。新建一个SearchService,实现一个简单的关键词搜索接口:
async searchProducts(keyword: string, page: number = 1, size: number = 10) { const from = (page - 1) * size; const result = await this.elasticsearchService.search({ index: 'products', from, size, query: { multi_match: { query: keyword, fields: ['name^3', 'description'], }, }, }); return { total: result.hits.total, list: result.hits.hits.map((item) => item._source), }; }这里我用了multi_match,同时搜索name和description两个字段,字段后面的^3表示权重:名称匹配的文档,相关度分数乘以 3,这样"名称里有关键词"的结果会排在"描述里有关键词"的结果前面。page和size是常见的分页参数,对应 ES 的from和size。
result.hits.total在 ES 7.x 之后返回的是一个对象,形如{ value: 100, relation: 'eq' },直接把这个对象传给前端有些多余,我会只取value。上面代码里我直接返回了对象,你在实际业务中需要注意这个细节,按需处理成数字或直接取result.hits.total.value。
跑通这个过程后,你已经具备了最基础的能力:写入数据、按关键词搜索、分页返回。但这只是开始,真实业务需求远不止这么简单。
4. 查询DSL实战:用bool查询组合解决真实搜索需求
4.1 must、should、filter怎么配合
真实世界的搜索几乎没有单条件查询。用户搜"手机",可能同时要求品牌是"小米"、价格在 1000 到 2000 之间、还要排除已下架商品。这种多条件组合在 ElasticSearch 中是用bool查询实现的,它包含四个重要子句:
must:必须匹配,会参与相关度评分should:应该匹配,不强制,匹配得越多,分数越高filter:必须匹配,但不参与评分,可以走缓存must_not:必须不匹配,也不参与评分
一个实际的例子:搜索名称或描述中包含"手机",品牌是小米或华为,价格在 1000 到 3000 之间,排除已下架商品:
await this.elasticsearchService.search({ index: 'products', query: { bool: { must: [ { multi_match: { query: '手机', fields: ['name^3', 'description'], }, }, ], filter: [ { terms: { brand: ['小米', '华为'] } }, { range: { price: { gte: 1000, lte: 3000 } } }, { term: { status: 'on_sale' } }, ], }, }, });这里的关键点是把brand、price、status这类结构化条件放到filter而不是must中。为什么?因为filter不参与相关度评分,ElasticSearch 会为它单独做缓存,下一次同样的过滤条件可以直接命中缓存,查询速度会快很多。而must中的全文搜索条件则需要每次都计算相关度分数,无法缓存。
should的典型用法是实现"加权搜索"。比如用户搜索"手机壳",你希望名称完全匹配的结果排最前,同时品牌为"某某"(某个在推广的品牌)的结果也能靠前:
{ bool: { must: [{ match: { name: '手机壳' } }], should: [{ term: { brand: '推广品牌' } }], } }这种写法下,带"推广品牌"的结果会在同等相关度的情况下优先展示,相当于一种隐性的广告位。
4.2 分页、高亮、排序:直接可用的封装
搜索列表页最基本的三个能力就是分页、高亮、排序。分页我们已经用from和size实现了,但要记住一个限制:ES 的默认max_result_window是 10000,也就是说from + size超过 10000 会直接报错。如果业务上确实需要翻很深的分页,建议换search_after方案,后面调优部分会细说。
高亮是搜索场景的刚需。用户在结果列表里看到关键词被标红,体验会好很多。ES 的高亮 API 很直接:
const result = await this.elasticsearchService.search({ index: 'products', query: { match: { name: '手机' }, }, highlight: { pre_tags: ['<b>'], post_tags: ['</b>'], fields: { name: {}, description: {}, }, }, }); // 每个 hit 里会有 highlight 字段,格式如下 // { // "name": ["<b>手机</b>壳"], // "description": ["一款<b>手机</b>周边配件"] // }前端拿到highlight字段后直接渲染即可。这里有个小坑:pre_tags和post_tags默认值是<em>标签,如果前端用的富文本组件不支持这个标签,样式会丢失,建议在服务端统一设置成前端好样式化的标签。
排序方面,ES 默认按相关度分数排序。实际业务中往往是相关度和其他业务字段的混合排序,比如"相关度优先,同分时销量高的靠前":
await this.elasticsearchService.search({ index: 'products', query: { match: { name: '手机' } }, sort: [ { _score: { order: 'desc' } }, { salesCount: { order: 'desc' } }, ], });注意:如果同时指定了sort,ES 默认的_score排序会被覆盖,所以需要显式把_score写进sort数组才能保证相关度仍然生效。
4.3 聚合查询:搜索之外的另一大价值
ElasticSearch 的另一大能力是聚合(aggregations),它能帮你在搜索结果上进行统计。很多时候这种统计需求如果在 MySQL 里做,会写出一大串GROUP BY加多个临时表,而在 ES 里一个aggs就能搞定。
比如商品搜索页左侧的类目筛选,通常要显示"每个类目下的商品数量"。针对当前搜索结果做类目聚合:
const result = await this.elasticsearchService.search({ index: 'products', query: { match: { name: '手机' } }, aggs: { byCategory: { terms: { field: 'category', size: 20 }, }, }, }); // 结果在 result.aggregations.byCategory.buckets // [ // { "key": "数码", "doc_count": 120 }, // { "key": "配件", "doc_count": 86 } // ]size是聚合返回的桶数量上限,默认只返回 10 个,所以如果你想知道全部类目的分布,要按需调大。这里还涉及一个知识点:聚合用的字段必须是keyword类型或者fielddata开启的text字段,直接用text字段聚合会报错。所以我在设计 mapping 时会把category、brand这类字段设成keyword,就是为了既能过滤又能聚合。
聚合还有更高级的玩法,比如价格区间统计:
aggs: { priceRanges: { range: { field: 'price', ranges: [ { to: 1000 }, { from: 1000, to: 3000 }, { from: 3000 }, ], }, }, }这类数据直接喂给前端的筛选栏,搜索体验会非常顺畅。聚合是 ElasticSearch 最值得挖掘的能力之一,建议你把官方文档里的 Aggregations 部分通读一遍,很多报表需求都能在搜索接口里顺带完成,不用再起服务跑统计任务。
5. 数据同步:MySQL到ElasticSearch的一致性问题
5.1 双写:最快见效但有代价
把 ElasticSearch 接入 NestJS 后,最容易想到的方案就是在写业务数据的同时也写一份到 ES,这就是"双写"。
async createProduct(createProductDto: CreateProductDto) { // 1. 写入 MySQL const product = await this.productRepository.save(createProductDto); // 2. 写入 ElasticSearch await this.searchService.indexProduct(product); return product; }这个方案的优点是实时性好,代码逻辑直观。但它的缺点也很明显:两边的写入没有事务保证。MySQL 写成功了,ES 写入失败,数据就不一致;ES 写成功了,MySQL 回滚了,同样不一致。生产环境里网络抖动、ES 集群临时不可用都会导致这种问题。
我的建议是,双写可以用于实时性要求高、且能容忍短时间不一致的场景,但必须配上补偿机制。一个务实的补偿方式是引入消息队列:MySQL 写入成功后,发一条消息到队列,由消费者异步写 ES。这样即使 ES 暂时不可用,消息也不会丢,等 ES 恢复后消费者重试就能补齐数据。这里的核心思路是把"双写一致性"转成"最终一致性"。
5.2 日志订阅增量同步:生产级方案
双写方案本质上是业务代码里同步调用,侵入性强。更生产级的做法是通过订阅 MySQL 的 binlog 日志做增量同步。简单说就是让一个独立服务监听 MySQL 的二进制日志,把新增、修改、删除操作解析出来,再同步到 ElasticSearch。
这个方案的好处是和业务代码完全解耦。NestJS 应用只管写 MySQL,同步动作由独立服务完成,就算同步进程挂了,也不会影响主业务的写入链路。而且 binlog 是 MySQL 层面的机制,任何入口写入的数据都能被捕获,即使是运营后台直接改数据库的场景也能覆盖。
不少团队会用现成的 CDC 框架来实现。但引入这套组件需要考虑运维成本,如果你们的部署环境比较轻量,只有一两个实例,我会建议先评估双写加消息队列的方案是否已经满足需求。日志订阅同步适合数据量大、节点多、对一致性要求严格的场景。
5.3 定时全量重建:最后一道兜底
即使有了增量同步,线上也难免出现数据对不上的情况。比如同事手动改数据库忘了触发同步、同步服务出过故障、或者代码里有漏同步的 bug。这时候最好的兜底手段是做一个定时全量重建任务。
全量重建的思路很简单:每天凌晨跑一个定时任务,从 MySQL 分批读取全部需要搜索的数据,重建当天甚至当周的索引。实现上可以用 NestJS 的@nestjs/schedule,配合 bulk 批量写入:
@Cron(CronExpression.EVERY_DAY_AT_3AM) async rebuildIndex() { const alias = 'products_alias'; // 1. 创建新索引,索引名带时间戳 const newIndex = `products_${dayjs().format('YYYYMMDDHHmmss')}`; await this.createIndex(newIndex); // 2. 分批从 MySQL 读数据写入新索引 const batchSize = 5000; let cursor = 0; while (true) { const products = await this.productRepository.find({ skip: cursor, take: batchSize, }); if (!products.length) break; await this.bulkIndex(newIndex, products); cursor += products.length; } // 3. 切换别名指向新索引 await this.elasticsearchService.indices.updateAliases({ actions: [ { add: { index: newIndex, alias } }, { remove_index: { index: this.oldIndex } }, ], }); }别名的设计是个非常实用的技巧。业务代码只面向别名products_alias查询,重建时生成新索引,等数据写完后把别名从旧索引切到新索引,再删掉旧索引。整个过程对查询方完全透明,还能顺便做索引的碎片整理。
全量重建虽然是兜底,但我强烈建议每个 ElasticSearch 接入项目都配上。它不仅能修复数据不一致,还能在索引 mapping 需要调整时派上用场——因为 ES 的 mapping 不能修改,唯一安全的重建方式就是新建索引再切换别名。
6. 上线前必须知道的调优项和踩坑记录
6.1 参数调优:bulk、连接池、刷新间隔
第一组需要调的是写入参数。前面提到 bulk 批量写入比单条写入快得多,但 bulk 的批次大小不是越大越好。我在实际测试中发现,超过 15MB 的批量请求很容易触发 ES 的max_bulk_size限制,导致整批失败。比较稳妥的做法是固定批量条数,比如每批 5000 条,如果文档较大就减少到 2000 条。还可以在写入时临时把索引的refresh_interval调大到 30 秒,写完再调回 1 秒。因为每次 refresh 都会产生一次磁盘 flush,频繁 refresh 是写入性能最大的杀手。
第二组是查询端的连接池配置。@nestjs/elasticsearch底层用的是官方客户端,默认的连接池参数在大多数场景下够用。如果你们在高峰期发现 ES 请求大量超时,可以适当调大requestTimeout,同时检查 Node 进程的文件描述符上限。连接池并不像数据库连接那样容易耗尽,我遇到过的超时问题大多源于 ES 节点本身的负载过高,而不是客户端连接不够。
第三组是索引层配置。分片数和副本数的设计很影响性能。分片数的原则是:单个分片的数据量控制在 5GB 到 10GB 之间。如果一个索引的数据量估计是 50GB,分片数在 5 到 10 之间比较合适。副本数至少 1,保证节点挂掉时数据不丢且查询有备选。如果你用的是云厂商的托管 ES,分片数往往直接和费用挂钩,更需要提前规划。
6.2 索引设计上的两个大坑
第一个坑是mapping 一旦建立就不能改。我见过不止一个项目,上线一段时间后发现某个字段的 type 设错了,想改成正确的类型,结果 ES 直接拒绝。要修改唯一能做的就是重建索引。所以在设计 mapping 时要特别谨慎,尤其是text和keyword这两个最容易混淆的类型。记住一个口诀:要全文搜索用 text,要精确匹配和聚合用 keyword。
第二个坑是深分页性能问题。默认from + size分页在超过 10000 条时会直接报错"Result window is too large"。如果你只是做普通的列表页,一般不会超过这个限制;但如果有导出全部数据、后台管理翻页很深的需求,就要改用search_after方案。search_after的核心思路是基于上一页最后一条数据的排序值继续向后翻,没有from的随机跳转能力,但性能很稳定,不会随翻页深度而下降。
第三个坑容易被忽略:ES 查询默认只返回 10 条。很多新手第一次调用 search 接口,看到返回的数据永远是 10 条,以为出 bug 了。其实这是 ES 的默认设计,size参数才决定返回数量。
6.3 我遇到过的几个诡异问题
写一个我印象很深的线上问题。某天同事反馈搜索接口偶尔返回 500,查日志发现报错是all shards failed。逐个排查后定位到原因是有一个字段在 mapping 里是long类型,但有部分文档写入了字符串,ES 在索引文档时直接拒绝写入,导致对应分片上的查询失败。这种问题在数据不一致的系统中特别容易出现,解决方式是:要么在写入前做好类型校验,要么用coerce: true让 ES 自动转换。我当时采用了写入前校验的方案,因为coerce虽然能转类型,但会把脏数据悄悄"洗白",掩盖上游问题,不利于排查。
另一个常见问题是不理解_source和字段存储的关系。ES 默认会把原始文档存在_source字段里,查询时返回_source给业务层用。如果你的文档数据量很大,且不需要返回原始完整文档,可以只存储某些字段或关闭_source来节省存储。但要记住,关闭_source后,update、reindex等依赖原始文档的操作都会受影响,务必谨慎。
最后说说 Kubernetes 环境下的部署调优。ES 是 Java 写的,内存管理有自己的脾气。堆内存大小建议设为物理内存的一半,且不要超过 31GB。因为 JVM 的压缩指针技术在 31GB 以下可以开启,超过这个值内存利用效率反而下降。这个规则在裸机部署和容器部署时都适用。
还有一个让我印象深刻的坑:某个服务在流量高峰时 ES 节点 CPU 飙升到 90% 以上,排查后发现是某个聚合查询的size设置得太小,导致大量热门词触发了很大的结果集。把size从 10 调到 100 后,CPU 反而降下来了。原因是size太小会让 ES 返回的桶数量少,但每个桶内部需要处理的文档更多,触发了不合理的算力开销。这种问题非常反直觉,遇到 CPU 异常时可以想想是不是聚合参数配得不合理。
写到这里,回头看看整个接入过程,最核心的收获其实不是 API 怎么调用,而是"搜索"和"存储"在架构中是两个完全不同的领域。MySQL 管事务、ES 管搜索,两者通过同步协作,各司其职。我在多个项目里来回调整过同步方案和数据一致性策略,一个深刻的体会是:不要指望一开始就能设计出完美方案。先把最简单的双写跑通,让搜索功能可用,再逐步引入消息队列、定时重建,在业务迭代中完善架构,这条路是走得最稳的。
最后分享一个非常实用的小技巧:调试 DSL 查询时,建议先在 Kibana 的 Dev Tools 控制台里验证好查询语句,确认结果符合预期,再挪到 NestJS 代码里。ElasticSearch 的查询 DSL 比较复杂,直接在代码里调试,每次改完都得重启服务,调试效率太低了。控制台里还能看到执行耗时、命中的分片分布等调试信息,定位问题比看代码日志直观得多。把这招练熟,你接 ElasticSearch 的速度会快很多。