当前位置:   article > 正文

OLAP 入门?看这一篇就够!

olap语句

01

基础问答

1. 什么是OLAP,其与OLTP有什么区别?

如果展开来说,这个问题估计可以写好几篇文章,这里简单谈谈个人的理解。

OLTP是Online transaction processing的英文缩写,指在线/联机事务处理,这么说其实还是比抽象的。OLTP典型的应用领域包括银行、证劵等金融行业,电子商务系统等,在此举最经典的银行例子,我们在招商银行APP上查询账户余额、收支信息和转账记录,在ATM机上存钱,取钱,将招行账号的钱转到工行账号上。这些都是典型的OLTP类操作,这些操作都比较简单,主要是对数据库中的数据进行增删改查。操作主体一般是产品的用户。

OLAP是Online analytical processing的英文缩写,指联机分析处理。从字面上我们能看出是做分析类操作。通过分析数据库中的数据来得出一些结论性的东西。比如给老总们看的报表,用于进行市场开拓的用户行为统计,不同维度的汇总分析结果等等。操作主体一般是运营、销售和市场等团队人员而不是用户。

单次OLTP处理的数据量比较小,所涉及的表非常有限,一般仅一两张表。而OLAP是为了从大量的数据中找出某种规律性的东西,经常用到count()、sum()和avg()等聚合方法,用于了解现状并为将来的计划/决策提供数据支撑,所以对多张表的数据进行连接汇总非常普遍。

为了表示跟OLTP的数据库(database)在数据量和复杂度上的不同,一般称OLAP的操作对象为数据仓库(data warehouse),简称数仓。数据库仓库中的数据,往往来源于多个数据库,以及相应的业务日志。

下表是对OLTP和OLAP的简单总结。

网易杭研OLTP数据库团队为业界培养了多位数据库资深专家,在线数仓团队也同样如此,在Impala、Kudu等技术上有深厚的积累,是网易猛犸、有数等网易大数据产品的核心基础设施。

2. MySQL等OLTP数据库能处理OLAP业务吗?

MySQL是当前最流行的开源数据库,一般作为OLTP数据库使用。在MySQL上也能执行一些OLAP操作,但这不是MySQL擅长的领域。虽然OLTP和OLAP都是通过SQL来执行,但SQL语句只是描述了我想要什么,而并没有说明应该怎么做(不考虑hint等),即确定最优的执行计划。由于OLTP操作比较简单,所涉及的表也少,因此不需要相应的数据库具有强大的执行优化能力,比如说MySQL在查询优化这块就比较弱,但这其实没有给它的大规模普及使用造成多大伤害。

当然,MySQL也在快速进步,尤其是最新的8.0版本,在查询优化模块添加了很多众望所归的功能特性,包括窗口函数,通用表达式和更强大的Join能力等。

而OLAP类操作不一样,没有强大的执行计划产生和优化能力,执行这类操作肯定不会有多高的效率,甚至会寸步难行。当然,如果总数据量较小,SQL也相对简单,那MySQL也是能够应付的。在MySQL高可用实例的从库做些报表类查询也有不少案例。

3. OLAP的查询跟OLTP查询具体有哪些不一样?

上文简要提及,OLTP查询一般仅涉及单表,点查为主,返回的是记录本身或该记录的多个列。即使是范围查询,基本上也会通过limit来限制返回的记录数。

而OLAP则不同,表中单条记录本身并不是查询所关心的,比较典型的特点包括有聚合类算子、涉及多表Join,查询所用谓语/条件没有索引,玩玩不是返回记录。由于这些操作都非常耗计算资源,而且数据仓库相比数据库在数据量上大很多,因此,OLAP类查询经常表现为cpu-bound而不是io-bound。

这样说可能还不够直观,下面换种形式。OLTP和OLAP发展到现在已经比较成熟,业界也有些公认的benchmark来进行性能评估。我们可以通过这些benchmark中的对应sql来了解两位服务的典型查询语句。对于OLTP来说,有sysbench和tpcc测试套件,对于OLAP来说,有tpch和tpcds 2种。这里分别例举sysbench oltp和tpcds的sql作为参考。

sysbench oltp查询

可以从sysbench的lua脚本中获取都有哪些查询类型。如下所示:

  1. local stmt_defs = {
  2. point_selects = {
  3. "SELECT c FROM sbtest%u WHERE id=?",
  4. t.INT},
  5. simple_ranges = {
  6. "SELECT c FROM sbtest%u WHERE id BETWEEN ? AND ?",
  7. t.INT, t.INT},
  8. sum_ranges = {
  9. "SELECT SUM(k) FROM sbtest%u WHERE id BETWEEN ? AND ?",
  10. t.INT, t.INT},
  11. order_ranges = {
  12. "SELECT c FROM sbtest%u WHERE id BETWEEN ? AND ? ORDER BY c",
  13. t.INT, t.INT},
  14. distinct_ranges = {
  15. "SELECT DISTINCT c FROM sbtest%u WHERE id BETWEEN ? AND ? ORDER BY c",
  16. t.INT, t.INT},

对应到测试时,就是下面的样子。

  1. SELECT c FROM sbtest10 WHERE id=4352
  2. SELECT c FROM sbtest10 WHERE id BETWEEN 5046 AND 5046+99 ORDER BY c
  3. SELECT c FROM sbtest3 WHERE id BETWEEN 4983 AND 4983+99
  4. SELECT SUM(K) FROM sbtest1 WHERE id BETWEEN 4981 AND 4981+99
  5. SELECT DISTINCT c FROM sbtest3 WHERE id BETWEEN 4989 AND 4989+99 ORDER BY c

感兴趣的同学可以查看github上sysbench代码。上述sql均位于oltp_common.lua中。

https://github.com/akopytov/sysbench/blob/master/src/lua/oltp_common.lua

同样的,我们也可以从github上找到tpcc的查询sql。如下:

https://github.com/Percona-Lab/sysbench-tpcc/blob/master/tpcc_run.lua
例子如下:
  1. -- SELECT c_id
  2. -- FROM customer
  3. -- WHERE c_w_id = :c_w_id
  4. -- AND c_d_id = :c_d_id
  5. -- AND c_last = :c_last
  6. -- ORDER BY c_first;
  7. -- SELECT c_balance, c_first, c_middle, c_last
  8. -- FROM customer
  9. -- WHERE c_w_id = :c_w_id
  10. -- AND c_d_id = :c_d_id
  11. -- AND c_id = :c_id;
  12. -- SELECT c_discount, c_last, c_credit, w_tax
  13. -- FROM customer, warehouse
  14. -- WHERE w_id = :w_id
  15. -- AND c_w_id = w_id
  16. -- AND c_d_id = :d_id
  17. -- AND c_id = :c_id;

相对来说,tpcc的查询比oltp查询更复杂些。包含了2表join操作。

tpcds查询

下面在看看复杂的tpcds查询是怎么样的。tpcds一共99个query,下面举例。

  1. -- query68
  2. SELECT
  3. "c_last_name"
  4. , "c_first_name"
  5. , "ca_city"
  6. , "bought_city"
  7. , "ss_ticket_number"
  8. , "extended_price"
  9. , "extended_tax"
  10. , "list_price"
  11. FROM
  12. (
  13. SELECT
  14. "ss_ticket_number"
  15. , "ss_customer_sk"
  16. , "ca_city" "bought_city"
  17. , "sum"("ss_ext_sales_price") "extended_price"
  18. , "sum"("ss_ext_list_price") "list_price"
  19. , "sum"("ss_ext_tax") "extended_tax"
  20. FROM
  21. ${database}.${schema}.store_sales
  22. , ${database}.${schema}.date_dim
  23. , ${database}.${schema}.store
  24. , ${database}.${schema}.household_demographics
  25. , ${database}.${schema}.customer_address
  26. WHERE ("store_sales"."ss_sold_date_sk" = "date_dim"."d_date_sk")
  27. AND ("store_sales"."ss_store_sk" = "store"."s_store_sk")
  28. AND ("store_sales"."ss_hdemo_sk" = "household_demographics"."hd_demo_sk")
  29. AND ("store_sales"."ss_addr_sk" = "customer_address"."ca_address_sk")
  30. AND ("date_dim"."d_dom" BETWEEN 1 AND 2)
  31. AND (("household_demographics"."hd_dep_count" = 4)
  32. OR ("household_demographics"."hd_vehicle_count" = 3))
  33. AND ("date_dim"."d_year" IN (1999 , (1999 + 1) , (1999 + 2)))
  34. AND ("store"."s_city" IN ('Midway' , 'Fairview'))
  35. GROUP BY "ss_ticket_number", "ss_customer_sk", "ss_addr_sk", "ca_city"
  36. ) dn
  37. , ${database}.${schema}.customer
  38. , ${database}.${schema}.customer_address current_addr
  39. WHERE ("ss_customer_sk" = "c_customer_sk")
  40. AND ("customer"."c_current_addr_sk" = "current_addr"."ca_address_sk")
  41. AND ("current_addr"."ca_city" <> "bought_city")
  42. ORDER BY "c_last_name" ASC, "ss_ticket_number" ASC
  43. LIMIT 100
  44. --query53
  45. SELECT *
  46. FROM
  47. (
  48. SELECT
  49. "i_manufact_id"
  50. , "sum"("ss_sales_price") "sum_sales"
  51. , "avg"("sum"("ss_sales_price")) OVER (PARTITION BY "i_manufact_id") "avg_quarterly_sales"
  52. FROM
  53. ${database}.${schema}.item
  54. , ${database}.${schema}.store_sales
  55. , ${database}.${schema}.date_dim
  56. , ${database}.${schema}.store
  57. WHERE ("ss_item_sk" = "i_item_sk")
  58. AND ("ss_sold_date_sk" = "d_date_sk")
  59. AND ("ss_store_sk" = "s_store_sk")
  60. AND ("d_month_seq" IN (1200 , (1200 + 1) , (1200 + 2) , (1200 + 3) , (1200 + 4) , (1200 + 5) , (1200 + 6) , (1200 + 7) , (1200 + 8) , (1200 + 9) , (1200 + 10) , (1200 + 11)))
  61. AND ((("i_category" IN ('Books ' , 'Children ' , 'Electronics '))
  62. AND ("i_class" IN ('personal ' , 'portable ' , 'reference ' , 'self-help '))
  63. AND ("i_brand" IN ('scholaramalgamalg #14 ' , 'scholaramalgamalg #7 ' , 'exportiunivamalg #9 ' , 'scholaramalgamalg #9 ')))
  64. OR (("i_category" IN ('Women ' , 'Music ' , 'Men '))
  65. AND ("i_class" IN ('accessories ' , 'classical ' , 'fragrances ' , 'pants '))
  66. AND ("i_brand" IN ('amalgimporto #1 ' , 'edu packscholar #1 ' , 'exportiimporto #1 ' , 'importoamalg #1 '))))
  67. GROUP BY "i_manufact_id", "d_qoy"
  68. ) tmp1
  69. WHERE ((CASE WHEN ("avg_quarterly_sales" > 0) THEN ("abs"((CAST("sum_sales" AS DECIMAL(38,4)) - "avg_quarterly_sales")) / "avg_quarterly_sales") ELSE null END) > DECIMAL '0.1')
  70. ORDER BY "avg_quarterly_sales" ASC, "sum_sales" ASC, "i_manufact_id" ASC
  71. LIMIT 100
  72. --query59
  73. WITH
  74. wss AS (
  75. SELECT
  76. "d_week_seq"
  77. , "ss_store_sk"
  78. , "sum"((CASE WHEN ("d_day_name" = 'Sunday ') THEN "ss_sales_price" ELSE null END)) "sun_sales"
  79. , "sum"((CASE WHEN ("d_day_name" = 'Monday ') THEN "ss_sales_price" ELSE null END)) "mon_sales"
  80. , "sum"((CASE WHEN ("d_day_name" = 'Tuesday ') THEN "ss_sales_price" ELSE null END)) "tue_sales"
  81. , "sum"((CASE WHEN ("d_day_name" = 'Wednesday') THEN "ss_sales_price" ELSE null END)) "wed_sales"
  82. , "sum"((CASE WHEN ("d_day_name" = 'Thursday ') THEN "ss_sales_price" ELSE null END)) "thu_sales"
  83. , "sum"((CASE WHEN ("d_day_name" = 'Friday ') THEN "ss_sales_price" ELSE null END)) "fri_sales"
  84. , "sum"((CASE WHEN ("d_day_name" = 'Saturday ') THEN "ss_sales_price" ELSE null END)) "sat_sales"
  85. FROM
  86. ${database}.${schema}.store_sales
  87. , ${database}.${schema}.date_dim
  88. WHERE ("d_date_sk" = "ss_sold_date_sk")
  89. GROUP BY "d_week_seq", "ss_store_sk"
  90. )
  91. SELECT
  92. "s_store_name1"
  93. , "s_store_id1"
  94. , "d_week_seq1"
  95. , ("sun_sales1" / "sun_sales2")
  96. , ("mon_sales1" / "mon_sales2")
  97. , ("tue_sales1" / "tue_sales2")
  98. , ("wed_sales1" / "wed_sales2")
  99. , ("thu_sales1" / "thu_sales2")
  100. , ("fri_sales1" / "fri_sales2")
  101. , ("sat_sales1" / "sat_sales2")
  102. FROM
  103. (
  104. SELECT
  105. "s_store_name" "s_store_name1"
  106. , "wss"."d_week_seq" "d_week_seq1"
  107. , "s_store_id" "s_store_id1"
  108. , "sun_sales" "sun_sales1"
  109. , "mon_sales" "mon_sales1"
  110. , "tue_sales" "tue_sales1"
  111. , "wed_sales" "wed_sales1"
  112. , "thu_sales" "thu_sales1"
  113. , "fri_sales" "fri_sales1"
  114. , "sat_sales" "sat_sales1"
  115. FROM
  116. wss
  117. , ${database}.${schema}.store
  118. , ${database}.${schema}.date_dim d
  119. WHERE ("d"."d_week_seq" = "wss"."d_week_seq")
  120. AND ("ss_store_sk" = "s_store_sk")
  121. AND ("d_month_seq" BETWEEN 1212 AND (1212 + 11))
  122. ) y
  123. , (
  124. SELECT
  125. "s_store_name" "s_store_name2"
  126. , "wss"."d_week_seq" "d_week_seq2"
  127. , "s_store_id" "s_store_id2"
  128. , "sun_sales" "sun_sales2"
  129. , "mon_sales" "mon_sales2"
  130. , "tue_sales" "tue_sales2"
  131. , "wed_sales" "wed_sales2"
  132. , "thu_sales" "thu_sales2"
  133. , "fri_sales" "fri_sales2"
  134. , "sat_sales" "sat_sales2"
  135. FROM
  136. wss
  137. , ${database}.${schema}.store
  138. , ${database}.${schema}.date_dim d
  139. WHERE ("d"."d_week_seq" = "wss"."d_week_seq")
  140. AND ("ss_store_sk" = "s_store_sk")
  141. AND ("d_month_seq" BETWEEN (1212 + 12) AND (1212 + 23))
  142. ) x
  143. WHERE ("s_store_id1" = "s_store_id2")
  144. AND ("d_week_seq1" = ("d_week_seq2" - 52))
  145. ORDER BY "s_store_name1" ASC, "s_store_id1" ASC, "d_week_seq1" ASC
  146. LIMIT 100

很显然,tpcds的查询复杂度相比oltp和tpcc高非常多。

4. 是否有可能将OLAP和OLTP统一起来?

目前有个趋势是将OLTP和OLAP相融合,在同一个系统中同时提供TP和AP 2种服务,即HTAP产品,国内的数据库创业公司PingCAP的TiDB即是其中的佼佼者。

但由于两者服务类型相差甚大,完全融合是很难的,如何解决AP业务对要求更高实时性和稳定性的TP业务带来影响,如何同时提供2种服务且2种服务与业界其他系统相比具备足够竞争力,这些都是很大的挑战。

在目前的HTAP系统中,一般通过存储层的数据多副本来进行针对AP和TP业务的不同方式的优化,使用多个副本来以行存方式更好满足TP业务,通过增加一个副本来以列存方式为AP业务提供服务。

在存储系统上,配置独立的计算/查询系统,分别满足TP和AP不同的要求。比如TP系统很重要的一个特点就是事务的ACID,而AP系统更加关心分布式并行查询能力。

TP和AP融合不是本系列文章关注焦点,因此下面我们聚焦到OLAP/数仓上来。

5. 数仓有哪些基础知识和概念?

OLAP的查询语句比OLTP更复杂,显然是因为两则操作的数据集和目的都是不一样的。数据库模型是2维的关系-实体模型。而数仓则是多维立方体模型。相对来说,给数仓建模的难度更高。为此,有必要再介绍下输出基础知识和一些重要概念。

先来看看这张图,基于该图,介绍下数仓的数据来源,作用和存在方式。

02

说说数仓中数据的前世今生?

1. 数仓中的数据从何而来?

OLAP对应的数据载体叫做数据仓库,称之为仓库个人认为挺贴切的。因为它不是数据的生产者,其中的数据都是从其他地方搬运过来的,而搬运和清洗的过程就是ETL流程(Extract-Transform-Load,即数据抽取、转换和加载),在此不展开。

图片来源:维基百科

那么这些数据从何而来,表现形式如何呢?归纳起来大体有3种:

  • 结构化数据:一般来自于数据库,比如MySQL等关系型数据库的表中保存的记录(rows)。即承担OLTP功能的数据载体。这类数据最好处理,因为数据表达方式作为规范,约束性最好;

  • 半结构化数据:该部分数据来源较多,包括用户行为日志(如app的页面访问记录)、平台或管理服务日志(tomcat、mysql等服务日志)等等,也包括存储于MongoDB等NoSQL数据库中的记录(Docs等)。这些数据一般以Json或XML等形式存在,在ETL时难度较大。

  • 非结构化数据:包括图片、音频、视频和网页等,这些数据非常复杂,信息量也很大,一般不会直接抽取出来直接保存到数仓中,而是记录他们的元数据信息(metadata),举图片为例,可能保存该图片的产生时间、格式、大小等等,至于图片本身,一般通过url链接保存在对象或文件存储系统中。

2. 数仓的作用有哪些?

数据仓库大致可以分为以下一些作用:

  • 进行交互式/即席查询(ad-hoc);

  • 用于报表类查询(BI Reporting);

  • 进行数据分析类查询(Data Analytics);

  • 用于数据挖掘类查询(Data Mining);

在数仓仓库之前可以部署至少如上所述4类数据应用。

3. 数据在数仓中是如何组织的?

简单介绍了数仓的数据来源,数仓中数据所能发挥的作用后,接下来聊聊这些通过不同方式进来的数据,如何存在于数仓当中的。相应地引入多维数据模型和数据立方体(data cube)概念。数仓中数据的存在方式跟数仓索要发挥的作用息息相关,即该数仓要承载什么样的业务模型。

基于业务模型设计对应的数据仓库的数据模型,进而针对性实现不同的ETL操作将外部数据经过不同程度的过滤、聚合等处理之后引入到数仓之中。

4. 什么是多维数据模型?

抽象的概念光通过文字描述是无法在大脑中具象化的,这是因为大自然存在的都是具体的事物,抽象的东西是竟然我们加工所得。为了更加清晰的进行说明,需要将抽象概念重新具体化。下面就通过例子来说明与数仓多维数据模型相关的概念,以便大家更好地建立初步的认识。

上图所示即为一个采用简单星型模型组织起来的多维数据模型,用来存储商品销售情况。在这张图中的6个表又可分为2种类型,分别是最中间的事实表,和围绕其展开的维度表。

5. 什么是事实表?

事实表(Fact Table)用来记录具体事件,包含了每个事件的具体要素,以及具体发生的事情。事实表是主干,简明扼要地介绍一个事实。例子中就通过一条事实表记录说明了某个地方(地域ID)的某人(用户ID)在某个时间(时间ID)通过某种方式(支付ID)买了某产品(产品ID)。

6. 什么是维度表?

维度表(Dimension Table )是依赖事实表而存在的,“皮之不存,毛将焉附”,没有事实表数据,维度表也就没有存在的意义。每个维度表都是对事实表中的每个列/字段进行展开描述。

比如事实表中的用户ID,就可以进一步展开成一张维度表,记录该用户ID实体的用户名、联系信息、地址信息、年龄、性别和注册方式等等;

一般来说,对于数仓,事实表的增删改操作相比维度表更为频繁,模型建立后,维度表中的数据保持相对稳定。试想,商品销售行为是一直在发生的,而用户注册和产品更新不总是随时有的。再说到地域和支付方式,那就更少变化了。

通过事实表和维度表组织起来的数仓多维数据模型,相比原本分散在数据库等各处的数据,能够有更有目的更高效的查询效率,比如可以查询汇总地域维度中某个省的商品销售情况,也可以通过时间维度分析每个季度的某类商品销售趋势。将多个维度表跟事实表进行不同程度的连接,可以展开得到各种各样的分析结果,满足商品运营等数据使用者的不同需求。

基于数据模型及操作又可以引入数据立方体概念及对其的常见操作。

7.什么是数据立方体?

中国作为信息技术领域的后起之秀,我们现在介绍的这些概念都源于英文。数据立方体就是从英文“Data Cube”而来。下图就是一个商品销售模型的数据立方体。

来源:http://webdataanalysis.net/web-data-warehouse/data-cube-and-olap/

其实我们也可以叫它”数据魔方体“,因为立方体是三维的,而多维数据模型并不仅仅三维,虽然受图形化展示限制,一般仅展示其三个维度。而”魔方“一词,则凸现出了其变化性,通过对其进行不同的操作,让数据呈现出千变万化的结果。

上图来源于参考资料,比较好展示了多维模型,从大立方体上可以看到商品类型、季度和地区三个维度。但对于每个维度又是一个小立方体,比如第一季度浙江的书籍销售情况就是左下角的小立方体。在这个小立方体中,根据需要,我们还可以按照书籍类型,从季度拆分为月度,浙江拆出各地级市。

上面的拆分例子正是基于立方体的场景操作之一,下面进一步介绍。

8. 数据立方体有哪些常见操作?

在进行OLAP查询时,基于数据立方体的多维分析操作包括:钻取(Drill-down)、上卷(Roll-up)、切片(Slice)、切块(Dice)以及旋转(Pivot),接下来以上面的数据立方体为例来逐一解释下:

钻取(Drill-down):

该操作我们上面简单举过例子,从钻取这个名字,就可以知道,这是往更细粒度深挖。从上一个层次到下一层,即深入该层内部。比如书籍中可以分计算机、数理化、文史地等,二季度又可分为4、5、6三个月,浙江省又可以分为杭、甬、温等地级市的销售数据。

上卷(Roll-up):

与钻取往深度挖相反,上卷顾名思义,即从细粒度数据向上层聚合,如将江苏省、上海市和浙江省的销售数据进行汇总来查看江浙沪地区的销售数据,将2010年的四个季度汇总成2010年的总数据;将电子产品、日用品和书籍汇总成实体商品,与服务相对应。

上面的钻取和上卷通过摊薄和加厚来改变维度的粒度。接下来介绍的切片和切块相似,是对维度进行筛选,获取其中一部分相同的样本。

切片(Slice):

如左图所示,切片就是选择维中特定的值进行分析,比如只选择电子产品的销售数据,或2010年第二季度的数据,或浙江一个省粒度进行分析。

切块(Dice):

如右图所示,切块是选择维中特定区间的数据或者某批特定值进行分析,比如选择2010年第一季度到2010年第二季度的销售数据,或者是电子产品和日用品的销售数据。

与切片不同的是,切块的粒度更大,会选择一个维度中某个区间或范围的值,而不仅仅是某个值。

旋转(Pivot):

即维的位置的互换,就像是二维表的行列转换,如图中通过旋转实现产品维和地域维的互换。

与上面几种操作不同,旋转并未减少或增加要分析的样本。而是根据不同的目的,改变了分析的角度,比如本来将产品作为观察角度,将地域和时间作为参照,分析不同产品在销售情况。通过旋转,转而分析江浙沪三个不同地区的产品销售情况。

以上仅简单介绍了数仓领域最最基础的知识和概念。下一篇重点分析现实中数仓的类型及其代表产品,并介绍优秀的数仓产品会用到的核心技术。

注:在调研过程中,看过不少数仓的基础文章,逐渐形成了对数仓的认识,在将其转化为自己的描述过程中,发现了一个博客(http://webdataanalysis.net),有论述有实践,在实践中不断加深自己的认识。虽然其中的文章都是很多年前的,但个人觉得对学习入门数仓很有帮助,所以文中很多内容和图片都参考了该博客。

来源:数据库内核@知乎专栏

- END -

有收获就点个「赞」吧 ▼

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

闽ICP备14008679号