CS-Notes全部笔记在阅读器中打开

Elasticsearch

本文目录

Elasticsearch(简称 ES)是基于 Lucene 的分布式搜索引擎,常用于商品搜索、站内搜索和日志检索。本文用一个 100 万件商品的索引贯穿全文:每件商品有标题、类目、价格和销量,标题形如“白鹭无线蓝牙耳机 降噪 绿色”。文中的数字均为单节点实测(ES 9.2.3、Lucene 10.3.2、MySQL 8.0),环境见附录。

一、为什么需要搜索引擎

在 MySQL 中按关键词搜索商品,通常写成 LIKE:

SELECT id, title FROM goods WHERE title LIKE '%蓝牙耳机%' ORDER BY sales DESC LIMIT 10;

title 上即使有 B+Tree 索引也用不上,因为索引按标题从左到右的顺序排列,前面有 % 时无法定位到起点(见 索引失效),执行计划中 type 为 ALL,100 万行全部扫描一遍再排序,约 240 ms。

更大的问题是结果不对。用户搜“降噪耳机”,LIKE '%降噪耳机%' 一条也查不到,因为标题里写的是“降噪蓝牙耳机”,“降噪”和“耳机”中间隔了别的字。用户希望的是包含这两个词的商品,并且越相关的越靠前。

ES 把标题拆成词,记录每个词出现在哪些商品里。同样的 100 万件商品,搜“蓝牙耳机”找到 99899 件,搜“降噪耳机”找到 24967 件,按销量取前 10 件都不到 10 ms(见附录 A1)。

和 MySQL 相比,ES 的特点:

因此 ES 一般不作为主存储,而是 MySQL 的一份“搜索副本”:数据以 MySQL 为准,变更同步到 ES,搜索请求查 ES,下单、扣库存这类操作仍然走 MySQL,同步的做法见 与 MySQL 的数据同步。

ES 中的几个基本概念:

早期版本一个索引中可以有多种类型(type),7.0 起废弃,8.0 起删除,文档的地址统一写成 goods/_doc/30 的形式。

二、倒排索引

正排与倒排

正排索引指的是从文档找内容,例如 MySQL 的主键索引,按 id 找到整行。倒排索引反过来,从词找文档:先把每个文档的内容拆成词,再为每个词记录包含它的文档列表。

以四件商品为例,标题分词后建成的倒排索引如下:

正排与倒排的配图

倒排索引分为两部分:

搜索“降噪耳机”时,先用同样的规则把它拆成“降噪”和“耳机”,在词典中找到这两个词,取出它们的倒排表求交集,得到同时包含两个词的文档,再按相关度排序。

查找时不需要扫描所有文档,代价取决于命中的倒排表有多长,而不是文档总数。倒排表按编号排好序,求交集时由最短的表领头,拿它的每个编号到其它表中向后跳着找,不必逐个比较。

为什么快

和 MySQL 的 B+Tree 相比,倒排索引快在以下几处:

倒排索引也有代价:写入时要分词、建索引,比 MySQL 插入一行慢;修改一个文档相当于删除后重新写入;不擅长按字段排序和聚合,这部分交给 doc_values,见下文。

分词

分词器(analyzer)决定了一段文本被拆成哪些词,查询能否命中全看这一步。它由三部分组成:字符过滤(例如去掉 HTML 标签)、分词(tokenizer)和词项过滤(例如转小写、去停用词)。

ES 自带的 standard 分词器适合英文,对中文是逐字拆分,“降噪蓝牙耳机”会拆成“降 噪 蓝 牙 耳 机”六个字,搜“牙耳”也能命中,结果很不准确。中文常用 IK 分词器插件,它按词典切分(见附录 A2):

standard     声悦降噪蓝牙耳机 长续航 黑色: 声 | 悦 | 降 | 噪 | 蓝 | 牙 | 耳 | 机 | 长 | 续 | 航 | 黑 | 色
ik_smart     声悦降噪蓝牙耳机 长续航 黑色: 声 | 悦 | 降噪 | 蓝牙 | 耳机 | 长 | 续航 | 黑色

IK 有两种模式:

常见的配置是写入用 ik_max_word、查询用 ik_smart。写入时多切,索引中的词更全,搜“保温”“保温杯”都能命中;查询时少切,避免把查询拆出“钢”“杯”这样的单字,召回一堆无关的商品。例如索引中有“316 不锈钢保温杯”“陶瓷马克杯”“钢化膜”,查询也用 ik_max_word 时,默认任意一个词命中即可,后两件因为“杯”“钢”也被搜了出来;查询用 ik_smart 时只返回保温杯(见附录 A2)。

反过来,写入时只用 ik_smart,索引中只有“保温杯”而没有“保温”,搜“保温”就一件也找不到;同样的 100 万件商品,用 ik_max_word 建的索引能找到 99708 件。

新词不在词典中时会被拆开,例如品牌“声悦”被切成“声”和“悦”。IK 支持远程扩展词典:ES 每分钟请求一次配置的地址,响应首部中的 Last-Modified 或 ETag 变化时重新加载词典,不用重启。需要注意的是,新词典只影响之后写入和查询时的分词,已有文档的倒排索引里仍是“声”“悦”两个词,搜“声悦”一件也找不到,要用 _update_by_query 让这些文档按新词典重新建索引(见附录 A3)。

三、映射与查询

text 与 keyword

字符串字段有两种类型:

不定义映射直接写入时,ES 会把字符串字段映射成 text,同时加一个 keyword 类型的子字段 xxx.keyword。这样每个字符串都建两份索引,也可能把本该是 keyword 的字段当成 text,生产环境通常预先定义映射。商品索引的映射:

PUT goods
{
  "settings": { "number_of_shards": 3, "number_of_replicas": 0 },
  "mappings": {
    "properties": {
      "title":    { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart",
                    "fields": { "raw": { "type": "keyword" } } },
      "category": { "type": "keyword" },
      "price":    { "type": "scaled_float", "scaling_factor": 100 },
      "sales":    { "type": "integer" }
    }
  }
}

title 是 text,还有一个 keyword 子字段 title.raw,用来按完整标题精确查找。price 是 scaled_float,按分存成整数,避免浮点误差。数值字段不建倒排索引,而是用 BKD 树(一种把数值分块组织的多维树)支持范围查询。

已有字段的类型不能修改,例如把 sales 从 integer 改成 keyword 会报错;可以新增字段(见附录 A13)。这是因为已经写入的数据是按原来的类型建的索引,改类型相当于重建索引:新建一个索引,用 _reindex 把数据复制过去,再把别名从旧索引切到新索引。查询一直通过别名访问,切换对业务透明:

POST _aliases
{
  "actions": [
    { "remove": { "index": "goods_v1", "alias": "goods_search" } },
    { "add":    { "index": "goods_v2", "alias": "goods_search" } }
  ]
}

两个动作在一个请求中原子地执行,不会出现别名同时指向两个索引或都不指向的情况。

常用查询

查询能否命中,取决于查询词和索引中的词是否一致:

最常见的错误是用 term 查 text 字段。title 中的“蓝牙耳机”在写入时已经被拆成“蓝牙”和“耳机”,索引中没有“蓝牙耳机”这个词,term 查询的结果是 0(见附录 A9):

{"term": {"title": "蓝牙耳机"}}       -> 0
{"term": {"title": "耳机"}}           -> 99899
{"match": {"title": "蓝牙耳机"}}      -> 99899
{"term": {"category": "蓝牙耳机"}}    -> 99899

match 默认的 operator 是 or,命中任意一个词就返回,只是命中越多分数越高;要求所有词都出现时设为 and。

match_phrase 依赖倒排表中记录的位置。“降噪蓝牙耳机”中“降噪”和“耳机”之间隔了“蓝牙”,match_phrase 搜“降噪耳机”的结果为 0;slop 指的是允许词之间移动的位置数,设为 1 时找到 12460 件,即标题中带有“降噪蓝牙耳机”的商品;用 match 加 and 是 24967 件,也包括“蓝牙耳机 降噪”这种词序不同的标题(见附录 A2)。

组合查询与过滤

实际的搜索通常是全文检索加上若干筛选条件,用 bool 查询组合:

例如搜“降噪耳机”,只要蓝牙耳机类目、价格在 200 到 500 元之间,按销量排序:

POST goods/_search
{
  "query": {
    "bool": {
      "must":   [ { "match": { "title": { "query": "降噪耳机", "operator": "and" } } } ],
      "filter": [ { "term":  { "category": "蓝牙耳机" } },
                  { "range": { "price": { "gte": 200, "lte": 500 } } } ]
    }
  },
  "sort": [ { "sales": "desc" } ],
  "size": 10
}

共命中 3766 件,耗时几毫秒(见附录 A10)。类目和价格这类是否满足的条件放在 filter 中,因为不需要算分数,而且 ES 会缓存经常使用的过滤结果,下次直接复用;关键词这种要看相关程度的条件才放在 must 中。

相关度

相关度分数默认用 BM25 计算,主要看三点:查询词在文档中出现得越多分数越高,但增长越来越慢;越少见的词权重越高;词频相同时,越短的文档分数越高。公式和算例见 混合检索。

需要注意的是,每个分片只用自己的统计信息计算分数。查询会分发到所有分片,各分片用本地的文档数和词频算出 IDF,数据少、分布不均时,同一个文档在不同分片上得分不同。例如两件标题相同的商品分别在两个分片上,其中一个分片上另有 8 件也含“降噪”,“降噪”在这个分片上更常见,两件商品的分数分别为 0.045 和 0.596。查询时加 search_type=dfs_query_then_fetch,先收集所有分片的统计信息再打分,两件商品都是 0.117(见附录 A8)。数据量大、分布均匀时差异很小,一般不需要。

四、doc_values 与 _source

倒排索引擅长从词找文档,但排序和聚合需要的是反方向:已知一批文档,取出它们的销量或类目。如果只有倒排索引,就要遍历所有词,看每个词的倒排表里有没有这些文档。

doc_values 是写入时额外生成的一份列式存储,按文档编号顺序存放每个文档的字段值,排序、聚合和脚本读取字段值时都用它。keyword、数值、日期等类型默认开启;text 字段没有 doc_values,对 title 做聚合会报错,要用 keyword 类型的 title.raw 或 category(见附录 A4)。

对 text 字段可以开启 fielddata,在第一次排序或聚合时把倒排索引反转后放到 JVM 堆中,它占用大量堆内存,容易引发频繁的垃圾收集,因此默认关闭,一般不使用。

不需要排序和聚合的字段可以关闭 doc_values 节省磁盘,只需要排序和聚合、不需要搜索的字段可以把 index 设为 false,不建倒排索引。

_source 指的是写入时的原始 JSON,搜索结果中返回的内容就来自它。它不能用于搜索,只用于返回和重建索引(_reindex 读的就是 _source)。

用 _disk_usage 统计商品索引中各部分的大小:100 万件商品共 78.2 MB,其中 _source 占 33.1 MB,title 的倒排索引 10.3 MB,title.raw 的倒排索引和 doc_values 各 5.5 MB、3.6 MB,price 和 sales 的 BKD 树和 doc_values 共 10.4 MB。

五、写入与近实时搜索

refresh

ES 写入一个文档后,默认不能马上被搜到。这是因为 Lucene 的索引由若干个段(segment)组成,每个段是一个完整的小倒排索引,写完就不再修改,搜索只能搜已经生成的段;新写入的文档先放在内存缓冲区中,还不属于任何一个段。

refresh 指的是把内存缓冲区中的文档写成一个新的段并打开,使它可以被搜索。ES 默认每 1 秒执行一次 refresh,所以写入后约 1 秒才能搜到,称为近实时(near real-time)。

refresh 的配图

实测写入一件商品后立即搜索,结果为 0,等待 0.93 秒后能搜到;同时,按 ID 读取(GET goods/_doc/30)立即就能读到,因为按 ID 读取是实时的,不依赖 refresh(见附录 A5)。

refresh 生成的段先写到文件系统缓存,还没有 fsync 到磁盘,开销小,所以可以每秒执行一次。

写入后需要立即可见时,有两个选项:

常见的问题是写完 ES 立刻发消息通知下游,下游去搜却搜不到,原因就是还没有 refresh。下游按 ID 读取,或者写入时加 refresh=wait_for 再发消息。

没有显式设置 refresh_interval 时,一个分片 30 秒内没有收到搜索请求就会进入闲置状态(search idle),不再在后台定时 refresh,等到下一个搜索请求到来时才执行一次。这样写多读少的索引不必每秒都生成新段,代价是闲置后的第一次搜索要先等待 refresh。

translog 与 flush

refresh 生成的段还在文件系统缓存中,宕机就会丢失。为了不丢数据,每次写入还会追加一条记录到 translog(事务日志),作用相当于 MySQL 的 redo log:

flush 指的是执行一次 Lucene 提交:把文件系统缓存中的段 fsync 到磁盘,写入提交点,然后开始新的 translog。宕机重启时,从最后一个提交点开始重放 translog,补回之后的写入。ES 根据 translog 的大小自动执行 flush,避免 translog 太大导致恢复时重放太久。

translog 和 MySQL 的 redo log 一样用顺序追加换取持久性,flush 类似于推进检查点,之后的恢复只需从提交点开始,见 redo log。

段合并

有新写入时每次 refresh 都会生成一个新段,段越来越多,每次搜索都要查所有的段,再合并结果。ES 在后台把小段合并成大段(merge),合并时还会真正删除那些标记为已删除的文档。

因为段写完就不再修改,更新一个文档的做法是在旧段中把旧版本标记为删除,在新段中写入新版本;删除文档也只是做标记,要等合并时才清除。实测在只有一个段的分片上更新约 1.7 万件商品,大段中多出约 1.7 万个已删除的文档,新版本写到了新生成的 3 个小段中(见附录 A6)。

合并要读写大量数据,ES 会自动限制合并的速度,但写入量大时仍可能和搜索争抢磁盘和 CPU,导致查询延迟突然升高。

forcemerge 用于手动合并,例如合并成一个段:商品索引每个分片从 17~19 个段合并为 1 个段,已删除的文档清空,大小从 161.9 MB 降为 74.2 MB。需要注意的是,官方建议只对不再写入的索引(例如前一天的日志)执行 forcemerge:合并出的段大于 5 GB 时不再参与自动合并,之后的更新和删除留下的已删除文档会一直堆积在这个大段中。

批量写入

大量导入数据时,常用的做法:

常见的建议还有导入期间把 refresh_interval 设为 -1,关闭 refresh。需要注意的是,这条建议在本文的单节点测试中并没有更快:100 万条数据关闭 refresh 用了 16.5~18.3 秒,默认 1 秒 refresh 用了 14.2~14.4 秒,前者只生成了 6 个段(见附录 A7)。效果取决于数据量、机器和段的大小,以实测为准。

六、分片与集群

分片与路由

一个索引的数据分成多个主分片,放在不同的节点上,单个索引的容量和写入能力就不受一台机器的限制。写入一个文档时,按下面的公式决定它属于哪个主分片:

shard_num = hash(_routing) % number_of_shards

其中 hash 是 Murmur3 哈希,_routing 默认是文档的 _id。例如商品 1024 落在分片 1、1025 落在分片 0(见附录 A8)。需要注意的是,这个公式适用于 ES 9.4 起新建的索引;之前创建的索引(包括附录中的实验)先对一个更大的数 number_of_routing_shards(3 个分片时默认是 768)取模,再除以它与主分片数之比,同一个路由值算出的分片可能不同,但同样由主分片数决定。

因为路由依赖主分片数,主分片数在创建索引后不能修改,修改会让已有文档按公式算出的分片变化,按 ID 就找不到了。实测直接修改 number_of_shards 会被拒绝,需要扩分片时有两种做法:

_shrink 可以反过来减少主分片,目标分片数必须是原分片数的因数。

分片数要提前规划,官方建议每个分片 10 GB~50 GB、文档数不超过 2 亿。分片太多也不好:每个分片都要占用内存、文件句柄和集群状态,一次搜索要查询所有分片再合并,搜索 1000 个 50 MB 的分片比搜索一个 50 GB 的分片慢得多。

自定义路由

订单按用户查询时,可以把 user_id 作为路由值,同一个用户的订单就都在一个分片上:

PUT orders/_doc/1?routing=1001
GET orders/_search?routing=1001

不指定路由时,“查询用户 1001 的订单”要发给全部 3 个分片;指定 routing=1001 后只查询 1 个分片。代价是:

副本与集群

每个主分片可以有若干个副本(replica),副本是主分片的完整拷贝,用于故障转移和分担读请求。同一个分片的主分片和副本不会放在同一个节点上,单节点集群中副本无法分配,集群状态为 yellow。

副本与集群的配图

集群中的节点按角色分工:

写入的过程:

  1. 协调节点按路由公式算出主分片,把请求转发给主分片所在的节点;
  2. 主分片校验并写入,再把该操作并行发给同步副本集合(in-sync copies,由 master 维护)中的所有副本;
  3. 所有副本都写入成功后,主分片返回成功。

副本写入失败时,主分片请求 master 把它移出同步副本集合,之后不再等待它,这样一个故障的副本不会拖住写入。这种复制方式称为主备复制(primary-backup),基于微软的 PacificA 论文。主分片所在的节点故障时,master 把同步副本集合中的一个副本提升为主分片。

搜索分两个阶段,称为 query then fetch:

  1. query:协调节点把请求发给每个分片的一个副本(主分片或副本都可以,默认按各节点的响应时间、队列长度自适应地选择),每个分片在本地查出前 from + size 个文档,只返回文档 ID 和排序值;
  2. fetch:协调节点合并各分片的结果,确定最终的这一页,再去相应的分片取这些文档的 _source。

脑裂

脑裂(split brain)指的是网络分区后,集群分成两部分,各自选出一个 master,分别接受写入,数据出现冲突。

ES 通过法定人数(quorum)避免脑裂:选举 master、提交集群状态的变更,都要得到投票配置中超过半数的 master 候选节点同意。3 个候选节点分成 2 和 1 两部分时,只有 2 个节点的一边能选出 master,另一边因为不到半数而无法工作。

master 候选节点通常取 3 个这样的奇数:4 个和 3 个一样只能容忍 1 个故障,候选节点为偶数时,ES 会把其中一个排除在投票配置之外。

ES 7.0 之前需要手动配置 discovery.zen.minimum_master_nodes 为候选节点数 / 2 + 1,配置错误或扩容后忘记修改就可能脑裂;7.0 起由 ES 自动维护投票配置,这个参数不再生效。超过半数的思路和 Raft 相同,见 Raft。

七、深分页

from + size 分页时,每个分片都要返回前 from + size 个文档,由协调节点合并排序后再取出最后一页。查第 1000 页(from = 9990,size = 10)时,3 个分片各返回 10000 个,协调节点合并 30000 个,只用其中 10 个,页数越深,内存和 CPU 的开销越大。

实测 from 从 0 增加到 9990,耗时从 9.8 ms 增加到 28.3 ms。ES 默认限制 from + size 不能超过 10000(index.max_result_window),超过时直接报错(见附录 A9)。调大这个参数能翻得更深,但开销仍随深度增长,通常不这样做。

更深的翻页用 search_after:每页按排序值加一个唯一的字段排序,下一页带上上一页最后一个文档的排序值,各分片只返回排在它之后的 size 个文档,原理与 MySQL 的游标分页相同(见 深分页)。

POST _search
{
  "query": { "match": { "title": "耳机" } },
  "sort": [ { "sales": "desc" }, { "_shard_doc": "asc" } ],
  "size": 10,
  "pit": { "id": "<上一页返回的 PIT ID>", "keep_alive": "1m" },
  "search_after": [ 67, 3169 ]
}

两页之间索引可能 refresh,新文档的插入会让后面的页出现重复或遗漏。时间点(Point In Time, PIT)用于固定一个时刻的索引快照,翻页期间都在这个快照上查询;使用 PIT 时可以用 _shard_doc 作为保证唯一的排序字段。实测用 search_after 加 PIT 连续翻到第 1000 页,第 2 页之后每页耗时的中位数约 3 ms,第 1000 页为 2.4 ms。

需要注意的是,search_after 只能一页一页往后翻,不能跳到第 N 页。产品上通常限制可跳转的页数(例如只给前 100 页),更深的结果用“加载更多”。

scroll 是早期用于遍历大量数据的接口,也会保存快照,官方已不再推荐用它做深分页,导出全量数据时也改用 search_after 加 PIT。

八、与 MySQL 的配合

与 MySQL 的数据同步

数据以 MySQL 为准,同步到 ES 的做法:

无论哪种方式,写 ES 都要处理两个问题:

ES 与 MySQL 之间只能做到最终一致:binlog 同步本身有延迟,写入 ES 后还要等 refresh。为了发现漏同步的数据,可以定期对账:按 ID 范围分批比较两边的记录数和版本号(或者数据的校验和),只对不一致的范围逐条比较并补写。

对一致性要求高的读取不走 ES:搜索结果列表来自 ES,用户点进商品详情或下单时,按 ID 回到 MySQL 查最新的数据。

适用场景

ES 适合的场景:

不适合的场景:

MySQL 也有全文索引(见 全文索引),中文要用 ngram 解析器按固定长度(默认 2 个字)切分,不能按词典分词,也不能像 ES 那样分片扩展,一般只在数据量小、不想引入新组件时使用。

慢查询的排查

ES 查询变慢时,常见的原因和做法:

定位时用 profile API 查看每个子查询在每个分片上的耗时,用慢查询日志(search slowlog)记录超过阈值的查询,阈值默认关闭,需要按索引设置。

附录:实测

环境:Apple M1 Pro、macOS 15.8.1;Elasticsearch 9.2.3(Lucene 10.3.2,自带 JDK 25.0.1),IK 分词器 9.2.3,单节点,堆 1 GB;MySQL 8.0.44。商品数据由脚本生成 100 万条(固定随机种子),标题为品牌加属性加类目加属性加颜色,例如“白鹭无线蓝牙耳机 降噪 绿色”,10 个类目各约 10 万件,MySQL 和 ES 中的数据相同。ES 的 goods 索引 3 个主分片、0 个副本,映射见 text 与 keyword;MySQL 的 goods 表在 title 上有普通索引。耗时为客户端测量的 5~7 次的中位数,took 为 ES 返回的服务端耗时;单机上的绝对耗时只供比较。

A1 LIKE 与全文检索

-- MySQL
EXPLAIN SELECT id, title FROM goods WHERE title LIKE '%蓝牙耳机%' ORDER BY sales DESC LIMIT 10;
type = ALL, rows = 995656, Extra = Using where; Using filesort
COUNT LIKE '%蓝牙耳机%': 99899 rows, 162 ms
top10 LIKE '%蓝牙耳机%' ORDER BY sales: 243 ms
COUNT LIKE '%降噪耳机%': 0 rows, 161 ms

-- ES:{"match": {"title": {"query": ..., "operator": "and"}}, "size": 10, "track_total_hits": true}
'蓝牙耳机': total 99899, client 4.0 ms, took 2 ms
'蓝牙耳机' sort sales: client 6.9 ms, took 5 ms
'降噪耳机': total 24967, client 3.9 ms, took 2 ms
  top 3: ['声悦降噪蓝牙耳机 运动 黑色', '声悦降噪蓝牙耳机 无线 黑色', '声悦降噪蓝牙耳机 运动 白色']
'降噪耳机' sort sales: client 3.3 ms, took 2 ms

A2 分词

$ POST _analyze {"analyzer": ..., "text": ...}
standard     声悦降噪蓝牙耳机 长续航 黑色: 声 | 悦 | 降 | 噪 | 蓝 | 牙 | 耳 | 机 | 长 | 续 | 航 | 黑 | 色
ik_smart     声悦降噪蓝牙耳机 长续航 黑色: 声 | 悦 | 降噪 | 蓝牙 | 耳机 | 长 | 续航 | 黑色
ik_max_word  声悦降噪蓝牙耳机 长续航 黑色: 声 | 悦 | 降噪 | 蓝牙 | 耳机 | 长 | 续航 | 黑色
ik_max_word  不锈钢保温杯: 不锈钢 | 不锈 | 钢 | 保温杯 | 保温 | 杯
ik_smart     不锈钢保温杯: 不锈钢 | 保温杯

查询时的分词器:单分片的小索引中有三件商品,写入用 ik_max_word,match 查询“不锈钢保温杯”(默认 or):

ik_max_word 钢化膜 高清: 钢 | 化 | 膜 | 高清
ik_max_word 陶瓷马克杯 白色: 陶瓷 | 马克杯 | 马克 | 杯 | 白色
查询时用 ik_max_word: [('石榴316不锈钢保温杯 便携 灰色', 3.932), ('钢化膜 高清', 0.553), ('陶瓷马克杯 白色', 0.514)]
查询时用 ik_smart:    [('石榴316不锈钢保温杯 便携 灰色', 1.586)]

写入时的分词器:把 goods 的标题 _reindex 到一个写入也用 ik_smart 的索引,match 加 and 的命中数:

                   保温     保温杯   不锈钢保温杯
写入 ik_max_word   99708    99708    25044
写入 ik_smart      0        99708    25044

match_phrase 与 slop:

{"match_phrase": {"title": "蓝牙耳机"}}                         -> 99899
{"match_phrase": {"title": "降噪耳机"}}                         -> 0
{"match_phrase": {"title": {"query": "降噪耳机", "slop": 1}}}   -> 12460
{"match": {"title": {"query": "降噪耳机", "operator": "and"}}}  -> 24967

A3 词典热更新

IKAnalyzer.cfg.xml 中配置 remote_ext_dict 为一个本地 HTTP 服务上的 brand.dic,修改配置后重启一次 ES。之后向 brand.dic 写入“声悦”,不重启:

before: 声 | 悦 | 降噪 | 蓝牙 | 耳机
dict updated at 17:01:15
after 51s: 声悦 | 降噪 | 蓝牙 | 耳机

HTTP 服务的访问日志(每分钟一次 HEAD,变化后 GET):
[17:01:04] "HEAD /brand.dic HTTP/1.1" 200
[17:01:05] "GET /brand.dic HTTP/1.1" 200
[17:02:04] "HEAD /brand.dic HTTP/1.1" 200
[17:02:05] "GET /brand.dic HTTP/1.1" 200
ES 日志:
[17:02:04,983][INFO ][o.w.a.d.Dictionary] start to reload ik dict.
[17:02:05,045][INFO ][o.w.a.d.Dictionary] reload ik dict finished.

match title "声悦"        -> 0
prefix title.raw "声悦"   -> 49998
POST goods/_update_by_query?conflicts=proceed&refresh=true {"query": {"prefix": {"title.raw": "声悦"}}}
 -> took 3522 ms, updated 49998
match title "声悦"        -> 49998

A4 doc_values 与磁盘占用

$ POST goods/_search {"size": 0, "aggs": {"t": {"terms": {"field": "title"}}}}
Fielddata is disabled on [title] in [goods]. Text fields are not optimised for operations that require
per-document field data like aggregations and sorting, so these operations are disabled by default.

$ POST goods/_disk_usage?run_expensive_tasks=true
total 78.2mb
title      total 11.3mb  inverted_index 10.3mb  doc_values 0b     stored 0b      points 0b
title.raw  total 9.2mb   inverted_index 5.5mb   doc_values 3.6mb  stored 0b      points 0b
category   total 1.2mb   inverted_index 768.7kb doc_values 514kb  stored 0b      points 0b
price      total 6.2mb   inverted_index 0b      doc_values 2.5mb  stored 0b      points 3.7mb
sales      total 4.2mb   inverted_index 0b      doc_values 2.1mb  stored 0b      points 2mb
_source    total 33.1mb  inverted_index 0b      doc_values 0b     stored 33.1mb  points 0b
_id        total 8.9mb   inverted_index 4.5mb   doc_values 0b     stored 4.4mb   points 0b

A5 近实时搜索与 translog

单分片的索引,写入一件商品后立即搜索,再每 50 ms 搜索一次直到命中:

search right after index: 0
GET by id right after index: found = True
visible to search after 0.93s
index with refresh=wait_for returned after 0.96s, search: 1

35 秒没有搜索后写入一件商品,3 秒后:
refresh stats: {'total': 5, 'external_total': 4}     -- 没有新的 refresh
searchable docs in segments: 2                         -- 新商品还不在可搜索的段中
first search after idle: hits=1, 0.015s
refresh stats after search: {'total': 6, 'external_total': 5}

translog before flush: {'operations': 3, 'uncommitted_operations': 3}
translog after flush:  {'operations': 0, 'uncommitted_operations': 0}
defaults: refresh_interval 1s, search.idle.after 30s, translog.durability REQUEST, translog.sync_interval 5s

A6 段与合并

$ GET _cat/indices/goods  (导入并做过一次 _update_by_query 后)
docs.count 1000000  docs.deleted 49323  store.size 161.9mb
shard 0 segments 17  shard 1 segments 17  shard 2 segments 19

$ POST goods/_forcemerge?max_num_segments=1    (2.9 s)
docs.count 1000000  docs.deleted 0  store.size 74.2mb
shard 0 segment _h 333316 docs  24.7mb   (另两个分片相同)

$ POST goods/_update_by_query {"query": {"prefix": {"title.raw": "青禾"}}}   (3 个分片共更新 50287 件)
docs.deleted 50287  store.size 74.6mb
shard segment docs.count docs.deleted    size committed
0     _h          316524        16792  24.7mb true
0     _i            1343            0 126.4kb false
0     _j           12319            0 912.8kb false
0     _k            3130            0 266.6kb false
...

A7 批量写入时的 refresh

100 万条数据,每批 5000 条,单线程发送 _bulk,副本数为 0,两种设置交替各运行两次:

refresh_interval=1s: 14.4s, refreshes=33, segments=27
refresh_interval=-1: 18.3s, refreshes=9,  segments=6
refresh_interval=1s: 14.2s, refreshes=32, segments=26
refresh_interval=-1: 16.5s, refreshes=9,  segments=6

统计中的 refresh 次数包括 ES 内部触发的。最初创建 goods 索引时用同样的批大小导入,约 15.0 s。

A8 分片与路由

$ GET goods/_search_shards?routing=1024      (_id 默认作为路由值)
_id 1024 -> shard 1      _id 1025 -> shard 0      _id 1026 -> shard 0

Murmur3(ES 9.2.3 的 Murmur3HashFunction):
hash("1024") = 509060644,   floorMod(hash, 768) = 292, 292 / 256 = 1
hash("1001") = -1823115355, floorMod(hash, 768) = 677, 677 / 256 = 2
(goods 在 9.2.3 中创建,routing_num_shards = 768,用旧的路由公式)

orders 索引:3 个分片,_routing 为 required,30 个用户的 3000 个订单按 user_id 路由
search without routing:   shards total 3, hits 100
search with routing=1001: shards total 1, hits 100
GET orders/_doc/1 without routing: routing_missing_exception
docs per shard: 400, 1300, 1300

$ PUT orders/_settings {"index": {"number_of_shards": 6}}
illegal_argument_exception: Can't update non dynamic settings [[index.number_of_shards]] for open indices
$ PUT orders/_settings?reopen=true ...
illegal_argument_exception: final orders setting [index.number_of_shards], not updateable
$ PUT orders/_block/write
$ POST orders/_split/orders6 {"settings": {"index.number_of_shards": 6}}   -> acknowledged
$ POST orders/_split/orders9 {"settings": {"index.number_of_shards": 9}}
illegal_state_exception: the number of routing shards [768] must be a multiple of the target shards [9]
$ GET _cat/shards/orders6       (1 个副本,单节点上无法分配)
orders6 0 p STARTED 100    orders6 0 r UNASSIGNED   ...   health: yellow

分片间的相关度:2 个分片的索引,两件标题相同的商品“知行降噪蓝牙耳机 长续航 黑色”分别路由到分片 0 和分片 1,分片 0 上另有 8 件标题含“降噪”的商品,match 查询“降噪”:

query_then_fetch:     分片 0 上的得分 0.045,分片 1 上的得分 0.5957
dfs_query_then_fetch: 分片 0 上的得分 0.1168,分片 1 上的得分 0.1168

A9 term 与深分页

{"term": {"title": "蓝牙耳机"}}                          -> 0
{"term": {"title": "耳机"}}                              -> 99899
{"match": {"title": "蓝牙耳机"}}                         -> 99899
{"term": {"title.raw": "蓝牙耳机"}}                      -> 0
{"term": {"title.raw": "木棉入耳式蓝牙耳机 降噪 粉色"}}   -> 13
{"term": {"category": "蓝牙耳机"}}                       -> 99899

match 查询“耳机”,按销量排序,每页 10 条:

from=0    size=10: 9.8 ms,  took=2
from=1000 size=10: 19.1 ms, took=9
from=9990 size=10: 28.3 ms, took=11
from=10000: Result window is too large, from + size must be less than or equal to: [10000] but was [10010].

search_after + PIT,sort [sales desc, _shard_doc asc],连续翻 1000 页:
page 1: 13.8 ms, page 1000: 2.4 ms, median of pages 2-1000: 3.0 ms, last sort values: [67, 3169]

A10 组合查询

请求见 组合查询与过滤:

total 3766, took 7 ms(第一次),连续运行后 took 1~2 ms
784329 {'title': '蓝鲸降噪蓝牙耳机 长续航 蓝色', 'price': 257.46, 'sales': 5113}
262074 {'title': '小满降噪蓝牙耳机 运动 白色', 'price': 207.53, 'sales': 4712}
962868 {'title': '拾光游戏低延迟蓝牙耳机 降噪 粉色', 'price': 380.29, 'sales': 4456}

只有 filter 的查询:_score 0.0

A11 混合检索

一个小索引中有“降噪蓝牙耳机”“无线耳机 安静”“保温杯”三个文档,每个文档带一个 3 维的向量。在基础许可证下:

retriever rrf:    403 current license is non-compliant for [Reciprocal Rank Fusion (RRF)]
retriever linear: 403 current license is non-compliant for [linear retriever]
knn only:         [('2', '无线耳机 安静', 0.9998), ('1', '降噪蓝牙耳机', 0.9972)]
query match + knn: [('1', '降噪蓝牙耳机', 2.5099), ('2', '无线耳机 安静', 1.4343)]

同时写 query 和 knn 时,两者的结果取并集,分数直接相加;RRF 和 linear 这两种融合方式需要付费许可证,用基础版时可以在应用中分别查询两路结果,再按 RRF 合并,RRF 的计算见 混合检索。

A12 外部版本号

PUT vgoods/_doc/1024?version=5&version_type=external {"price": 299}   -> created
PUT vgoods/_doc/1024?version=4&version_type=external {"price": 199}
  -> 409 version_conflict_engine_exception: current version [5] is higher or equal to the one provided [4]
PUT vgoods/_doc/1024?version=6&version_type=external {"price": 259}   -> updated
GET vgoods/_doc/1024 -> {'price': 259}, _version 6, _seq_no 1, _primary_term 1
PUT vgoods/_doc/1024?if_seq_no=0&if_primary_term=1 ...   -> 409 version_conflict_engine_exception

A13 修改映射与别名切换

PUT goods/_mapping {"properties": {"sales": {"type": "keyword"}}}
  -> illegal_argument_exception: mapper [sales] cannot be changed from type [integer] to [keyword]
PUT goods/_mapping {"properties": {"brand": {"type": "keyword"}}}
  -> acknowledged

别名 goods_search 指向 goods_v1;新建 goods_v2(6 个分片,title 写入改用 ik_smart):
POST _reindex {"source": {"index": "goods_v1"}, "dest": {"index": "goods_v2"}}  -> created 1, failures []
POST _aliases {remove goods_v1, add goods_v2}  -> acknowledged
GET _alias/goods_search -> ['goods_v2']

参考资料

在阅读器中可以用目录和搜索、切换深色模式、练习这篇笔记中的面试题:打开《Elasticsearch》