ElasticSearch核心之——聚合分析

前言

掌握ElasticSearch(简称ES)的基本API之后,可以让我们实现对业务数据精确检索,若是要进一步对检索到的数据进行分析和统计,得到清晰细致的可视化视图的话,那么掌握ES的聚合分析就十分重要了。本篇文章将对ES聚合分析的常用API和其原理进行讲解,希望能够给各位读者学习ES聚合分析提供参考。

一、什么是聚合分析

讲聚合分析前,我们先来聊一下聚合分析和常见的检索可以解决哪些问题?

我们可以ES检索的基本API来回答这种问题:地址为上海的所有订单? 最近1天内创建但没有付款的所有订单?
而聚合分析基于其聚合的特性,可以回答如下问题:最近1周每天的订单成交量有多少?最近1个月每天的平均订单金额是多少?
我们可以发现聚合分析所能解决的问题是时间和空间数据上的统计再分析。简单理解的话,可以把它看做是数据库中的group by和各种聚合函数。但ES的聚合分析方法更加强大和灵活。

(一)聚合分析的含义

聚合分析,英文为Aggregation,是ES除搜索功能外提供的针对ES数据做统计分析的功能:

  1. 功能丰富,提供Bucket、Metric、Pipeline等多种分析方式,可以满足大部分的分析需求
  2. 实时性高,所有的计算结果都是即时返回的,而 hadoop等大数据系统一般都是T+1级别的
    在实际的项目应用中,我们可以使用聚合分析来做范围条件筛选,结合kibana做图表分析等。
(二)聚合分析的API和类型介绍

聚合分析的API介绍

我们举一个实际应用的例子,在ES中当前各个职业的员工分布情况,相当于我们根据job字段进行了group by,使用的聚合关键词是terms

查询当前各个职业的员工分布情况

ES将聚合分析主要分为如下4类:
1. Bucket,分桶类型,类似SQL中的GROUP BY语法
2. Metric,指标分析类型,如计算最大值、最小值、平均值等等
3. Pipeline,管道分析类型,基于上一级的聚合分析结果进行再分析
4. Matrix,矩阵分析类型(可用于热力图分析)

二、Metric聚合分析

Metric聚合分析主要分如下两类:单值分析和多值分析。其中单字分析只输出一个分析结果,常见的关键字有min,max,avg,sumcardinality。多字分析会输出多个分析结果,其关键字主要有stats,extended statspercentile, percentile ranktop hits。下面将对这两类分析的API关键词进行讲解。

(一)单值分析
1. 求最小值:min
GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "min_salary": {
      "min": {
        "field": "salary"
      }
    }
  }
}
求最低工资
2. 求最大值:Max
GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "max_salary": {
      "max": {
        "field": "salary"
      }
    }
  }
}
求最高工资
3. 求平均值:Avg
GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "max_salary": {
      "avg": {
        "field": "salary"
      }
    }
  }
}
求平均工资
4. 求总和:Sum
GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "sum_salary": {
      "sum": {
        "field": "salary"
      }
    }
  }
}
所有员工工资总和

我们可以在一个请求体中发起多个聚合函数请求:

GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "sum_salary": {
      "sum": {
        "field": "salary"
      }
    },
    "avg_salary": {
      "avg": {
        "field": "salary"
      }
    },
    "min_salary": {
      "min": {
        "field": "salary"
      }
    },
    "max_salary": {
      "max": {
        "field": "salary"
      }
    }
  }
}
一次发起多个聚合函数请求
5. 求基数:Cardinality

Cardinality,意为集合的势,或者基数,是指不同数值的个数,类似SQL中的distinctcount概念。可以把基数理解为是去重之后的个数,比如员工表中有职位相同的员工,我们可以用cardinality来查找去重后员工表中所有职位数量

GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "cardinality_job": {
      "cardinality": {
        "field": "job.keyword"
      }
    }
  }
}
所有职位的类型总数
(二)多值分析
1. 字段状态:Stats

我们可以利用stats函数来快速获取某个字段的最大值、最小值、平均值等聚合分析后的结果。可以把stats看成是同时执行了min、max等函数。

GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "salary_stats": {
      "stats": {
        "field": "salary"
      }
    }
  }
}
stats演示用例
2. stats属性拓展:Extended Stats

Extended Stats是对stats 的扩展,包含了更多的统计数据,如方差、标准差等

GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "salary_extended_stats": {
      "extended_stats": {
        "field": "salary"
      }
    }
  }
}
extened_stats演示
3. 百分位数统计:Percentile

百分位数展现某以具体百分比下观察到的数值。例如,第95个百分位上的数值,是高于 95% 的数据总和。

GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "percent_salary": {
      "percentiles": {
        "field": "salary"
      }
    }
  }
}
Percentile演示用例
4. 百分位等级统计:Percentile Rank

这个可以看成是给定一个数值,返回这个数值在结果集中的排名情况。

GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "percent_rank_salary": {
      "percentile_ranks": {
        "field": "salary",
        "values": [
            5500
          ]
      }
    }
  }
}
Percentile Rank演示用例
5. 分桶最匹配:Top Hit

Top Hit一般用于分桶后获取该桶内最匹配的顶部文档列表,即详情数据

GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "per_job": {
      "terms": {
        "field": "job.keyword",
        "size": 10
      },
      "aggs": {
        "top developer": {
          "top_hits": {
            "size": 10,
            "sort": [
               {
                 "salary": {
                   "order": "desc"
                 }
               }
              ]
          }
        }
      }
    }
  }
}
Top Hit演示用例
(三)Bucket 聚合分析

Bucket,意为桶,即按照一定的规则将文档分配到不同的桶中,达到分类分析的目的

Bucket分析像是根据条件将结果装在了不同的桶中

按照 Bucket的分桶策略,常见的Bucket聚合分析有:TermsRangeDate RangeHistogramDate Histogram。下面将对这些聚合分析的API进行举例讲解:

1. 字段分桶:Terms

该分桶策略最简单,直接按照term来分桶,如果是text类型,则按照分词后的结果分桶(需要开启fielddata为true的开关)。

GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "per_job": {
      "terms": {
        "field": "job.keyword",
        "size": 10
      }
    }
  }
}
Terms演示用例
2. 范围分桶:Range

Range分桶策略通过指定数值的范围来设定分桶规则

GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "salary_range": {
      "range": {
        "field": "salary",
        "ranges": [
          {
            "key": "0-6000", 
            "to": 6000
          },
          {
            "from": 6000,
            "to": 9000
          },
          {
            "from": 9000
          }
        ]
      }
    }
  }
}
Range演示用例
3. 日期范围分桶:Date Range

通过指定日期的范围来设定分桶规则,这里需要注意我们给定分组的日期范围可以使用data-math的格式来进行书写。

GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "range by date": {
      "date_range": {
        "field": "birth",
        "format": "yyyy", 
        "ranges": [
          {
            "from": "1980",
            "to": "1985"
          },
          {
            "from": "1985",
            "to": "1990"
          },
          {
            "from": "1990",
            "to": "1995"
          },
          {
            "from": "1995"
          }
        ]
      }
    }
  }
}
Date Range演示用例
4. 直方图:Historgram
GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "salary_hist": {
      "histogram": {
        "field": "salary",
        "interval": 2000,
        "extended_bounds": {
          "min": 0,
          "max": 15000
        }
      }
    }
  }
}

Historgram演示用例
5. 日期直方图:Date Historgram

针对日期的直方图或者柱状图,是时序数据分析中常用的聚合分析类型。Date HistrogramHistrogram类似,区别在于它是根据日期来进行范围分组的。

GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "birth_hist": {
      "date_histogram": {
        "field": "birth",
        "interval": "year",
        "format": "yyyy"
      }
    }
  }
}

Date Historgram演示用例

从结果来看,Date Historgram很像之前提到的Date Range分桶,但二者区别还是不小的,一方面这个API主要用于做日期的直方图,另外其日期间隔的设置也不如Date Range这般自由。

四、Bucket + Metric 聚合分析

Bucket聚合分析允许通过添加子分析来进一步进行分析,该子分析可以是Bucket也可以是 Metric。这也使得ES的聚合分析能力变得异常强大

(一)分桶 + 分桶

有时候,我们需要在分桶的基础上再进行分桶,通常是出现在我们要对已经分桶的结果集中再进行细化分,比如说分出公司内各个职业后,再查看同种职业中不同的工资分布情况。

GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "per_job": {
      "terms": {
        "field": "job.keyword",
        "size": 10
      },
      "aggs": {
        "salary_range": {
          "range": {
            "field": "salary",
            "ranges": [
              {
                "to": 6000
              },
              {
                "from": 6000,
                "to": 9000
              },
              {
                "from": 9000
              }
            ]
          }
        }
      }
    }
  }
}
分桶之后再分桶演示用例
(二)分桶之后聚合分析

分桶之后进行聚合分析是比较常见的操作啦,就像sql中我们经常会在group by后使用聚合函数来统计某些数据,进行更加针对性的分析。

GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "per_job": {
      "terms": {
        "field": "job.keyword",
        "size": 10
      },
      "aggs": {
        "salary_stats": {
          "stats": {
            "field": "salary"
          }
        }
      }
    }
  }
}
分桶之后再聚合演示用例

五、Pipeline 聚合分析

针对聚合分析的结果再次进行聚合分析,而且支持链式调用,可以回答类似订单月平均销售额是多少?这类问题。同时Pipeline的分析结果会输出到原结果中,根据输出位置的不同,分为以下两类:
Parent结果内嵌到现有的聚合分析结果中(Derivative,Moving Average,Cumulative Sum)
Sibling结果与现有聚合分析结果同级(Max/Min/Avg/Sum Bucket- Stats/Extended Stats Bucket- Percentiles Bucket)
下面将对上述两类分析分别进行介绍和演示

(一)Sibling
1. 最大桶 Min Bucket

找出所有Bucket 中值最小的 Bucket名称和值。比如说现在已经根据员工的职位进行分桶,并得到各个职位的平均工资,如果现在想要进一步得到工资最低的职位的话,我们可以用Pipeline进行再聚合分析解决这个问题。(大家不妨想一想这里用sql可以怎么解决)

GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "per_job": {
      "terms": {
        "field": "job.keyword",
        "size": 10
      },
      "aggs": {
        "salary_avg": {
          "avg": {
            "field": "salary"
          }
        }
      }
    },
    "min_salary_in_job": {
      "min_bucket": {
        "buckets_path": "per_job>salary_avg"
      }
    }
  }
}
Min Bucket演示用例
2. 最大桶 Max Bucket

找出所有Bucket中值最大的Bucket名称和值。这里的话,可以根据员工的职位进行分桶,并得到各个职位的平均工资,再通过Max Bucket进一步得到工资最高的职位

GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "per_job": {
      "terms": {
        "field": "job.keyword",
        "size": 10
      },
      "aggs": {
        "salary_avg": {
          "avg": {
            "field": "salary"
          }
        }
      }
    },
    "max_salary_in_job": {
      "max_bucket": {
        "buckets_path": "per_job>salary_avg"
      }
    }
  }
}

Max Bucket演示用例

由于API类似,下面就不再对AvgSumStats等API进行介绍啦。。。

(二)Parent
1. 导数分析:Derivative

高中数学就有学过导数了,我们可以通过导数来查看某个指标的变化速率。使用Parent下的API时,尤其需要注意的API的位置,这里的话函数是放在父聚合分析里面的。同时Derivative要求父函数是根据historgram或者date historgram进行分桶的。

GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "birth_hist": {
      "date_histogram": {
        "field": "birth",
        "interval": "year",
        "format": "yyyy"
      },
      "aggs": {
        "salary_avg": {
          "avg": {
            "field": "salary"
          }
        },
        "derivative_salary_in_job": {
          "derivative": {
            "buckets_path": "salary_avg"
          }
        }
      }
    }
  }
}
Derivative演示用例
2. 移动平均值:Moving Average

移动平均值的使用和求导的函数类似,在父聚合函数下使用对应的API即可。这里也就不再展开解释

GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "birth_hist": {
      "date_histogram": {
        "field": "birth",
        "interval": "year",
        "format": "yyyy"
      },
      "aggs": {
        "salary_avg": {
          "avg": {
            "field": "salary"
          }
        },
        "moving_avg_salary": {
          "moving_avg": {
            "buckets_path": "salary_avg"
          }
        }
      }
    }
  }
}
移动平均值演示用例
3. 累积求和:Cumulative Sum

该函数可以求得Bucket值的累计相加

GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "birth_hist": {
      "date_histogram": {
        "field": "birth",
        "interval": "year",
        "format": "yyyy"
      },
      "aggs": {
        "salary_avg": {
          "avg": {
            "field": "salary"
          }
        },
        "sum_avg_salary": {
          "cumulative_sum": {
            "buckets_path": "salary_avg"
          }
        }
      }
    }
  }
}
Cumulative演示用例

六、聚合函数的作用范围

es聚合分析默认作用范围是query的结果集,也就是说是基于query得到的结果再进行聚合分析的。我们可以通过三种方式改变其作用范围,分别是:filterpost_filterglobal。下面将对这几种方式进行详细讲解。

聚合函数默认基于query的结果集为作用范围

(一)Filter

为某个聚合分析设定过滤条件,从而在不更改整体query语句的情况下修改了作用范围

GET /test_aggs_index/_search
{
  "size": 0,
  "aggs": {
    "filter_job_in_limit":{
      "filter": {
        "range": {
          "salary": {
            "lt": 10000
          }
        }
      },
      "aggs": {
        "bucket_by_job": {
          "terms": {
            "field": "job.keyword",
            "size": 10
          }
        }
      }
    },
    "bucket_by_job": {
      "terms": {
        "field": "job.keyword",
        "size": 10
      }
    }
  }
}
Filter使用演示用例
(二)Post Filter

作用于文档过滤,但在聚合分析后生效

GET /test_aggs_index/_search
{
  "aggs": {
    "filter_salary": {
      "filter": {
        "range": {
          "salary": {
            "lte": 10000
          }
        }
      },
      "aggs": {
        "bucket_job": {
          "terms": {
            "field": "job.keyword",
            "size": 10
          }
        }
      }
    }
  },
  "post_filter": {
    "match": {
      "job": "java"
    }
  }
}
post filter演示用例
(三)Global

这种条件下,将无视query过滤条件,基于全部文档进行分析

GET /test_aggs_index/_search
{
  "query": {
    "term": {
      "job": {
        "value": "java"
      }
    }
  },
  "aggs": {
    "job_bucket": {
      "terms": {
        "field": "job.keyword",
        "size": 10
      }
    },
    "all_job_bucket": {
      "global": {},
      "aggs": {
        "job_bucket": {
          "terms": {
            "field": "job.keyword",
            "size": 10
          }
        }
      }
    }
  }
}
Global演示用例

七、聚合分析结果排序

可以使用聚合结果中自带的关键数据进行排序,比如根据_count文档数、_key key值排序

GET /test_aggs_index/_search
{
  "size": 0, 
  "aggs": {
    "job_bucket": {
      "terms": {
        "field": "job.keyword",
        "size": 10,
        "order": {
          "_count": "desc"
        }
      }
    }
  }
}

聚合分析使用order排序演示用例

同时,ES也支持我们进行更加深层次的嵌套
深层次排序嵌套

八、聚合分析的原理和精准度问题

(一)聚合分析的原理

我们从前面的文章学习中知道,ES的文档是存储在多个分片上的,那么使用聚合分析时,ES实际上是怎么运作的呢?
以Min函数为例,假如现在索引一共有5个分片分布,那么ES会从各个分片上面取得各自满足最小值条件的文档后进行汇总,最后再返回汇总结果中最小的文档。


Min聚合函数调用过程图

简单的聚合函数调用过程比较简单,我们再来看一下使用Terms分桶时,ES的执行过程是怎么样的。
如图所示,假如terms分桶时设置的size为5,即返回的分桶数量为5,那么ES会在每个分片上各取分桶后文档数量最多的5个桶,再进行汇总,最后将汇总后桶中文档数量最多的前五个桶进行返回。

terms分桶函数调用过程

分桶的执行过程看上去似乎一切都很合理,但是其中却暗藏着一个问题:如何保证各个分片上提取的前n个分桶就是最匹配分桶?听上去似乎有点绕,我们可以来看一下下面这个例子:

分桶过程中出现不准确的情况

存在一个分片数为2的索引,假如现在需要获取分桶后桶内文档数量前三个的分桶,那么就需要在2个分片中先分别提取文档总数前三的分桶,P0中得到的是a(5),b(4),d(4),P1中得到的是a(5),c(3)、b(2);最终汇总得到前三个桶为a(10)、b(6)、d(4)。但实际上,两个分片中归属于c分桶的文档一共有6个,最正确的结果应该是:a(10)、b(6)、c(6)。然而由于P0中的c(3)在该分片中没有前三,所以不被作为汇总单位计算入内。
数据分散在多 Shard 上 ,Coordinating Node无法得悉数据全貌,这就是ES使用terms进行分桶有时会出现结果不准确的原因。

(二)如何解决terms不准确的方法

我们前面提到,terms不准确的原因是文档数据分布在多个Shard分片上导致的,所以如果我们将Shard分片数设置为1,就可以消除数据分散的问题。但这样的话缺点也很明显:单个分片无法承载大数据量

另外的解决方法是合理设置Shard_Size大小,即每次从Shard上额外多获取数据。我们从上一节的分析中可以知道,当文档数据分布在多个分片上时,如果每次从分片上提取的分桶数量越多,就越能够获取结果准确的数据,从而提升准确度。

设置shard_size解决terms不准确问题

在讲如何设置Shard_Size大小之前,先说说如何判断当前结果是否准确?

terms聚合返回结果中有两个统计值:doc count_error_upper_bound(表示被遗漏的term可能的最大值)、sum_other_doc_count(表示返回结果bucket的term外其他term的文档总数)。以下图为例,分片1汇总起来的桶内文档数量最少的分桶内有4个文档,所以该分片内可能遗漏掉的其他分桶文档数最大值为4,依次类推,node2的count_error_upper_bound为2,所以count_error_upper_bound最终为各个分片所得之和,即为6。

两个聚合结果统计值的计算

之所以要介绍这两个统计值,是因为我们可以通过doc_count_error_upper_bound这个值的大小来查看每个bucket误算的最大值,0表示计算准确。
使用 show_term_doc_count_error 查看误算值

我们可以通过不断调整shard_size的值,结合上述的指标校验,以此获取最准确的匹配结果。

在ES中Shard_Size默认大小如下:shard_size = (size x 1.5)+ 10。我们可以看到,在默认情况下ES为了避免出现结果不准确的问题出现,shard_size往往会比size要大许多,以此保障结果的相对准确。如果我们对结果精准度有较高的要求的话,就需要自己再去调整Shard_Size的大小,降低 doc_count_error_upper_bound来提升准确度。但这样会增大整体的计算量,从而降低响应时间,这也是各位读者在实际操作中需要注意的地方。

一些小拓展

在ES的聚合分析中,CardinalityPercentile分析使用的是近似统计算法,其结果是近似准确的,但不一定精准,我们可以通过参数的调整使其结果精准,但同时也意味着更多的计算时间和更大的性能消耗。近似统计算法可以让我们在实际的项目应用中获得数据量和实时性的优势,丢失部分准确度。

数据量、精准度、实时性之间的三角关系
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,233评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,013评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,030评论 0 241
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,827评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,221评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,542评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,814评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,513评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,225评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,497评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,998评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,342评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,986评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,812评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,560评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,461评论 2 266

推荐阅读更多精彩内容