使用 ElasticSearch 的 44 条建议


本文地址:http://www.6aiq.com/article/1559924453879
知乎专栏 点击关注
本文版权归作者和AIQ共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出

跳跳爸 _ 的 Abc 公众号

在搜索业务上摸爬滚打 3 年,使用的 Es 版本也从 1.x 升级到了 5.x,扮演的角色也逐渐从 Es 的使用方变为维护方,这里大致汇总了使用 Es 过程中踩的一些坑以及一些注意事项,也会穿插一下我们的解法。

01

es 中建索引是指创建一个保存数据的目录,用于保存倒排索引,索引创建之后是不可变的(Immutable),只允许新增字段。

因为 lucene 中 field 是带类型的,不同类型的字段进入倒排索引后会经过压缩,long/int/short 占用的字节长度不一,如果修改字段类型,很可能导致从索引文件中解压字段失败,更不要提 string=>int 之类的变换了;此外 es 会将文档 id 压缩存储(有序列表 -> 差值形式 -> 压缩),并通过跳跃表来提高查询性能,倒排索引查出 term 对应的 doc_id 集合,再用 doc_id 取 field value 用于排序或聚合,其实是 lucene 的实现,所以要变更索引文件代价很大,需要解压 => 变更 => 压缩,大量的 CPU 和 io 操作。

02

es 字段是否索引只能在创建索引时配置,不能在字段创建后再给字段“加索引”。

lucene 的字段有 indexed 的属性,如果设置 false 则不会写入倒排索引文件,如果要后期将某个字段改为 indexed,相当于把整个索引重建一次,既然索引都要重建,也就没必要提供类似的功能了。

03

索引字段有为“索引(indexed)”和“存储(stored)”两个属性,只有被“索引”的字段才能在查询 / 排序条件中使用,只有被“存储”的字段才能在请求的时候返回字段内容。

同上,另外要说明的是,如果字段没有被设置为 stored,则这个文档在 update 后会丢失该字段,因为 Es 的 update 操作其实是从索引文件中取到 stored 的原始值,合并后 index 回去,如果没有存储该字段内容,新生成的文档也不会带有该字段,index 后原文档被覆盖也就丢失该字段了。

04

必须保证索引字段都存储(stored)才能使用 update 操作,update 原理是先从索引中 get 到原文档内容,然后与传入的欲更新字段合并,作为一个新的文档 index 回去,如果有字段不是 stored,那么 update 之后该字段就丢失了。

同 03 条注释。

05

为了提升查询性能,索引文件被设计为不可变文件(便于如跳跃表之类的访问性能优化),在生成后不会发生变更,通过 Es 看到的数据均为某个时刻引擎打开的快照数据,为了能做到反应数据变化,会有刷新时间 refresh_interval 的概念。

这里会反复提到近实时(NRT)的概念,希望大家在使用过程中一定明确一点,Es 不是一个真实时的存储服务,务必不要用在实时业务场景中。
假定某个时刻为 t,t 时刻引擎打开的快照数据也就是 t 时刻的全部有效段文件,t 时刻之后写入的数据是不可见的,这些数据会生成新的段,在 t+refresh_interval 时刻,Es 会重新扫描并打开该时刻的全部有效索引文件,以此近实时的反应数据变化。

06

es 还无法做到资源二级调度(共享线程池 / 缓存区等,无法按索引隔离资源),所以如果集群内某一个索引发生大量慢查询或者污染缓存区(用低复用率内容踢出其他索引高复用率缓存),会导致 search 线程池满或者引起 gc,阻塞集群内其他索引的响应。

如题,重要业务最好保障物理隔离。

07

es 每次 refresh 需要重新打开所有索引文件(需要解压 / 刷缓存等),如果索引文件较多且更新频繁,每次 refresh 的开销会比较大,使机器负载升高,影响查询 rt,所以更新频繁的大索引设置的刷新时间会限制到 5s 以上(实时程度下降),要提高实时性,必须减小索引大小。

Es 默认是 1s 刷新,如果索引到达千万级别且更新频繁,rt 可能会上升到 100ms 级别,算算 qps 是不是很虚呢。

08

es 有 window size 的概念(from+size),每次查询先从每个 shard 中取 window size 条数据,然后在集群中某个节点汇聚数据,排序后取 size 条数据返回,假设有 n 个 shard,有效数据占比=size / (n * (from+size)),故 Es 查询时会限制 window size,避免产生过多垃圾数据加重 gc 和 IO 负担。

如题。

09

因为索引文件是不可变的,要反应数据的变化需要一次刷新操作来重新扫描并加载新的索引文件,所以任意时刻查询的数据都是 t 时刻(扫描并加载索引文件的时刻)的快照数据,刷新时间的长短决定了数据的近实时(nrt)程度。
原因见第 05 条,刷新时间默认 1s,可以通过 settings API 动态调整,一般建议 5s,Es/Solr 都是 nrt(近实时)的服务,务必明确,不要当实时存储来用。

10

es 有 translog,也就是 write ahead log,用来保障数据的故障恢复,写操作都必须保证写 translog 成功,避免服务挂的时候数据丢失。

translog 其实也不是绝对的靠谱,有 async 和 request 两种模式,如果是 async 模式(Es1.x 只有这个模式),会在内存中积累一定大小或到一定时间后触发 flush 操作来持久化到磁盘,如果掉电还是有丢数据的可能性,得通过主从副本来保障数据的持久化,如果是 request 模式(Es5.x 默认)则要靠谱的多,每次写操作都会将 translog 落盘,但是很明显会导致磁盘 io 压力上升,依赖各自的实际业务场景来取舍。

11

search 操作是检索 searcher 打开的快照数据,所以 search 是个近实时操作,取决于快照数据的近实时程度;不同于 search,es 的 get 操作可以在保证必要条件的情况下做到真实时,可以从 translog 中提取文档,拿到最新写入的文档数据。

其实 get 也有 refresh 和 realtime 的,要真实时必须指定 realtime 为 true(默认),Es 会通过检查 versionMap(存储两次刷新间隔中写入数据的元信息)中是否有当前文档的 _uid 来判断是否触发一次刷新操作(Es 是通过 searcher 来 get 到文档的,translog 只是用来取 source),refresh 参数用来提示 get 操作前是否一定进行一次刷新操作(可以达到真实时目的,但是不建议开,性能损耗太大,用 realtime 就可以达到目的了),另外必须指定 preference 为 _primary,否则还是可能会 get 到旧版本数据,比如写入时没有指定 wait_for_active_shards,默认只要 _primary 存活即可,且不保证写入到全部副本,如果副本短时上下线就有可能会导致数据延迟。

12

在假设正常情况下并发更新概率很小的前提下,为了性能考虑,es 通过乐观锁解决文档并发更新问题,创建文档时如果不设置 version,默认初始 version=1,之后每次 update 时 version 自增;如果要重置 version,只能通过 index 操作并设置 force=true,来强制重置文档 version。
如题,另外 Es6.x 开始已经在考虑取消 force 参数了。

13

es 原生不支持在 update 时设置 version(理论上是可以实现的,给开发组提了个 issue:https://github.com/elastic/elasticsearch/issues/25996,但是没通过),实在要做可以在业务程序中一定程度上模拟带 version 更新操作。

Es 的更新操作就是先 get 到最新文档,然后与传入的文档合并后再 index 回去,同时标记原文档为 deleted,这个过程就可以将自增的版本号设置为外部版本号。
还有要注意的一点是 VersionType 尽量不要设置为新 version>= 原 version,可能会导致并发更新时的多个请求的数据有一个或多个被覆盖。

14

es1.x 版本默认 date 类型处理会在 format parse 失败后尝试用 long.valueOf 来转换,假设字段配置为 date 类型,format 为 YYYY-MM-dd HH🇲🇲ss,那么传入一个 "12345" 的 string 类型,es1.x 版本是不会报错的,会把 "12345" 转成 long 再转成 UTC 时间;es5.x 版本已经修复这个问题。

如题。

15

es 的 source 是单独作为一个字段存储的,而且是保持传入的样式原样保存,假设字段 A 类型为 long,如果传入的 doc={A: “12345”},即使 A 为 string 类型也是可以正确录入的,但是返回的 source 中字段 A 还是保持 string 形式 "12345",不会转换成配置的 long 类型。

Es 作为存储来说更像是一个文档数据库,下文有提到。

16

es 版本执行写请求时,如果源文档设置的 version 与已存在的文档冲突(默认策略 provided version > stored version 算成功),会报 version conflict 异常,即使是在 index 或者 create 操作时显式设置 version,也有可能会抛出版本冲突异常。

如题,除非修改 VersionType 为 gte(设置 force=true 也行,不建议做,会强行覆盖原数据),否则有并发更新时,如果别的请求先于你的更新,乐观锁检查就会失败。

17

update 时可以通过设置 retry-on-conflict 来降低版本冲突异常出现次数,在遇到 version 冲突时,引擎会根据设置的 retry 次数(默认是 0)来自动重试,如果重试后更新成功则返回成功,当然在极端情况下(重试 n 次之后依然冲突)还是会抛 version conflict 异常。

注意,设置 retryOnConflict 请保证此次操作是幂等的,如果不是,还是在业务程序内处理重试吧,比如一个带状态的字段更新,A 请求更新为 1,B 请求更新为 2,B 先于 A 更新成功,A 报 conflict 异常,如果设置了重试,最终的更新结果是状态变为 1,sad。

18

es 的 match 操作不走缓存,即使索引量较小(几十万文档),一次匹配全文档的 match 操作(match: {title: xxx})至少有几十万次计算(与文档数正相关),如果 qps 很高,同样会因为集群 CPU 过高而阻塞 search 线程池,导致集群无响应。

如题,match 操作每次都会实打实的计算,耗 CPU,搜索应用务必保证基础的结果缓存(需要自己实现)可用,减少 match 请求次数。

19

translog 有 request 落盘的方式(每次写数据都会落盘)和 async 方式(batch,累积一定量数据后落盘),es5.x 默认使用的是 request 方式,也就是优先保障数据不丢;但 es1.x 只有 async 方式,以性能优先,每隔一段时间(默认 5s)检查是否需要将 translog 落盘,在机器掉电情况会有数据丢失风险。

translog 的持久化参数主要有三个:index.translog.durability 控制通过 request 还是 async 方式持久化到磁盘,如果通过 async 方式,index.translog.flush_threshold_size 控制堆积多少数据后触发一次 flush 操作,index.translog.sync_interval 控制间隔多少时间出发一次 flush 操作。

20

如果 Es 应用场景数据更新很频繁,新对象生命周期很短,如果 young 区分配比较小,可能会造成大量短生命周期的新对象涌入 old 区,引起 full gc 导致集群不能正常响应(视 old gc 的算法而定,如果是 g1 则 full gc 会退化成 serial gc),需要控制 young 区在合适的比例,但是也会造成 ygc 的停顿时间变长,表现为比较明显的 rt 毛刺。

视 gc 算法而定,g1 算法的 ygc 也会 stw,cms 算法在 heap 超过 16g 情况下表现不是很好。

21

操作 es 常见的三种异常:DocumentAlreadyExists(文档已存在,有并发 create 操作易发生),VersionConflict(版本冲突,有并发 update 操作易发生),DocumentMissing(文档不存在,有并发的 create/update/delete 操作时易发生)。
Es5.x 取消了 DocumentAlreadyExists 异常,也将其视为 VersionConflict。

22

should 查询子句在 query 和 filter 中语义不同,在 filter 中 should 条件必须至少满足一个(is or not 的问题),在 query 中如果同时存在 must 或 must_not 条件则不要求 should 条件必须满足一个(how well 的问题,should 条件满足会提高查询得分 _score),如果要求 query 中 should 与 filter 的语义类似,可以设置 bool 字句中的 minimum_should_match 参数为 1 解决。

bool query 中的 should 默认不做必须命中要求(只有 should 条件除外),bool filter 中的 should 必要至少命中其中一个子条件。



23

推荐将不用来计算相似度的字段的 norm 属性关闭,比如时间 / 状态等仅用来 filter 或 aggregation 的字段,可以减小索引大小,默认会用 1 byte/doc 来存储字段的 norm 值,即使某个文档根本没有对应的字段。

tf/idf 是经典的相似度计算模型,可以用来理解相似度计算,一个查询先分解为 n 个 term,计算每个 term 的 tf/idf 值(其中就有 norm),然后以该值作为空间向量权重,形成 n 维向量空间,再应用余弦定理计算 n 维空间内查询词与文档的相似度,得到文档匹配分。
Es5.x 默认使用 bm25 作为相似度计算方法了。

24

Es 的核心是倒排索引,也就是 term -> doc_id 的形式,如果是分词字段,索引过程是先分词,然后将分词后的 term 与文档 id 结对成为倒排索引文件,假设索引字段为 title=“测试商品”,采用 bigram 的分词方式,那么最终的倒排索引是 "测试"=>id / “试商”=>id / “商品”=>id;match 查询则是将查询词分词后转为 term,再用 term 去和倒排索引进行 "精确" 匹配,比如查询 "商品",分词后 term=“商品”,和倒排表精确匹配得到 doc_id 即是查询结果,如果搜索 "商",分词 term=“商”,倒排索引中是没有这个 term 的,因此查询结果为空;搜索引擎的 match 查询并不等同与 mysql 的 like 查询,如果要搜索类似 mysql 的 "商品 %" 条件,Es 的表现是不太好的。

Es 没有 B 树索引,prefix 查询是通过状态机实现的。
match 操作能不能搜到结果主要看存储字段分词后的 term 能不能与查询条件分词后的 term 匹配上,假设 "美观建筑真好看" 分词后得到 "美观"/“建筑”/“真”/“好看”/“好”,如果用 term 搜索 "真好看",那么在倒排表中无法找到对应的词条(term 不分词),找不到结果;如果用 match 搜索 "真好看",那么也会被分词为 "真"/“好看”/“好”,这时就可以在倒排表中找到对应的词条了;再比如搜索 "观建",假设分词后得到 "观"/“建”/“观建”,虽然字段中存在这几个字符,但是倒排表中并没有,所以也找不到结果。

25

match_phrase 匹配比较严格,在全匹配的基础上还要求字符顺序一致,可以有效提高查询准确率,但是为了保证召回率,一般情况下会搭配一个半匹配 match 使用,例如:“bool”:{“should”:[{“match_phrase”:{“title”:“test”}},{“match”:{“title”:“test”}}]}。

比如 "漂亮又美观的建筑",通过 match 查询 "漂亮 建筑" 可以得到结果,但是通过 match_phrase 查不到,因为 "漂亮" 和 "建筑" 之间的 position_gap>1,可以通过 slop 参数控制 position_gap 的大小。

26

使用 scan 操作时需注意,es1.x 版本 init scan 不会返回 hits,只有在 next scroll 时才会返回,循环调用 scan 时注意控制条件,第一次应当判断 totalHits>0,后续可以用 hits.length>0 判断。

scan 操作只有 Es1.x 版本支持,Es5.x 版本只提供 scroll。

27

es1.x 版本 scroll 和 scan 是不同的操作,在 scan 时如果设置 size=10,则返回 size num_of_shards 条数据,假设索引分了 5 个 shard,共返回 50 条数据,而普通 scroll 操作时如果设置 size=10,则最多只会返回 size 条数据,即使索引分了 5 个 shard,也只会返回 10 条数据,但是普通 scroll 效率没有 scan 高;es5.x 对默认排序的 scroll 操作做了定向优化来替换 scan,因此只保留了 scroll。

按照段内顺序(doc_id)直接出结果,减去聚合排序步骤,但是得到的结果可以被认为是无序的,doc_id 按照写入顺序排列。

28

es 的数值类型都是带符号的,尽量将数值字段值控制在 signed long 范围内 [-2^63]~[2^63-1],否则只能用分拆方法或者用 string 类型来存储,但是会使排序或者范围查询达不到预期效果。

对于 java 不是很熟悉的同学更需注意,不要因为数值范围限制而导致重建索引。

29

索引 mapping 默认关闭了自动映射功能,写入不在 mapping 中的字段会抛出异常,原因是自动映射是根据第一次遇到的字段内容来推断类型的,假设字段 A 是商品名称且事先未配置 mapping,那么如果第一个写入 es 的 doc 中字段 A 内容是 "12345",es 就会给字段 A 定义为 long 类型,就不符合预期了。

推荐将 mapping 中的 dynamic 设置为 strict,在出现未配置的字段时抛出异常,避免因为字段自动映射错误而导致重建索引(原因见 01 条)。

30

es 通过任务队列来削峰限流,默认 search queue=1000,index queue=200,如果短时流量过高导致队列溢出,就会抛出 EsExecutionReject 异常,而任务队列都是全集群共享的(某索引大量占用队列就会导致其他索引占不到资源,而无法响应),如果多索引共享集群,尽量能控制每个索引的写入速率。

如题,另外最好也能控制每个索引的 match qps,避免过多 match 导致 CPU 资源耗尽。

31

慢查询比较多的索引也同样会堵塞 es 的 search 任务队列(典型的如单一的海量数据索引,短时一个波峰很容易导致 es 抛 Reject 异常),因此线上业务最好根据自身应用场景开启索引的慢查询日志。

Es 默认的慢查询阈值有点大(秒级),一般偏存储类的应用能接受的 rt 在 200ms 以内,线上业务需要做好慢查询优化,比如没有合理使用用 filter 缓存 /terms 条件内 id 过多 / 多次 sort/match 长度过长 / 索引数据量过大等。

32

Es 的 Java API 需要通过捕捉运行时异常来处理异常操作。

如题,写业务代码最好加上 try catch 代码块。

33

es 的增改删(cud)操作本质都是 lucene 的 index/delete 两种操作的组合,update 操作就是先取出 stored 的原文档字段,与本次操作数据合并后重新 index 回索引,然后 delete 原文档,也因此写操作 index queue 其实就包含了所有增改删的任务。

lucene 暴露的接口是 addDocument/updateDocument,而 addDocument 接口还是调用 updateDocument 方法,不要只看名字,其实 updateDocument 就是先写入新文档(不是 partial update),然后标记老文档为 deleted 状态。

34

Es 是一个近实时(NRT)的服务,索引的刷新时间(refresh_interval)控制了文档数据的延时程度,如果设置了 -1,则新增文档或被更新的文档必须等到索引的 translog 达到 commit 条件触发刷新操作后才能可见。

也就是达到 index.translog.flush_threshold_size 配置的大小,进行一次 lucene commit 操作,生成新段并打开,注意不要混淆索引的 flush 操作和 translog 的 fsync 操作。

35

因为 lucene 在删除文档时只是标记删除,标记删除的 doc_id 在查询出候选结果时被用来过滤,标记删除的文档只有在 merge 阶段才会被物理删除,真正释放磁盘空间和机器资源,一般更新比较多的索引残留的 deleted docs 会比较多(更新就是 index+delete 的组合),在实际的搜索过程中,标记删除的索引文档会和普通文档一样会被加载到内存并纳入计算,也会被 decode 到 doc_id,撑大倒排索引,这直接影响到索引的读写性能。

Es 的删除操作(也就是 lucene 的删除操作)是先标记删除,并单独在一个文件中存放标记删除的 doc_id,用于在查询时将删除文档过滤掉;删除的文档只有在段合并(merge)阶段通过重写索引文件才会物理删除。
更新太频繁导致 merge 跟不上新产生的标记删除文档,可以通过 deleted 文档比例来判断更新操作是否过于频繁,尽量合并多个字段的更新为一次请求。

36

Es 带 sort 的查询需要在倒排索引匹配结束后,拿到索引文档的 id_set(此时无序),然后通过 id 获取对应字段的值(fielddata/doc_value),通过优先级队列或者其他容器来计算顺序,建议能少做 sort 就少做。

如题,尽量减少带文档字段排序的请求,如果有多个字段排序,避免第一字段值太单一(第一排序相等,降级到第二字段排序来确定文档顺序)
另外尽量避免用 string/array/nested 等高级类型来做排序,string 排序是字典序,多值字段排序会有更多运行时计算,会拖慢查询影响时间。

37

Es 的 _score 排序表示按照查询匹配分排序,需要注意两点:1. 比如 term 之类的精确匹配其得分是固定值的;2. 模糊查询 match 条件返回的得分经过 normalize 之后也可能会得到相同的得分,得分相同的查询结果会在结果展示上表现出一定的随机性,建议在 _score 之后加上第二排序条件,在匹配分相同时保证顺序固定,比如:[{“_score”:“desc”},{“_id”:“desc”}]。

如题,另外需要注意的是,如果查询的意图是 match,匹配度最高在前,但是又在 sort 条件中指定了字段排序,比如 time:desc,那么得到的结果是满足 match 匹配度(默认 75%)前提下 time 越新的越前(一般是不满足期望的)。解决方法可以是设置匹配度 100%,要求全部文字匹配,或者用上面提到的方法,将 _score 排序放到第一位。

38

close 索引的时候最好先把 alias 去掉,如果一个 alias 包含多个索引,其中一个索引带着 alias 被 close 掉,用这个 alias 来检索会失败。

Es 可以通过一个 alias 来关联多个真实索引,可以实现比如按天切分的索引当作单一索引使用,但是如果 alias 包含了 closed 状态的索引,Es 不会跳过这个索引,而是会抛出 IndexClosed 异常。

39

使用了 nested 字段类型的索引,其创建某个文档,如果其中嵌套了 2 个子文档,加上父文档,总共会创建 2+1 个文档。

使用 nested 字段类型如果嵌套文档过多,会导致索引极速膨胀,影响读写性能,使用嵌套字段务必先了解清楚业务应用场景。

40

lucene 6.x 使用有限状态机 FSA 来提高模糊查询性能(fuzzy/prefix),但是实现有缺陷,使用了递归来判断状态机是否有限状态,如果前缀查询输入了一个长字符串(状态机很大),在调用 Operations.isFinite 方法时会导致栈溢出 StackOverflow 异常,使得执行此查询的索引的 shard 所在进程直接退出,Es6.x 版本修复,6 以下版本建议限制查询长度及正则查询,同时控制索引 shard 个数,避免全集群机器全部宕机。

lucene 内部用了一个递归操作来判断状态机是否有限,如果是个无限状态机或者状态机很大,递归太深就会导致栈溢出。。sad。

41

es5.x 版本在 update 时会判断更新前后的值是否有变化,如果欲更新字段的新值与已存在的值一致,那么会跳过实际的写操作直接返回 OK,所以如果发现更新返回成功,但是 version 没有自增,可以检查是否欲更新字段的值与已存在的值相同。

如果发现更新后 version 没有变化,可以 check 一下是否此类情况,如有依赖 version 自增的业务,这点更是务必明确。

42

Es 作为存储更像是个文档数据库,存的是个 json,返回的数据格式也是 json 反序列化时自动推测的,不会按照预置的 mapping 字段类型返回,Es 设置的 mapping 对存储内容无效,只是在建索引时类型检查 / 转换用。

Es 的 _source 内部是 lucene 的一个 indexed=false/stored=true 的字段,之所以单独存放到一个 _source 字段,猜想是为了提高存储内容的访问速度,如果是用 lucene 索引字段的 stored 内容,取一次 source 就需要遍历全部 fields 了。

43

推荐根据字段的取值来设置字段类型,如小于 7 个枚举值可以用 byte,减少索引文件的 overhead,也避免在 Es 中存储大容量字段,即使不用来索引;可以不索引的字段就不索引(indexed: no),可以减小倒排索引文件,提高读写性能。

lucene 生成的索引文件在存储时会根据字段类型占用的字节长度进行补齐,方便跳跃访问,用精确的字段类型可以减少 io 上的虚耗。
Es 中存储的大字段也会在段文件中保存(段文件有很多不同用途文件组成),会影响读写性能(merge 操作会变多,也变慢,访问频率如果很高也会导致内存中不断生成大对象)

44

分词后的字段会变成小粒度的词条,比如 "美观的建筑" 分词后可能会变成 "美观"/“建筑”(视分词算法而定),不会保留原始内容("美观的建筑" 不会进入倒排表),也就无法用 term 条件来查询了,只能用 match。

如果需要同时对某个字段进行多种分词(包括不分词),可以通过 multi-field 来解决。
分词问题详见第 24 条。
这里列了遇到比较的一些点,不是全部,更多的细节还需要深入挖掘。


本文地址:http://www.6aiq.com/article/1559924453879
知乎专栏 点击关注
本文版权归作者和AIQ共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出