Elasticsearch分布式全文检索引擎
全文检索(搜索)引擎:汇合了网络爬虫技术、检索排序技术、网页处理技术、大数据处理技术、自然语言处理技术等综合性的学科
检索引擎分类:Lucene、Nutch、Solr、Elasticsearch
下面Elasticsearch以7.0+版本做介绍
1、基本概述
-
Elasticsearch(简写es), Elasticsearch是一个开源的高扩展的分布式全文检索引擎,它可以近乎实时的存储、检索数据;本身扩展性很好,可以扩展到上百台服务器,处理PB级别(1PB=1024T=1048576G)的数据。
-
Elasticsearch使用Java开发并使用Lucene作为其核心来实现所有索引和搜索的功能,Lucene非常复杂,而es通过简单的RESTful API隐藏Lucene的复杂性,可理解为简化使用Lucene进行开发
-
es的定位是:专注于搜索的非关系型数据库,其严重“偏科”,相比其他数据库,其搜索的性能甩几条街,但是对应的DML操作就非常复杂且低性能,因此,使用es就是海量的数据的DQL操作,而DML操作则需要对应权衡
2、es数据架构(与mysql对比区别)及数据类型
- 关系型数据库中的数据库(DataBase),等价于ES中的索引(Index)
- 一个数据库下面有N张表(Table),等价于1个索引Index下面仅有一个类型(Type)是_doc
- 一个数据库表(Table)下的数据由多行(ROW)多列(column,属性)组成,等价于1个Type由多个文档(Document)和多字段Field组成。
- 在数据库中的增insert、删delete、改update、查search操作等价于ES中的增PUT/POST、删Delete、改_update、查GET
一级分类 | 二级分类 | 具体类型 |
---|---|---|
核心类型 | 字符串类型 | string,text,keyword |
核心类型 | 整数类型 | integer,long,short,byte |
核心类型 | 浮点类型 | double,float,half_float,scaled_float |
核心类型 | 逻辑类型 | boolean |
核心类型 | 日期类型 | date |
核心类型 | 范围类型 | range |
核心类型 | 二进制类型 | binary |
复合类型 | 数组类型 | array |
复合类型 | 对象类型 | object |
复合类型 | 嵌套类型 | nested |
地理类型 | 地理坐标类型 | geo_point |
地理类型 | 地理地图 | geo_shape |
3、es基本语法操作
- 创建索引/表(索引即表)
PUT /my_index
{
"settings": {
"number_of_shards": 5, //设置5个片区
"number_of_replicas": 1 //设置1个备份
}
}
创建映射 PUT /索引名(可以和索引一起创建,映射就是对应的文档类型)
PUT /user
{
"mappings": {
"properties":{
"id":{
"type":"long"
},
"name":{
"type":"keyword"
},
"age":{
"type":"integer"
}
}
}
}
#或者
PUT /user/_doc/1
{
"name":{
"type":"keyword"
},
"age":{
"type":"integer"
}
}
每个文档都有一个ID,如果插入的时候没有指定ID的话,ElasticSearch会自动生成一个字符串_id。
- 删除DELETE /索引名、DELETE /索引名/_doc/文档ID
DELETE /user/user/10
注意:这里的删除并且不是真正意义上的删除,仅仅是清空文档内容,并且标记该文档的状态为删除而已。如果后续有数据新增进来则会替换它的位置,如果一直没有数据替换则定时删除
- 修改 PUT/索引名/_doc/文档ID
PUT /user/user/10
{
"name":"lll",
"age":12
}
注意:如果有其它字段没有指定的话会清空该字段值,如果仅是更新某些字段可以如此
POST /user/user/10
{
"doc":{
"name":"lll",
"age":12
}
}
- 查询GET /索引名/_doc/xxx
GET /user/_doc/10 //根据id查询
GET /索引名/_doc/search //查询所有
#批量查询
GET /user/_doc
{
"docs":[
{"_id":"1"},
{"_id":"11"},
{"_id":"111"}
]
}
- 全文搜索(核心)
GET /索引名/_search
{
"query":{
"match":{
"field":"value" //按模糊值匹配属性名“模糊“查询,其还会自动按照匹配度自高向低排序
}
}
}
GET /索引名/_search
{
"query": {
"multi_match": {
"query": value,
"fields": [field1, field2, ...]
}
}
}
- 高亮显示
"query":{
"multi_match": {
"query": "广州", //关键词
"fields": ["title","subTitle","summary"] //对应关键词检索字段
}
},
4、分词器
es的分词器把文本内容按照一定标准进行切分,默认使用standard分词器,该分词器按单词(注意是单词不是字母)、文字(单拆)匹配查询
es支持额外的分词器插件IK分词器
IK分词器:
-
ik_smart 粗粒度分词
会做最粗粒度的拆分,即尽可能长地拆分。比如会将“中华人民共和国人民大会堂”拆分为中华人民共和国、人民大会堂。 -
ik_max_word 细粒度分词
字段尽可能短地拆分,且会从短到更短地拆分。比如会将“中华人民共和国人民大会堂”拆分为“中华人民共和国、中华人民、中华、华人、人民共和国、人民、共和国、大会堂、大会、会堂等词语。
5、倒排索引(查询)
正排索引(模糊查询):所谓正排就是在模糊匹配的时候,扫描索引库中的所有文档,找出所有包含关键词的文档,再根据打分模型进行打分(即匹配度高低),说白了就是找到所有文档再筛选。MySQL的索引在使用模糊匹配时即失效,并且索引没有全局性。
倒排索引则是根据映射直接定点索引(查询)
这也是es为什么如此快搜索的原因,具体什么是倒排索引我们需要先了解es搜索的完整过程:
①es新增文档的时候,使用事先指定的分词器对文档中的每个内容进行拆词
②将拆词之后的到的每一个Term (词根),保存在一张倒排索引列表中(一个main.dic文件,也即Term Dictionary字典),然后会建立词根与文档id(新增的时候就有指定了文档id(也就是_id),所以是直接对应)的一对多映射(一个词根对应很可能有多个id)
③到了搜索时,分词器会对搜索的内容进行分词,然后对应也得到词根
④根据词根到字典中进行匹配文档id列
⑤获得文档id(一般将表id列作为文档id列,为了后面查询方便)之后进行综合处理,然后对其进行热度排序,数据封装成一个集合返回给搜索者(正排索引是全表查询后筛选)
总结:
像通过id查找表中的某行就是正排索引,而倒排索引就是通过某个列或者某些列甚至全表列(具体看你的关键词需要在哪部分出现)中的某个关键词(也即模糊词)到表中查找某行;一般我们从mysql中预热数据到es的时候,都会选择关键词会出现的列作为es的keyword,这将是提高es效率关键的一步。
注意事项:
-
es只有text类型的数据会分词,而keyword类型的数据是直接建立索引的
-
所以实际的倒排列表中并不只是存了文档ID那么简单,还有一些其它的信息,比如:词频(Term出现的次数)、偏移量(offset)等。而上述过程也反映了对应的DML操作的复杂性,特别是UPDATE操作,特别消耗性能(update其实就是delete+put)如 当用户在主页上搜索关键词“华为手机”时,假设只存在正向索引(forward index),那么就需要扫描索引库中的所有文档,找出所有包含关键词“华为手机”的文档,再根据打分模型进行打分,排出名次后呈现给用户。
6、全文搜索思考问题
我们进行全文搜索的时候,涉及到的问题无非是:
- 搜索的数据从何而来:mysql中加载,或者说从主库中获取(因为es是搜索能手,所以自然是从主库中备份数据过来)
- 有哪些数据需要初始化:搜索的数据对象,即目标对象
- 对应的数据有哪些明确字段:也就是说,确定好数据对象之后,进行检索的时候,对应的关键词总要匹配某个或某些字段,因为倒排索引收集的字典的词根是从字段内容中拆分出来的,而我们检索的关键词对应的也是有具体方向的,比如我要找有关广州的文章,有关指的是目的地或标题或发布者,对应的字段就应该是这些,而不是无脑全选
注意:我们在初始化数据的时候,除了需要需要所以要明确字段外,其次更重要的原因是,es只是做搜索的,主库中的数据会随时更新,那对应的es也要更新,但是由于es“偏科“,所以我们一般会周期性并且避峰进行更新数据到es(比如凌晨1点)
7、SpringBoot集成Elasticsearch
数据准备,从mysql中初始化数据到es,下面以全文检索目的地/攻略/游记/用户为例
使用es的时候要开启终端bat
mgsire/DataController
@RestController
public class DataController {
//es服务
@Autowired
private IDestinationEsService destinationEsService;
@Autowired
private IStrategyEsService strategyEsService;
@Autowired
private ITravelEsService travelEsService;
@Autowired
private IUserInfoEsService userInfoEsService;
//mysql服务
@Autowired
private IDestinationService destinationService;
@Autowired
private IStrategyService strategyService;
@Autowired
private ITravelService travelService;
@Autowired
private IUserInfoService userInfoService;
@GetMapping("/dataInit")
public Object dataInit() {
//把mysql中的目的地/攻略/游记/用户数据备份到es(对应的只取关键词检索会出现的字段)
//攻略
List<Strategy> sts = strategyService.list();
for (Strategy st : sts) {
StrategyEs es = new StrategyEs();
BeanUtils.copyProperties(st, es);
strategyEsService.save(es);
}
//游记
List<Travel> ts = travelService.list();
for (Travel t : ts) {
TravelEs es = new TravelEs();
BeanUtils.copyProperties(t, es);
travelEsService.save(es);
}
//用户
List<UserInfo> uf = userInfoService.list();
for (UserInfo u : uf) {
UserInfoEs es = new UserInfoEs();
BeanUtils.copyProperties(u, es);
userInfoEsService.save(es);
}
//目的地
List<Destination> dests = destinationService.list();
for (Destination d : dests) {
DestinationEs es = new DestinationEs();
BeanUtils.copyProperties(d, es);
destinationEsService.save(es);
}
return "ok";
}
}
本项目在core的pom和properties中操作
1.依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
2.配置文件
#elasticsearch端口
spring.elasticsearch.rest.uris = localhost:9200
3.自定义domain
其余相关domain差不多,区别一下检索字段即可(就是你的关键词检索内容的位置)(跟mongodb一样,使用es的时候最好分search或elasticsearch包区分,对应的domain后缀名用ES、Es)
/** * 目的地搜索对象 */
@Getter
@Setter
@Document(indexName = "destination")
public class DestinationEs implements Serializable {
public static final String INDEX_NAME = "destination";
@Id
//@Field 每个文档的字段配置(store是否存储、index是否分词、type类型,analyzer、searchAnalyzer分词器)
@Field(store = true, index = false, type = FieldType.Long)
private Long id;
//攻略id
@Field(index = true, store = true, type = FieldType.Keyword)
private String name;
@Field(index = true, analyzer = "ik_max_word", store = true, searchAnalyzer = "ik_max_word", type = FieldType.Text)
private String info;
}
4.通用化repository接口
public interface DestinationEsRepository extends ElasticsearchRepository<DestinationEs,String> {
//...
}
5全文搜索接口
@GetMapping("/search")
public JsonResult queryDestination(SearchQueryObject qo)throws UnsupportedEncodingException{
//前端传参的时候可能发生编码转换
String keyWord=URLDecoder.decode(qo.getKeyword(),"utf-8");qo.setKeyword(keyWord);
//根据目的地/攻略/游记/用户全文检索,其余domain跟目的地一样,只是区别一下检索字段
return this.searchAll(qo);
}
private JsonResult searchAll(SearchQueryObject qo){
SearchResultVO vo=new SearchResultVO();
//获取全文查找的集合
List<Destination> destinationList=this.createDestinationPage(qo).getContent();
List<Strategy> strategyList=this.createStrategyPage(qo).getContent();
List<Travel> travelList=this.createTravelPage(qo).getContent();
List<UserInfo> userInfoList=this.createUserInfoPage(qo).getContent();
//结果对象封装
vo.setDests(destinationList);
vo.setStrategys(strategyList);
vo.setTravels(travelList);
vo.setUsers(userInfoList);
//结果数据条数封装
vo.setTotal((long)destinationList.size()+strategyList.size()+travelList.size()+userInfoList.size());
//ParamMap就是手写的小工具类,即new HashMap<String,Object>,当然也可以直接new map对象即可
return JsonResult.success(ParamMap.newInstance().put("result",vo).put("qo",qo));
}
private Page<Strategy> createStrategyPage(SearchQueryObject qo){
//攻略全文查找,根据攻略标题、副标题、简介
return searchService.searchWithHighlight(StrategyEs.INDEX_NAME,Strategy.class,qo,"title","subTitle","summary");
}
private Page<Travel> createTravelPage(SearchQueryObject qo){
//游记全文查找,根据标题、简介
Page<Travel> page=searchService.searchWithHighlight(TravelEs.INDEX_NAME,Travel.class,qo,"title","summary");
//游记需要对author关联
for(Travel travel:page){
travel.setAuthor(userInfoService.getById(travel.getAuthorId()));
}
return page;
}
private Page<UserInfo> createUserInfoPage(SearchQueryObject qo){
//用户全文查找,根据昵称、城市、简介
return searchService.searchWithHighlight(UserInfoEs.INDEX_NAME,UserInfo.class,qo,"info","city");
}
private Page<Destination> createDestinationPage(SearchQueryObject qo){
return searchService.searchWithHighlight(DestinationEs.INDEX_NAME,Destination.class,qo,"name","info");
}
SearchResultVO结果集封装
@Setter
@Getter
public class SearchResultVO implements Serializable{
private Long total = 0L; //检索数量
private List<Strategy> strategys = new ArrayList<>();
private List<Travel> travels = new ArrayList<>();
private List<UserInfo> users = new ArrayList<>();
private List<Destination> dests = new ArrayList<>();
}
全文检索及高亮显示,所谓高亮显示,就是在检索的时候,对出现的关键词加颜色或者高亮区别其他非关键词,虽然实现很简单,但确是友好对待用户的基本。
es中的分页api和mongodb类似(毕竟同样继承spring-data依赖,可以说是完全一样了)
@Override
public<T> Page<T> searchWithHighlight(String index,Class<T> clz,SearchQueryObject qo,String...fields){
SearchRequest searchRequest=new SearchRequest(index);
SearchSourceBuilder searchSourceBuilder=new SearchSourceBuilder();
//我们需要做的就是通过java的方式将es语法条件拼接起来
//高亮显示
/*"query":{
"multi_match": {
"query": "广州",
"fields": ["title","subTitle","summary"]
}
},*/
MultiMatchQueryBuilder queryBuilder=QueryBuilders.multiMatchQuery(qo.getKeyword(),fields);
HighlightBuilder highlightBuilder = new HighlightBuilder();
// 生成高亮查询器
for(String field:fields){
highlightBuilder.field(field);// 高亮查询字段
}
highlightBuilder.requireFieldMatch(false); // 如果要多个字段高亮,这项要为false
highlightBuilder.preTags("<span style='color:red'>"); // 高亮设置
highlightBuilder.postTags("</span>");
highlightBuilder.fragmentSize(800000); // 最大高亮分片数
highlightBuilder.numOfFragments(0); // 从第一个分片获取高亮片段
/** 分页显示 "from": 0, "size":3, */
Pageable pageable = PageRequest.of(
qo.getCurrentPage()-1,
qo.getPageSize(),
Sort.Direction.ASC,"_id"
);// 设置分页参数
//构建条件,也即条件拼接
NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
.withQuery(queryBuilder) // match查询
.withPageable(pageable)
.withHighlightBuilder(highlightBuilder) // 设置高亮
.build();
SearchHits<T> searchHits = template.search(searchQuery,clz,IndexCoordinates.of(index));
List<T> list = new ArrayList();
for ( SearchHit<T> searchHit : searchHits){
// 获取搜索到的数据
T content = this.parseType(clz,searchHit.getId());
// 处理高亮
Map<String, String> map = highlightFieldsCopy(searchHit.getHighlightFields(),fields);
//1:spring 框架中BeanUtils 类,如果是map集合是无法进行属性复制
// copyProperties(源, 目标)
//2: apache BeanUtils 类 可以进map集合属性复制
// copyProperties(目标, 源)
try{
BeanUtils.copyProperties(content,map);
}catch(IllegalAccessException e){
e.printStackTrace();
}catch(InvocationTargetException e){
e.printStackTrace();
}
list.add(content);
}
Page page=new PageImpl(list,pageable,searchHits.getTotalHits());
return page;
}
/**
* 从es中查询到的_id(因为domain和预热数据的时候就将id作为_id,所以es文档_id即表id) * 通过反射获取对象,根据id找到mysql中的数据
*/
private<T> T parseType(Class<T> clz,String id){
Long lId = 0L;
if(StringUtils.hasLength(id)){
lId=Long.valueOf(id);
}
T t = null;
if (clz == UserInfo.class){
t = (T)userInfoService.getById(lId);
} else if(clz == Travel.class){
t = (T)travelService.getById(lId);
} else if(clz == Strategy.class){
t = (T)strategyService.getById(lId);
} else if(clz == Destination.class){
t = (T)destinationService.getById(lId);
} else{
t = null;
}
return t;
}
//fields: title subTitle summary
private Map<String, String> highlightFieldsCopy(Map<String, List<String>>map,String...fields){
Map<String, String> mm=new HashMap<>();
//title: "有娃必看,<span style='color:red;'>广州</span>长隆野生动物园全攻略"
//subTitle: "<span style='color:red;'>广州</span>长隆野生动物园"
//summary: "如果要说动物园,楼主强烈推荐带娃去<span style='color:red;'>广州</span>长隆野生动物园
//title subTitle summary
for(String field:fields){
List<String> hfs=map.get(field);
if(hfs!=null&&!hfs.isEmpty()){
//获取高亮显示字段值, 因为是一个数组, 所有使用string拼接
StringBuilder sb=new StringBuilder();
for(String hf : hfs){
sb.append(hf);
}
mm.put(field,sb.toString());//使用map对象将所有能替换字段先缓存, 后续统一替换
}
}
return mm;
}
}
QueryBuilders:高亮条件构建
PageRequest:分页条件构建
NativeSearchQuery:整合条件构建
SearchHits:template.search的条件检索结果
6.初始化数据
es从mysql(或者其他关系型数据库)初始化数据有两种方式:同步更新、异步更新
- 同步更新
所谓同步更新就是代码对mysql数据进行增删改(下面统称更新)操作之后,紧接着对es数据更新。
抛开es性能不说,这个方法存在一个弊端,就是mysql支持事务,如果mysql在更新完成之后,紧接着es更新数据出现异常,按照事务回滚来说,本该更新完成的数据却因为外界而导致无法更新成功,它们之间互相影响显然不是一个好结果。因此该方案是不可行的。
- 异步更新
异步更新有两种方式,一是使用数据库中间件方式(数据库中间件canal将在后面补充),一种是定时器更新方式。
定时器更新即使用定时器从mysql中获取数据到es中,es更新性能很低,所以对应的时间应该设置在凌晨或者用户访问量较低的时间段
3、MySql、MongDB、Redis、Elasticsearch选型
-
mysql作为主库,存储核心数据,其数据关系可以很复杂,因此对应支持复杂联表条件查询。非关系型数据库中的数据都是从关系型数据库获取的,无论后者的数据如何,都是从前者中引申、或者备份而来的。
-
redis是非关系型数据库,严格来说定位是缓存。用于存储具有时效性、海量的数据。时效性如存储登录用户信息(类session)、短信验证码。redis的读写性能优于mysql,性能大概是mysql的1.0x10^6倍,而其缺点就是断电即失效,毕竟是内存操作,所以对应的关键数据(相比之下短时效的数据是没关系的)需要定期持久化到关系型数据库中。
-
**mongodb作为关系型数据库和非关系型数据库之间,其可以单独实现如redis的内存操作、mysql的持久化。将mongodb作为类似redis、memcache来做缓存db,为mysql提供服务,或是后端日志收集分析。 **考虑到mongodb属于nosql型数据库,sql语句与数据结构不如mysql那么亲和 ,也会有很多时候将mongodb做为辅助mysql而使用的类redis、memcache 之类的缓存db来使用。 **亦或是仅作日志收集分析。**或者是存储某文章相关的评论,n方级别的数据(sql是中间表的形式,数据量太大)。说白了就是
- elasticsearch是非关系型数据库,其直接操作内存,其数据来源于关系型数据库,插入数据时生成字典供mysql目标检索使用,字典文件存储在main.dic文件中,而其作为字典不需要持久化数据回关系型数据库。其最大的优势就是通过倒排索引做到瞬时检索,但也因为倒排索引是从数据插入就开始定义的,索引其对应的增删改操作性能就会特别慢,而其只能在用户访问少的时候做数据更新。
总结:
- 如果需要将mongodb作为后端db来代替mysql使用,即这里mysql与mongodb 属于平行级别,那么,这样的使用可能有以下几种情况的考量:
- mongodb所负责部分以文档形式存储,能够有较好的代码亲和性,json格式的直接写入方便(如日志之类)
- 从datamodels设计阶段就将原子性考虑于其中(说白了就是在选数据库之前必须考虑数据的特点才能选择合适的数据库),无需事务之类的辅助。开发用如nodejs之类的语言来进行开发,对开发比较方便。
- mongodb本身的failover机制,无需使用如MHA之类的方式实现
- 综上所述,非关系型数据库就需要对应的有关系型数据库的加持,反过来也一样,也就是说开发中关系型数据库和非关系型数据库是互补的、不可分割的。因为操作内存性能好,同时带来的问题就是数据容易丢失;因为操作IO,同时带来的问题就是低性能。而关系型数据库和非关系型数据的配合,就需要非关系型数据库将数据从关系型数据库中定期初始化,同时又必须定期持久化回数据库。