当前位置:   article > 正文

Apache Doris (Incubating) 原理与实践

doris 单机最大并发

奇技指南

近日,我们邀请到百度高级研发工程师李超勇前来360,分享百度开源数据库Apache Doris 的原理与实践。


640?wx_fmt=jpeg

Apache Doris简介

Doris(原百度 Palo)是一款基于大规模并行处理技术的分布式 SQL 数据库,由百度在2017年开源,2018年8月进入 Apache 孵化器。


本次将主要从以下三部分介绍Apache Doris.

  • Doris定位:即 Doris所要面临的业务场景及解决的问题

  • Doris关键技术

  • Doris案例介绍


01

 Doris定位  

实时数据仓库Doris

产品定位

我们首先看一下Doris整个的定位。

  • MPP 架构的关系型分析数据库

  • PB 级别大数据集,秒级/毫秒级查询

  • 主要用于多维分析和报表查询

  • 2018年进入 Apache 孵化器


使用场景

640?wx_fmt=png

上图是整个Doris的具体使用场景,主要是它的接收数据源,以及它的一个整体的模块,还有最后它的一个可视化的呈现。后面会有一张更详细的图去介绍它整个的来源,以及最后可以输出的数据流向。


产品特点

640?wx_fmt=png


社区及用户

640?wx_fmt=png

上图是我们开源之后的状况。目前有几家公司已经把Doris用到生产环境里面,主要是小米和美团,他们最近也在跟我们一起做一些联合的开发。


Doris数据流向

640?wx_fmt=png

这是上文所说的,Doris更具体的数据流向。用户数据可以存在HDFS或者Kafka上,或者在对象存储上,百度内部我们叫BOS,外部应用最广的对象存储系统就是Amazon S3。我们可以把这个数据读到Doris里面来,然后在Doris里面对数据做各种的存储,以及做各种的多副本管理,最后可以在上面接一个可视化工具,用来展示报表以及各种多维分析的检索的效果。这对于业务方在做可视化分析的时候效果是比较明显的。


02

 Doris关键技术  

前面主要介绍了Doris整体的定位,以及它目前在外部的公司的使用状况,下面我们来介绍一下它的关键技术。


Doris整体架构

640?wx_fmt=png

这张图是Doris的整体架构。


Doris的架构很简洁,只设FE(Frontend)、BE(Backend)两种角色、两个进程,不依赖于外部组件,方便部署和运维。


  • 以数据存储的角度观之,FE存储、维护集群元数据;BE存储物理数据。

  • 以查询处理的角度观之, FE节点接收、解析查询请求,规划查询计划,调度查询执行,返回查询结果;BE节点依据FE生成的物理计划,分布式地执行查询。


FE,BE都可线性扩展。


FE主要有有三个角色,一个是leader,一个是follower,还有一个observer。leader跟follower,主要是用来达到元数据的高可用,保证单节点宕机的情况下,元数据能够实时地在线恢复,而不影响整个服务。


右边observer只是用来扩展查询节点,就是说如果在发现集群压力非常大的情况下,需要去扩展整个查询的能力,那么可以加observer的节点。observer不参与任何的写入,只参与读取。


数据的可靠性由BE保证,BE会对整个数据存储多副本或者是三副本。副本数可根据需求动态调整。 


Frontend MetaData Management

640?wx_fmt=png

元数据层面,Doris采用Paxos协议以及Memory + Checkpoint + Journal的机制来确保元数据的高性能及高可靠。元数据的每次更新,都首先写入到磁盘的日志文件中,然后再写到内存中,最后定期checkpoint到本地磁盘上。我们相当于是一个纯内存的一个结构,也就是说所有的元数据都会缓存在内存之中,从而保证FE在宕机后能够快速恢复元数据,而且不丢失元数据。Leader、follower和 observer它们三个构成一个可靠的服务,这样如果发生节点宕机的情况,在百度内部的话,我们一般是部署一个leader两个follower,外部公司目前来说基本上也是这么部署的。就是说三个节点去达到一个高可用服务。以我们的经验来说,单机的节点故障的时候其实基本上三个就够了,因为FE节点毕竟它只存了一份元数据,它的压力不大,所以如果FE太多的时候它会去消耗机器资源,所以多数情况下三个就足够了,可以达到一个很高可用的元数据服务。


Doris数据分布

前面介绍了元数据,下面我们来介绍下整个数据在集群中的分布。


数据主要都是存储在BE里面,BE节点上物理数据的可靠性通过多副本来实现,默认是3副本,副本数可配置且可随时动态调整,满足不同可用性级别的业务需求。FE调度BE上副本的分布与补齐。


如果说用户对可用性要求不高,而对资源的消耗比较敏感的话,我们可以在建表的时候选择建两副本或者一副本。比如在百度云上我们给用户建表的时候,有些用户对它的整个资源消耗比较敏感,因为他要付费,所以他可能会建两副本。但是我们一般不太建议用户建一副本,因为一副本的情况下可能一旦机器出问题了,数据直接就丢了,很难再恢复。我们在公司内部的话,一般是默认建三副本,这样基本可以保证一台机器单机节点宕机的情况下不会影响整个服务的正常运作。


MySQL兼容性

640?wx_fmt=png

这个就是前面说的,Doris和用户交互的协议部分,它主要兼容一个MySQL的协议。


前面基本介绍了Doris的整体架构,以及它的FE跟BE节点所达到的目的。Doris它的架构是比较简单的,编译部署完之后,也只有FE跟BE两个进程,把FE跟BE进程一分发,就可以启动服务了。它不依赖于Hadoop,也不依赖其他的外部组件,数据都是自成一体的,所以可以很方便地部署启动。


Distributed Logical Plan 

下面就会涉及一些Doris查询跟存储的一些具体的技术。


我先说查询的部分,因为用户可能跟查询打交道多一点,因为存储毕竟是一个更下面的东西。

640?wx_fmt=png

我们最早是借鉴了Impala的查询引擎,把它改造了一下引入到Doris里面形成一个分布式的查询引擎。而我们所做的主要的改造主要就是把Impala的架构给改了。因为如果大家熟悉Impala的话,会知道Impala是一个完全的P2P的结构,每个节点都缓存元数据,对于一个高性能的报表分析来说,它有可能会面临着元数据落后的问题。所以我们把它查询规划所有的部分,都放到了一个FE里面,即在这个图中所看到的整个的逻辑规划,都会由FE来完成。FE来根据用户的查询生成一个完整的逻辑规划,然后这个逻辑规划最后生成一个分布式的逻辑规划,会发给整个集群去执行。


Distributed Physical Plan

640?wx_fmt=png

可以看一个具体的例子。上图这样一个查询,展示了最后生成的物理规划是什么样的。左下角这个图就是展示了它的逻辑,就是说查询会生成几个算子,主要有扫描的算子,聚合的算子,还有Join的算子,最后再排序。右边就相当于对整个的算子的一个实际物理划分。这个规划途中单个方框我们称之为一个Fragment, Fragment由单个BE节点执行,Fragment之间的数据交换通过RPC来完成。整个物理规划执行完之后,由FE把数据收集起来反馈给用户。返回用户的方式就是前面提到过的通过一个MySQL的协议。


这基本上就是一个简单的物理的执行规划,以及最后的展现。


如果大家使用Doris的话,可能会涉及到怎么去优化整个引擎查询方式的问题。因为有时候查询写的不太好的时候,或者说用户写的不太好的时候,Doris是有工具可以让你去看一下物理规划,这样主要是可以去分析出这个物理规划的性能可能在哪里不够好,可能稍微改一下查询它的规划就会更契合整个建表的结构,以及整个查询引擎的处理方式。


上面简单地介绍了Doris查询处理的逻辑。因为查询处理是比较复杂的一个部分,我主要是说了一下它的物理规划,还有很多的其他的细节比较复杂,我在这里就不再展开讲。 


数据模型

前面讲完了查询的部分,下面我来再介绍一下存储引擎的部分。这里主要分为两个部分展开,一个是数据模型,一个就是数据组织。


首先讲数据模型。一个正常的模型它肯定会把明细的数据存储在一个数据库中,也就是存在Doris中。但是因为Doris它最早是给凤巢的一个广告报表做的,广告报表有一个很大的特点,就是它只关心统计分析的结果,而不太关心明细的数据,所以Doris最早一代的数据模型,是一个聚合的模型。

640?wx_fmt=png

如上图,它有三列,左边的三列是一个Key,我们叫做维度列;右边的两列相当于是一个Value列,也就是指标列。这个图是一个典型的一个广告报表的样子,展示的是一个用户按地域消费的情况。


我们会按左边的这三列也就是他的维度列去聚合数据,如果前面的三列数据相同我们会把这些数据合并(compaction)起来。也就相当于在数据库里面存储的其实是一个合并之后的结果,这个结果对于统计分析来说是很有效果的。因为广告报表只关心这种统计之后的数据,现在我们把大量的数据聚合,比如一天的数据可能有一千条,我们聚合成一条,相当于整个的I/O节省一千倍,效果非常明显。


我们当时在做凤巢的广告报表时,每天导入的数据量在一两百亿条的情况下,基本可以做到在一百毫秒以内出广告报表,这样可以满足整个广告用户实时查看广告报表的需求。


640?wx_fmt=png

我刚才讲了数据在导入的时候会有一次合并,因为我们因为要聚合。还有一种情况是如果我先导入了一批数据,然后又导入了一批数据,这两批的数据之间有相同的时候,也需要进行一个合并。


上图展示的就是合并的策略,它把导入分成了三级,左边是一个Base,也就是最早导入的数据的合并,然后中间是一个增量的部分,最后是每一次导入就生成的一个单独的版本。然后这个单独的版本会逐渐的合并到一个更高的一个增量的版本,增量的版本再合并成一个Base的版本。分几级的目的其实就是想尽量把小文件合并成一个中等规模的文件,然后再把中等规模的文件合并成一个大文件,这样会减少一个大文件直接跟一个小文件合并的读写放大量。因为大文件大量的读取其实是划不来的,所以Doris采用一个分级的策略去做整个的合并。同时这样做还有一个好处就是新来的导入,不会影响正在进行的查询。因为我现在的导入只有在整个批次完成的时候查询(也就是我们前面所讲的FE的元数据)才能感知到,这样它可以做到一个比较好的读写分离,用户在查询历史数据不会受到新来导入的影响。


然后我们当然也保证整个导入的一个原子性,就是说一个单批次的导入要么生效,要么不生效。即使是一个批次,导入多张表,也是同样。比如这几张表是一个关联表,你想一次把它们全部导入,这样我们也保证整个一个批次的导入对这几张表要么同时生效,要么同时不生效,这是所谓的导入的原子性。


前面主要介绍了Doris的数据模型,我们目前来说可以看到,其实数据模型主要是针对报表统计分析是比较有用的。后来逐步扩展一个通用的数据模型之后,它有一些限制。因为有些业务场景做这种分析的时候,是需要明细数据的,它不太关心统计的结果,而是更关心流程分析,更关心的是我要拿着历史的全量数据跟现在的数据做对比。所以我们后来扩展了这个模型。


640?wx_fmt=png

Doris新提供两类新的数据模型,一个是一个Unique Key的模型,就是说我们提供一个唯一Key模型,在整个历史数据导入的时候,我们保证Key的唯一,不聚合。第二个模型是一个Duplicate Key的模型,就是说支持一个用户导入之后把这个数据全部放在数据库里面,我们不再做提前的聚合,也不单独保证唯一性,只做一个排序。那么这两个Key的主要面向场景就有所不同了,就相当于跟聚合模式有所不同了,Unique Key的模型主要面向留存分析或者订单分析的场景,他们需要一个Unique Key的约束去保证整个数据不丢不重。然后Duplicate Key的模型,就是这个数据可能重复,对于有些日志分析它不太在意数据多几条或者少几条,可能只关心排序,这个时候可能重复Key的模型会更加有效果。


在开源的版本里面这几个目前都是有的。目前对于报表分析来说,聚合模型是比较有很有效果的,在其他的场景下,另外两个模型可能会相对更好一点,用户在建表的时候就可以去指定用哪一个模型,建表语句里面都会有。


数据组织

前面介绍了数据模型的部分,当然存储引擎除了数据模型还有一个很重要的部分,就是它的数据组织。Doris作为一个数据库,主要是一个列存的数据库,就是说我们的数据都是以列的形式留在存储引擎,每一列单独存放。因为对分析的场景来说,多数时候用户只关心几列的数据,这个时候如果用一个列存的话,它可以只访问查询涉及的列,大量降低I/O,达到一个比较好的一个I/O的效果。同时因为按列存储,数据类型一致,方便压缩。举个例子,比如我们从磁盘上读1M的数据,其实解压之后展开的数据就会大大扩张,而这相当于变相地节省了整个磁盘的I/O,通过这样一种方式,可以达到一个去快速地检索数据并把数据读取出来,做一个大规模的扫描分析的效果。


640?wx_fmt=png

上图涉及了具体的一些数据压缩的方法,因为列存有很多具体的压缩方法,对于不同的类型有一些不同的压缩办法。除了简单的压缩之外,我们还有很重要的一个特点就是对数据会排序。多数分析型数据库它都只会去建一个稀疏索引,这个时候把数据排一下序,然后按一个一千行或者一万行,甚至是十万行的维度去见一个稀疏的索引,效果是非常明显的。


Doris存储引擎对于排序列,会存储min/max/sum等智能索引技术,将数据集扫描范围尽可能地缩小,减少磁盘I/O,提升查询性能。比如说这一列排过序了,然后我在这一列的十万行所组成的一个粒度上面,给它加一个min/max,然后这样查询的时候,它就可以快速去过滤这个十万行,这会大大地减少整个数据的扫描量,从而减少I/O。


向量化执行

对于数据组织来说,它同时会去改造一下整个查询在存储引擎上的执行过程,主要就是一个向量化的过程。


对于传统的关系数据库来说,它所返回数据的方式,都是按行进行。这样的问题是:每行一次函数调用,打断CPU流水,不利于分支预测;指令和数据cache miss高;编译器不友好,不利于循环展开,不利于使用SIMD等CPU加速指令。


但是对于列存的数据库来说,其实可以按列的形式去做,这样达到的效果,其实就是能减少整个缓存失效率。这个时候相当于用一个向量化执行方式来达到这样一个效果,这是列存数据库特有的一个优化技巧,可以达到一个减少CPU的消耗,提升整个CPU的利用率。

640?wx_fmt=png

我们做过star-schema的测试,向量化执行的方式可以提高3到4倍的性能。


物化视图

我们刚刚也说过了,Doris没有一个强索引,它只有一个稀疏索引,而稀疏索引有时满足不了用户的场景。比如说我做统计分析的时候,可能有各种维度进行组合分析的一个需求,不是只有一个固定的维度,它当换维度的时候,原来的前缀索引可能就无法命中。所以我们也支持物化视图。

640?wx_fmt=png

以这个图为例,左下角相当于它的原始数据,右边是把这个数据重新排序,因为改变排序就会相应地去改变整个数据的前缀索引的方式,这样的话就相当于可以去换维度进行分析了,就是右上边的一个表,可以更好地去满足整个查询的需求。最右下角的是说我如果说经常只按一个维度进行组合分析,我可以只选它其中部分列,把它聚合,达到一个更好的聚合效果。这样的方式就可以满足不同的统计分析的需求,以及不同的多维分析的需求。


这个物化视图,其实它核心会去牺牲一部分存储空间,跟原来的表是一个绑定的形式。但是我们在导入的时候会保证它跟原始表一定是一个原子生效的过程,查询的时候我们会自动去判断它满不满足物化视图,满足的话我们就会自动落在它的上面,用户不需要修改任何的查询。用户所需要做的只是说我发现我的业务可能换一个表的结构,会更好地满足整个查询需求,那么他需要去建一个物化视图就行了,物化视图发起之后后续所有的操作,从导入到查询,用户都不需要再关心。


两层区分与分级存储

Doris是一个跨节点的分布式数据库,建表的时候我们要求用户指定分区方式,划分数据到集群中。


640?wx_fmt=png

Doris提供两层分区。第一层分区,主要是一个逻辑分区,比方比较常见的是按时间分区,把历史的数据按天/月/年作一个分区,不同区间的数据会落在不同分区里面。这样如果你经常只看最新的数据,那历史的数据在查询的时候,就会自动帮你判断出来,就是说这些历史数据的所在的分区就不用看了。这样减少了大量历史数据不必要的重复BE/CE,节省了大量的I/O和CPU开销。


时间分区还有一个作用就是可以把冷热的数据区分开,方便新旧数据分离,使用不同的存储介质。比如说在现在一个机器环境下,用户他的机器上是有一部分SATA盘,也有一部分SSD盘的情况下,我们通过一个分区的方式把冷热的数据给区分开来之后,它可以达到一个效果就是,可以把最新的数据放在SSD上,用在一个更好的介质上,历史的数据放在一个SATA盘上。


第二层分区是一个物理分区,而这种分区的作用主要就是把数据打散到整个集群里面去。现在假如说有20台机器所构成的一个集群,然后我要把20台机器的性能全部给用上,我会去指定第二层分区,我们根据哈希分区的方式把这个数据打散到整个集群里面,这样的话查询的时候就可以用上整个分布式集群的性能,去更好的去满足这个效果。


这里有一点点技巧,就是说要根据用户的一个就是读写的一个密集程度做分区。如果是一个读很密集的一个场景,那样的话我们建议用户尽量选择一个区分度很大的列,也就是基数很大的列,我们就可以把这个数据均匀地打散到整个集群里面去,用户查询的时候就可以把整个集群的资源可以用上,相当于没有数据倾斜。如果是写很密集的情况下,就建议用户选择一个区分度少一点的列。这个可以在用户建表的时候根据自己的一个业务场景去选择。可以指定数据放到SSD上或者SATA盘上,也支持根据TTL将冷数据从SSD迁移到SATA上,高效利用SSD提高查询性能


建表对于最后的优化效果是非常关键的。因为会涉及很多建表的场景的优化,如果你建表不好的话,可能整个集群资源用不上,或者说整个资源用上了,但是分布很不均匀,数据倾斜非常严重,这个时候会影响整个查询的性能。所以我们一般会建议用户建表的时候多考虑一下,去看一下我们的官方文档,以及最佳实践,去看看怎么建表会更加的优化。


Doris on Elasticsearch

640?wx_fmt=png

因为Doris这本身是一个Impala的存储引擎,我们最近半年也扩展了一下它支持的存储引擎,主要就是支持Elasticsearch。想支持它的目的主要是因为Impala只是一个稀疏索引,对于这种大规模点查询来说,随机的I/O会比较严重,所以我们会想去支持Elasticsearch,用到它的倒排索引,去丰富整个Doris的生态。同时因为Elasticsearch本身在支持分布式的查询方面不是特别的友好,因为它缺少一个很良好的分布式的查询框架,所以我们就想把他们两个的功能结合起来,去用到一个Impala里面,这样可以结合两者的优势。


目前来说已经这个功能应该在最新的开源版本里面是有的,并且已经开始逐步在使用。如果大家有兴趣可以去看一下。


640?wx_fmt=png

这个是一个例子,就是说在怎么在Doris里面建一个外部表,然后外部表给你指定了一个Elasticsearch的表,再怎么通过Doris发一个查询去命中Elasticsearch表,然后它帮我去返回结果的一个过程。


Kafka routine load

前面主要讲的是整体架构,下面就会单独介绍一下导入的部分。


百度内部有自己的一整套消息队列,但是开源之后用户有很多Kafka导入的需求,就是说数据从Kafka来的时候,然后怎么进入到Doris里面去。最早Doris只支持批量导入或者小批量导入,不太支持Kafka这类流式导入。开源之后有很多用户反馈,他们有大量Kafka micro batch导入的需求,也就是几千条或者几万条数据单批次灌入的需求。Doris 0.10版本中,原生支持了流式导入的功能。同时为了减少用户的一个使用的成本,Doris内嵌了一个例行机制,也就是说可以在Doris里建一个例行的任务,它帮你去订阅Kafka的变更,帮你把数据灌到Doris里面去,不需要外层业务方自己去写外层脚本去追踪Kafka的变化。

640?wx_fmt=png

这个图展示的是Doris订阅Kafka的一个过程,这个是比较新的功能,也是在最新的版本里面发布了。


其他特性

  • ACID

  • LLVM

  • Online Schema Change

  • UDF

  • Support Hyperloglog Type


上面是Doris涉及的一些其他的特性,我们目前是支持UDF的。然后我们主要用Schema Change做改表结构的方式,支持在线的改表结构。不涉及重新排序的情况下,可以把一个TB级别表在分钟级别内完成它的schema change操作,让业务方可以去迅速地使用。另外Doris作为一个数据库它也支持一部分ACID的特性,主要是保证一个原子性,包括导入的原子性、Schema Change的原子性等。除此之外我们也做了很多优化,比如LLVM或者说支持Hyperloglog的类型,去优化整个查询的读取的性能。


以上这些基本就是Doris整体的特性以及功能。


后续规划

  • 优化存储引擎的格式:最早做的列存格式支持的压缩算法不够丰富,我们希望支持更多的压缩算法。

  • 优化整个文件的组织格式:提升不同的压缩效果,这样一是可以减少IO,二是可以在一个对资源或者对消费比较敏感的情况下,节省磁盘的带宽成本。

  • 优化查询部分:就是目前正在做的一个就是基于cost model的一个优化

  • 计算存储的分离:以便更好地适应整个云上的一个环境。




03

 案例介绍  


下面我主要介绍一下案例的部分。目前Doris在百度内部接入了200多个业务,外部也有一些用户在使用。百度内部以百度统计为例,外部以美团为例。


百度统计

640?wx_fmt=png


百度统计主要有两类的大的场景分析。

640?wx_fmt=png

一是统计报表。报表的特点是它的模式比较固定,经常只需要对几个维度进行组合分析。但是它的并发比较高,在我们内部统计,它单机的并发量可以达到1000。所以它要求必须支持高并发,并且延迟必须非常低,这样才能实时地展现。目前百度统计用的是聚合模型。我们按特定维度聚合数据,使用Aggregate Key数据模型,提前聚合数据。在这个模式之下,我们基本上可以做到1000并发在50到60毫秒级别就出查询展示的效果。


640?wx_fmt=png

二是多维分析。

  • 存储最细粒度数据:支持细粒度数据存储(Duplicate Key数据模型)

  • 用户分群功能需要大量跨表join:需要分布式计算引擎。


640?wx_fmt=png

上图展示了百度统计的架构,Doris在其中主要起到存储它们整个的数据的作用,最后在Doris上面接一个可视化的报表分析做展示。


统计报表基本都是类似的,前端收集数据做一些预处理,然后再灌到Doris里面去,目前我们做的导入的延迟是在五分钟级别,也就是数据从最左边的产生到最后进到Doris里面展示就用了五分钟。


美团

第二个案例是美团,美团跟我们合作比较多。

640?wx_fmt=png

他们在选型的时候,比较看重的一个特点就是需要有比较丰富的一个SQL特性,要兼容SQL-92标准。因为他们作为平台方希望用户可以快速入手,而大家对SQL的接受度一般是比较高的,所以不需要写太多复杂的查询语句,然后这样可以很快速入手。同时他们如果有一个完整的SQL协议,就可以很好的做一些报表工具的展示,不需要再做额外的开发去自己定制。


第二个他们比较看重的是监控运维的方面。监控的话,我们是集中到Prometheus;运维的话,他们希望尽量方便,不需要依赖太多的组件。Doris的整体架构就比较简单,涉及的组件比较少,可以把问题收敛在几个组件之下,比较符合他们的需求。

640?wx_fmt=png

第三个他们比较关注的是扩展性跟可用性。因为他们的业务变动比较大而且是一个24小时的服务。所以需要架构本身就能自动扩展,而不用去手动去操作。并且必须要做到高可用,就是说在单台机器,甚至两台以上机器宕机的状况下,也要保证服务是继续可用的状态。


我们采取一下的方式来保证服务的高可用与高扩展

  • FE/BE支持动态扩缩容

  • FE采用一致性协议保证高可用

  • BE采用多副本保证高可用


640?wx_fmt=png

目前美团内部部署的最大的一个集群由70个节点,存储超过18TB的数据。


04

 总结  


以上就是Doris整体的原理及实践的介绍。一些更具体的案例分析可以关注我们的公众号“ApacheDoris”。

640?wx_fmt=jpeg


最新活动

640?wx_fmt=png

点击阅读原文立即报名


界世的你当不

只做你的肩膀

640?wx_fmt=jpeg 640?wx_fmt=jpeg

 360官方技术公众号 

技术干货|一手资讯|精彩活动

空·

640?wx_fmt=gif

点击“阅读原文”报名

右边给我一朵小花花

640?wx_fmt=gif


声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/Monodyee/article/detail/383909
推荐阅读
相关标签
  

闽ICP备14008679号