分享

Kylin原理解析

问题导读:
1.Apache Kylin是什么?
2.Kylin有哪些特点?
3.Kylin的工作原理是什么?

1 概述

      Apache Kylin是一个开源的分布式分析引擎,提供Hadoop/Spark智商的SQL查询接口及多维分析(OLAP)能力以支持超大规模数据。
      注:OLAP(Online Analytical Process),联机分析处理或在线分析。
      最初由eBay开发并贡献至开发社区,它能在亚秒内查询巨大的hive表。Kylin也是由中国人主导的、唯一的Apache顶级开源项目,在开源社区有世界级的影响力。

1.1 Kylin特点
1.1.1 大数据OLAP的两个事实
(1)大数据查询要的一般是统计结果,是多条记录经过聚合函数计算后的统计值。原始的记录则不是必须的,或者访问频率和概率都极低。
(2)聚合是按维度进行的,由于业务范围和分析需求是有限的,有意义的维度聚合组合也是相对有限的,一般不会随着数据的膨胀而增长。
      Kylin基于以上两点,得到一个新的思路——预计算。应尽量多地预先计算聚合结果,在查询时刻应尽量使用预算的结果得出查询结果,从而避免直接扫描可能无限增长的原始记录。

1.1.2 Kylin特点
      Kylin的主要特点包括支持SQL接口、支持超大规模数据集、亚秒级响应、可伸缩性、高吞吐率和BI工具集成等。
(1)可扩展超快OLAP引擎Kylin是为减少在Hadoop/Spark上百亿规模数据查询延迟而设计。
(2)Hadoop ANSI SQL接口Kylin为Hadoop提供标准SQL支持大部分查询功能。
(3)交互式查询能力通过Kylin,用户可以与Hadoop数据进行亚秒级交互,在同样的数据集上提供比Hive更好的性能。
(4)多维立方体(MOLAP Cube)用户能够在Kylin里为百亿以上数据集定义数据模型并构建立方体。
(5)与BI工具无缝整合Kylin提供与BI工具的整合能力,如Tableau、PowerBI/Excel、MSTR、QlikSense、Hue和SuperSet。

1.2 Kylin工作原理
      Kylin的工作原理本质上是MOLAP(Multidimensional Online Analytical Processing)Cube,即多维立方体分析。
      这是数据分析中相当经典的理论,在关系数据库年代就已经有了广泛的应用。
      在说明MOLAP Cube之前需要先介绍一下维度(Dimension)和度量(Measure)这两个概念。

1.2.1 维度(Dimension)和度量(Measure)简介
(1)维度
      简单来讲,维度就是观察数据的角度。它通常是数据记录的一个属性,例如时间和地点等。
      比如电商的销售数据,可以从时间的维度来观察,也可以进一步细化,从时间和地区的维度来观察。
a1.png


   a2.png



      维度一般是一组离散的值,比如时间维度上的每一个独立的日期,或者商品维度上的每一件独立的商品。
      因此统计时可以把维度值相同的记录聚合在一起,然后应用聚合函数做累加、平均、去重技术等聚合操作
(2)度量
      度量就是被聚合后的统计值,也就是聚合运算的结果,如图中的销售额,或者是销售商品的总件数。
      度量是基于数据所计算出来的考量值;它通常是一个数值,如总销售额、不同的用户数等。
      通过比较和测算度量,分析师可以对数据进行评估,比如今年的销售额相比去年有多大的增长,增长的速度是否达到预期,不同商品类别的增长比例是否合理等。
      在一个SQL查询中,Group By的属性通常就是维度,而计算出的值则是度量。

1.2.2 Cube和Cuboid
      有了维度和度量,一个数据表或数据模型上的所有子弹就可以分类了,它们要么是维度,要么是度量(可以被聚合)。于是就有了根据维度和度量做预计算的Cube理论。
      给定一个数据模型,我们可以对其上的所有维度进行组合。对于N个维度来说,组合的所有可能性共有2^n种。
      对于每一种维度的组合,将度量做聚合运算,然后将运算的结果保存为一个物化视图,称为Cuboid。
      所有维度组合的Cuboid作为一个整体,被称为Cube。
      所以简单来说,一个Cube就是许多按维度聚合的物化视图的集合。
      下面来列举一个具体的例子:
      假定有一个电商的销售数据集,其中维度包括:时间(Time)、商品(Item)、地点(Location)和供应商(Supplier),度量包括:销售额(GMV)。
      那么所有维度的组合就有2^4=16种。

a3.png


(1)一维度(1D)的组合有[Time]、[Item]、[Location]和[Supplier]4种;
(2)二维度(2D)的组合有[Time,Item]、[Time,Location]、[Time、Supplier]、 [Item,Location]、[Item,Supplier]、[Location,Supplier]6种;
(3)三维度(3D)的组合也有4种;
(4)最后零维度(0D)和四维度(4D)的组合各有1种。
      计算Cuboid,即按维度来聚合销售额。如果用SQL语句来表达计算Cuboid[Time,Loca-tion],那么SQL语句如下:
  1. SELECT Time,Location,Sum(GMV) as GMV FROM Sales GROUP BY Time,Location;
复制代码
     将计算的结果保存为物化视图,所有Cuboid物化视图的总称为Cube。

1.2.3 工作原理
      Kylin的工作原理就是对数据模型做Cube预计算,并利用计算的结果加速查询,具体工作过程如下:
(1)指定数据模型,定义维度和度量;
(2)预计算Cube,计算所有Cuboid并保存为物化视图;
(3)执行查询时,读取Cuboid运算,产生查询结果。
      由于Kylin的查询过程不会扫描原始记录,而是通过预计算预先完成表的关联、聚合等负责运算,并利用预计算的结果来执行查询,因此相比于非预计算的查询技术,其速度一般要快到一到两个数量级,并且这点在超大的数据集上优势更明显。
      当数据集达到千亿乃至万亿级别时,Kylin的速度甚至可以超越其他非预计算技术1000倍以上。

1.3 Kylin技术架构

a4.png


(1)数据源
      从上图可以看出,数据源在左侧,保存着待分析的用户数据。数据源可以是Hadoop、Hive、Kafka、RDBMS。其中,Hive是用的最多的一种数据源。
(2)Cube构建引擎
      Cube构建引擎根据元数据的定义,从数据源抽取数据,并构建Cube。
      这套引擎的设计目的在于处理所有离线任务,其中包括shell脚本,Java API以及Map Reduce任务等等。
      任务引擎对Kylin当中的全部任务加以管理与协调,从而确保每一项任务都能得到切实执行并解决其间出现的故障。
(3)元数据管理工具
      Kylin是一款元数据驱动型应用程序。元数据管理工具是一大关键性组件,用于对保存在Kylin当中的所有元数据进行管理,其中包括最为重要的Cube元数据。
      其它全部组件的正常运作都需以元数据管理工具为基础。Kylin的元数据存储在HBase中。
(4)REST Server
      REST Server是一套面向应用程序开发的入口点,旨在实现针对 Kylin 平台的应用开发工作。此类应用程序可以提供查询、获取结果、触发Cube构建任务、获取元数据以及获取用户权限等等。另外,可以通过Restful接口实现SQL查询。
(5)查询引擎
      当Cube准备就绪后,查询引擎就能够获取并解析用户查询。它随后会与系统中的其它组件进行交互,从而向用户返回对应的结果。
(6)Routing(路由选择)
      Routing负责将解析的SQL生成的执行计划转换成Cube缓存的查询。Cube是通过预计算缓存在HBase中,这部分查询可以在秒级甚至毫秒级完成。
      而且,还有一些操作查询原始数据(存储在Hadoop的HDFS中通过Hive查询)。这部分查询延迟较高。

1.4 核心算法
      预计算过程是 Kylin 从 Hive 中读取原始数据,按照我们选定的维度进行计算,并将结果集保存到 Hbase 中,默认的计算引擎为 MapReduce,可以选择 Spark 作为计算引擎。
      一次 build 的结果,我们称为一个 Segment。构建过程中会涉及多个 Cuboid 的创建,具体创建过程算法由kylin.cube.algorithm参数决定,参数值可选 auto,layer 和 inmem, 默认值为 auto,即 Kylin 会通过采集数据动态地选择一个算法 (layer or inmem),如果用户很了解 Kylin 和自身的数据、集群,可以直接设置喜欢的算法。

1.4.1 逐层构建算法(layer)
(1)算法内容

a5.png


      我们知道,一个 N 维的 Cube,是由 1 个 N 维子立方体、N 个 (N-1) 维子立方体、N*(N-1)/2个(N-2)维子立方体、…、N个1维子立方体和1个0维子立方体构成,总共有2^N个子立方体组成,在逐层算法中,按维度数逐层减少来计算,每个层级的计算(除了第一层,它是从原始数据聚合而来),是基于它上一层级的结果来计算的。比如,[Group by A, B]的结果,可以基于[Group by A, B, C]的结果,通过去掉C后聚合得来的;这样可以减少重复计算;当 0 维度Cuboid计算出来的时候,整个Cube的计算也就完成了。
      每一轮的计算都是一个MapReduce任务,且串行执行;一个 N 维的 Cube,至少需要 N 次 MapReduce Job。
(2)算法优点
      ① 此算法充分利用了 MapReduce 的能力,处理了中间复杂的排序和洗牌工作,故而算法代码清晰简单,易于维护;
      ② 受益于 Hadoop 的日趋成熟,此算法对集群要求低,运行稳定;在内部维护 Kylin 的过程中,很少遇到在这几步出错的情况;即便是在Hadoop集群比较繁忙的时候,任务也能完成。
(3)算法缺点
      ① 当Cube有比较多维度的时候,所需要的MapReduce任务也相应增加;由于Hadoop的任务调度需要耗费额外资源,特别是集群较庞大的时候,反复递交任务造成的额外开销会相当可观;
      ② 由于Mapper不做预聚合,此算法会对Hadoop MapReduce输出较多数据; 虽然已经使用了Combiner来减少从Mapper端到Reducer端的数据传输,但是所有数据依然需要通过Hadoop MapReduce来排序和组合才能被聚合,无形之中增加了集群的压力;
      ③ 对HDFS的读写操作较多:由于每一层计算的输出会用做下一层计算的输入,这些Key-Value需要写到HDFS上;当所有计算都完成后,Kylin还需要额外的一轮任务将这些文件转成HBase的HFile格式,以导入到 HBase中去;
      总体而言,该算法的效率较低,尤其是当Cube维度数较大的时候。

1.4.2 快速构建算法(inmem)
      也被称作“逐段”(By Segment) 或“逐块”(By Split) 算法,从1.5.x开始引入该算法。利用Mapper端计算先完成大部分聚合,再将聚合后的结果交给Reducer,从而降低对网络瓶颈的压力。算法的流程如下图所示:

a6.png


      该算法的主要思想是,对Mapper所分配的数据块,将它计算成一个完整的小Cube段(包含所有Cuboid);每个Mapper将计算完的Cube段输出给Reducer 做合并,生成大Cube,也就是最终结果。。(本质上就是对Key做了一些处理,之前是每一轮都要都有各自组合的Key,现在是将不同轮的Key一同放在Mapper和Reducer中统一处理。)
      与旧算法相比,快速算法主要有两点不同:
      ① Mapper会利用内存做预聚合,算出所有组合;Mapper输出的每个Key 都是不同的,这样会减少输出到Hadoop MapReduce的数据量,Combiner也不再需要;
      ② 一轮MapReduce便会完成所有层次的计算,减少Hadoop任务的调配。

1.5 Kylin中的核心概念
1.5.1 维度和度量
      这部分内容已在1.2.1节讲解,这里不再赘述。

1.5.2 事实表和维度表
(1)事实表(Fact Table)
      事实表(Fact Table)是指存储有事实记录的表,如系统日志、销售记录等。事实表的记录在不断地动态增长,所以它的体积通常远大于其他表。
(2)维度表(Dimension Table)
      维度表(Dimension Table)或维表,有时也称查找表(Lookup Table)。该表是与事实表相对应的一种表: 它保存了维度的属性值,可以跟事实表做关联;相当于将事实表上经常重复出现的属性抽取、规范出来用一张表进行管理。
      常见的维度表有:日期表(存储与日期对应的周、月、季度等的属 性)、地点表(包含国家、省/州、城市等属性)等。
      使用维度表有诸多好处,具体如下:
      ① 缩小了事实表的大小;
      ② 便于维度的管理和维护,增加、删除和修改维度的属性,不必对事实表的大量记录进行改动;
      ③ 维度表可以为多个事实表重用,以减少重复工作。

1.5.3 Cube、Cuboid和Cube Segment
      Cube(或Data Cube),即数据立方体,是一种常用于数据分析与索引的技术;它可以对原始数据建立多维度索引。通过Cube对数据进行分析,可以大大加快数据的查询效率。
      Cuboid在Kylin中特指在某一种维度组合下所计算的数据。
      Cube Segment是指针对源数据中的某一个片段,计算出来的Cube数据。通常数据仓库中的数据数量会随着时间的增长而增长,而Cube Segment也是按时间顺序来构建的。

1.5.4 星型模型
      数据挖掘有几种常见的多维数据模型,如星形模型(Star Schema)、雪花模型(Snowflake Schema)等。
(1)星形模型
      星形模型中有一张事实表,以及零个或多个维度表;事实表与维度表通过主键外键相关联,维度表与维度表之间没有关联,就像很多星星围绕在一个恒星周围,故取名为星形模型。
(2)雪花模型
      如果将星形模型中某些维度的表再做规范,抽取成更细的维度表,然后让维度表之间也进行关联,那么这种模型称为雪花模型。
      不过,Kylin只支持星形模型的数据集,这是基于以下考虑:
      ① 星形模型是最简单,也是最常用的模型。
      ② 由于星形模型只有一张大表,因此它相比于其他模型更适合于大数据处理。
      ③ 其他模型可以通过一定的转换,变为星形模型。

1.5.5 维度的基数
      维度的基数(Cardinality)指的是该维度在数据集中出现的不同值的个数;例如“国家”是一个维度,如果有200个不同的值,那么此维度的基数就是200。
      通常一个维度的基数会从几十到几万个不等,个别维度如“用户ID”的基数会超过百万甚至千万。基数超过一百万的维度通常被称为超高基数维度(Ultra High Cardinality,UHC),需要引起设计者的注意。

2 Cube构建优化

      从之前章节的介绍可以知道,在没有采取任何优化措施的情况下,Kylin会对每一种维度的组合进行预计算,每种维度的组合的预计算结果被称为Cuboid。假设有4个维度,我们最终会有2^4=16个Cuboid 需要计算。
      但在现实情况中,用户的维度数量一般远远大于4个。假设用户有10个维度,那么没有经过任何优化的Cube就会存在2^10=1024个Cuboid;
      如果用户有20个维度,那么Cube中总共会存在2^20 =1048576个Cuboid。
      虽然每个Cuboid的大小存在很大的差异,但是单单想到Cuboid的数量就足以让人想象到这样的Cube对构建引擎、存储引擎来说压力有多么巨大。
      因此,在构建维度数量较多的Cube时,尤其要注意Cube的剪枝优化(即减少Cuboid的生成)。

2.1找到问题Cube
2.1.1 检查Cuboid数量
      Kylin提供了一个简单的工具,供用户检查Cube中哪些Cuboid最终被预计算了,我们称其为被物化(Materialized)的Cuboid。同时,这种方法还能给出每个Cuboid所占空间的估计值。
      由于该工具需要在对数据进行一定阶段的处理之后才能估算Cuboid的大小,因此一般来说只能在Cube构建完毕之后再使用该工具。
      目前关于这一点也是该工具的一大不足,由于同一个Cube的不同Segment 之间仅是输入数据不同,模型信息和优化策略都是共享的,所以不同Segment中 哪些Cuboid被物化哪些没有被物化都是一样的。
      因此只要Cube中至少有一个Segment,那么就能使用如下的命令行工具去检查这个Cube中的Cuboid状态:
  1. bin/kylin.sh org.apache.kylin.engine.mr.common.CubeStatsReader CUBE_NAME
  2. // 其中CUBE_NAME是你要检查的 Cube 的 Name.
复制代码
     例如:
  1. bin/kylin.sh org.apache.kylin.engine.mr.common.CubeStatsReader sav_cube
  2. ...
  3. ============================================================================
  4. Statistics of sav_cube[FULL_BUILD]
  5. Cube statistics hll precision: 14
  6. Total cuboids: 7
  7. Total estimated rows: 51
  8. Total estimated size(MB): 3.027915954589844E-4
  9. Sampling percentage:  100
  10. Mapper overlap ratio: 1.0
  11. Mapper number: 1
  12. Length of dimension DEFAULT.EMP.JOB is 1
  13. Length of dimension DEFAULT.EMP.MGR is 1
  14. Length of dimension DEFAULT.EMP.DEPTNO is 1
  15. |---- Cuboid 111, est row: 10, est MB: 0
  16.     |---- Cuboid 011, est row: 9, est MB: 0, shrink: 90%
  17.         |---- Cuboid 001, est row: 3, est MB: 0, shrink: 33.33%
  18.         |---- Cuboid 010, est row: 7, est MB: 0, shrink: 77.78%
  19.     |---- Cuboid 101, est row: 9, est MB: 0, shrink: 90%
  20.         |---- Cuboid 100, est row: 5, est MB: 0, shrink: 55.56%
  21.     |---- Cuboid 110, est row: 8, est MB: 0, shrink: 80%
  22. ----------------------------------------------------------------------------
复制代码

      从分析结果的下半部分可以看到,所有的Cuboid及它的分析结果都以树状的形式打印了出来。
      在这棵树中,每个节点代表一个Cuboid,每个Cuboid都由一连串1或0的数字组成,如果数字为0,则代表这个Cuboid中不存在相应的维度;如果数字为 1,则代表这个Cuboid中存在相应的维度。
      除了最顶端的Cuboid之外,每个Cuboid都有一个父亲Cuboid,且都比父亲Cuboid 少了一个“1”。其意义是这个Cuboid就是由它的父亲节点减少一个维度聚合而来的(上卷)。
      最顶端的Cuboid称为Base Cuboid,它直接由源数据计算而来。
      每行Cuboid的输出中除了0和1的数字串以外,后面还有每个Cuboid的行数与父亲节点的对比(Shrink值)。
      所有Cuboid行数的估计值之和应该等于Segment的行数估计值,每个 Cuboid都是在它的父亲节点的基础上进一步聚合而成的,因此从理论上说每个 Cuboid无论是行数还是大小都应该小于它的父亲。
      在这棵树中,我们可以观察每个节点的Shrink值,如果该值接近100%,则说明这个Cuboid虽然比它的父亲Cuboid少了一个维度,但是并没有比它的父亲 Cuboid少很多行数据。换而言之,即使没有这个Cuboid,我们在查询时使用它的父亲Cuboid,也不会有太大的代价。那么我们就可以对这个Cuboid进行剪枝操作。

2.1.2 检查Cube大小
      还有一种更为简单的方法可以帮助我们判断Cube是否已经足够优化。在 Web GUI的Model页面选择一个READY状态的Cube,当我们把光标移到该Cube的Cube Size列时,Web GUI 会提示Cube的源数据大小,以及当前Cube 的大小除以源数据大小的比例,称为膨胀率(Expansion Rate),如图所示。

a7.png


      一般来说,Cube的膨胀率应该在0%~1000%之间,如果一个Cube的膨胀率超过1000%,那么Cube管理员应当开始挖掘其中的原因。通常,膨胀率高有以下几个方面的原因。
      ① Cube中的维度数量较多,且没有进行很好的Cuboid剪枝优化,导致Cuboid 数量极多;
      ② Cube中存在较高基数的维度,导致包含这类维度的每一个Cuboid占用的空间都很大,这些Cuboid累积造成整体Cube体积变大;
      因此,对于Cube膨胀率居高不下的情况,管理员需要结合实际数据进行分析,可灵活地运用接下来介绍的优化方法对Cube进行优化。
      综上所述,我们不难发现,上述的这两种方法并不是值得推荐的好方法。原因如下:这两种方法被使用的前提是Cube已经构建完成,这就意味着,每次优化都需要先构建Cube,这极大地增加了集群的开销。所以,这两种方法并不推荐使用。

2.2 优化构建
2.2.1 聚合组
      聚合组(Aggregation Group)是一种强大的剪枝工具。聚合组假设一个Cube 的所有维度均可以根据业务需求划分成若干组(当然也可以是一个组),由于同一个组内的维度更可能同时被同一个查询用到,因此会表现出更加紧密的内在关联。
      每个分组的维度集合均是Cube所有维度的一个子集,不同的分组各自拥有一套维度集合,它们可能与其他分组有相同的维度,也可能没有相同的维度。每个分组各自独立地根据自身的规则贡献出一批需要被物化的Cuboid,所有分组贡献的Cuboid的并集就成为了当前Cube中所有需要物化的Cuboid的集合。不同的分组有可能会贡献出相同的Cuboid,构建引擎会察觉到这点,并且保证每一个Cuboid无论在多少个分组中出现,它都只会被物化一次。 对于每个分组内部的维度,用户可以使用如下三种可选的方式定义,它们之间的关系,具体如下。
(1)强制维度(Mandatory)
      如果一个维度被定义为强制维度,那么这个分组产生的所有Cuboid中每一个Cuboid都会包含该维度。
      每个分组中都可以有0个、1个或多个强制维度。如果根据这个分组的业务逻辑,则相关的查询一定会在过滤条件或分组条件中,因此可以在该分组中把该维度设置为强制维度。

a8.png


      A作为强制维度等价于所有组合必须有A。
(2)层级维度 (Hierarchy)
      每个层级包含两个或更多个维度。假设一个层级中包含D1,D2…Dn这n个维度,那么在该分组产生的任何Cuboid中, 这n个维度只会以(),(D1),(D1,D2)…(D1,D2…Dn)这n+1种形式中的一种出现。每个分组中可以有0个、1个或多个层级,不同的层级之间不应当有共享的维度。如果根据这个分组的业务逻辑,则多个维度直接存在层级关系,因此可以在该分组中把这些维度设置为层级维度。

a9.png


      A->B作为层级维度等价于若有B则必须有A。
(3)联合维度(Joint)
      每个联合中包含两个或更多个维度,如果某些列形成一个联合,那么在该分组产生的任何Cuboid中,这些联合维度要么一起出现,要么都不出现。每个分组中可以有0个或多个联合,但是不同的联合之间不应当有共享的维度(否则它们可以合并成一个联合)。如果根据这个分组的业务逻辑,多个维度在查询中总是同时出现,则可以在该分组中把这些维度设置为联合维度。

a10.png


      AB作为联合维度等价于要么AB同时出现,要么同时不出现。
      聚合组的设计非常灵活,甚至可以用来描述一些极端的设计。
      假设我们的业务需求非常单一,只需要某些特定的Cuboid,那么可以创建多个聚合组,每个聚合组代表一个Cuboid。具体的方法是在聚合组中先包含某个Cuboid所需的所有维度,然后把这些维度都设置为强制维度。这样当前的聚合组就只能产生我们想要的那一个Cuboid了。
      再比如,有的时候我们的Cube中有一些基数非常大的维度,如果不做特殊处理,它就会和其他的维度进行各种组合,从而产生一大堆包含它的Cuboid。包含高基数维度的Cuboid在行数和体积上往往非常庞大,这会导致整个Cube的膨胀率变大。如果根据业务需求知道这个高基数的维度只会与若干个维度(而不是所有维度)同时被查询到,那么就可以通过聚合组对这个高基数维度做一定的“隔离”。我们把这个高基数的维度放入一个单独的聚合组,再把所有可能会与这个高基数维度一起被查询到的其他维度也放进来。这样,这个高基数的维度就被“隔离”在一个聚合组中了,所有不会与它一起被查询到的维度都没有和它一起出现在任何一个分组中,因此也就不会有多余的Cuboid产生。这点也大大减少了包含该高基数维度的Cuboid的数量,可以有效地控制Cube的膨胀率。

2.2.2 并发粒度优化
      当Segment中某一个Cuboid的大小超出一定的阈值时,系统会将该Cuboid 的数据分片到多个分区中,以实现Cuboid数据读取的并行化,从而优化Cube的查询速度。
      具体的实现方式如下:构建引擎根据Segment估计的大小,以及参数kylin.hbase.region.cut的设置决定Segment在存储引擎中总共需要几个分区来存储,如果存储引擎是HBase,那么分区的数量就对应于HBase中的Region数量。kylin.hbase.region.cut的默认值是5.0,单位是GB,也就是说对于一个大小估计是50GB的Segment,构建引擎会给它分配10个分区。用户还可以通过设置kylin.hbase.region.count.min(默认为1)和kylin.hbase.region.count.max(默认为500)两个配置来决定每个Segment最少或最多被划分成多少个分区。


最新经典文章,欢迎关注公众号


原文链接:https://blog.csdn.net/huahuaxiaoshao/article/details/107521303


没找到任何评论,期待你打破沉寂

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

关闭

推荐上一条 /2 下一条