贷前系统ElasticSearch实践总结 (信贷前置系统)

贷前系统ElasticSearch实践总结 (信贷前置系统)

贷前系统负责从进件到放款前所有业务流程的实现,其中涉及一些数据量较大、条件多样且复杂的综合查询,引入 ElasticSearch 主要是为了提高查询效率;并希望基于 ElasticSearch 快速实现一个简易的数据仓库,提供一些 OLAP 相关功能。有一些心得总结,向大家介绍一下。

一 索引

描述:为快速定位数据而设计的某种数据结构。

索引好比是一本书前面的目录,能加快数据库的查询速度。了解索引的构造及使用,对理解 ES 的工作模式有非常大的帮助。

常用索引介绍

1.1.位图索引(BitMap)

位图索引适用于字段值为可枚举的有限个数值的情况

下图 1 为用户表,存储了性别和婚姻状况两个字段;

图 2 中 分别为性别和婚姻状态建立了两个位图索引。

例如:性别->男 对应索引为:101110011,表示第 1、3、4、5、8、9 个用户为男性。其他属性以此类推。

使用位图索引查询:

•男性 并且已婚 的记录 = 101110011 & 11010010 = 100100010,即第 1、4、8 个用户为已婚男性。

•女性 或者未婚的记录 = 010001100 | 001010100 = 011011100, 即第 2、3、5、6、7 个用户为女性或者未婚。

1.2.哈希索引

顾名思义,是指使用某种哈希函数实现 key->value 映射的索引结构。

哈希索引适用于等值检索,通过一次哈希计算即可定位数据的位置。

下图 3 展示了哈希索引的结构,与 JAVA 中 HashMap 的实现类似,是用冲突表的方式解决哈希冲突的。

图三

1.3.BTREE 索引

BTREE 索引是关系型数据库最常用的索引结构,方便了数据的查询操作。

BTREE: 有序平衡 N 阶树, 每个节点有 N 个键值和 N+1 个指针, 指向 N+1 个子节点。

一棵 BTREE 的简单结构如下图 4 所示,为一棵 2 层的 3 叉树,有 7 条数据:

图四

以 Mysql 最常用的 InnoDB 引擎为例,描述下 BTREE 索引的应用。

Innodb 下的表都是以索引组织表形式存储的,也就是整个数据表的存储都是 B+tree 结构的,如图 5 所示。

图 5

主键索引为图 5 的左半部分(如果没有显式定义自主主键,就用不为空的唯一索引来做聚簇索引,如果也没有唯一索引,则 innodb 内部会自动生成 6 字节的隐藏主键来做聚簇索引),叶子节点存储了完整的数据行信息(以主键 + row_data 形式存储)。

二级索引也是以 B+tree 的形式进行存储,图 5 右半部分,与主键不同的是二级索引的叶子节点存储的不是行数据,而是索引键值和对应的主键值,由此可以推断出,二级索引查询多了一步查找数据主键的过程。

维护一颗有序平衡 N 叉树,比较复杂的就是当插入节点时节点位置的调整,尤其是插入的节点是随机无序的情况;而插入有序的节点,节点的调整只发生了整个树的局部,影响范围较小,效率较高。

可以参考红黑树的节点的插入算法:

–black_tree

因此如果 innodb 表有自增主键,则数据写入是有序写入的,效率会很高;如果 innodb 表没有自增的主键,插入随机的主键值,将导致 B+tree 的大量的变动操作,效率较低。这也是为什么会建议 innodb 表要有无业务意义的自增主键,可以大大提高数据插入效率。

注:

•Mysql Innodb 使用自增主键的插入效率高。

•使用类似 Snowflake 的 ID 生成算法,生成的 ID 是趋势递增的,插入效率也比较高。

1.4.倒排索引(反向索引)

倒排索引也叫反向索引,可以相对于正向索引进行比较理解。

正向索引反映了一篇文档与文档中关键词之间的对应关系;给定文档标识,可以获取当前文档的关键词、词频以及该词在文档中出现的位置信息,如图 6 所示,左侧是文档,右侧是索引。

图 6

反向索引则是指某关键词和该词所在的文档之间的对应关系;给定了关键词标识,可以获取关键词所在的所有文档列表,同时包含词频、位置等信息,如图 7 所示。

图 7

反向索引(倒排索引)的单词的集合和文档的集合就组成了如图 8 所示的”单词-文档矩阵“,打钩的单元格表示存在该单词和文档的映射关系。

图 8

倒排索引的存储结构可以参考图 9。其中词典是存放的内存里的,词典就是整个文档集合中解析出的所有单词的列表集合;每个单词又指向了其对应的倒排列表,倒排列表的集合组成了倒排文件,倒排文件存放在磁盘上,其中的倒排列表内记录了对应单词在文档中信息,即前面提到的词频、位置等信息。

图 9 倒排索引结构

下面以一个具体的例子来描述下,如何从一个文档集合中生成倒排索引。

如图 10,共存在 5 个文档,第一列为文档编号,第二列为文档的文本内容。

将上述文档集合进行分词解析,其中发现的 10 个单词为:[谷歌,地图,之父,跳槽,Facebook,加盟,创始人,拉斯,离开,与],以第一个单词”谷歌“为例:首先为其赋予一个唯一标识 ”单词 ID“, 值为 1,统计出文档频率为 5,即 5 个文档都有出现,除了在第 3 个文档中出现 2 次外,其余文档都出现一次,于是就有了图 11 所示的倒排索引。

1.4.1.单词词典查询优化

对于一个规模很大的文档集合来说,可能包含几十万甚至上百万的不同单词,能否快速定位某个单词,这直接影响搜索时的响应速度,其中的优化方案就是为单词词典建立索引,有以下几种方案可供参考:

词典 Hash 索引

Hash 索引简单直接,查询某个单词,通过计算哈希函数,如果哈希表命中则表示存在该数据,否则直接返回空就可以;适合于完全匹配,等值查询。如图 12,相同 hash 值的单词会放在一个冲突表中。

词典 BTREE 索引

类似于 Innodb 的二级索引,将单词按照一定的规则排序,生成一个 BTree 索引,数据节点为指向倒排索引的指针。

二分查找

同样将单词按照一定的规则排序,建立一个有序单词数组,在查找时使用二分查找法;二分查找法可以映射为一个有序平衡二叉树,如图 14 这样的结构。

FST(Finite State Transducers )实现

FST 为一种有限状态转移机,FST 有两个优点:1)空间占用小。通过对词典中单词前缀和后缀的重复利用,压缩了存储空间;2)查询速度快。O(len(str))的查询时间复杂度。

以插入“cat”、 “deep”、 “do”、 “dog” 、“dogs”这 5 个单词为例构建 FST(注:必须已排序)。

如图 15 最终我们得到了如上一个有向无环图。利用该结构可以很方便的进行查询,如给定一个词 “dog”,我们可以通过上述结构很方便的查询存不存在,甚至我们在构建过程中可以将单词与某一数字、单词进行关联,从而实现 key-value 的映射。

当然还有其他的优化方式,如使用 Skip List、Trie、Double Array Trie 等结构进行优化,不再一一赘述。

二 ElasticSearch 使用心得

下面结合贷前系统具体的使用案例,介绍 ES 的一些心得总结。

基本概念

•索引(index)

ES 的索引,也就是 Index,和前面提到的索引并不是一个概念,这里是指所有文档的集合,可以类比为 RDB 中的一个数据库。

•文档(document)

即写入 ES 的一条记录,一般是 JSON 形式的。

•映射(Mapping)

文档数据结构的元数据描述,一般是 JSON schema 形式,可动态生成或提前预定义。

类型(type)

由于理解和使用上的错误,type 已不推荐使用,目前我们使用的 ES 中一个索引只建立了一个默认 type。

•节点

一个 ES 的服务实例,称为一个服务节点。为了实现数据的安全可靠,并且提高数据的查询性能,ES 一般采用集群模式进行部署。

•集群

多个 ES 节点相互通信,共同分担数据的存储及查询,这样就构成了一个集群。

•分片

分片主要是为解决大量数据的存储,将数据分割为若干部分,分片一般是均匀分布在各 ES 节点上的。需要注意:分片数量无法修改。

•副本

分片数据的一份完全的复制,一般一个分片会有一个副本,副本可以提供数据查询,集群环境下可以提高查询性能。

安装部署

•JDK 版本: JDK1.8

•安装过程比较简单,可参考官网:下载安装包 -> 解压 -> 运行

•安装过程遇到的坑:

ES 启动占用的系统资源比较多,需要调整诸如文件句柄数、线程数、内存等系统参数,可参考下面的文档。

实例讲解

下面以一些具体的操作介绍 ES 的使用:

•初始化索引

初始化索引,主要是在 ES 中新建一个索引并初始化一些参数,包括索引名、文档映射(Mapping)、索引别名、分片数(默认:5)、副本数(默认:1)等,其中分片数和副本数在数据量不大的情况下直接使用默认值即可,无需配置。

下面举两个初始化索引的方式,一个使用基于 Dynamic Template(动态模板) 的 Dynamic Mapping(动态映射),一个使用显式预定义映射。

1.动态模板 (Dynamic Template)

<pstyle=``"line-height:2em;"``><spanstyle=``"font-size:14px;"``>curl-XPUThttp:``//ip:9200/loan_idx-H'content-type:application/json'<br>-d'{"mappings":{"order_info":{"dynamic_date_formats":["yyyy-MM-ddHH:mm:ss||yyyy-MM-dd],<br>"dynamic_templates":[<br>{"orderId2":{<br>"match_mapping_type":"string",<br>"match_pattern":"regex",<br>"match":"^orderId$",<br>"mapping":{<br>"type":"long"<br>}<br>}<br>},<br>{"strings_as_keywords":{<br>"match_mapping_type":"string",<br>"mapping":{<br>"type":"keyword",<br>"norms":false<br>}<br>}<br>}<br>]<br>}<br>},<br>"aliases":{<br>"loan_alias":{}<br>}}'<br></span></p>

2.预定义映射

预定义映射和上面的区别就是预先把所有已知的字段类型描述写到 mapping 里,下图截取了一部分作为示例:

图 16 中 JSON 结构的上半部分与动态模板相同,红框中内容内容为预先定义的属性:apply.applyInfo.appSubmissionTime, apply.applyInfo.applyId, apply.applyInfo.applyInputSource 等字段,type 表明了该字段的类型,映射定义完成后,再插入的数据必须符合字段定义,否则 ES 将返回异常。

•常用数据类型:

常用的数据类型有 text, keyword, date, long, double, boolean, ip

实际使用中,将字符串类型定义为 keyword 而不是 text,主要原因是 text 类型的数据会被当做文本进行语法分析,做一些分词、过滤等操作,而 keyword 类型则是当做一个完整数据存储起来,省去了多余的操作,提高索引性能。

配合 keyword 使用的还有一个关键词 norm,置为 false 表示当前字段不参与评分;所谓评分是指根据单词的 TF/IDF 或其他一些规则,对查询出的结果赋予一个分值,供展示搜索结果时进行排序, 而一般的业务场景并不需要这样的排序操作(都有明确的排序字段),从而进一步优化查询效率。

•索引名无法修改

初始化一个索引,都要在 URL 中明确指定一个索引名,一旦指定则无法修改,所以一般建立索引都要指定一个默认的别名(alias):

<pstyle=``"line-height:2em;"``><spanstyle=``"font-size:14px;"``>``"aliases"``:{``"loan_alias"``:{}<br>}<br></span></p>

别名和索引名是多对多的关系,也就是一个索引可以有多个别名,一个别名也可以映射多个索引;在一对一这种模式下,所有用到索引名的地方都可以用别名进行替换;别名的好处就是可以随时的变动,非常灵活。

•Mapping 中已存在的字段无法更新

如果一个字段已经初始化完毕(动态映射通过插入数据,预定义通过设置字段类型),那就确定了该字段的类型,插入不兼容的数据则会报错,比如定义了一个 long 类型字段,如果写入一个非数字类型的数据,ES 则会返回数据类型错误的提示。

这种情况下可能就需要重建索引,上面讲到的别名就派上了用场;一般分 3 步完成:1)新建一个索引将格式错误的字段指定为正确格式,2)使用 ES 的 Reindex API 将数据从旧索引迁移到新索引,3)使用 Aliases API 将旧索引的别名添加到新索引上,删除旧索引和别名的关联。

上述步骤适合于离线迁移,如果要实现不停机实时迁移步骤会稍微复杂些。

基本的操作就是增删改查,可以参考 ES 的官方文档:

一些比较复杂的操作需要用到 ES Script,一般使用类 Groovy 的 painless script,这种脚本支持一些常用的 JAVA API(ES 安装使用的是 JDK8,所以支持一些 JDK8 的 API),还支持 Joda time 等。

举个比较复杂的更新的例子,说明 painless script 如何使用:

需求描述:

Painless Script 如下:

<pstyle=``"line-height:2em;"``><spanstyle=``"font-size:14px;"``>POSTloan_idx/_update_by_query<br>{``"script"``:{``"source"``:``"longgetDayDiff(defdateStr1,defdateStr2){<br>LocalDateTimedate1=toLocalDate(dateStr1);LocalDateTimedate2=toLocalDate(dateStr2);ChronoUnit.DAYS.between(date1,date2);<br>}<br>LocalDateTimetoLocalDate(defdateStr)<br>{<br>DateTimeFormatterformatter=DateTimeFormatter.ofPattern(\"yyyy-MM-ddHH:mm:ss\");LocalDateTime.parse(dateStr,formatter);<br>}<br>if(getDayDiff(ctx._source.appSubmissionTime,ctx._source.lenssonStartDate)<2)<br>{<br>ctx._source.expectLoanDate=ctx._source.appSubmissionTime<br>}"``,``"lang"``:``"painless"``<br>}<br>,``"query"``:<br>{``"bool"``:{``"filter"``:[<br>{``"bool"``:{``"must"``:[<br>{``"range"``:{<br>``"appSubmissionTime"``:<br>{<br>``"from"``:``"2018-09-1000:00:00"``,``"to"``:``"2018-09-1023:59:59"``,``"include_lower"``:``true``,``"include_upper"``:``true``<br>}<br>}<br>}<br>]<br>}<br>}<br>]<br>}<br>}<br>}<br></span></p>

解释:整个文本分两部分,下半部分 query 关键字表示一个按范围时间查询(2018 年 9 月 10 号),上半部分 script 表示对匹配到的记录进行的操作,是一段类 Groovy 代码(有 Java 基础很容易读懂),格式化后如下, 其中定义了两个方法 getDayDiff()和 toLocalDate(),if 语句里包含了具体的操作:

<pstyle=``"line-height:2em;"``><spanstyle=``"font-size:14px;"``>longgetDayDiff(defdateStr1,defdateStr2){<br>LocalDateTimedate1=toLocalDate(dateStr1);<br>LocalDateTimedate2=toLocalDate(dateStr2);<br>ChronoUnit.DAYS.between(date1,date2);<br>}<br>LocalDateTimetoLocalDate(defdateStr){<br>DateTimeFormatterformatter=DateTimeFormatter.ofPattern(``"yyyy-MM-ddHH:mm:ss"``);<br>LocalDateTime.parse(dateStr,formatter);<br>}``if``(getDayDiff(ctx._source.appSubmissionTime,ctx._source.lenssonStartDate)<``2``){<br>ctx._source.expectLoanDate=ctx._source.appSubmissionTime<br>}<br></span></p>

然后提交该 POST 请求,完成数据修改。

•查询数据

这里重点推荐一个 ES 的插件 ES-SQL:

这个插件提供了比较丰富的 SQL 查询语法,让我们可以使用熟悉的 SQL 语句进行数据查询。其中,有几个需要注意的点:

•ES-SQL 使用 Http GET 方式发送情况,所以 SQL 的长度是受限制的(4kb),可以通过以下参数进行修改:

http.max_initial_line_length: “8k”

•计算总和、平均值这些数字操作,如果字段被设置为非数值类型,直接使用 ESQL 会报错,可改用 painless 脚本。

•使用 Select as 语法查询出的结果和一般的查询结果,数据的位置结构是不同的,需要单独处理。

•NRT(Near Real Time):准实时

向 ES 中插入一条记录,然后再查询出来,一般都能查出最新的记录,ES 给人的感觉就是一个实时的搜索引擎,这也是我们所期望的,然而实际情况却并非总是如此,这跟 ES 的写入机制有关,做个简单介绍:

–Lucene 索引段 -> ES 索引

写入 ES 的数据,首先是写入到 Lucene 索引段中的,然后才写入 ES 的索引中,在写入 ES 索引前查到的都是旧数据。

–commit:原子写操作

索引段中的数据会以原子写的方式写入到 ES 索引中,所以提交到 ES 的一条记录,能够保证完全写入成功,而不用担心只写入了一部分,而另一部分写入失败。

–refresh:刷新操作,可以保证最新的提交被搜索到

索引段提交后还有最后一个步骤:refresh,这步完成后才能保证新索引的数据能被搜索到。

出于性能考虑,Lucene 推迟了耗时的刷新,因此它不会在每次新增一个文档的时候刷新,默认每秒刷新一次。这种刷新已经非常频繁了,然而有很多应用却需要更快的刷新频率。如果碰到这种状况,要么使用其他技术,要么审视需求是否合理。

不过,ES 给我们提供了方便的实时查询接口,使用该接口查询出的数据总是最新的,调用方式描述如下:

GET:PORT/index_name/type_name/id

上述接口使用了 HTTP GET 方法,基于数据主键(id)进行查询,这种查询方式会同时查找 ES 索引和 Lucene 索引段中的数据,并进行合并,所以最终结果总是最新的。但有个副作用:每次执行完这个操作,ES 就会强制执行 refresh 操作,导致一次 IO,如果使用频繁,对 ES 性能也会有影响。

•数组处理

数组的处理比较特殊,拿出来单独讲一下。

–表示方式就是普通的 JSON 数组格式,如:

•[1, 2, 3]、 [“a”, “b”]、 [ { “first” : “John”, “last” : “Smith” },{“first” : “Alice”, “last” : “White”} ]

–需要注意 ES 中并不存在数组类型,最终会被转换为 object,keyword 等类型。

–普通数组对象查询的问题

普通数组对象的存储,会把数据打平后将字段单独存储,如:

<pstyle=``"line-height:2em;"``><spanstyle=``"font-size:14px;"``>{``"user"``:[<br>{``"first"``:``"John"``,``"last"``:``"Smith"``<br>},<br>{``"first"``:``"Alice"``,``"last"``:``"White"``<br>}<br>]<br>}<br></span></p>

会转化为下面的文本

<pstyle=``"line-height:2em;"``><spanstyle=``"font-size:14px;"``>{``"user.first"``:[``"John"``,``"Alice"``<br>],``"user.last"``:[``"Smith"``,``"White"``<br>]<br>}<br></span></p>

将原来文本之间的关联打破了,图 17 展示了这条数据从进入索引到查询出来的简略过程:

1.组装数据,一个 JSONArray 结构的文本。

2.写入 ES 后,默认类型置为 object。

3.查询 user.first 为 Alice 并且 user.last 为 Smith 的文档(实际并不存在同时满足这两个条件的)。

4.返回了和预期不符的结果。

–嵌套(Nested)数组对象查询

嵌套数组对象可以解决上面查询不符的问题,ES 的解决方案就是为数组中的每个对象单独建立一个文档,独立于原始文档。如图 18 所示,将数据声明为 nested 后,再进行相同的查询,返回的是空,因为确实不存在 user.first 为 Alice 并且 user.last 为 Smith 的文档。

–一般对数组的修改是全量的,如果需要单独修改某个字段,需要借助 painless script,参考:安全

数据安全是至关重要的环节,主要通过以下三点提供数据的访问安全控制:

XPACK 提供了 Security 插件,可以提供基于用户名密码的访问控制,可以提供一个月的免费试用期,过后收取一定的费用换取一个 license。

•IP 白名单

是指在 ES 服务器开启防火墙,配置只有内网中若干服务器可以直接连接本服务。

•代理

一般不允许业务系统直连 ES 服务进行查询,需要对 ES 接口做一层包装,这个工作就需要代理去完成;并且代理服务器可以做一些安全认证工作,即使不适用 XPACK 也可以实现安全控制。

网络

•ElasticSearch 服务器默认需要开通 9200、9300 这两个端口。

下面主要介绍一个和网络相关的错误,如果大家遇到类似的错误,可以做个借鉴。

引出异常前,先介绍一个网络相关的关键词,keepalive :

Http keep-alive 和 Tcp keepalive。

HTTP1.1 中默认启用"Connection: Keep-Alive",表示这个 HTTP 连接可以复用,下次的 HTTP 请求就可以直接使用当前连接,从而提高性能,一般 HTTP 连接池实现都用到 keep-alive;

TCP 的 keepalive 的作用和 HTTP 中的不同,TPC 中主要用来实现连接保活,相关配置主要是 net.ipv4.tcp_keepalive_time 这个参数,表示如果经过多长时间(默认 2 小时)一个 TCP 连接没有交换数据,就发送一个心跳包,探测下当前链接是否有效,正常情况下会收到对方的 ack 包,表示这个连接可用。

下面介绍具体异常信息,描述如下:

两台业务服务器,用 restClient(基于 HTTPClient,实现了长连接)连接的 ES 集群(集群有三台机器),与 ES 服务器分别部署在不同的网段,有个异常会有规律的出现:

每天 9 点左右会发生异常 Connection reset by peer. 而且是连续有三个 Connection reset by peer

<pstyle=``"line-height:2em;"``><spanstyle=``"font-size:14px;"``>Causedby:java.io.IOException:Connectionresetbypeer<br>atsun.nio.ch.FileDispatcherImpl.read0(NativeMethod)<br>atsun.nio.ch.SocketDispatcher.read(SocketDispatcher.java:``39``)<br>atsun.nio.ch.IOUtil.readIntoNativeBuffer(IOUtil.java:``223``)<br>atsun.nio.ch.IOUtil.read(IOUtil.java:``197``)<br></span></p>

为了解决这个问题,我们尝试了多种方案,查官方文档、比对代码、抓包。。。经过若干天的努力,最终发现这个异常是和上面提到 keepalive 关键词相关(多亏运维组的同事帮忙)。

实际线上环境,业务服务器和 ES 集群之间有一道防火墙,而防火墙策略定义空闲连接超时时间为例如为 1 小时,与上面提到的 linux 服务器默认的例如为 2 小时不一致。由于我们当前系统晚上访问量较少,导致某些连接超过 2 小时没有使用,在其中 1 小时后防火墙自动就终止了当前连接,到了 2 小时后服务器尝试发送心跳保活连接,直接被防火墙拦截,若干次尝试后服务端发送 RST 中断了链接,而此时的客户端并不知情;当第二天早上使用这个失效的链接请求时,服务端直接返回 RST,客户端报错 Connection reset by peer,尝试了集群中的三台服务器都返回同样错误,所以连续报了 3 个相同的异常。解决方案也比较简单,修改服务端 keepalive 超时配置,小于防火墙的 1 小时即可。

原文链接:

声明:本文来自用户分享和网络收集,仅供学习与参考,测试请备份。