Elasticsearch新手入门与性能优化实战

写在前面:本文适用于还不会使用ES的新手小白,同时也适用于已经在产品中使用ES但是想进一步优化性能的同学。有很多小细节是笔者在实际产品(单索引十几亿数据的生产系统)中使用趟出来的坑,希望可以帮助到您。由于笔者使用ES是做监控系统及数据分析,因此文中对全文索引相关经验基本没有介绍。

一、ElasticSearch能干什么

简单说:能存储 能搜索 能分析

Elasticsearch 在速度和可扩展性方面都表现出色,而且还能够索引多种类型的数据,这意味着其可用于多种场景:

  • 应用程序搜索
  • 网站搜索
  • 企业搜索
  • 日志处理和分析
  • 基础设施指标和容器监测
  • 应用程序性能监测
  • 地理空间数据分析和可视化
  • 安全分析
  • 业务分析

二 关键名词

Elasticsearch RDBMS
Index(索引) DataBase(数据库)
Type(类型) Table(表)
Document(文档) Row(行)
Field(字段) Column(列)
Mapping(映射) Schema(约束)

索引 具有相似结构的文档的集合。如果上下文中作为动词,是新增文档的含义。

类型 文档类型,7.X版本开始,限制一个索引只能有一个类型_doc,因此设计时尽量与索引一对一。

文档 数据存储最小单元,存储在ES中的一个个JSON格式的字符串

分片 代表索引分片,es可以把一个完整的索引分成多个分片,这样的好处是可以把一个大的索引拆分成多个,分布到不同的节点上。构成分布式搜索。分片的数量只能在索引创建前指定,并且索引创建后不能更改(为啥不能改,下文中有说明)。

副本 为了容灾+提高查询效率。

可以在索引创建后修改副本数量。啥场景要这样干呢?生产系统,从A集群往B集群迁移数据时,可以在B集群创建副本为1的相同索引,迁移完成后再扩副本,这样会更快。

三 常用操作

1 mapping

创建索引,定义映射(即表结构)。

ES支持不定义映射,在数据写入时会自动识别数据类型,生成映射。但是注意尽量不要这样干,因为这样可能导致映射的数据类型不是最适合查询的类型。这会影响后续的数据搜索和统计分析。

kibina中操作 复制代码
PUT ai_log_invoke
{
	"settings": {
		"number_of_shards": "2",
		"number_of_replicas": "1"
	},
	"mappings": {
		"_doc": {
			"properties": {
				"apiId": {
					"type": "keyword"
				},
				"beginAt": {
					"type": "date"
				},
				... ...
				"userId": {
					"type": "keyword"
				}
			}
		}
	}
}

常用字段类型:

text:长文本,默认分词、会创建倒排索引、归一化处理(搜索用)、不按列存储

keyword:字符串,默认不分词,最大32KB,默认创建倒排索引、会按列存储、不归一化处理

date: 日期, 默认倒排索引、列存储、可格式化、有时区概念

long、double: 数字, 默认倒排索引、列存储

为啥会强调各字段是否会分词、是否创建倒排索引、是否列存储?因为这些区别会直接影响到后面数据的搜索和统计。尤其注意text和keyword的区别,在生产中,这两个类型绝不是简单的长短区别。

字段属性:

doc_values: 列存储,用来排序、聚合

index: 倒排索引,用来过滤

fielddata: 字段可在内存排序、聚合等

根据需求创建索引时合理设置字段及属性,实现需求前提下减少磁盘占用,原谅我第二次啰嗦,这真的会直接影响你的需求能否实现。

别名

kibana中操作 复制代码
#设置别名
POST /_aliases?pretty
{
  "actions": [
    {
      "add": {
        "index": "ai_log_invoke",
        "alias": "ai_log_invoke_aliases"
      }
    }
  ]
}
#使用别名
GET ai_log_invoke_aliases/_search

别小看这玩意,这可以让你的系统零停机,避免WTF

2 增

java 复制代码
#:rest-high-level-client
List<LogServiceInvoke> logs = new ArrayList<>();
BulkRequest bulkRequest = new BulkRequest();
logs.forEach(doc -> {
  IndexRequest indexRequest = new IndexRequest("indexname", "_doc");
  indexRequest.source(JsonUtils.object2String(doc), XContentType.JSON);
  bulkRequest.add(indexRequest);
});
restHighLevelClient.bulk(bulkRequest, RequestOptions.DEFAULT);
kibana 复制代码
POST ai_log_invoke/_doc
{
  "name" :"zhongjie12"
}

索引请求(新增数据)到集群,先要寻找要写入的分片,寻找方式为:hash(routing) % number_of_shards, 未设置routing则使用_id。

该分片方式决定了索引的分片数创建后不能更改。除非重建索引。

新增数据请求寻找并写入索引分片流程:

分片内写入流程(可简单了解):

可以看到数据写入时,由buffer到存储会有延时,这也导致了ES的"近实时"的特性。所以,千万不要将ES当成mysql用作实时业务存储。

segment内数据存储

行存储:_source存储整个文档数据

列存储:支持排序、聚合

倒排索引存储:支持搜索

3 查

3.1 搜索

3.1.1 Term 查询
java 复制代码
QueryBuilder termQuery = QueryBuilders.termQuery("serviceId", "6114a11a340b1426fcf296fe");
```kibana
GET ai_log_invoke/_search
{
	"query": {
				
		"term": {
			"serviceId": {
				"value": "6114a11a340b1426fcf296fe",
				"boost": 1.0
			}
		}
	}
}
3.1.2 Range 查询
java 复制代码
QueryBuilder timeRangeQuery = QueryBuilders.rangeQuery("beginAt")
												.from("2021-08-05 15:26:00")
                        .to("2021-08-05 15:27:00")
                        .format("yyyy-MM-dd HH:mm:ss").timeZone("+08:00");
kibana 复制代码
GET ai_log_invoke/_search
{
	"query": {
		"range": {
			"beginAt": {
				"from": "2021-08-05 15:26:00",
				"to": "2021-08-05 15:27:00",
				"include_lower": true,
				"include_upper": true,
				"time_zone": "+08:00",
				"format": "yyyy-MM-dd HH:mm:ss",
				"boost": 1.0
			}
		}
	}
}
3.1.3 组合查询
java 复制代码
QueryBuilder termQuery = QueryBuilders.termQuery("serviceId", "60c0bd37e28a1f74abc3a55f");
QueryBuilder timeRangeQuery = QueryBuilders.rangeQuery("beginAt").from("2021-08-05 15:26:00")
.to("2021-08-05 15:27:00").format("yyyy-MM-dd HH:mm:ss").timeZone("+08:00");

BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
boolQueryBuilder.filter(termQuery);
boolQueryBuilder.filter(timeRangeQuery);
kibana 复制代码
GET ai_log_invoke/_search
{
	"query": {
		"bool": {
			"filter": [
				{
					"term": {
						"serviceId": {
							"value": "60c0bd37e28a1f74abc3a55f",
							"boost": 1.0
						}
					}
				},
				{
					"range": {
						"beginAt": {
							"from": "2021-08-05 15:26:00",
							"to": "2021-08-05 15:27:00",
							"include_lower": true,
							"include_upper": true,
							"time_zone": "+08:00",
							"format": "yyyy-MM-dd HH:mm:ss",
							"boost": 1.0
						}
					}
				}
			],
			"adjust_pure_negative": true,
			"boost": 1.0
		}
	}
}

不计算相关性的,使用filter查询,可以优化查询性能

分页、排序

java 复制代码
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.query(queryBuilder);
searchSourceBuilder.from(0).size(10).sort("duration", SortOrder.DESC); // 分页、排序
searchSourceBuilder.fetchSource("showfields", "excludefields"); // 查出需要的(不要的)字段,按需获取,能省一点是一点
kibana 复制代码
GET ai_log_invoke/_search
{
	"from": 0,
	"size": 10,
	"query": {
		"term": {
			"serviceId": {
				"value": "6114a11a340b1426fcf296fe",
				"boost": 1.0
			}
		}
	},
	"_source": {
		"includes": [
			"serviceId"
		],
		"excludes": []
	},
	"sort": [
		{
			"duration": {
				"order": "desc"
			}
		}
	]
}
搜索流程

1)客户端发送请求到一个coordinate node

2)协调节点将搜索请求转发到所有的shard对应的primary shard或replica shard也可以

3)每个shard将自己的搜索结果,返回给协调节点,由协调节点进行数据的合并、排序、分页等操作,产出最终结果

从这里看出,ES不适合深分页,那样合并计算的量会很大。得提前跟产品经理沟通。

3.2 聚合 (Aggregations)更多请看官方文档

3.2.1 桶聚合 (Bucket aggregations)更多请看官方文档
  • Term聚合(Terms aggregations)即:group by
java 复制代码
// group by serviceId , 获取count数量前10个,默认按组内文档数倒序排序
AggregationBuilder top10Agg = AggregationBuilders.terms("serviceId").field("serviceId").size(10);
kibana 复制代码
GET ai_log_invoke/_search
{
	"size": 0,
	"aggregations": {
		"serviceId": {
			"terms": {
				"field": "serviceId",
				"size": 10,
				"min_doc_count": 1
			}
		}
	}
}

对于分组数过多的业务场景,size设置过大查询效率会很低(甚至直接查崩ES),需要说服产品经理

  • 日期直方图聚合 (Date histogram aggregations)
java 复制代码
// 筛选结果下 按 beginAt 字段分组,统计时间直方图(趋势图)
AggregationBuilder trend = AggregationBuilders.dateHistogram("beginAt").field("beginAt")
  .dateHistogramInterval(DateHistogramInterval.DAY) // 时间直方图步长
  .minDocCount(0L) // 最小文档数,设置为0可对无数据的时间段解析出结果
  .format("yyyy-MM-dd") // key格式化
  .timeZone(DateTimeZone.forID("+08:00")) // 时区设置
  .extendedBounds(new ExtendedBounds(1627776000000L, 1628985600000L)); // 时间范围设置
kibana 复制代码
# 时间直方图 聚合
GET ai_log_invoke/_search
{
	"size": 0,
	"aggregations": {
		"beginAt": {
			"date_histogram": {
				"field": "beginAt",
				"format": "yyyy-MM-dd",
				"time_zone": "+08:00",
				"interval": "1d",
				"offset": 0,
				"order": {
					"_key": "asc"
				},
				"keyed": false,
				"min_doc_count": 0,
				"extended_bounds": {
					"min": 1627776000000,
					"max": 1628985600000
				}
			}
		}
	}
}
  • 区间分组聚合 (Range aggregations)
plain 复制代码
// duration字段,分组:[0, 100),[100, 200)
AggregationBuilder range = AggregationBuilders.range("duration").field("duration")
  .addRange("range1", 0, 100)
  .addRange("range2", 100, 200);
bash 复制代码
# range 聚合
GET ai_log_invoke/_search
{
	"size": 0,
	"aggregations": {
		"duration": {
			"range": {
				"field": "duration",
				"ranges": [
					{
						"key": "range1",
						"from": 0.0,
						"to": 100.0
					},
					{
						"key": "range2",
						"from": 100.0,
						"to": 200.0
					}
				],
				"keyed": false
			}
		}
	}
}
3.2.2 度量型聚合 更多(分组后统计最大值、最小值等)
  • max avg sum
java 复制代码
// 筛选结果下 group by serviceId , 获取count数量前10个
AggregationBuilder top10Agg = AggregationBuilders.terms("serviceId").field("serviceId").size(10);
// serviceId组内成功调用次数
top10Agg.subAggregation(AggregationBuilders.sum("successFlag").field("successFlag"));
// serviceId组内平均耗时
top10Agg.subAggregation(AggregationBuilders.avg("duration").field("duration"));
kibana 复制代码
# 度量型 聚合
GET ai_log_invoke/_search
{
	"size": 0,
	"aggregations": {
		"serviceId": {
			"terms": {
				"field": "serviceId",
				"size": 10,
				"min_doc_count": 1,
				"shard_min_doc_count": 0,
				"show_term_doc_count_error": false,
				"order": [
					{
						"_count": "desc"
					},
					{
						"_key": "asc"
					}
				]
			},
			"aggregations": {
				"successFlag": {
					"sum": {
						"field": "successFlag"
					}
				},
				"duration": {
					"avg": {
						"field": "duration"
					}
				}
			}
		}
	}
}
java 复制代码
#按serviceId分组后,按每组的duration排序
AggregationBuilder top10Agg = AggregationBuilders.terms("serviceId").field("serviceId").size(10)
                        .order(BucketOrder.aggregation("duration", true));
// serviceId组内成功调用次数
top10Agg.subAggregation(AggregationBuilders.sum("successFlag").field("successFlag"));
// serviceId组内平均耗时
top10Agg.subAggregation(AggregationBuilders.avg("duration").field("duration"));
kibana 复制代码
# 分组聚合后排序
GET ai_log_invoke/_search
{
	"size": 0,
	"aggregations": {
		"serviceId": {
			"terms": {
				"field": "serviceId",
				"size": 10,
				"min_doc_count": 1,
				"shard_min_doc_count": 0,
				"show_term_doc_count_error": false,
				"order": [
					{
						"duration": "asc"
					},
					{
						"_key": "asc"
					}
				]
			},
			"aggregations": {
				"successFlag": {
					"sum": {
						"field": "successFlag"
					}
				},
				"duration": {
					"avg": {
						"field": "duration"
					}
				}
			}
		}
	}
}
  • 百分位统计 (Percentiles/Percentile ranks aggregations)
java 复制代码
// 5% 95% 分位的数值分别是多少
AggregationBuilder percent = AggregationBuilders.percentiles("duration").field("duration").percentiles(5, 95);

// 200 500 分别位于多少百分位上
double[] doubles = {200.0, 500.0};
AggregationBuilder percentRank = AggregationBuilders.percentileRanks("duration", doubles).field("duration");
  • 基数统计(Cardinality aggregations),类似:count(distinct)
java 复制代码
// 访问用户量
AggregationBuilder userCount = AggregationBuilders.cardinality("userId").field("userId")
	.precisionThreshold(3000); // 精度, max=40000

ES官方介绍,基数统计算法导致统计结果为近似结果,虽然可以设置精度,但理论上还是近似结果。因此需要业务场景可容忍(笔者在实际使用中,从小样本量中没找到有误差的,几百条数据)

  • Top Hits Aggregation

组内前几个文档

3.2.3 pipline聚合

桶内聚合,可以用在分组排序分页、子聚合做二次计算等场景

需要注意分组过多情况下的性能问题,一不小心就能查崩ES。慎用。

4 删、改

Update和Delete实现原理删除和更新操作也是写操作。

删除: 磁盘上的每个分段(segment)都有一个.del文件与它相关联。当发送删除请求时,该文档未被真正删除,而是在.del文件中标记为已删除。此文档可能仍然能被搜索到,但会从结果中过滤掉。当分段合并时在.del文件中标记为已删除的文档不会被包括在新的合并段中。

更新: 创建新文档时,Elasticsearch将为该文档分配一个版本号。对文档的每次更改都会产生一个新的版本号。当执行更新时,旧版本在.del文件中被标记为已删除,并且新版本在新的分段中编入索引。旧版本可能仍然与搜索查询匹配,但是从结果中将其过滤掉。

四 实战一点经验

1. 分析需求,设计索引mapping,考虑因素:

a. 每个字段是否需要被搜索,如需搜索,是否模糊搜索、大小写敏感

b. 尽量识别出不必要的列存储字段,减小磁盘占用

c. 根据统计需求,可以设计冗余字段以支持组合分组场景,避免在统计时使用二次分组或script。

2. 估算数据量(文档数量+磁盘占用),预估分索引策略,预估分片数

以下根据笔者生产使用状况给个参考。索引字段大概10个出头,其中一个时间字段,剩下一半为keyword,字符串长度大概平均100个字符,一半为数字类型。

现状 预估
日文档数量 10M 50M
月文档数量 300M 1500M
月磁盘大小 300G 1.5T
按月分索引分片数 10shards 30shards
半年磁盘大小 1.8T 9T
集群预估大小 20T
节点数量 20个

3.设计索引管理方案

手动管理 ILM
方案 1. 创建索引:通过定时任务轮询预先创建索引ai_log_invoke20210716 2. 使用查询:通过查询条件中的时间匹配指定索引 3. 删除索引:自定义轮询任务删除过期索引,清理空间 1. 使用LifeCycle策略管理索引 2. 使用别名存储、查询
优点 1. 索引名直观,容易管理使用 1. 管理方便且索引大小稳定可控 2. 可以一定情况避免数据延时问题 3. 可以自动将索引分配到Hot,Warm,Cold节点,充分利用资源
缺点 1. 索引分片大小分配不稳定 2. 需要注意数据延时到达问题(由于kafka的存在,今天的数据可能明天才被消费出且存入明天的索引中) 3. 需要人工监控索引是否提前创建成功 1. 索引名自动生成,不够直观 2. 查询时使用别名查询索引,不能保证最小范围匹配到索引
相关推荐
Elasticsearch2 小时前
Elasticsearch:什么是搜索相关性?
elasticsearch
Elastic 中国社区官方博客8 小时前
通过 AIOps 、生成式 AI 和机器学习实现更智能的可观测性
大数据·人工智能·elasticsearch·机器学习·搜索引擎·ai·可用性测试
Elasticsearch1 天前
通过 AIOps 、生成式 AI 和机器学习实现更智能的可观测
elasticsearch
DavidSoCool2 天前
Elasticsearch 中实现推荐搜索(方案设想)
大数据·elasticsearch·搜索引擎
文艺倾年2 天前
【八股消消乐】Elasticsearch优化—检索Labubu
大数据·elasticsearch·搜索引擎
会飞的小妖2 天前
Elasticsearch相关操作
elasticsearch
Elastic 中国社区官方博客2 天前
ECK 简化:在 GCP GKE Autopilot 上部署 Elasticsearch
大数据·elasticsearch·搜索引擎·k8s·全文检索·googlecloud
超级小忍2 天前
Spring Boot 集成 Elasticsearch(含 ElasticsearchRestTemplate 示例)
spring boot·elasticsearch
乐世东方客3 天前
Kafka使用Elasticsearch Service Sink Connector直接传输topic数据到Elasticsearch
分布式·elasticsearch·kafka