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. 查询时使用别名查询索引,不能保证最小范围匹配到索引
相关推荐
努力的小郑6 小时前
Elasticsearch 避坑指南:我在项目中总结的 14 条实用经验
后端·elasticsearch·性能优化
qq_54702617912 小时前
Canal实时同步MySQL数据到Elasticsearch
数据库·mysql·elasticsearch
星光一影19 小时前
基于SpringBoot智慧社区系统/乡村振兴系统/大数据与人工智能平台
大数据·spring boot·后端·mysql·elasticsearch·vue
Elasticsearch2 天前
在 Kibana 中引入 Elasticsearch 查询规则界面
elasticsearch
Elastic 中国社区官方博客2 天前
使用 Mastra 和 Elasticsearch 构建具有语义回忆功能的知识 agent
大数据·数据库·人工智能·elasticsearch·搜索引擎·ai·全文检索
新手小白*2 天前
Elasticsearch+Logstash+Filebeat+Kibana部署【7.1.1版本】
大数据·elasticsearch·搜索引擎
lpfasd1232 天前
git-团队协作基础
chrome·git·elasticsearch
苗壮.2 天前
「个人 Gitee 仓库」与「企业 Gitee 仓库」同步的几种常见方式
大数据·elasticsearch·gitee
Elastic 中国社区官方博客2 天前
如何使用 Ollama 在本地设置和运行 GPT-OSS
人工智能·gpt·elasticsearch·搜索引擎·ai·语言模型
Elasticsearch2 天前
Elastic Streams 中的数据协调:稳健架构深度解析
elasticsearch