暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

SELECT语句,底层原理分析


PostgreSQL 客户端-服务器协议的基本目的有两个:它将 SQL 查询发送到服务器,并接收整个执行结果作为响应。服务器收到的要执行的查询要经过以下几个阶段:


1. 解析





首先,解析查询文本,以便服务器准确了解需要做什么。
词法分析器和解析器。词法分析器负责识别查询字符串中的词位(如 SQL 关键字、字符串和数字文字等),而解析器确保生成的词位集在语法上是有效的。解析器和词法分析器是使用标准工具 Bison 和 Flex 实现的。解析的查询表示为抽象语法树。
例子:
SELECT schemaname, tablename
FROM pg_tables
WHERE tableowner = 'postgres'
ORDER BY tablename;

在这里,将在后端内存中构建一棵树。下图以高度简化的形式显示了树。树的节点用查询的相应部分标记。

RTE 是一个晦涩的缩写,代表“范围表条目”。
PostgreSQL 源代码中的名称“范围表”指的是表、子查询、连接结果。换句话说,SQL 语句操作的任何记录集。
语义分析器。语义分析器确定数据库中是否存在查询通过名称引用的表和其他对象,以及用户是否有权访问这些对象。语义分析所需的所有信息都存储在系统目录中。
语义分析器从解析器接收解析树并重建它,并用对特定数据库对象、数据类型信息等的引用来补充它。
如果参数debug_print_parse开启,则完整的树将显示在服务器消息日志中,尽管这没有什么实际意义。


2. 转换





接下来,可以转换(重写)查询。
系统核心将转换用于多种目的。其中之一是将解析树中的视图名称替换为与该视图的查询相对应的子树。pg_tables是一个视图,转换后解析树将采用以下形式:
此解析树对应于以下查询(尽管所有操作仅在树上执行,而不是在查询文本上执行):
SELECT schemaname, tablename
FROM (
    -- pg_tables
    SELECT n.nspname AS schemaname,
      c.relname AS tablename,
      pg_get_userbyid(c.relowner)
 AS tableowner,
      ...
    FROM pg_class c
      LEFT JOIN pg_namespace n ON n.oid 
= c.relnamespace
      LEFT JOIN pg_tablespace t ON t.oid = c.reltablespace
    WHERE c.relkind = ANY (ARRAY['r'::char'p'::char])
)
WHERE tableowner = 'postgres'
ORDER BY tablename;

解析树反映查询的句法结构,但不反映执行操作的顺序。行级安全性在转换阶段实施。
系统核心使用转换的另一个例子是版本 14 中递归查询的 SEARCH 和 CYCLE 子句的实现。PostgreSQL 支持自定义转换,用户可以使用重写规则系统来实现。
规则系统指在作为Postgres的主要功能之一。这些规则得到了项目基础的支持,并在早期开发过程中反复重新设计。这是一个强大的机制,但难以理解和调试。甚至有人提议从 PostgreSQL 中完全删除规则,但没有得到普遍支持。在大多数情况下,使用触发器而不是规则更安全、更方便。
如果参数debug_print_rewritten开启,则完整转换的解析树将显示在服务器消息日志中。


3. 计划





SQL 是一种声明性语言:查询指定要检索什么,但不指定如何检索它。任何查询都可以通过多种方式执行。解析树中的每个操作都有多个执行选项。
例如,您可以通过读取整个表并丢弃不需要的行来从表中检索特定记录,或者您可以使用索引来查找与您的查询匹配的行。数据集总是成对连接。连接顺序的变化会产生大量的执行选项。然后有多种方法可以将两组行连接在一起。
例如,您可以逐个遍历第一个集合中的行并在另一个集合中查找匹配的行,或者您可以先对两个集合进行排序,然后将它们合并在一起。不同的方法在某些情况下表现更好,在另一些情况下表现更差。
最佳计划的执行速度可能比非最佳计划快几个数量级。这就是为什么优化解析查询的计划器是系统中最复杂的元素之一。

计划树
执行计划也可以表示为树,但其节点是对数据的物理操作而不是逻辑操作。

如果参数debug_print_plan开启,则完整的计划树将显示在服务器消息日志中。这是非常不切实际的,因为日志非常混乱。更方便的选择是使用 EXPLAIN 命令:
EXPLAIN
SELECT schemaname, tablename
FROM pg_tables
WHERE tableowner = 'postgres'
ORDER BY tablename;


                            QUERY PLAN
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
 Sort  (cost=21.03..21.04 rows=1 width=128)
   Sort Key: c.relname
   −> Nested Loop Left Join  (cost=0.00..21.02 rows=1 width=128)
       Join Filter: (n.oid = c.relnamespace)
       −> Seq Scan on pg_class c  (cost=0.00..19.93 rows=1 width=72)
           Filter: ((relkind = ANY ('{r,p}'::"char"[])AND (pg_g...
       −> Seq Scan on pg_namespace n  (cost=0.00..1.04 rows=4 wid...
(7 rows)

该图显示了树的主要节点。相同的节点在 EXPLAIN 输出中用箭头标记。
Seq Scan 节点代表读表操作,而 Nested Loop 节点代表连接操作。这里有两个有趣的点需要注意:
  • 初始表之一从计划树中消失了,因为计划者发现不需要处理查询并将其删除。

  • 估计要处理的行数和每个节点旁边的处理成本。

计划搜索
为了找到最佳计划,PostgreSQL 使用了基于成本的查询优化器。优化器会检查各种可用的执行计划并估计所需的资源量,例如 I/O 操作和 CPU 周期。这个计算出的估计,转换成任意单位,被称为计划成本。选择结果成本最低的计划来执行。
问题是,可能的计划数量随着连接数量的增加而呈指数增长,即使对于相对简单的查询,也无法一一筛选所有计划。因此,动态规划和启发式用于限制搜索范围。这允许在合理的时间内精确地解决查询中更多表的问题,但不能保证所选计划是真正最优的,因为计划器使用简化的数学模型并且可能使用不精确的初始数据。
Ordering joins。可以以特定方式构建查询,以显着缩小搜索范围(有可能错过找到最佳计划的机会):
  • 公共表表达式通常与主查询分开优化。从版本 12 开始,可以使用 MATERIALIZE 子句强制执行此操作。

  • 来自非 SQL 函数的查询与主查询分开优化。(在某些情况下,SQL 函数可以内联到主查询中。)

  • join_collapse_limit参数与显式 JOIN 子句以及from_collapse_limit参数与子查询一起可以定义某些连接的顺序,具体取决于查询语法。


最后一个可能需要解释。下面的查询调用 FROM 子句中的几个表,没有显式连接:
SELECT ...
FROM a, b, c, d, e
WHERE ...

这是此查询的解析树:


在这个查询中,规划器将考虑所有可能的连接顺序。在下一个示例中,一些连接由 JOIN 子句显式定义:
SELECT ...
FROM a, b JOIN c ON ..., d, e
WHERE ...

解析树反映了这一点:

规划器折叠连接树,有效地将其转换为上一个示例中的树。该算法递归地遍历树并用其组件的平面列表替换每个 JOINEXPR 节点。
但是,只有在生成的平面列表包含不超过join_collapse_limit个元素(默认为 8 个)时,才会发生这种“扁平化”。在上面的示例中,如果将 join_collapse_limit 设置为 5 或更少,则不会折叠 JOINEXPR 节点。
对于规划者来说,这意味着两件事:
  • 表 B 必须连接到表 C(反之亦然,对中的连接顺序不受限制)。
  • 表 A、D、E 以及 B 到 C 的连接可以按任何顺序连接。
如果join_collapse_limit设置为 1,则将保留任何显式 JOIN 顺序。请注意,无论join_collapse_limit如何,操作 FULL OUTER JOIN 都不会折叠。参数from_collapse_limit(默认也是 8)以类似的方式限制子查询的展平。子查询似乎与连接没有太多共同之处,但是当它归结为解析树级别时,相似性是显而易见的。
例子:
SELECT ...
FROM a, b JOIN c ON ..., d, e
WHERE ...

这是树:

这里唯一的区别是 JOINEXPR 节点被替换为 FROMEXPR(因此参数名称为 FROM)。

遗传搜索
每当生成的扁平树以太多相同级别的节点(表或连接结果)结束时,规划时间可能会飙升,因为每个节点都需要单独优化。如果参数geqo开启(默认开启),当同级节点数量达到geqo_threshold(默认为12 )时,PostgreSQL将切换到遗传搜索。
遗传搜索比动态规划方法快得多,但它并不能保证找到最佳计划。该算法有许多可调整的选项,但这是另一篇文章的主题。

选择最佳计划
最佳计划的定义因预期用途而异。当需要完整的输出(例如,生成报告)时,计划必须优化与查询匹配的所有行的检索。另一方面,如果您只想要前几个匹配的行(例如,显示在屏幕上),则最佳计划可能会完全不同。
PostgreSQL 通过计算两个成本组件来解决这个问题。它们显示在“成本”一词之后的查询计划输出中:
 Sort  (cost=21.03..21.04 rows=1 width=128)
第一个组成部分,启动成本,是为节点执行做准备的成本;第二个组成部分,总成本,代表总节点执行成本。
选择计划时,计划者首先检查光标是否正在使用(可以在pl pgsql中使用declare命令设置游标或明确声明)。如果不是,则计划者假定需要全部输出并选择总成本最低的计划。
否则,如果使用游标,则规划器会选择一个规划,以最佳方式检索匹配行总数中等于cursor_tuple_fraction (默认为 0.1)的行数。或者,更具体地说,最低的计划
启动成本 + cursor_tuple_fraction  ×(总成本 - 启动成本)。

成本计算过程
要估计计划成本,必须单独估计其每个节点。节点成本取决于节点类型(从表中读取的成本远低于对表排序的成本)和处理的数据量(通常,数据越多,成本越高)。虽然节点类型是立即知道的,但要评估数据量,我们首先需要估计节点的基数(输入行的数量)和选择性(剩余用于输出的行的比例)。为此,我们需要数据统计:表大小、跨列的数据分布。
因此,优化依赖于准确的统计数据,这些数据由自动分析过程收集并保持最新。
如果每个计划节点的基数估计准确,计算出的总成本通常会与实际成本相匹配。常见的计划偏差通常是基数和选择性估计不正确的结果。这些错误是由不准确、过时或不可用的统计数据引起的,并且在较小程度上是规划器所基于的固有模型不完善。

基数估计
基数估计是递归执行的。节点基数使用两个值计算:
  • 节点的子节点的基数,或输入行数。

  • 节点的选择性,或输出行与输入行的比例。

基数是这两个值的乘积。
选择性是一个介于 0 和 1 之间的数字。接近于零的选择性值称为高选择性,接近一的值称为低选择性。这是因为高选择性会消除较高比例的行,而较低的选择性值会降低阈值,因此丢弃的行数会更少。
首先处理具有数据访问方法的叶节点。这就是表大小等统计信息的来源。
应用于表的条件的选择性取决于条件类型。在最简单的形式中,选择性可以是一个常数值,但计划者会尝试使用所有可用信息来产生最准确的估计。最简单条件的选择性估计作为基础,使用布尔运算构建的复杂条件可以使用以下简单公式进一步计算:
选择x和y =选择x选择y
sel x或y = 1−(1− sel x )(1− sel y ) = sel x + sel y − sel x sel y。
在这些公式中,x和y被认为是独立的。如果它们相关,则仍使用这些公式,但估计会不太准确。
对于连接的基数估计,计算两个值:笛卡尔积的基数(两个数据集的基数的乘积)和连接条件的选择性,这又取决于条件类型。
其他节点类型的基数,例如排序或聚合节点,也是类似地计算的。
请注意,较低节点中的基数计算错误将向上传播,导致成本估算不准确,并最终导致次优计划。计划器只有表的统计数据,而不是连接结果的统计数据,这使情况变得更糟。

成本估算
成本估算过程也是递归的。子树的成本包括其子节点的成本加上父节点的成本。
节点成本计算基于其执行操作的数学模型。已经计算的基数用作输入。该过程计算启动成本和总成本。
有些操作不需要任何准备,可以立即开始执行。对于这些操作,启动成本将为零。
其他操作可能有先决条件。例如,排序节点通常需要来自其子节点的所有数据才能开始操作。这些节点的启动成本不为零。即使下一个节点(或客户端)只需要单行输出,也必须支付此成本。
成本是计划者的最佳估计。任何计划错误都会影响成本与实际执行时间的相关程度。成本评估的主要目的是让计划者在相同条件下比较相同查询的不同执行计划。在任何其他情况下,按成本比较查询(更糟糕的是,不同的查询)是没有意义和错误的。例如,考虑由于统计数据不准确而被低估的成本。更新统计数据——成本可能会发生变化,但估算会变得更加准确,计划最终会得到改进。


4. 执行





按照计划执行优化查询。在后端内存中创建了一个称为门户的对象。门户在查询执行时存储查询的状态。此状态表示为一棵树,其结构与计划树相同。树的节点充当装配线,相互请求和传递行。
执行从根节点开始。根节点(示例中的排序节点 SORT)向子节点请求数据。当它接收到所有请求的数据时,它会执行排序操作,然后将数据向上传递给客户端。
一些节点(例如 NESTLOOP 节点)连接来自不同来源的数据。该节点从两个子节点请求数据。在接收到与连接条件匹配的两行后,节点立即将结果行传递给父节点(与排序不同,排序必须在处理它们之前接收所有行)。然后该节点停止,直到其父节点请求另一行。因此,如果只需要部分结果(例如由 LIMIT 设置),则操作将不会完全执行。
两个 SEQSCAN 叶是表扫描。根据父节点的请求,叶节点从表中读取下一行并将其返回。这个节点和其他一些节点根本不存储行,而只是交付并立即忘记它们。其他节点,例如排序,可能需要一次存储大量数据。为了解决这个问题,在后端内存中分配了一个work_mem内存块。它的默认大小是保守的 4MB 限制;当内存用完时,多余的数据会被发送到磁盘上的临时文件中。
一个计划可能包括多个具有存储要求的节点,因此它可能分配了几块内存,每个块的大小为work_mem。查询进程可能占用的总内存大小没有限制。


5. 扩展查询协议





使用简单的查询协议,任何命令,即使它一次又一次地重复,也会经历上述所有这些阶段:
    1. 解析

    2. 转型

    3. 规划

    4. 执行
但是没有理由一遍又一遍地解析同一个查询。如果它们仅在常量上有所不同,也没有任何理由重新解析查询:解析树将是相同的。简单查询协议的另一个烦恼是客户端接收完整的输出,不管它可能有多长。
这两个问题都可以通过使用 SQL 命令来解决:为第一个问题准备一个查询并执行它,为第二个问题声明一个游标并获取所需的行。但是随后客户端将不得不处理命名新对象,而服务器将需要解析额外的命令。扩展查询协议可以在协议命令级别对单独的执行阶段进行精确控制。


6. 准备





在准备期间,查询会像往常一样被解析和转换,但解析树存储在后端内存中。
PostgreSQL 没有用于解析查询的全局缓存。即使一个进程之前已经解析过查询,其他进程也必须再次解析它。然而,这种设计也有好处。在高负载下,全局内存缓存很容易因为锁而成为瓶颈。一个客户端发送多个小命令可能会影响整个实例的性能。在 PostgreSQL 中,查询解析很便宜并且与其他进程隔离。
可以使用附加参数准备查询。下面是一个使用SQL命令的例子(同样,这并不等同于协议命令级别的准备,但最终的效果是一样的):
PREPARE plane(text) AS
SELECT * FROM aircrafts WHERE aircraft_code 
= $1;

本系列文章中的大多数示例将使用演示数据库“航空公司”。此视图显示所有命名的预准备语句:
SELECT name, statement, parameter_types
FROM pg_prepared_statements \gx

−[ RECORD 1 ]−−−+−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
name            | plane
statement       | PREPARE plane(text) AS                           +
                | SELECT * FROM aircrafts WHERE aircraft_code 
= $1;
parameter_types | {text}

该视图没有列出任何未命名的语句(使用扩展协议或 PL/pgSQL)。它也没有列出来自其他会话的准备好的语句:访问另一个会话的内存是不可能的。



7. 参数绑定





在执行准备好的查询之前,会绑定当前参数值。
EXECUTE plane('733');
 aircraft_code |     model     | range
−−−−−−−−−−−−−−−+−−−−−−−−−−−−−−−+−−−−−−−
 733           | Boeing 737300 |  4200
(1 row)

与文字表达式的串联相比,准备好的语句的一个优点是可以防止任何类型的 SQL 注入,因为参数值不会影响已经构建的解析树。在没有准备好的声明的情况下达到相同的安全级别将需要对来自不受信任来源的所有值进行广泛的转义。



8. 计划和执行





当一个准备好的语句被执行时,首先考虑到提供的参数来计划它的查询,然后发送选择的计划执行。
实际参数值对计划者很重要,因为不同参数集的最佳计划也可能不同。例如,在查找高级航班预订时,使用索引扫描(如“索引扫描”字样所示),因为计划者预计匹配的行不多:
CREATE INDEX ON bookings(total_amount);
EXPLAIN SELECT * FROM bookings WHERE total_amount > 1000000;

                             QUERY PLAN
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
 Bitmap Heap Scan on bookings  (cost=86.38..9227.74 rows=4380 wid...
   Recheck Cond: (total_amount > '1000000'::numeric)

   −> Bitmap Index Scan on bookings_total_amount_idx  (cost=0.00....
       Index Cond: (total_amount > '1000000'::numeric)

(4 rows)

然而,下一个条件完全符合所有预订。索引扫描在这里没用,进行顺序扫描(Seq Scan):
EXPLAIN SELECT * FROM bookings 
WHERE total_amount > 100;

                            QUERY PLAN
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
 Seq Scan on bookings  (cost=0.00..39835.88 rows=2111110 width=21)
   Filter: (total_amount > '100'::numeric)
(2 rows)

在某些情况下,除了解析树之外,规划器还会存储查询计划,以避免在出现时再次规划它。这个没有参数值的计划称为通用计划,而不是使用给定参数值生成的自定义计划。通用计划的一个明显用例是没有参数的语句。

对于前四次运行,带有参数的预处理语句总是根据实际参数值进行优化。然后计算平均计划成本。在第五次及以后,如果通用计划平均比自定义计划便宜(每次都必须重新构建),那么规划器将从那时起存储和使用通用计划,并进行进一步优化。
plane准备好的语句已经执行过一次。在接下来的两次执行中,仍然使用自定义计划,如查询计划中的参数值所示:
EXECUTE plane('763');
EXECUTE plane('773');
EXPLAIN EXECUTE plane('319');

                            QUERY PLAN
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
 Seq Scan on aircrafts_data ml  (cost=0.00..1.39 rows=1 width=52)
   Filter: ((aircraft_code)::text 
'319'::text)
(2 rows)

执行四次后,规划器将切换到通用规划。在这种情况下,通用计划与定制计划相同,成本相同,因此更可取。现在 EXPLAIN 命令显示参数编号,而不是实际值:
EXECUTE plane('320');
EXPLAIN EXECUTE plane('321');

                            QUERY PLAN
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
 Seq Scan on aircrafts_data ml  (cost=0.00..1.39 rows=1 width=52)
   Filter: ((aircraft_code)::text 
'$1'::text)
(2 rows)

不幸的是,只有前四个定制计划比通用计划更昂贵,而任何进一步的定制计划都会更便宜——但计划者会完全忽略它们。另一个可能不完善的来源是计划者比较成本估算,而不是要花费的实际资源成本。
这就是为什么在版本 12 及更高版本中,如果用户不喜欢自动结果,他们可以强制系统使用通用计划或自定义计划。这是通过参数plan_cache_mode完成的:
SET plan_cache_mode = 'force_custom_plan';
EXPLAIN EXECUTE plane('CN1');

                           QUERY PLAN
−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−
Seq Scan on aircrafts_data ml  (cost=0.00..1.39 rows=1 width=52)
  Filter: ((aircraft_code)::text 
'CN1'::text)
(2 rows)


在 14 及更高版本中,pg_prepared_statements 
视图还显示计划选择统计信息:
SELECT name, generic_plans, custom_plans
FROM pg_prepared_statements;

 name  | generic_plans | custom_plans
−−−−−−−+−−−−−−−−−−−−−−−+−−−−−−−−−−−−−−
 plane |             1 |            6
(1 row)



9. 输出查询





扩展查询协议允许客户端批量获取输出,一次多行,而不是一次全部获取。借助 SQL 游标也可以实现相同的目的,但成本更高,并且规划器将优化对第一个cursor_tuple_fraction行的检索:
BEGIN;
DECLARE cur CURSOR FOR
  SELECT * FROM aircrafts ORDER BY aircraft_code;
FETCH 3 FROM cur;

 aircraft_code |      model       | range
−−−−−−−−−−−−−−−+−−−−−−−−−−−−−−−−−−+−−−−−−−
 319           | Airbus A319−100 |  6700
 320           | Airbus A320−200 |  5700
 321           | Airbus A321−200 |  5600
(3 rows)

FETCH 2 FROM cur;
 aircraft_code |     model     | range  
−−−−−−−−−−−−−−−+−−−−−−−−−−−−−−−+−−−−−−−
           733 | Boeing 737300 |  4200
           763 | Boeing 767300 |  7900
(2 rows)

COMMIT;
每当查询返回大量行并且客户端都需要它们时,一次检索的行数对于整体数据传输速度至关重要。单批行越大,往返延迟损失的时间就越少。
然而,随着批量大小的增加,节省的效率会下降。例如,从批量大小 1 切换到批量大小 10 将显着增加时间节省,但从 10 切换到 100 几乎没有任何区别。


PG考试咨询



往期回顾

2022年中国PostgreSQL考试认证计划

PGCCC,公众号:PostgreSQL考试认证中心通知:2022年中国PostgreSQL考试认证计划

PostgreSQL认证专家-广州班开班风采

PGCCC,公众号:PostgreSQL考试认证中心永远都不晚:PostgreSQL认证专家(培训考试-广州站)
开班通知-PCP认证专家(上海站)培训开班1106
PGCCC,公众号:PostgreSQL考试认证中心
开班通知-PCP认证专家(上海站)培训开班1106

PostgreSQL-PCP认证专家-北京站-精彩花絮

PGCCC,公众号:PostgreSQL考试认证中心PostgreSQL认证专家考试(培训)(10月16日北京站)精彩花絮

PostgreSQL-PCP认证专家-成都站

公众号:PostgreSQL考试认证中心开班通知-PCP认证专家(成都站)培训开班1016

PostgreSQL-PCP认证专家考试-北京站-考试风采

PGCCC,公众号:PostgreSQL考试认证中心PostgreSQL认证专家考试(培训)-北京站-成功举办

PostgreSQL-PCA认证考试-贵阳站-考试风采

PGCCC,公众号:PostgreSQL考试认证中心PostgreSQL PCA+PCP认证考试在贵阳成功举办

PostgreSQL-PCP认证专家考试-上海站-考试风采

PGCCC,公众号:PostgreSQL考试认证中心PostgreSQL PCP认证考试(上海站)成功举办

PostgreSQL认证专家考试-学员考试总结

薛晓刚,公众号:PostgreSQL考试认证中心难考的PostgreSQL认证考试

PostgreSQL-PCM认证大师考试-天津站-考试风采

PGCCC,公众号:PostgreSQL考试认证中心PostgreSQL-PCM认证大师考试(天津站)成功举办

如何在工业和信息化部教育与考试中心官网查询证书

PG考试认证中心,公众号:PostgreSQL考试认证中心如何在工业和信息化部教育与考试中心查询PostgreSQL证书

中国PostgreSQL考试认证体系

PG考试认证中心,公众号:PostgreSQL考试认证中心中国PostgreSQL考试认证体系


文章转载自PostgreSQL考试认证中心,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论