Elasticsearch 分布式搜索
开源分布式搜索引擎。用于日志数据分析、实时监控
ELK: Elastic stack
- Elasticsearch 存储,计算,搜索数据
- logstash, beats 数据抓取
- kibana 数据可视化
倒排索引
正向: 比如给id创建索引,形成一个b+树。
如果搜索title而不是id
select * from tb_goods where title like '%手机%'
逐条扫描判断是否包含"手机"
- 是: 存入结果集
- 否: 丢弃
倒排:
- 文档 documetn:每条数据就是一个文档
- 词条 term: 文档按照语义分成词语
存储时先把文档中的词语分成词条。再存储对应的id。基于词条创建索引
词条 | 文档id |
---|
查找过程:
-
对搜索词条分词
-
去词条列表查询文档id - 第一次检索
-
基于id查询文档 - 第二次检索
-
放到结果集
文档
elasticsearch是面向文档存储的。文档可以是数据库中一条商品数据,一个订单信息。
文档数据会被序列化为json格式后存储再存入elasticsearch中
索引
索引: 相同类型的文档的集合
比如订单一类,商品信息一类
映射: 文档字段中的约束,类似数据库中的约束
MySQL | Elasticsearch | 说明 |
---|---|---|
Table | Index | 索引(Index),就是文档的集合,类似数据库中的表(Table) |
Row | Document | 文档(Document),就是一条条的数据,类似数据库中的行(Row),文档通常是JSON格式 |
Column | Field | 字段(Field),就是JSON文档中的字段,类似数据库中的列(Column) |
Schema | Mapping | Mapping(映射)是索引中文档的结构,如何来字段类型等信息。类似数据库中的表结构(Schema) |
SQL | DSL | DSL是Elasticsearch中的JSON格式的查询语言,用来操作Elasticsearch,实现CRUD |
架构
MySQL擅长事务类型的操作,可以确保数据的安全和一致性
Elasticsearch: 擅长海量的数据、分析、计算
ES部署
docker network create es-net #创建网络
运行
docker run -d \
--name es \
-e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
-e "discovery.type=single-node" \
-v es-data:/usr/share/elasticsearch/data \
-v es-plugins:/usr/share/elasticsearch/plugins \
--privileged \
--network es-net \
-p 9200:9200 \
-p 9300:9300 \
elasticsearch:7.12.1
部署Kibana
docker run -d \
--name kibana \
-e ELASTICSEARCH_HOSTS=http://es:9200 \
--network=es-net \
-p 5601:5601 \
kibana:7.12.1
运行
docker run -d \
--name es \
-e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
-e "discovery.type=single-node" \
-v es-data:/usr/share/elasticsearch/data \
-v es-plugins:/usr/share/elasticsearch/plugins \
--privileged \
--network es-net \
-p 9200:9200 \
-p 9300:9300 \
elasticsearch:7.12.1
分词器
默认的分词器对中文处理不太好
安装ik分词器
# 进入容器内部
docker exec -it elasticsearch /bin/bash
# 在容器内安装插件
/bin/elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.12.1/elasticsearch-analysis-ik-7.12.1.zip
# 退出
exit
# 重启容器
docker restart elasticsearch
粒度:
粗 ik_smart
细 ik_max_word
分词器原理
依赖字典
个性化设置字典: ikAnalyzer.cfg.xml
<properties>
<entry key="ext_dict">ext.dic</entry> #扩展词典
<entry key="ext_stopwords">stopword.dic</entry>
</properties>
ext.dic和stopword.dic是文件名,在当前文件所在目录创建,一行一个词
在什么时候用分词:
- 在创建倒排索引时对文档分词
- 用户搜索时对输入内容分词
Mapping 约束
type: 数据类型
- 字符串: text (可分词的文本), keyword (精确值。拆分后没意义了 比如邮箱地址)
- 数值: long, integer, short, byte, double, float
- 布尔: boolean
- 日期: date
- 对象: object,嵌套的
没有数组,但是允许某个数据类型有多个值
index: 是否创建索引,默认为true。
- 实际文档需要搜索;图片,邮箱等字段不需要搜索
analyzer: 分词器 iksmart, ikmax
properties: 该字段的子字段
DSL 索引库操作
ES中通过Restful请求操作索引库,文档。请求内容用DSL语句来表示
创建索引库和mapping的DSL:
PUT /索引名称
{
"mappings": {
"properties": {
"字段名1": {
"type": "text",
"analyzer": "ik_smart"
},
"字段名2": {
"type": "keyword",
"index": "false"
},
"字段名3": {
"properties": {
"子字段名": {
"type": "keyword"
}
}
}
}
}
// ...省略
}
查找:
GET /索引名称
删除
DELETE /索引名称
修改
不允许修改库
添加新字段
PUT /索引名称/_mapping
{
"properties": {
"新字段名": {
"type": "xxx"
}
}
}
改旧字段名会报错
DSL 文档操作
增
POST /索引名称/_doc/文档id
{
"字段1": "值1",
"字段2": "值2",
"字段3": {
"子字段1": "值3",
"子字段2": "值4"
}
// ...
}
不指定id会随机生成新的id,就相当于创建新文档了
查
GET /索引名称/_doc/文档id
删
DELETE /索引名称/_doc/文档id
改
方式1: 全量修改,会删除旧文档,添加新文档
PUT /索引名称/_doc/文档id
{
"字段1": "值5",
"字段2": "值2",
"字段3": {
"子字段1": "值3",
"子字段2": "值4"
}
// ...
}
方式2: 局部修改
POST /索引库名/_update/文档id
{
"doc": {
“字段名": ”新的值"
}
}
注意:
id用keyword记
地理位置有两种 geo_point和geo_sharpe
多个字段合并搜索: copy_to
-
"all": { "type": "text", "analyzer": "ik_max_word" } "xxx": { "type": "keyword", "copy_to": "all" }
Java RestClient
组装DSL语句,通过http发送给ES
初始化客户端
- 引入es的RestHighLevelClient依赖:
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
</dependency>
- 配置SpringBoot默认的ES版本是7.6.2,所以我们需要覆盖默认的ES版本:
<properties>
<java.version>1.8</java.version>
<elasticsearch.version>7.12.1</elasticsearch.version>
</properties>
- 初始化RestHighLevelClient:
RestHighLevelClient client = new RestHighLevelClient(
RestClient.builder(
HttpHost.create("http://192.168.150.101:9200")
)
);
操作索引库
增
@Test
void testCreateHotelIndex() throws IOException {
// 1.创建Request对象
CreateIndexRequest request = new CreateIndexRequest("hotel");
// 2. 请求参数,MAPPING_TEMPLATE是映射模板字符串,由我们提前定义好的DSL语句
request.source(MAPPING_TEMPLATE, XContentType.JSON);
// 3. 客户端发送请求
client.indices().create(request, RequestOptions.DEFAULT);
}
删
@Test
void testDeleteHotelInex throws IOException {
// 1.创建Request对象
CreateIndexRequest request = new CreateIndexRequest("hotel");
// 2. 客户端发送请求
client.indices().delete(request, RequestOptions.DEFAULT);
}
判断索引库是否存在
@Test
void testExistsHotelInex throws IOException {
// 1.创建Request对象
CreateIndexRequest request = new CreateIndexRequest("hotel");
// 2. 客户端发送请求
boolean exists = clinet.indices().exists(request, RequestOptions,DEFAULT);
}
操作文档
初始化JavaRestClient
public class ElasticsearchDocumentTest {
// 客户端
private RestHighLevelClient client;
@BeforeEach
void setUp() {
client = new RestHighLevelClient(
RestClient.builder(
HttpHost.create("http://192.168.150.101:9200")
)
);
}
@AfterEach
void tearDown() throws IOException {
client.close();
}
}
增: 添加数据到索引库
@Test
void testIndexDocument() throws IOException {
// 1.创建request对象
IndexRequest request = new IndexRequest("indexName").id("1");
// 2. 准备JSON文档
request.source("{\"name\": \"Jack\", \"age\": 21}", XContentType.JSON);
// request.source(JSON.toJSONString(hotelDoc), XContentType.JSON);
// 3. 客户端发送请求
client.index(request, RequestOptions.DEFAULT);
}
相当于
POST /indexName/_doc/1
{
"name": "Jack",
"age": 21
}
处理地理位置的技巧:
比如原来的Hotel类 (数据库中存的):
public class Hotel {
private String name;
private String longitude;
private String latitude;
}
新建一个HotelDoc类 用于es :
public class HotelDoc {
private String name;
private String location;
public HotelDoc(Hotel hotel){
this.name = hotel.getName();
this.location = hotel.getLatitude() + ", " + hotel.getLongitude();
}
}
查:
@Test
void testGetDocumentById() throws IOException {
// 1.创建request对象
GetRequest request = new GetRequest("indexName", "1");
// 2. 发送请求,得到结果
GetResponse response = client.get(request, RequestOptions.DEFAULT);
// 3. 解析结果
String json = response.getSourceAsString();
System.out.println(json);
// HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class); // 反序列化
}
相当于
GET /indexName/_doc/1
改:
也分全量更新和局部更新
局部更新:
@Test
void testUpdateDocumentById() throws IOException {
// 1.创建request对象
UpdateRequest request = new UpdateRequest("indexName", "1");
// 2. 准备数据,键值对方式 => key value
request.doc(
"age", 18,
"name", "Rose"
);
// 3. 客户端发送
client.update(request, RequestOptions.DEFAULT);
}
相当于
POST /users/_update/1
{
"doc": {
"name": "Rose",
"age": 18
}
}
删:
@Test
void TestDeleteDocument(){
// 1.创建request对象
DeleteRequest request = new DeleteRequest("indexName","1");
client.delete(request, RequestOptions.DEFAULT);
}
相当于
DELETE /indexName/_doc/1
批量导入文档
Bulk 批处理
@Test
void testBulk() throws IOException {
// 1. 创建Bulk请求
BulkRequest request = new BulkRequest();
// 2. 添加批量处理的请求;这里演示了两个索引文档的请求
request.add(new IndexRequest("hotel")
.id("101").source("json source1", XContentType.JSON));
request.add(new IndexRequest("hotel")
.id("102").source("json source2", XContentType.JSON));
// 3. 客户端批量发送
client.bulk(request, RequestOptions.DEFAULT);
}
多个Index请求合并到一起,一并提交
DSL
DSL Query的分类
- 查询所有: match_all
- 全文检索: 利用分词器对用户输入内容分词,然后去倒排索引库中匹配
- match_query
- multi_match_query
- 精确查询: 根据精确词条值查找数据,一般是查找keyword、数值、日期、boolean等类型字段
- ids 基于id
- range 数值范围
- term 精确查找
- 地理查询: 根据经纬度查询
- geo_distance
- geo_bounding_box
- 复核查询: 上述各种条件查询组合起来
- bool
- function_score
DSL Query的基本语法
GET /indexName/_search
{
"query": {
"查询类型": {
"查询字段": "搜索条件"
}
}
}
如果是查询所有: match_all: {}
DSL 查询语法
全文检索查询
match查询: 全文检索查询的一种,会对用户输入进行分词,然后去倒排索引库检索
GET /indexName/_search
{
"query": {
"match": {
"FIELD": "TEXT"
}
}
}
这里FIELD是all (copy_to合并后的) 的话是查询所有。TEXT是用户输入
multi_match查询: 与match查询类似,只不过允许同时查询多个字段
GET /indexName/_search
{
"query": {
"multi_match": {
"query": "TEXT",
"fields": ["FIELD1", "FIELD2"]
}
}
}
但是搜索字段越多,效率越低。推荐检索all
精确查询
精确查询一般是查找keyword、数值、日期、boolean等字段。不会对搜索条件进行分词
-
term: 根据词条精确值查询
-
GET /indexName/_search { "query": { "term": { "FIELD": { "value": "VALUE" } } } }
-
range: 根据值的范围查询
-
GET /indexName/_search { "query": { "range": { "FIELD": { "gte": 10, // greater than equals "lte": 20 // less than equals } } } }
地理查询
-
geo_bounding_box: 一个矩形范围内
-
// geo_bounding_box查询 GET /indexName/_search { "query": { "geo_bounding_box": { "FIELD": { "top_left": { // 左上 "lat": 31.1, "lon": 121.5 }, "bottom_right": { "lat": 30.9, "lon": 121.7 // 右下 } } } } }
-
geo_distance: 指定中心点少于某个距离
-
// geo_distance 查询 GET /indexName/_search { "query": { "geo_distance": { "distance": "15km", "FIELD": "31.21,121.5" } } }
复合查询
- function score: 算分函数查询,可以控制文档相关性算分,控制文档排名
score算法:
TF算法
$$
TF(词频) = \frac{某个词出现次数}{文档中的总词数}
$$
TF-IDF算法
$$
IDF(逆文档频率) = \log\left(\frac{文档总数}{包含该词的文档数}\right)
$$
$$
\text{score} = \sum{TF(词频) \times IDF(逆文档频率)}
$$
所有文本都包含的某个词条的情况下,这个词条会不计入权重
比如3个文档,其中3个文档都包含词条 word。log 3/3 = 0,会不计入权重
BM25算法
$$
\text{Score}(q,d) = \sum_i{\log \left(1 + \frac{N - n_i + 0.5}{n_i + 0.5}\right) \cdot \frac{f_i \cdot (k_1 + 1)}{f_i + k_1 \cdot (1 - b + b \cdot \frac{d}{avgdl})}}
$$
TF-IDF受词频影响较大。得分会随着词频无限增长。但BF25算法不会
function score query
控制得分
GET /hotel/_search
{
"query": {
"function_score": {
"query": { "match": { "all": "外滩" } }, // 原始查询条件,搜索文档并根据相关性打分 (query score)
"functions": [
{
"filter": { "term": { "id": "1" }}, // 过滤条件,符合条件的才会被重新算分
"weight": 10 // 算分函数,结果称为function score, 与query score运算得到新算分
}
],
"boost_mode": "multiply" // 加权模式。定义function score与query score的运算方式
}
}
}
算分函数:
weight
: 给一个静态值,作为函数得分(function score)field_value_factor
: 用文档中的某个字段值来影响得分random_score
: 随机生成一个值,作为函数得分script_score
: 自定义评分公式,公式结果作为函数得分
加权模式:
multiply
: 两者相乘replace
: 用function score替换query score- 其他: sum、avg、max、min
Boolean Query
一个或多个查询子句的组合
must
: 必须匹配的一个查询,类似 “与”should
: 选择性匹配的查询,类似 “或”must_not
: 必须不匹配,不参与打分,类似 “非”filter
: 必须匹配,不参与打分
不算分有助于提升性能,提升查询效率
GET /hotel/_search
{
"query": {
"bool": {
"must": [
{"term": {"city": "上海"}} // 常用于用户输入的搜索,用于算分
],
"should": [
{"term": {"brand": "品牌A"}}, // 广告竞价会用到分数计算排序
{"term": {"brand": "品牌B"}}
],
"must_not": [
{"range": {"price": {"lte": 500}}} // 不用参与算分的搜索
],
"filter": [
{"range": {"score": {"gte": 45}}}
]
}
}
}
搜索结果处理
排序:
可以排序的字段类型有keyword, 数值, 地理坐标, 日期。默认是基于_score排序
GET /indexName/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"FIELD": "desc" // 排序字段和排序方式为ASC, DESC
}
]
}
地理坐标:
GET /indexName/_search
{
"query": {
"match_all": {}
},
"sort": [
{
"_geo_distance": {
"FIELD": "纬度,经度",
// "location": {
// "lat": 31.034661,
// "lon": 121.612282
// }
"order": "asc",
"unit": "km"
}
}
]
}
发生排序时 _score会变成null
分页:
ES默认只返回top10数据。查询更多数据需要修改分页参数
GET /hotel/_search
{
"query": {
"match_all": {}
},
"from": 990, // 分页开始的位置, 默认为0
"size": 10, // 每页要获取的文档数量
"sort": [
{"price": "asc"}
]
}
倒排索引不容易做分页。每次查询都要先排序,获取前xx条数据,再在截取。
ES是分布式的,这样做会面临深度分页问题。
获取from = 990, size = 10 的数据:
- 查询在每个数据分片上按排名方式查询前1000条文档。
- 然后将所有分片的结果归并,在内存中重新排序并返回前1000条文档
- 最后从这1000条中,选取从990开始的10条文档
结果集 (from+size) 的上限是10000。一般业务层面会限制超过10000条结果
解决:
search after
: 分页时需要排除掉,原理是从上一次的排序结果开始,查询下一批数据。它方便在排序的分页中。缺点: 不能查找之前的结果。scroll
: 保证搜索的数据状态不改变,保持在早期的。它方便在不排序的使用。缺点: 更新后缓存的仍然是旧数据,搜索结果非实时。
高亮:
搜索结果中把搜索关键词突出表示
服务端会把关键词加入标签,前端再添加css样式
GET /hotel/_search
{
"query": {
"match": {
"FIELD": "TEXT"
}
},
"highlight": {
"fields": {
"FIELD": {
"pre_tags": ["<em>"],
"post_tags": ["</em>"],
// "required_field_match": false
}
}
}
}
默认情况下ES搜索字段必须与高亮字段一致。修改 required_field_match
可以设置成不一致。
RestClient查询文档
match_all
@Test
void testMatchAll() throws IOException {
// 1.创建Request
SearchRequest request = new SearchRequest("hotel");
// 2.准备DSL参数
request.source()
.query(QueryBuilders.matchAllQuery());
// 3.发送请求,获取响应结果
SearchResponse response = client.search(request, RequestOptions.DEFAULT);
// ...后续的结果处理
}
基本api
@Test
void testMatchAll() throws IOException {
// ... 省略
// 4.解析结果
SearchHits searchHits = response.getHits();
// 4.1.获取总的记录数
long total = searchHits.getTotalHits().value;
// 4.2.获取的详细结果
SearchHit[] hits = searchHits.getHits();
for (SearchHit hit : hits) {
// 4.3.获取source
String json = hit.getSourceAsString();
// 4.4.打印
System.out.println(json);
}
}
全文检索查询
使用不同的 QueryBuilders
提供的方法来构造 match
和multi_match
// 单字段查询
QueryBuilders.matchQuery("all", "酒店");
// 多字段查询
QueryBuilders.multiMatchQuery("酒店", "name", "business");
抽取代码用于响应结果
private void handleResponse(SearchResponse response) {
// 4. 解析响应
SearchHits searchHits = response.getHits();
// 4.1, 获取总条数
long total = searchHits.getTotalHits().value;
System.out.println("共找到记录" + total + "条记录");
// 4.2. 处理数据
SearchHit[] hits = searchHits.getHits();
// 4.3. 遍历
for (SearchHit hit : hits) {
// 获取文档source
String json = hit.getSourceAsString();
// 反序列化
HotelDoc hotelDoc = JSON.parseObject(json, HotelDoc.class);
System.out.println("hotelDoc = " + hotelDoc);
}
}
精确查询
使用不同的 QueryBuilders
提供的方法来构造 term
和range
// 精确查询
QueryBuilders.termQuery("city", "南京");
// 范围查询
QueryBuilders.rangeQuery("price").gte(100).lte(150);
复合查询
// 组合查询
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 添加must条件
boolQuery.must(QueryBuilders.termQuery("city", "南京"));
// 添加filter条件
boolQuery.filter(QueryBuilders.rangeQuery("price").lte(250));
排序和分页
// 查询
request.source().query(QueryBuilders.matchAllQuery());
// 分页
request.source().from(0).size(5);
// request.source().from((PAGE-1)* SIZE).size(SIZE)
// 按价格升序
request.source().sort("price", SortOrder.ASC);
高亮
request.source().highlighter(new HighlightBuilder())
.field("name")
.requireFieldMatch(false)
);