↓ 跳过正文
  1. Posts/

基于 ElasticSearch 开发垂直搜索系统

·3979 字·8 分钟·

一,背景介绍
#

ElasticSearch 是由 Lucene 包装上分布式复制一致性算法等附加功能,构成的开源搜索引擎系统。

近两年在业界热度大增,主要有 3 种应用场景:

  1. 全文搜索引擎
  2. NOSQL 数据库
  3. 日志分析数据库 ELK

很多垂直领域搜索需求,都可以基于 ElasticSearch 来设计架构。

ElasticSearch 能大幅度提升相关业务的迭代开发速度,实现类似 sql 数据库增删改查一样的快速开发。 并在相对高 qps 的在线业务中,保证毫秒级的延迟,提供极高的可用性和稳定性。

经过持续的研读官方文档,调研业界经验,并在实践中应用反思后,总结出一套架构方案。供参考,欢迎意见建议。

二,架构方案
#

一个 ElasticSearch 集群 cluster ,配套:

  1. 转发代理 proxy
  2. 队列

1. 转发代理 proxy
#

proxy 的功能是:

  1. 协议转换 ,做 rpc 协议如 protobuf 和 ElasticSearch REST json 之间的协议转换, 对文档更新等请求,定义了 rpc 协议,便于队列做各种处理。
  2. 引入 RPC 框架成熟的负载均衡,容灾,故障屏蔽等功能,到对 ES 的 RPC 中, 比如如果单机 ES 进程挂了,通过返回码让调用方自动换机器重试。
  3. 统一监控告警系统,监控各种请求失败,延迟分布等,并监控 ElasticSearch java 进程状态,集群状态
  4. 转发文档更新请求给本机的队列 。用队列做削峰填谷,自动合并批量,做限流。
  5. 提供双写能力,便于索引升级切换
  6. proxy 到本机 ES 做了 http 连接池,避免频繁的 HTTP tcp 建连接。

2. 队列
#

队列 实现了 出队限流,请求合并,削峰填谷 3个功能。

在实际业务中,常常会定期做文档全量更新,会出现短时间内写请求高峰,

如果直接写 ES,请求高峰时,经常出现 ES write 线程池占满,导致部分写请求失败。

另外部分业务每次请求只更新1个文档,导致 ES cpu 高,影响 ES 的写性能,不符合官方推荐做法。

为此,引入队列:

  1. 配置限制了出队的 QPS ,确保集中高峰被抹平,以匀速稳定地写入 ES,彻底消除了更新失败。

  2. 并用配置自动把多个请求合并成批量 (比如 5000个文档一个批量),优化了 ES 的写入性能。

  3. 请求高峰中超出配置 QPS 的请求,队列自动暂存在文件中,随后处理,保证了 ES 服务平稳。

3. 其他工具
#

另外,繁荣的 ES 开源生态中,周边工具非常丰富便捷, 我们常用的两种周边工具:kinana 和 bin/elasticsearch-sql-cli,极其方便快捷,大幅度提升了开发效率。

三,搜索应用开发优化指南
#

垂直搜索系统的在线检索部分,一般流程如下

ES 用来实现 召回和粗排环节 ,和部分自动补全环节。

基于 ES 开发的优点:

  1. ES/Lucene 的 Query DSL 极其强大全面灵活,业务逻辑代码大幅度简化,开发简单便捷,业务迭代开发速度大大提高。
  2. 有商业公司维护的高质量官方文档, 网上也有海量资料,新人几天就可以上手,快速形成生产力,提升团队效率。
  3. 成熟稳定,就目前经验看没有遇到过 bug
  4. 业务如果扩展,后续伸缩性,扩展性,分 shard ,多副本等,都有比较成熟方案。

1. query DSL 语法
#

基于 ES 的开发,首先需要学习常见的几种 query,

ES 的 query 简单分成 4 类:

  1. term query,对单个词的 query,包括 term/terms/range/exists/missing/ids/regexp 等
  2. full text query ,全文检索query,对多个词(即句子)的query,包括 match/multi_match/common 等
  3. compound query 复合 query,包括 bool/dis_max/function_score 等
  4. match_all ,简单匹配所有文档

建议先学习 term/match/range/bool ,就可以实现大部分业务逻辑。

网上资料较多,就不转述了。

可以先看看这些中文资料,在 test 环境的 kibana 做做实验,快速上手:

https://www.elastic.co/guide/cn/elasticsearch/guide/current/search-in-depth.html

https://my.oschina.net/yumg/blog/637409

https://www.cnblogs.com/yjf512/p/4897294.html

当然最好的还是官方英文文档:

https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html

2. 分词
#

中文搜索的一个核心议题,就是分词。

ElasticSearch 常用的中文分词是 ik analyzer。ik 是开箱即用,便于小型业务快速开发的。

但是作为对分词可定制性要求较高的业务,我们实际测试,发现 ik analyzer :

  1. 不支持本地自定义词典文件的热加载;
  2. 无法针对不同 index 配置不同自定义词典;
  3. 另外对一些分词的 bad case ,比如没有正确切分的词,没法简单 fix。

因此推荐不用 ik ,而是在更新文档和搜索的时候,在外部做分词,然后用空格拼起来,传给 ES 做索引/搜索。这种方案中,在 ES mapping 中配置成 whitespace 分词器。

外部分词可以用 cppjieba 等,索引分词还可以合并多种分词算法结果提高召回率。

对 cppjieba ,我之前做过内存优化,将内存优化到了 1/100。

另外,索引之前,也有必要做 UTF8 的 normalize,全角转半角,英文大小写统一,和英文的词干提取, mapping 中常用

"cjk_width", "lowercase", "porter_stem"

这些filter

具体可以参考已有业务代码。

3.关系型搜索
#

实际开发遇到典型的 one-many 关系型数据上的 query,

比如在某业务中,就遇到这种逻辑,经过调研发现常见有 4 种方案:

  1. 分开2 个 index : one + many ,分开2次串行 search, 问题: 需要2次延迟大

  2. 反范式,完全展开,one 的数据追加到每一个 many 文档中, 问题:数据量变大,更新 one 需要用 _update_by_query 如果 one 数据更新频繁,可能导致大量写操作

  3. nested ,比如 one 嵌套 many 子文档 问题是:nested 嵌套文档更新需要更新整个 root 文档,即要把整个 one 文档 和含有的 many 文档 select 出来,修改,再写回。 对热门 one 文档, 更新会操作大量数据,并发写还可能 data race。

  4. join, has_parent, has_child 把 one 和 many 的所有字段合并到一个 index 中, one 和 many 分别独立更新。

经过实际数据测试 join field 方案, 发现当 one:many = 1:1000万 时, 延迟在 5ms 可以接受,因此目前采用了这种方案。

当然,官方文档指出 join 性能是会慢的,后续也有待实践检验。

4. 粗排相关性打分
#

Lucene 从 2016年的 6.0 版本开始,默认的相关性算法切换成了 bm25 , bm25 是一种调整过的 tf idf 算法。

这里可以做一简单举例介绍,更深入的介绍可以参见下面文章,以及官方文档:

https://farer.org/2018/09/10/practical-bm25-part-1-how-shards-affect-relevance-scoring-in-elasticsearch/

https://www.cnblogs.com/richaaaard/p/5254988.html

ES 的 explain 对 bm25 算分的过程有详尽的解释,推荐自行实验。

https://www.elastic.co/guide/en/elasticsearch/reference/current/search-explain.html

4.1. BM25 例解
#

比如某业务的真实数据中,我们在所有文档的 title 这个 field 搜索 “牛奶 ” 这个词,

explain 可以看到,这个 bm25 分数的是这样得来的:

sum( weight(title:牛奶 in 77341) [PerFieldSimilarity] ) ,
weight(title:牛奶 in 77341) [PerFieldSimilarity] = idf * tfNorm

首先,如果某 field 被多个 term 命中,分别算每个 term 的分数 (PerFieldSimilarity),然后求和,本例子只有1个 term “牛奶”。

每个 term 的分数 PerFieldSimilarity

PerFieldSimilarity = idf * tfNorm

而 idf 表征词的重要程度,与具体文档无关。

idf = log(1 + (docCount - docFreq + 0.5) / (docFreq + 0.5))

其中 docFreq 就是本shard 中,有多少个文档含有 “牛奶”, docCount 就是本shard 一共有多少个文档。

tfNorm = (freq * (k1 + 1)) / (freq + k1 * (1 - b + b * fieldLength / avgFieldLength))
termFreq=1.0
k1=1.2
b=0.75
avgFieldLength=14.456173
fieldLength=2 //比如如果 title 是 "牛奶 醪糟" ,那就是 2个 term

freq 即该 field 中,“牛奶”这个词出现了几次 k1 和 b 都是固定常数。 fieldLength 是当前文档的当前field ,一共有多少个 term。 avgFieldLength 即本 shard 中的所有文档的本 field 的 fieldLength 的平均值

4.2. BM25 shard 调整
#

实际业务发现,当 index 内文档太少(比如 10w 量级就算少) 时 , 有的词在多个 shard 内,词频分布会出现严重不均匀,可能会导致 bm25 分数产生较大偏差,

实践中的解决办法:

  1. Search 的 url_parameters 参数填 “&search_type=dfs_query_then_fetch”
  2. 减少 shard 数

4.3. BM25 similarity 参数调整
#

如上计算过程可见,bm25 中的 b 参数,是用来给短文档做加权的,即 b 越大,越倾向于给短文档更高的 score, 实际中,和算法同学一起分析后,发现针对我们的某业务,不应该对短文本有太高偏向,所以我们把 b 调整成了 0.3 , 实测发现解决了一批 bad case,用户体验有明显改善。

四,性能优化
#

计算机程序的性能取决于数据结构和算法, ES/Lucene 中主要有几种数据结构:

  1. FST
  2. Posting List ,著名的倒排索引,PForDelta 压缩,支持 SkipList 方式跳跃
  3. BKD Tree,用来实现 int 和 geo 查询
  4. DocValues , 以 DocID 为 Key 的列存储

https://zhuanlan.zhihu.com/p/47951652

https://www.elastic.co/blog/elasticsearch-query-execution-order

更深入的理解,我目前也在探索中。

在垂直搜索引擎业务中,用户对延迟非常敏感,一般业界经验认为,良好的用户体验应该是在 ** 200毫秒 ** 内返回搜索结果, 这就意味着 ES 延迟最好控制在 100毫秒之内。

经过我们实际业务发现,决定 ES 延迟的因素主要有:

  1. 内存是否足够, page cache 是否 cache 了检索过程用到的文件数据
  2. 具体 query 的优化,类比 mysql query 优化

1. page cache 内存优化
#

page cache 是决定 ES 延迟的首要因素,用作在线检索服务的 ES , 实际中在线检索的代码路径不能有硬盘 io 访问 (实践证明, SSD也不行)

当 ES 用作在线垂直搜索引擎时,

《查询亿级数据毫秒级返回!牛逼哄哄的ElasticSearch是如何做到的?》 https://zhuanlan.zhihu.com/p/68706615

《ElasticSearch在数十亿级别数据下,如何提高查询效率?》 https://zhuanlan.zhihu.com/p/60458049

实践中,某 index 发现延迟非常高,达到了 1-2秒,用户体验很差。 调查发现,iostat 看下 io util 很高,经常到 80% 90%,单机索引数据文件是 page cache 可用内存的 4倍, 于是降低了副本数,单机数据量减少到 page cache 可用内存2倍后, 硬盘 io 降到了 0 ,延迟一下降低到了 150ms 。

2. int 字段查询优化
#

业务中常会有一些 int 型的字段,存一些枚举性质的值。 在 10亿以上文档的情况下,实际发现有的会出性能问题。

比如 比如前述业务有1个 int 类型的 filter 字段,实际只有 {0,1} 2种取值,

借助 ES 的 profile ,我们发现搜索 query 93% 的耗时在 filter 字段的 PointInSetQuery 中,

随后发现,针对该业务,只需要返回 filter 为 0 的文档,于是我们在更新文档时,发现 filter 非0 的文档,直接把所有字段都清空,并随后在 query 中去掉了 filter 字段的过滤。

之后发现耗时从 150ms 降到了 20ms。

3. 副本数 replicas num
#

确定副本数的思路:

  1. 副本数越小越好。越小,单机数据越少,文件被 cache 的比例越高,性能越好。
  2. 副本太少会影响可用性。因此必须大于最大能容忍故障单机个数 max_failures 。

综合起来就是:

max(max_failures, ceil(num_nodes / num_primaries) - 1).

num_primaries 是 primary shards 的数量,就是一个 index 有多少个 shards,一般都 > num_nodes

replicas_might_help_with_throughput_but_not_always

对数据量特别少的 index,可以每台机都存一个副本 “auto_expand_replicas”: “0-all”,

4. 多 SSD
#

在 elasticsearc.yml 的 path.data 配置多个路径,ES 会自动把 shard 均分到多个路径上,如果有多个硬盘,可以充分利用多设备的 io 带宽,当然对在线业务意义不大。

5. 内存配置
#

最开始我们使用 16G 内存机型, 后来发现出现大量 Elasticsearch Data too large Error 错误,随后发现,解决办法就是换到 64G 内存机型,

改 jvm.options 加大 jvm 的 heap 解决,从 10G 加大到 30G 解决 -Xms30g -Xmx30g

需要注意的是,不建议大于 32G,避免 jvm 的指针压缩优化失效。 可以看 ES 的启动 log 确定

[2019-05-22T12:29:16,961][INFO ][o.e.e.NodeEnvironment    ] [node_xxx] heap size [29.7gb], compressed ordinary object pointers [true]

https://www.elastic.co/cn/blog/a-heap-of-trouble

6. refresh_interval
#

如网上众多文章所说, refresh_interval 一般都设成了 30秒。

一些参考资料:

相关文章

用 abstract unix socket 实现进程单实例运行

·802 字·2 分钟
一,问题背景 # 很多时候,我们需要确保进程只有一个实例运行。 有几种方法: http://stackoverflow.com/questions/2964391/preventing-multiple-process-instances-on-linux http://stackoverflow.com/questions/5339200/how-to-create-a-single-instance-application-in-c-or-c https://github.com/qtproject/qt-solutions/tree/master/qtsingleapplication/src 比较常规的做法,是对一个文件加文件锁 flock,比如对 pid 文件 flock( LOCK_EX|LOCK_NB ) 但是这种方法有些弊端: 如果文件被 mv 或者 rm,是会被绕过的。 如果磁盘故障比如磁盘满,目录没有写权限,会失败。 二,abstract namespace unix socket # http://linux.die.net/man/7/unix unix socket 有3种: 基于文件的 socketpair 创建的,匿名的 abstract namespace 的,Linux特有 Linux 下, AF_UNIX socket 支持一种特殊的 abstract namespace unix socket 。 相比 普通的基于文件系统的 unix socket,abstract namespace unix socket : 没有磁盘文件 进程挂了以后自动删除,无残留文件 无需担心与 文件系统上的文件冲突,不需要关心文件系统上的绝对路径是否存在的问题

借助 ext4 文件系统的 打洞 功能实现消息队列

·217 字·1 分钟
借助 ext4 文件系统的 打洞 功能,可以实现一个消息队列 https://gist.github.com/CAFxX/571a1558db9a7b393579 fallocate(fd, FALLOC_FL_PUNCH_HOLE|FALLOC_FL_KEEP_SIZE, offset, length); Punching holes in files https://lwn.net/Articles/415889/ 怎样对大文件的中间部分进行增删? 首先,如果复制/写文件的后半段的话,肯定是可以实现的,但是这样不“高效”。 所以这里的需求是 “怎样在大文件中间 insert 或者 delete 一段数据而不用读写文件的后半部分”。

Linux LoadAvg 编程比赛

·547 字·2 分钟
问题背景 # https://timyang.net/programming/load-average/ 2015.08.13 高可用架构群 Load 编程比赛,Tim 在群征集一段代码使 load average 最高 多线程写几个 while 循环的方法太 trivial 了,就不提了。 下面是 byronhe 的两个解:

用 DAT 重实现 CppJieba 中文分词算法,降低 99% 内存消耗

·995 字·2 分钟
一,问题背景 # 中文分词应用比较广泛的开源算法,是 jieba 结巴分词,结巴分词较高性能的实现是 C++ 版本的 CppJieba : https://github.com/yanyiwu/cppjieba 在实际使用 CppJieba 的过程中,我们发现 CppJieba 的内存占用比较高。 比如对一个 76W 词 大小 11MB 的词典 ,加载 2份 (比如为了支持平滑改动用户词典)就需要耗费 505MB内存。 这对一些多进程的后台服务,浪费大量内存,难以接受,因此这里希望削减内存耗费。 经过初步调查,确定改进方法,然后动手改造,最终把 505MB 缩减到了 4.7MB ,实现了 99% 内存降低。 此处也有 issue 讨论 https://github.com/yanyiwu/cppjieba/issues/3 代码在 https://github.com/byronhe/cppjieba 。

GB 规模语料上的高性能新词发现算法

·398 字·1 分钟
分词是中文搜索的重要环节,目前分词算法已经比较成熟,分词错误的主要是由于未登录词。 因此发现业务领域语料库中的新词,减少未登录词,对改善搜索引擎的用户体验有重要意义。 新词发现的一种常用算法,是 matrix67 大神 2012 年提出的 《互联网时代的社会语言学:基于SNS的文本数据挖掘》 https://www.matrix67.com/blog/archives/5044 其主要思路,是统计语料中出现的所有 ngram 子字符串的凝固度,自由度,信息熵。 算法中需要统计所有 ngram 子字符串的 左熵右熵,实现该算法时,一般以子字符串为 key,用哈希表来存。 但随着语料库变大时,内存消耗变大, 比如之前的 python 版本实现,对 200MB 的语料,就需要约 30G 内存来存哈希表, 导致单机内存不足无法运行,而且对这样规模的语料库,算法需要跑一两天才能出结果。 这里我应用一些工程实现方面的技巧, 把用哈希表统计左熵右熵的计算,拆分成多个子哈希表,分批计算,并利用多核并行,大幅度优化了算法的性能。