贷前系统负责从进件到放款前全部业务流程的实现,其中涉及一些数据量较大、条件多样且复杂的综合查询,引入ElasticSearch主要是为了提升查询效率,并但愿基于ElasticSearch快速实现一个简易的数据仓库,提供一些OLAP相关功能。本文将介绍贷前系统ElasticSearch的实践经验。html
描述:为快速定位数据而设计的某种数据结构。java
索引比如是一本书前面的目录,能加快数据库的查询速度。了解索引的构造及使用,对理解ES的工做模式有很是大的帮助。linux
经常使用索引:git
位图索引github
哈希索引算法
BTREE索引sql
倒排索引数据库
位图索引适用于字段值为可枚举的有限个数值的状况。json
位图索引使用二进制的数字串(bitMap)标识数据是否存在,1标识当前位置(序号)存在数据,0则表示当前位置没有数据。数组
下图1 为用户表,存储了性别和婚姻情况两个字段;
图2中分别为性别和婚姻状态创建了两个位图索引。
例如:性别->男 对应索引为:101110011,表示第一、三、四、五、八、9个用户为男性。其余属性以此类推。
使用位图索引查询:
男性 而且已婚 的记录 = 101110011 & 11010010 = 100100010,即第一、四、8个用户为已婚男性。
女性 或者未婚的记录 = 010001100 | 001010100 = 011011100, 即第二、三、五、六、7个用户为女性或者未婚。
顾名思义,是指使用某种哈希函数实现key->value 映射的索引结构。
哈希索引适用于等值检索,经过一次哈希计算便可定位数据的位置。
下图3 展现了哈希索引的结构,与JAVA中HashMap的实现相似,是用冲突表的方式解决哈希冲突的。
BTREE索引是关系型数据库最经常使用的索引结构,方便了数据的查询操做。
BTREE: 有序平衡N阶树, 每一个节点有N个键值和N+1个指针, 指向N+1个子节点。
一棵BTREE的简单结构以下图4所示,为一棵2层的3叉树,有7条数据:
以Mysql最经常使用的InnoDB引擎为例,描述下BTREE索引的应用。
Innodb下的表都是以索引组织表形式存储的,也就是整个数据表的存储都是B+tree结构的,如图5所示。
主键索引为图5的左半部分(若是没有显式定义自主主键,就用不为空的惟一索引来作聚簇索引,若是也没有惟一索引,则innodb内部会自动生成6字节的隐藏主键来作聚簇索引),叶子节点存储了完整的数据行信息(以主键 + row_data形式存储)。
二级索引也是以B+tree的形式进行存储,图5右半部分,与主键不一样的是二级索引的叶子节点存储的不是行数据,而是索引键值和对应的主键值,由此能够推断出,二级索引查询多了一步查找数据主键的过程。
维护一颗有序平衡N叉树,比较复杂的就是当插入节点时节点位置的调整,尤为是插入的节点是随机无序的状况;而插入有序的节点,节点的调整只发生了整个树的局部,影响范围较小,效率较高。
能够参考红黑树的节点的插入算法:
https://en.wikipedia.org/wiki/Red%E2%80%93black_tree
所以若是innodb表有自增主键,则数据写入是有序写入的,效率会很高;若是innodb表没有自增的主键,插入随机的主键值,将致使B+tree的大量的变更操做,效率较低。这也是为何会建议innodb表要有无业务意义的自增主键,能够大大提升数据插入效率。
注:
Mysql Innodb使用自增主键的插入效率高。
使用相似Snowflake的ID生成算法,生成的ID是趋势递增的,插入效率也比较高。
倒排索引也叫反向索引,能够相对于正向索引进行比较理解。
正向索引反映了一篇文档与文档中关键词之间的对应关系;给定文档标识,能够获取当前文档的关键词、词频以及该词在文档中出现的位置信息,如图6 所示,左侧是文档,右侧是索引。
反向索引则是指某关键词和该词所在的文档之间的对应关系;给定了关键词标识,能够获取关键词所在的全部文档列表,同时包含词频、位置等信息,如图7所示。
反向索引(倒排索引)的单词的集合和文档的集合就组成了如图8所示的”单词-文档矩阵“,打钩的单元格表示存在该单词和文档的映射关系。
倒排索引的存储结构能够参考图9。其中词典是存放的内存里的,词典就是整个文档集合中解析出的全部单词的列表集合;每一个单词又指向了其对应的倒排列表,倒排列表的集合组成了倒排文件,倒排文件存放在磁盘上,其中的倒排列表内记录了对应单词在文档中信息,即前面提到的词频、位置等信息。
下面以一个具体的例子来描述下,如何从一个文档集合中生成倒排索引。
如图10,共存在5个文档,第一列为文档编号,第二列为文档的文本内容。
将上述文档集合进行分词解析,其中发现的10个单词为:[谷歌,地图,之父,跳槽,Facebook,加盟,创始人,拉斯,离开,与],以第一个单词”谷歌“为例:首先为其赋予一个惟一标识 ”单词ID“, 值为1,统计出文档频率为5,即5个文档都有出现,除了在第3个文档中出现2次外,其他文档都出现一次,因而就有了图11所示的倒排索引。
对于一个规模很大的文档集合来讲,可能包含几十万甚至上百万的不一样单词,可否快速定位某个单词,这直接影响搜索时的响应速度,其中的优化方案就是为单词词典创建索引,有如下几种方案可供参考:
Hash索引简单直接,查询某个单词,经过计算哈希函数,若是哈希表命中则表示存在该数据,不然直接返回空就能够;适合于彻底匹配,等值查询。如图12,相同hash值的单词会放在一个冲突表中。
相似于Innodb的二级索引,将单词按照必定的规则排序,生成一个BTree索引,数据节点为指向倒排索引的指针。
一样将单词按照必定的规则排序,创建一个有序单词数组,在查找时使用二分查找法;二分查找法能够映射为一个有序平衡二叉树,如图14这样的结构。
FST为一种有限状态转移机,FST有两个优势:1)空间占用小。经过对词典中单词前缀和后缀的重复利用,压缩了存储空间;2)查询速度快。O(len(str))的查询时间复杂度。
以插入“cat”、 “deep”、 “do”、 “dog” 、“dogs”这5个单词为例构建FST(注:必须已排序)。
如图15 最终咱们获得了如上一个有向无环图。利用该结构能够很方便的进行查询,如给定一个词 “dog”,咱们能够经过上述结构很方便的查询存不存在,甚至咱们在构建过程当中能够将单词与某一数字、单词进行关联,从而实现key-value的映射。
固然还有其余的优化方式,如使用Skip List、Trie、Double Array Trie等结构进行优化,再也不一一赘述。
下面结合贷前系统具体的使用案例,介绍ES的一些心得总结。
目前使用的ES版本:5.6
官网地址:https://www.elastic.co/products/elasticsearch
ES一句话介绍:The Heart of the Elastic Stack(摘自官网)
ES的一些关键信息:
2010年2月首次发布
Elasticsearch Store, Search, and Analyze
丰富的Restful接口
ES的索引,也就是Index,和前面提到的索引并非一个概念,这里是指全部文档的集合,能够类比为RDB中的一个数据库。
即写入ES的一条记录,通常是JSON形式的。
文档数据结构的元数据描述,通常是JSON schema形式,可动态生成或提早预约义。
因为理解和使用上的错误,type已不推荐使用,目前咱们使用的ES中一个索引只创建了一个默认type。
一个ES的服务实例,称为一个服务节点。为了实现数据的安全可靠,而且提升数据的查询性能,ES通常采用集群模式进行部署。
多个ES节点相互通讯,共同分担数据的存储及查询,这样就构成了一个集群。
分片主要是为解决大量数据的存储,将数据分割为若干部分,分片通常是均匀分布在各ES节点上的。须要注意:分片数量没法修改。
分片数据的一份彻底的复制,通常一个分片会有一个副本,副本能够提供数据查询,集群环境下能够提升查询性能。
JDK版本: JDK1.8
安装过程比较简单,可参考官网:下载安装包 -> 解压 -> 运行
安装过程遇到的坑:
ES启动占用的系统资源比较多,须要调整诸如文件句柄数、线程数、内存等系统参数,可参考下面的文档。
http://www.cnblogs.com/sloveling/p/elasticsearch.html
下面以一些具体的操做介绍ES的使用:
初始化索引,主要是在ES中新建一个索引并初始化一些参数,包括索引名、文档映射(Mapping)、索引别名、分片数(默认:5)、副本数(默认:1)等,其中分片数和副本数在数据量不大的状况下直接使用默认值便可,无需配置。
下面举两个初始化索引的方式,一个使用基于Dynamic Template(动态模板) 的Dynamic Mapping(动态映射),一个使用显式预约义映射。
1) 动态模板 (Dynamic Template)
<p style="line-height: 2em;"><span style="font-size: 14px;">curl -X PUT http://ip:9200/loan_idx -H 'content-type: application/json' <br> -d '{"mappings":{ "order_info":{ "dynamic_date_formats":["yyyy-MM-dd HH: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>
上面的JSON串就是咱们用到的动态模板,其中定义了日期格式:dynamic_date_formats 字段;定义了规则orderId2:凡是遇到orderId这个字段,则将其转换为long型;定义了规则strings_as_keywords:凡是遇到string类型的字段都映射为keyword类型,norms属性为false;关于keyword类型和norms关键字,将在下面的数据类型小节介绍。
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):
<p style="line-height: 2em;"><span style="font-size: 14px;">"aliases":{ "loan_alias":{ }<br> }<br></span></p>
别名和索引名是多对多的关系,也就是一个索引能够有多个别名,一个别名也能够映射多个索引;在一对一这种模式下,全部用到索引名的地方均可以用别名进行替换;别名的好处就是能够随时的变更,很是灵活。
若是一个字段已经初始化完毕(动态映射经过插入数据,预约义经过设置字段类型),那就肯定了该字段的类型,插入不兼容的数据则会报错,好比定义了一个long类型字段,若是写入一个非数字类型的数据,ES则会返回数据类型错误的提示。
这种状况下可能就须要重建索引,上面讲到的别名就派上了用场;通常分3步完成:
上述步骤适合于离线迁移,若是要实现不停机实时迁移步骤会稍微复杂些。
基本的操做就是增删改查,能够参考ES的官方文档:
https://www.elastic.co/guide/en/elasticsearch/reference/current/docs.html
一些比较复杂的操做须要用到ES Script,通常使用类Groovy的painless script,这种脚本支持一些经常使用的JAVA API(ES安装使用的是JDK8,因此支持一些JDK8的API),还支持Joda time等。
举个比较复杂的更新的例子,说明painless script如何使用:
需求描述
appSubmissionTime表示进件时间,lenssonStartDate表示开课时间,expectLoanDate表示放款时间。要求2018年9月10日的进件,若是进件时间 与 开课时间的日期差小于2天,则将放款时间设置为进件时间。
Painless Script以下:
<p style="line-height: 2em;"><span style="font-size: 14px;">POST loan_idx/_update_by_query<br> { "script":{ "source":"long getDayDiff(def dateStr1, def dateStr2){ <br> LocalDateTime date1= toLocalDate(dateStr1); LocalDateTime date2= toLocalDate(dateStr2); ChronoUnit.DAYS.between(date1, date2);<br> }<br> LocalDateTime toLocalDate(def dateStr)<br> { <br> DateTimeFormatter formatter = DateTimeFormatter.ofPattern(\"yyyy-MM-dd HH: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-10 00:00:00", "to":"2018-09-10 23: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语句里包含了具体的操做:
<p style="line-height: 2em;"><span style="font-size: 14px;">long getDayDiff(def dateStr1, def dateStr2){<br> LocalDateTime date1= toLocalDate(dateStr1);<br> LocalDateTime date2= toLocalDate(dateStr2);<br> ChronoUnit.DAYS.between(date1, date2);<br>}<br>LocalDateTime toLocalDate(def dateStr){<br> DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH: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:
https://github.com/NLPchina/elasticsearch-sql/wiki/Basic-Queries-And-Conditions
这个插件提供了比较丰富的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的写入机制有关,作个简单介绍:
写入ES的数据,首先是写入到Lucene索引段中的,而后才写入ES的索引中,在写入ES索引前查到的都是旧数据。
索引段中的数据会以原子写的方式写入到ES索引中,因此提交到ES的一条记录,可以保证彻底写入成功,而不用担忧只写入了一部分,而另外一部分写入失败。
索引段提交后还有最后一个步骤:refresh,这步完成后才能保证新索引的数据能被搜索到。
出于性能考虑,Lucene推迟了耗时的刷新,所以它不会在每次新增一个文档的时候刷新,默认每秒刷新一次。这种刷新已经很是频繁了,然而有不少应用却须要更快的刷新频率。若是碰到这种情况,要么使用其余技术,要么审视需求是否合理。
不过,ES给咱们提供了方便的实时查询接口,使用该接口查询出的数据老是最新的,调用方式描述以下:
GET http://IP:PORT/index_name/type_name/id
上述接口使用了HTTP GET方法,基于数据主键(id)进行查询,这种查询方式会同时查找ES索引和Lucene索引段中的数据,并进行合并,因此最终结果老是最新的。但有个反作用:每次执行完这个操做,ES就会强制执行refresh操做,致使一次IO,若是使用频繁,对ES性能也会有影响。
数组的处理比较特殊,拿出来单独讲一下。
1)表示方式就是普通的JSON数组格式,如:
[1, 2, 3]、 [“a”, “b”]、 [ { "first" : "John", "last" : "Smith" },{"first" : "Alice", "last" : "White"} ]
2)须要注意ES中并不存在数组类型,最终会被转换为object,keyword等类型。
3)普通数组对象查询的问题。
普通数组对象的存储,会把数据打平后将字段单独存储,如:
<p style="line-height: 2em;"><span style="font-size: 14px;">{ "user":[<br> { "first":"John", "last":"Smith"<br> },<br> { "first":"Alice", "last":"White"<br> }<br> ]<br>}<br></span></p>
会转化为下面的文本
<p style="line-height: 2em;"><span style="font-size: 14px;">{ "user.first":[ "John", "Alice"<br> ], "user.last":[ "Smith", "White"<br> ]<br>}<br></span></p>
将原来文本之间的关联打破了,图17展现了这条数据从进入索引到查询出来的简略过程:
组装数据,一个JSONArray结构的文本。
写入ES后,默认类型置为object。
查询user.first为Alice而且user.last为Smith的文档(实际并不存在同时知足这两个条件的)。
返回了和预期不符的结果。
4)嵌套(Nested)数组对象查询
嵌套数组对象能够解决上面查询不符的问题,ES的解决方案就是为数组中的每一个对象单独创建一个文档,独立于原始文档。如图18所示,将数据声明为nested后,再进行相同的查询,返回的是空,由于确实不存在user.first为Alice而且user.last为Smith的文档。
5)通常对数组的修改是全量的,若是须要单独修改某个字段,须要借助painless script,参考:https://www.elastic.co/guide/en/elasticsearch/reference/5.6/docs-update.html
数据安全是相当重要的环节,主要经过如下三点提供数据的访问安全控制:
XPACK提供了Security插件,能够提供基于用户名密码的访问控制,能够提供一个月的免费试用期,事后收取必定的费用换取一个license。
是指在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
<p style="line-height: 2em;"><span style="font-size: 14px;">Caused by: java.io.IOException: Connection reset by peer <br> at sun.nio.ch.FileDispatcherImpl.read0(Native Method) <br> at sun.nio.ch.SocketDispatcher.read(SocketDispatcher.java:39) <br> at sun.nio.ch.IOUtil.readIntoNativeBuffer(IOUtil.java:223) <br> at sun.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小时便可。
《深刻理解ElasticSearch》
http://www.cnblogs.com/Creator/p/3722408.html
https://yq.aliyun.com/articles/108048
http://www.cnblogs.com/LBSer/p/4119841.html
http://www.cnblogs.com/yjf512/p/5354055.html
做者:综合信贷雷鹏
来源:宜信技术学院