写在前面:本文适用于还不会使用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. 查询时使用别名查询索引,不能保证最小范围匹配到索引 |