Tidb SQL优化(一)

1.SQL 优化流程
在 TiDB 中,从输入的查询文本到最终的执行计划执行结果的过程可以见下图。
在经过了  parser  对原始查询文本的解析以及一些简单的合法性验证后,TiDB 首先会对查询做一些逻辑上的等价变化,
通过这些等价变化,使得这个查询在逻辑执行计划上可以变得更易于处理。在等价变化结束之后,TiDB 会得到一个与原始查询等价的查询计划结构,之后根据数据分布、以及一个算子具体的执行开销,来获得一个最终的执行计划,
同时,TiDB 在执行  PREPARE  语句时,可以选择开启缓存来降低 TiDB 生成执行计划的开销
2.逻辑优化
2.1 子查询相关的优化
通常会遇到如下情况的子查询:
  • NOT IN (SELECT ... FROM ...)
  • NOT EXISTS (SELECT ... FROM ...)
  • IN (SELECT ... FROM ..)
  • EXISTS (SELECT ... FROM ...)
  • ... >/>=/</<=/=/!= (SELECT ... FROM ...)
有时,子查询中包含了非子查询中的列,如  select * from t where t.a in (select * from t2 where t.b=t2.b)  中,子查询中的  t.b  不是子查询中的列,而是从子查询外面引入的列。这种子查询通常会被称为 关联子查询 ,外部引入的列会被称为 关联列 ,关联子查询相关的优化参见 关联子查询去关联 。本文主要关注不涉及关联列的子查询。
子查询默认会以 理解 TiDB 执行计划 中提到的  Hash join  作为默认的执行方式,同时对于一些特殊的子查询,TiDB 会做一些逻辑上的替换使得查询可以获得更好的执行性能。

... < ALL (SELECT ... FROM ...) 或者 ... > ANY (SELECT ... FROM ...)

对于这种情况,可以将  ALL  或者  ANY  用  MAX  以及  MIN  来代替。不过由于在表为空时, MAX(EXPR)  以及  MIN(EXPR)  的结果会为  NULL ,其表现形式和  EXPR  是有  NULL  值的结果一样。以及外部表达式结果为  NULL  时也会影响表达式的最终结果,因此这里完整的改写会是如下的形式:
  • t.id < all(select s.id from s)  会被改写为  t.id < min(s.id) and if(sum(s.id is null) != 0, null, true) 。
  • t.id > any (select s.id from s)  会被改写为  t.id > max(s.id) or if(sum(s.id is null) != 0, null, false) 。

... != ANY (SELECT ... FROM ...)

对于这种情况,当子查询中不同值的各种只有一种的话,那只要和这个值对比就即可。如果子查询中不同值的个数多于 1 个,那么必然会有不相等的情况出现。因此这样的子查询可以采取如下的改写手段:
  • select * from t where t.id != any (select s.id from s)  会被改写为  select t.* from t, (select s.id, count(distinct s.id) as cnt_distinct from s) where (t.id != s.id or cnt_distinct > 1)

... = ALL (SELECT ... FROM ...)

对于这种情况,当子查询中不同值的个数多于一种的话,那么这个表达式的结果必然为假。因此这样的子查询在 TiDB 中会改写为如下的形式:
  • select * from t where t.id = all (select s.id from s)  会被改写为  select t.* from t, (select s.id, count(distinct s.id) as cnt_distinct from s) where (t.id = s.id and cnt_distinct <= 1)

... IN (SELECT ... FROM ...)

对于这种情况,会将其改写为  IN  的子查询改写为  SELECT ... FROM ... GROUP ...  的形式,然后将  IN  改写为普通的  JOIN  的形式。 如  select * from t1 where t1.a in (select t2.a from t2)  会被改写为  select t1.* from t1, (select distinct(a) a from t2) t2 where t1.a = t2.a  的形式。同时这里的  DISTINCT  可以在  t2.a  具有  UNIQUE  属性时被自动消去。
explain select * from t1 where t1 . a in ( select t2 . a from t2 ) ;
+ ------------------------------+---------+-----------+------------------------+----------------------------------------------------------------------------+ | id | estRows | task | access object | operator info | + ------------------------------+---------+-----------+------------------------+----------------------------------------------------------------------------+ | IndexJoin_12 | 9990.00 | root | | inner join , inner :TableReader_11 , outer key :test . t2 . a , inner key :test . t1 . a | | ├─HashAgg_21 ( Build ) | 7992.00 | root | | group by :test . t2 . a , funcs:firstrow ( test . t2 . a ) - > test . t2 . a | | │ └─IndexReader_28 | 9990.00 | root | | index :IndexFullScan_27 | | │ └─IndexFullScan_27 | 9990.00 | cop [ tikv ] | table :t2 , index :idx ( a ) | keep order : false , stats:pseudo | | └─TableReader_11 ( Probe ) | 1.00 | root | | data :TableRangeScan_10 | | └─TableRangeScan_10 | 1.00 | cop [ tikv ] | table :t1 | range: decided by [ test . t2 . a ] , keep order : false , stats:pseudo | + ------------------------------+---------+-----------+------------------------+----------------------------------------------------------------------------+
这个改写会在  IN  子查询相对较小,而外部查询相对较大时产生更好的执行性能。因为不经过改写的情况下,我们无法使用以 t2 为驱动表的  index join 。同时这里的弊端便是,当改写删成的聚合无法被自动消去且  t2  表比较大时,反而会影响查询的性能。目前 TiDB 中使用  tidb_opt_insubq_to_join_and_agg  变量来控制这个优化的打开与否。当遇到不合适这个优化的情况可以手动关闭。

EXISTS 子查询以及 ... >/>=/</<=/=/!= (SELECT ... FROM ...)

当前对于这种场景的子查询,当它不是关联子查询时,TiDB 会在优化阶段提前展开它,将其直接替换为一个结果集直接判断结果。如下图中, EXISTS  会提前在优化阶段被执行为  TRUE ,从而不会在最终的执行结果中看到它。
create table t1 ( a int ) ; create table t2 ( a int ) ; insert into t2 values ( 1 ) ; explain select * from t where exists ( select * from t2 ) ;
+ ------------------------+----------+-----------+---------------+--------------------------------+ | id | estRows | task | access object | operator info | + ------------------------+----------+-----------+---------------+--------------------------------+ | TableReader_12 | 10000.00 | root | | data :TableFullScan_11 | | └─TableFullScan_11 | 10000.00 | cop [ tikv ] | table :t | keep order : false , stats:pseudo | + ------------------------+----------+-----------+---------------+--------------------------------+

 

2.2列裁剪

列裁剪的基本思想在于:对于算子中实际用不上的列,优化器在优化的过程中没有必要保留它们。 对这些列的删除会减少 I/O 资源占用,并为后续的优化带来便利。下面给出一个列重复的例子:
假设表 t 里面有 a b c d 四列,执行如下语句:
select a from t where b > 5
在该查询的过程中,t 表实际上只有 a, b 两列会被用到,而 c, d 的数据则显得多余。对应到该语句的查询计划,Selection 算子会用到 b 列,下面接着的 DataSource 算子会用到 a, b 两列,而剩下 c, d 两列则都可以裁剪掉,DataSource 算子在读数据时不需要将它们读进来。
出于上述考量,TiDB 会在逻辑优化阶段进行自上而下的扫描,裁剪不需要的列,减少资源浪费。该扫描过程称作 “列裁剪”,对应逻辑优化规则中的  columnPruner 。如果要关闭这个规则,可以在参照 优化规则及表达式下推的黑名单 中的关闭方法。
 
 

2.3关联子查询去关联

以  select * from t1 where t1.a < (select sum(t2.a) from t2 where t2.b = t1.b)  为例,这里子查询  t1.a < (select sum(t2.a) from t2 where t2.b = t1.b)  中涉及了关联列上的条件  t2.b=t1.b ,不过恰好由于这是一个等值条件,因此可以将其等价的改写为  select t1.* from t1, (select b, sum(a) sum_a from t2 group by b) t2 where t1.b = t2.b and t1.a < t2.sum_a; 。这样,一个关联子查询就被重新改写为  JOIN  的形式。
TiDB 之所以要进行这样的改写,是因为关联子查询每次子查询执行时都是要和它的外部查询结果绑定的。在上面的例子中,如果  t1.a  有一千万个值,那这个子查询就要被重复执行一千万次,因为  t2.b=t1.b  这个条件会随着  t1.a  值的不同而发生变化。当通过一些手段将关联依赖解除后,这个子查询就只需要被执行一次了。

限制

这种改写的弊端在于,在关联没有被解除时,优化器是可以使用关联列上的索引的。也就是说,虽然这个子查询可能被重复执行多次,但是每次都可以使用索引过滤数据。而解除关联的变换上,通常是会导致关联列的位置发生改变而导致虽然子查询只被执行了一次,但是单次执行的时间会比没有解除关联时的单次执行时间长。
因此,在外部的值比较少的情况下,不解除关联依赖反而可能对执行性能更优帮助。这时可以通过 优化规则及表达式下推的黑名单 中关闭 子查询去关联 优化规则的方式来关闭这个优化。

样例

create table t1 ( a int , b int ) ; create table t2 ( a int , b int , index idx ( b ) ) ; explain select * from t1 where t1 . a < ( select sum ( t2 . a ) from t2 where t2 . b = t1 . b ) ;
+ ----------------------------------+----------+-----------+---------------+-----------------------------------------------------------------------------------------+ | id | estRows | task | access object | operator info | + ----------------------------------+----------+-----------+---------------+-----------------------------------------------------------------------------------------+ | HashJoin_11 | 9990.00 | root | | inner join , equal: [ eq ( test . t1 . b , test . t2 . b ) ] , other cond:lt ( cast ( test . t1 . a ) , Column #7) | | ├─HashAgg_23 ( Build ) | 7992.00 | root | | group by :test . t2 . b , funcs: sum ( Column #8)->Column#7, funcs:firstrow(test.t2.b)->test.t2.b | | │ └─TableReader_24 | 7992.00 | root | | data :HashAgg_16 | | │ └─HashAgg_16 | 7992.00 | cop [ tikv ] | | group by :test . t2 . b , funcs: sum ( test . t2 . a ) - > Column #8 | | │ └─Selection_22 | 9990.00 | cop [ tikv ] | | not ( isnull ( test . t2 . b ) ) | | │ └─TableFullScan_21 | 10000.00 | cop [ tikv ] | table :t2 | keep order : false , stats:pseudo | | └─TableReader_15 ( Probe ) | 9990.00 | root | | data :Selection_14 | | └─Selection_14 | 9990.00 | cop [ tikv ] | | not ( isnull ( test . t1 . b ) ) | | └─TableFullScan_13 | 10000.00 | cop [ tikv ] | table :t1 | keep order : false , stats:pseudo | + ----------------------------------+----------+-----------+---------------+-----------------------------------------------------------------------------------------+
上面是优化生效的情况,可以看到  HashJoin_11  是一个普通的  inner join 。
接下来,关闭关联规则:
insert into mysql . opt_rule_blacklist values ( "decorrelate" ) ;
admin reload opt_rule_blacklist ; explain select * from t1 where t1 . a < ( select sum ( t2 . a ) from t2 where t2 . b = t1 . b ) ;
+ ----------------------------------------+----------+-----------+------------------------+------------------------------------------------------------------------------+ | id | estRows | task | access object | operator info | + ----------------------------------------+----------+-----------+------------------------+------------------------------------------------------------------------------+ | Projection_10 | 10000.00 | root | | test . t1 . a , test . t1 . b | | └─Apply_12 | 10000.00 | root | | CARTESIAN inner join , other cond:lt ( cast ( test . t1 . a ) , Column #7) | | ├─TableReader_14 ( Build ) | 10000.00 | root | | data :TableFullScan_13 | | │ └─TableFullScan_13 | 10000.00 | cop [ tikv ] | table :t1 | keep order : false , stats:pseudo | | └─MaxOneRow_15 ( Probe ) | 1.00 | root | | | | └─HashAgg_27 | 1.00 | root | | funcs: sum ( Column #10)->Column#7 | | └─IndexLookUp_28 | 1.00 | root | | | | ├─IndexRangeScan_25 ( Build ) | 10.00 | cop [ tikv ] | table :t2 , index :idx ( b ) | range: decided by [ eq ( test . t2 . b , test . t1 . b ) ] , keep order : false , stats:pseudo | | └─HashAgg_17 ( Probe ) | 1.00 | cop [ tikv ] | | funcs: sum ( test . t2 . a ) - > Column #10 | | └─TableRowIDScan_26 | 10.00 | cop [ tikv ] | table :t2 | keep order : false , stats:pseudo | + ----------------------------------------+----------+-----------+------------------------+------------------------------------------------------------------------------+
在执行了关闭关联规则的语句后,可以在  IndexRangeScan_25(Build)  的  operator info  中看到  range: decided by [eq(test.t2.b, test.t1.b)] 。这部分信息就是关联依赖未被解除时,TiDB 使用关联条件进行索引范围查询的显示结果。
 
 

2.4Max/Min 函数消除规则

在 SQL 中包含了  max / min  函数时,查询优化器会尝试使用  max / min  消除优化规则来将  max / min  聚合函数转换为 TopN 算子,从而能够有效地利用索引进行查询。
根据  select  语句中  max / min  函数的个数,这一优化规则有以下两种表现形式:

只有一个 max/min 函数时的优化规则

当一个 SQL 满足以下条件时,就会应用这个规则:
  • 只有一个聚合函数,且为  max  或者  min  函数。
  • 聚合函数没有相应的  group by  语句。
例如:
select max ( a ) from t
这时  max / min  消除优化规则会将其重写为:
select max ( a ) from ( select a from t where a is not null order by a desc limit 1 ) t
这个新的 SQL 语句在  a  列存在索引(或  a  列是某个联合索引的前缀)时,能够利用索引只扫描一行数据来得到最大或者最小值,从而避免对整个表的扫描。
上述例子最终得到的执行计划如下:
mysql> explain select max(a) from t;
+------------------------------+---------+-----------+-------------------------+-------------------------------------+
| id | estRows | task | access object | operator info |
+------------------------------+---------+-----------+-------------------------+-------------------------------------+
| StreamAgg_13 | 1.00 | root | | funcs:max(test.t.a)->Column#4 |
| └─Limit_17 | 1.00 | root | | offset:0, count:1 |
| └─IndexReader_27 | 1.00 | root | | index:Limit_26 |
| └─Limit_26 | 1.00 | cop[tikv] | | offset:0, count:1 |
| └─IndexFullScan_25 | 1.00 | cop[tikv] | table:t, index:idx_a(a) | keep order:true, desc, stats:pseudo |
+------------------------------+---------+-----------+-------------------------+-------------------------------------+
5 rows in set (0.00 sec)

存在多个 max/min 函数时的优化规则

当一个 SQL 满足以下条件时,就会应用这个规则:
  • 有多个聚合函数,且所有的聚合函数都是 max/min
  • 聚合函数没有相应的  group by  语句。
  • 每个  max / min  聚合函数参数中的列都有索引能够保序。
下面是一个简单的例子:
select max ( a ) - min ( a ) from t
优化规则会先检查  a  列是否存在索引能够为其保序,如果存在,这个 SQL 会先被重写为两个子查询的笛卡尔积:
select max_a - min_a
from
( select max ( a ) as max_a from t ) t1 ,
( select min ( a ) as min_a from t ) t2
这样,两个子句中的  max / min  函数就可以使用上述“只有一个  max / min  函数时的优化规则”分别进行优化,最终重写为:
select max_a - min_a
from
( select max ( a ) as max_a from ( select a from t where a is not null order by a desc limit 1 ) t ) t1 ,
( select min ( a ) as min_a from ( select a from t where a is not null order by a asc limit 1 ) t ) t2
同样的,如果  a  列能够使用索引保序,那这个优化只会扫描两行数据,避免了对整个表的扫描。但如果  a  列没有可以保序的索引,这个变换会使原本只需一次的全表扫描变成两次,因此这个规则就不会被应用。
最后得到的执行计划:
mysql> explain select max(a)-min(a) from t;
+------------------------------------+---------+-----------+-------------------------+-------------------------------------+
| id | estRows | task | access object | operator info |
+------------------------------------+---------+-----------+-------------------------+-------------------------------------+
| Projection_17 | 1.00 | root | | minus(Column#4, Column#5)->Column#6 |
| └─HashJoin_18 | 1.00 | root | | CARTESIAN inner join |
| ├─StreamAgg_45(Build) | 1.00 | root | | funcs:min(test.t.a)->Column#5 |
| │ └─Limit_49 | 1.00 | root | | offset:0, count:1 |
| │ └─IndexReader_59 | 1.00 | root | | index:Limit_58 |
| │ └─Limit_58 | 1.00 | cop[tikv] | | offset:0, count:1 |
| │ └─IndexFullScan_57 | 1.00 | cop[tikv] | table:t, index:idx_a(a) | keep order:true, stats:pseudo |
| └─StreamAgg_24(Probe) | 1.00 | root | | funcs:max(test.t.a)->Column#4 |
| └─Limit_28 | 1.00 | root | | offset:0, count:1 |
| └─IndexReader_38 | 1.00 | root | | index:Limit_37 |
| └─Limit_37 | 1.00 | cop[tikv] | | offset:0, count:1 |
| └─IndexFullScan_36 | 1.00 | cop[tikv] | table:t, index:idx_a(a) | keep order:true, desc, stats:pseudo |
+------------------------------------+---------+-----------+-------------------------+-------------------------------------+
12 rows in set (0.01 sec)
 

2.5谓词下推

谓词下推将查询语句中的过滤表达式计算尽可能下推到距离数据源最近的地方,以尽早完成数据的过滤,进而显著地减少数据传输或计算的开销。

示例

以下通过一些例子对谓词下推优化进行说明,其中示例1、2、3为谓词下推适用的案例,示例4、5、6为谓词下推不适用的案例。

示例 1: 谓词下推到存储层

create table t ( id int primary key , a int ) ; explain select * from t where a < 1 ; + -------------------------+----------+-----------+---------------+--------------------------------+ | id | estRows | task | access object | operator info | + -------------------------+----------+-----------+---------------+--------------------------------+ | TableReader_7 | 3323.33 | root | | data :Selection_6 | | └─Selection_6 | 3323.33 | cop [ tikv ] | | lt ( test . t . a , 1 ) | | └─TableFullScan_5 | 10000.00 | cop [ tikv ] | table :t | keep order : false , stats:pseudo | + -------------------------+----------+-----------+---------------+--------------------------------+ 3 rows in set ( 0.00 sec )
在该查询中,将谓词  a < 1  下推到 TiKV 上对数据进行过滤,可以减少由于网络传输带来的开销。

示例 2: 谓词下推到存储层

create table t ( id int primary key , a int not null ) ; explain select * from t where a < substring ( '123' , 1 , 1 ) ; + -------------------------+----------+-----------+---------------+--------------------------------+ | id | estRows | task | access object | operator info | + -------------------------+----------+-----------+---------------+--------------------------------+ | TableReader_7 | 3323.33 | root | | data :Selection_6 | | └─Selection_6 | 3323.33 | cop [ tikv ] | | lt ( test . t . a , 1 ) | | └─TableFullScan_5 | 10000.00 | cop [ tikv ] | table :t | keep order : false , stats:pseudo | + -------------------------+----------+-----------+---------------+--------------------------------+
该查询与示例 1 中的查询生成了完成一样的执行计划,这是因为谓词  a < substring('123', 1, 1)  的  substring  的入参均为常量,因此可以提前计算,进而简化得到等价的谓词  a < 1 。进一步的,可以将  a < 1  下推至 TiKV 上。

示例 3: 谓词下推到 join 下方

create table t ( id int primary key , a int not null ) ; create table s ( id int primary key , a int not null ) ; explain select * from t join s on t . a = s . a where t . a < 1 ; + ------------------------------+----------+-----------+---------------+--------------------------------------------+ | id | estRows | task | access object | operator info | + ------------------------------+----------+-----------+---------------+--------------------------------------------+ | HashJoin_8 | 4154.17 | root | | inner join , equal: [ eq ( test . t . a , test . s . a ) ] | | ├─TableReader_15 ( Build ) | 3323.33 | root | | data :Selection_14 | | │ └─Selection_14 | 3323.33 | cop [ tikv ] | | lt ( test . s . a , 1 ) | | │ └─TableFullScan_13 | 10000.00 | cop [ tikv ] | table :s | keep order : false , stats:pseudo | | └─TableReader_12 ( Probe ) | 3323.33 | root | | data :Selection_11 | | └─Selection_11 | 3323.33 | cop [ tikv ] | | lt ( test . t . a , 1 ) | | └─TableFullScan_10 | 10000.00 | cop [ tikv ] | table :t | keep order : false , stats:pseudo | + ------------------------------+----------+-----------+---------------+--------------------------------------------+ 7 rows in set ( 0.00 sec )
在该查询中,将谓词  t.a < 1  下推到 join 前进行过滤,可以减少 join 时的计算开销。
此外,这条 SQL 执行的是内连接,且  ON  条件时  t.a = s.a ,可以由  t.a < 1  推导出谓词  s.a < 1 ,并将其下推至 join 运算前对  s  表进行过滤,可以进一步减少 join 时的计算开销。

示例 4: 存储层不支持的谓词无法下推

create table t ( id int primary key , a int not null ) ; desc select * from t where substring ( '123' , a , 1 ) = '1' ; + -------------------------+---------+-----------+---------------+----------------------------------------+ | id | estRows | task | access object | operator info | + -------------------------+---------+-----------+---------------+----------------------------------------+ | Selection_7 | 2.00 | root | | eq ( substring ( "123" , test . t . a , 1 ) , "1" ) | | └─TableReader_6 | 2.00 | root | | data :TableFullScan_5 | | └─TableFullScan_5 | 2.00 | cop [ tikv ] | table :t | keep order : false , stats:pseudo | + -------------------------+---------+-----------+---------------+----------------------------------------+
在该查询中,存在谓词  substring('123', a, 1) = '1' 。
从 explain 结果中可以看到,该谓词没有被下推到 TiKV 上进行计算,这是因为 TiKV coprocessor 中没有对  substring  内置函数进行支持, 因此无法将其下推到 TiKV 上。

示例 5: 外连接中内表上的谓词不能下推

create table t ( id int primary key , a int not null ) ; create table s ( id int primary key , a int not null ) ; explain select * from t left join s on t . a = s . a where s . a is null ; + -------------------------------+----------+-----------+---------------+-------------------------------------------------+ | id | estRows | task | access object | operator info | + -------------------------------+----------+-----------+---------------+-------------------------------------------------+ | Selection_7 | 10000.00 | root | | isnull ( test . s . a ) | | └─HashJoin_8 | 12500.00 | root | | left outer join , equal: [ eq ( test . t . a , test . s . a ) ] | | ├─TableReader_13 ( Build ) | 10000.00 | root | | data :TableFullScan_12 | | │ └─TableFullScan_12 | 10000.00 | cop [ tikv ] | table :s | keep order : false , stats:pseudo | | └─TableReader_11 ( Probe ) | 10000.00 | root | | data :TableFullScan_10 | | └─TableFullScan_10 | 10000.00 | cop [ tikv ] | table :t | keep order : false , stats:pseudo | + -------------------------------+----------+-----------+---------------+-------------------------------------------------+ 6 rows in set ( 0.00 sec )
在该查询中,内表 s 上存在谓词  s.a is null 。
从 explain 中可以看到,该谓词没有被下推到 join 前进行计算,这是因为外连接在不满足 on 条件时会对内表填充 NULL,而在该查询中  s.a is null  用来对 join 后的结果进行过滤,如果将其下推到 join 前在内表上进行过滤,则下推前后不等价, 因此不可进行下推。

示例 6: 谓词中包含用户变量时不能下推

create table t ( id int primary key , a char ) ; set @a = 1 ; explain select * from t where a < @a ; + -------------------------+----------+-----------+---------------+--------------------------------+ | id | estRows | task | access object | operator info | + -------------------------+----------+-----------+---------------+--------------------------------+ | Selection_5 | 8000.00 | root | | lt ( test . t . a , getvar ( "a" ) ) | | └─TableReader_7 | 10000.00 | root | | data :TableFullScan_6 | | └─TableFullScan_6 | 10000.00 | cop [ tikv ] | table :t | keep order : false , stats:pseudo | + -------------------------+----------+-----------+---------------+--------------------------------+ 3 rows in set ( 0.00 sec )
在该查询中,表 t 上存在谓词  a < @a , 其中  @a  为值为 1 的用户变量。
从 explain 中可以看到,该谓词没有像示例 2 中一样,将谓词简化为  a < 1  并下推到 TiKV 上进行计算。这是因为,用户变量  @a  的值可能会某些场景下在查询过程中发生改变,且 TiKV 对于用户变量  @a  的值不可知,因此 TiDB 不会将  @a  替换为 1,且不会下推至 TiKV 上进行计算。
一个帮助理解的例子如下:
create table t ( id int primary key , a int ) ; insert into t values ( 1 , 1 ) , ( 2 , 2 ) ; set @a = 1 ; select id , a , @a : = @a + 1 from t where a = @a ; + ----+------+----------+ | id | a | @a : = @a + 1 | + ----+------+----------+ | 1 | 1 | 2 | | 2 | 2 | 3 | + ----+------+----------+ 2 rows in set ( 0.00 sec )
可以从在该查询中看到, @a  的值会在查询过程中发生改变,因此如果将  a = @a  替换为  a = 1  并下推至 TiKV,则优化前后不等价。
 
 

2.6分区裁剪

分区裁剪是只有当目标表为分区表时,才可以进行的一种优化方式。分区裁剪通过分析查询语句中的过滤条件,只选择可能满足条件的分区,不扫描匹配不上的分区,进而显著地减少计算的数据量。
例如:
CREATE TABLE t1 (
id INT NOT NULL PRIMARY KEY ,
pad VARCHAR ( 100 ) ) PARTITION BY RANGE COLUMNS ( id ) (
PARTITION p0 VALUES LESS THAN ( 100 ) ,
PARTITION p1 VALUES LESS THAN ( 200 ) ,
PARTITION p2 VALUES LESS THAN ( MAXVALUE ) ) ; INSERT INTO t1 VALUES ( 1 , 'test1' ) , ( 101 , 'test2' ) , ( 201 , 'test3' ) ; EXPLAIN SELECT * FROM t1 WHERE id BETWEEN 80 AND 120 ;
+ ----------------------------+---------+-----------+------------------------+------------------------------------------------+ | id | estRows | task | access object | operator info | + ----------------------------+---------+-----------+------------------------+------------------------------------------------+ | PartitionUnion_8 | 80.00 | root | | | | ├─TableReader_10 | 40.00 | root | | data :TableRangeScan_9 | | │ └─TableRangeScan_9 | 40.00 | cop [ tikv ] | table :t1 , partition :p0 | range: [ 80 , 120 ] , keep order : false , stats:pseudo | | └─TableReader_12 | 40.00 | root | | data :TableRangeScan_11 | | └─TableRangeScan_11 | 40.00 | cop [ tikv ] | table :t1 , partition :p1 | range: [ 80 , 120 ] , keep order : false , stats:pseudo | + ----------------------------+---------+-----------+------------------------+------------------------------------------------+ 5 rows in set ( 0.00 sec )

分区裁剪的使用场景

分区表有 Range 分区和 hash 分区两种形式,分区裁剪对两种分区表也有不同的使用场景。

分区裁剪在 Hash 分区表上的应用

Hash 分区表上可以使用分区裁剪的场景

只有等值比较的查询条件能够支持 Hash 分区表的裁剪。
create table t ( x int ) partition by hash ( x ) partitions 4 ; explain select * from t where x = 1 ;
+ -------------------------+----------+-----------+-----------------------+--------------------------------+ | id | estRows | task | access object | operator info | + -------------------------+----------+-----------+-----------------------+--------------------------------+ | TableReader_8 | 10.00 | root | | data :Selection_7 | | └─Selection_7 | 10.00 | cop [ tikv ] | | eq ( test . t . x , 1 ) | | └─TableFullScan_6 | 10000.00 | cop [ tikv ] | table :t , partition :p1 | keep order : false , stats:pseudo | + -------------------------+----------+-----------+-----------------------+--------------------------------+
在这条 SQL 中,由条件  x = 1  可以知道所有结果均在一个分区上。数值  1  在经过 Hash 后,可以确定其在分区  p1  中。因此只需要扫描分区  p1  ,而无需访问一定不会出现相关结果的  p2  、 p3  、 p4  分区。从执行计划来看,其中只出现了一个  TableFullScan  算子,且在  access object  中指定了  p1  分区,确认  partition pruning  生效了。

Hash 分区表上不能使用分区裁剪的场景

场景一

不能确定查询结果只在一个分区上的条件:如  in ,  between ,  > < >= <=  等查询条件,不能使用分区裁剪的优化。
create table t ( x int ) partition by hash ( x ) partitions 4 ; explain select * from t where x > 2 ;
+ ------------------------------+----------+-----------+-----------------------+--------------------------------+ | id | estRows | task | access object | operator info | + ------------------------------+----------+-----------+-----------------------+--------------------------------+ | Union_10 | 13333.33 | root | | | | ├─TableReader_13 | 3333.33 | root | | data :Selection_12 | | │ └─Selection_12 | 3333.33 | cop [ tikv ] | | gt ( test . t . x , 2 ) | | │ └─TableFullScan_11 | 10000.00 | cop [ tikv ] | table :t , partition :p0 | keep order : false , stats:pseudo | | ├─TableReader_16 | 3333.33 | root | | data :Selection_15 | | │ └─Selection_15 | 3333.33 | cop [ tikv ] | | gt ( test . t . x , 2 ) | | │ └─TableFullScan_14 | 10000.00 | cop [ tikv ] | table :t , partition :p1 | keep order : false , stats:pseudo | | ├─TableReader_19 | 3333.33 | root | | data :Selection_18 | | │ └─Selection_18 | 3333.33 | cop [ tikv ] | | gt ( test . t . x , 2 ) | | │ └─TableFullScan_17 | 10000.00 | cop [ tikv ] | table :t , partition :p2 | keep order : false , stats:pseudo | | └─TableReader_22 | 3333.33 | root | | data :Selection_21 | | └─Selection_21 | 3333.33 | cop [ tikv ] | | gt ( test . t . x , 2 ) | | └─TableFullScan_20 | 10000.00 | cop [ tikv ] | table :t , partition :p3 | keep order : false , stats:pseudo | + ------------------------------+----------+-----------+-----------------------+--------------------------------+
在这条 SQL 中, x > 2  条件无法确定对应的 Hash Partition,所以不能使用分区裁剪。

场景二

由于分区裁剪的规则优化是在查询计划的生成阶段,对于执行阶段才能获取到过滤条件的场景,无法利用分区裁剪的优化。
create table t ( x int ) partition by hash ( x ) partitions 4 ; explain select * from t2 where x = ( select * from t1 where t2 . x = t1 . x and t2 . x < 2 ) ;
+ --------------------------------------+----------+-----------+------------------------+----------------------------------------------+ | id | estRows | task | access object | operator info | + --------------------------------------+----------+-----------+------------------------+----------------------------------------------+ | Projection_13 | 9990.00 | root | | test . t2 . x | | └─Apply_15 | 9990.00 | root | | inner join , equal: [ eq ( test . t2 . x , test . t1 . x ) ] | | ├─TableReader_18 ( Build ) | 9990.00 | root | | data :Selection_17 | | │ └─Selection_17 | 9990.00 | cop [ tikv ] | | not ( isnull ( test . t2 . x ) ) | | │ └─TableFullScan_16 | 10000.00 | cop [ tikv ] | table :t2 | keep order : false , stats:pseudo | | └─Selection_19 ( Probe ) | 0.80 | root | | not ( isnull ( test . t1 . x ) ) | | └─MaxOneRow_20 | 1.00 | root | | | | └─Union_21 | 2.00 | root | | | | ├─TableReader_24 | 2.00 | root | | data :Selection_23 | | │ └─Selection_23 | 2.00 | cop [ tikv ] | | eq ( test . t2 . x , test . t1 . x ) , lt ( test . t2 . x , 2 ) | | │ └─TableFullScan_22 | 2500.00 | cop [ tikv ] | table :t1 , partition :p0 | keep order : false , stats:pseudo | | └─TableReader_27 | 2.00 | root | | data :Selection_26 | | └─Selection_26 | 2.00 | cop [ tikv ] | | eq ( test . t2 . x , test . t1 . x ) , lt ( test . t2 . x , 2 ) | | └─TableFullScan_25 | 2500.00 | cop [ tikv ] | table :t1 , partition :p1 | keep order : false , stats:pseudo | + --------------------------------------+----------+-----------+------------------------+----------------------------------------------+
这个查询每从  t2  读取一行,都会去分区表  t1  上进行查询,理论上这时会满足  t1.x = val  的过滤条件,但实际上由于分区裁剪只作用于查询计划生成阶段,而不是执行阶段,因而不会做裁剪。

分区裁剪在 Range 分区表上的应用

Range 分区表上可以使用分区裁剪的场景

场景一

等值比较的查询条件可以使用分区裁剪。
create table t ( x int ) partition by range ( x ) (
partition p0 values less than ( 5 ) ,
partition p1 values less than ( 10 ) ,
partition p2 values less than ( 15 )
) ; explain select * from t where x = 3 ;
+ -------------------------+----------+-----------+-----------------------+--------------------------------+ | id | estRows | task | access object | operator info | + -------------------------+----------+-----------+-----------------------+--------------------------------+ | TableReader_8 | 10.00 | root | | data :Selection_7 | | └─Selection_7 | 10.00 | cop [ tikv ] | | eq ( test . t . x , 3 ) | | └─TableFullScan_6 | 10000.00 | cop [ tikv ] | table :t , partition :p0 | keep order : false , stats:pseudo | + -------------------------+----------+-----------+-----------------------+--------------------------------+
使用  in  条件的等值比较查询条件也可以使用分区裁剪。
create table t ( x int ) partition by range ( x ) (
partition p0 values less than ( 5 ) ,
partition p1 values less than ( 10 ) ,
partition p2 values less than ( 15 )
) ; explain select * from t where x in ( 1 , 13 ) ;
+ -----------------------------+----------+-----------+-----------------------+--------------------------------+ | id | estRows | task | access object | operator info | + -----------------------------+----------+-----------+-----------------------+--------------------------------+ | Union_8 | 40.00 | root | | | | ├─TableReader_11 | 20.00 | root | | data :Selection_10 | | │ └─Selection_10 | 20.00 | cop [ tikv ] | | in ( test . t . x , 1 , 13 ) | | │ └─TableFullScan_9 | 10000.00 | cop [ tikv ] | table :t , partition :p0 | keep order : false , stats:pseudo | | └─TableReader_14 | 20.00 | root | | data :Selection_13 | | └─Selection_13 | 20.00 | cop [ tikv ] | | in ( test . t . x , 1 , 13 ) | | └─TableFullScan_12 | 10000.00 | cop [ tikv ] | table :t , partition :p2 | keep order : false , stats:pseudo | + -----------------------------+----------+-----------+-----------------------+--------------------------------+
在这条 SQL 中,由条件  x in(1,13)  可以知道所有结果只会分布在几个分区上。经过分析,发现所有  x = 1  的记录都在分区  p0  上, 所有  x = 13  的记录都在分区  p2  上,因此只需要访问  p0 、 p2  这两个分区,

场景二

区间比较的查询条件如  between ,  > < = >= <=  可以使用分区裁剪。
create table t ( x int ) partition by range ( x ) (
partition p0 values less than ( 5 ) ,
partition p1 values less than ( 10 ) ,
partition p2 values less than ( 15 )
) ; explain select * from t where x between 7 and 14 ;
+ -----------------------------+----------+-----------+-----------------------+-----------------------------------+ | id | estRows | task | access object | operator info | + -----------------------------+----------+-----------+-----------------------+-----------------------------------+ | Union_8 | 500.00 | root | | | | ├─TableReader_11 | 250.00 | root | | data :Selection_10 | | │ └─Selection_10 | 250.00 | cop [ tikv ] | | ge ( test . t . x , 7 ) , le ( test . t . x , 14 ) | | │ └─TableFullScan_9 | 10000.00 | cop [ tikv ] | table :t , partition :p1 | keep order : false , stats:pseudo | | └─TableReader_14 | 250.00 | root | | data :Selection_13 | | └─Selection_13 | 250.00 | cop [ tikv ] | | ge ( test . t . x , 7 ) , le ( test . t . x , 14 ) | | └─TableFullScan_12 | 10000.00 | cop [ tikv ] | table :t , partition :p2 | keep order : false , stats:pseudo | + -----------------------------+----------+-----------+-----------------------+-----------------------------------+

场景三

分区表达式为  fn(col)  的简单形式,查询条件是  >  <  =  >=  <=  之一,且  fn  是单调函数,可以使用分区裁剪。
关于  fn  函数,对于任意  x  y ,如果  x > y ,则  fn(x) > fn(y) ,那么这种是严格递增的单调函数。非严格递增的单调函数也可以符合分区裁剪要求,只要函数  fn  满足:对于任意  x  y ,如果  x > y ,则  fn(x) >= fn(y) 。理论上,所有满足单调条件(严格或者非严格)的函数都支持分区裁剪。目前,TiDB 支持的单调函数如下:
unix_timestamp
to_days
例如,分区表达式是  fn(col)  形式, fn  为我们支持的单调函数  to_days ,就可以使用分区裁剪:
create table t ( id datetime ) partition by range ( to_days ( id ) ) (
partition p0 values less than ( to_days ( '2020-04-01' ) ) ,
partition p1 values less than ( to_days ( '2020-05-01' ) ) ) ; explain select * from t where id > '2020-04-18' ;
+ -------------------------+----------+-----------+-----------------------+-------------------------------------------+ | id | estRows | task | access object | operator info | + -------------------------+----------+-----------+-----------------------+-------------------------------------------+ | TableReader_8 | 3333.33 | root | | data :Selection_7 | | └─Selection_7 | 3333.33 | cop [ tikv ] | | gt ( test . t . id , 2020 - 04 - 18 00 : 00 : 00.000000 ) | | └─TableFullScan_6 | 10000.00 | cop [ tikv ] | table :t , partition :p1 | keep order : false , stats:pseudo | + -------------------------+----------+-----------+-----------------------+-------------------------------------------+

Range 分区表上不能使用分区裁剪的场景

由于分区裁剪的规则优化是在查询计划的生成阶段,对于执行阶段才能获取到过滤条件的场景,无法利用分区裁剪的优化。
create table t1 ( x int ) partition by range ( x ) (
partition p0 values less than ( 5 ) ,
partition p1 values less than ( 10 ) ) ; create table t2 ( x int ) ; explain select * from t2 where x < ( select * from t1 where t2 . x < t1 . x and t2 . x < 2 ) ;
+ --------------------------------------+----------+-----------+------------------------+-----------------------------------------------------------+ | id | estRows | task | access object | operator info | + --------------------------------------+----------+-----------+------------------------+-----------------------------------------------------------+ | Projection_13 | 9990.00 | root | | test . t2 . x | | └─Apply_15 | 9990.00 | root | | CARTESIAN inner join , other cond:lt ( test . t2 . x , test . t1 . x ) | | ├─TableReader_18 ( Build ) | 9990.00 | root | | data :Selection_17 | | │ └─Selection_17 | 9990.00 | cop [ tikv ] | | not ( isnull ( test . t2 . x ) ) | | │ └─TableFullScan_16 | 10000.00 | cop [ tikv ] | table :t2 | keep order : false , stats:pseudo | | └─Selection_19 ( Probe ) | 0.80 | root | | not ( isnull ( test . t1 . x ) ) | | └─MaxOneRow_20 | 1.00 | root | | | | └─Union_21 | 2.00 | root | | | | ├─TableReader_24 | 2.00 | root | | data :Selection_23 | | │ └─Selection_23 | 2.00 | cop [ tikv ] | | lt ( test . t2 . x , 2 ) , lt ( test . t2 . x , test . t1 . x ) | | │ └─TableFullScan_22 | 2.50 | cop [ tikv ] | table :t1 , partition :p0 | keep order : false , stats:pseudo | | └─TableReader_27 | 2.00 | root | | data :Selection_26 | | └─Selection_26 | 2.00 | cop [ tikv ] | | lt ( test . t2 . x , 2 ) , lt ( test . t2 . x , test . t1 . x ) | | └─TableFullScan_25 | 2.50 | cop [ tikv ] | table :t1 , partition :p1 | keep order : false , stats:pseudo | + --------------------------------------+----------+-----------+------------------------+-----------------------------------------------------------+ 14 rows in set ( 0.00 sec )
这个查询每从  t2  读取一行,都会去分区表  t1  上进行查询,理论上这时会满足  t1.x > val  的过滤条件,但实际上由于分区裁剪只作用于查询计划生成阶段,而不是执行阶段,因而不会做裁剪。
 
 

2.7TopN 和 Limit 下推

SQL 中的 LIMIT 子句在 TiDB 查询计划树中对应 Limit 算子节点,ORDER BY 子句在查询计划树中对应 Sort 算子节点,此外,我们会将相邻的 Limit 和 Sort 算子组合成 TopN 算子节点,表示按某个排序规则提取记录的前 N 项。从另一方面来说,Limit 节点等价于一个排序规则为空的 TopN 节点。
和谓词下推类似,TopN(及 Limit,下同)下推将查询计划树中的 TopN 计算尽可能下推到距离数据源最近的地方,以尽早完成数据的过滤,进而显著地减少数据传输或计算的开销。
如果要关闭这个规则,可参照 优化规则及表达式下推的黑名单 中的关闭方法。

示例

以下通过一些例子对 TopN 下推进行说明。

示例 1:下推到存储层 Coprocessor

create table t ( id int primary key , a int not null ) ; explain select * from t order by a limit 10 ;
+----------------------------+----------+-----------+---------------+--------------------------------+
| id | estRows | task | access object | operator info |
+----------------------------+----------+-----------+---------------+--------------------------------+
| TopN_7 | 10.00 | root | | test.t.a, offset:0, count:10 |
| └─TableReader_15 | 10.00 | root | | data:TopN_14 |
| └─TopN_14 | 10.00 | cop[tikv] | | test.t.a, offset:0, count:10 |
| └─TableFullScan_13 | 10000.00 | cop[tikv] | table:t | keep order:false, stats:pseudo |
+----------------------------+----------+-----------+---------------+--------------------------------+
4 rows in set (0.00 sec)
在该查询中,将 TopN 算子节点下推到 TiKV 上对数据进行过滤,每个 Coprocessor 只向 TiDB 传输 10 条记录。在 TiDB 将数据整合后,再进行最终的过滤。

示例 2:TopN 下推过 Join 的情况(排序规则仅依赖于外表中的列)

create table t ( id int primary key , a int not null ) ; create table s ( id int primary key , a int not null ) ; explain select * from t left join s on t . a = s . a order by t . a limit 10 ;
+----------------------------------+----------+-----------+---------------+-------------------------------------------------+
| id | estRows | task | access object | operator info |
+----------------------------------+----------+-----------+---------------+-------------------------------------------------+
| TopN_12 | 10.00 | root | | test.t.a, offset:0, count:10 |
| └─HashJoin_17 | 12.50 | root | | left outer join, equal:[eq(test.t.a, test.s.a)] |
| ├─TopN_18(Build) | 10.00 | root | | test.t.a, offset:0, count:10 |
| │ └─TableReader_26 | 10.00 | root | | data:TopN_25 |
| │ └─TopN_25 | 10.00 | cop[tikv] | | test.t.a, offset:0, count:10 |
| │ └─TableFullScan_24 | 10000.00 | cop[tikv] | table:t | keep order:false, stats:pseudo |
| └─TableReader_30(Probe) | 10000.00 | root | | data:TableFullScan_29 |
| └─TableFullScan_29 | 10000.00 | cop[tikv] | table:s | keep order:false, stats:pseudo |
+----------------------------------+----------+-----------+---------------+-------------------------------------------------+
8 rows in set (0.01 sec)
在该查询中,TopN 算子的排序规则仅依赖于外表 t 中的列,可以将 TopN 下推到 Join 之前进行一次计算,以减少 Join 时的计算开销。除此之外,TiDB 同样将 TopN 下推到了存储层中。

示例 3:TopN 不能下推过 Join 的情况

create table t ( id int primary key , a int not null ) ; create table s ( id int primary key , a int not null ) ; explain select * from t join s on t . a = s . a order by t . id limit 10 ;
+-------------------------------+----------+-----------+---------------+--------------------------------------------+
| id | estRows | task | access object | operator info |
+-------------------------------+----------+-----------+---------------+--------------------------------------------+
| TopN_12 | 10.00 | root | | test.t.id, offset:0, count:10 |
| └─HashJoin_16 | 12500.00 | root | | inner join, equal:[eq(test.t.a, test.s.a)] |
| ├─TableReader_21(Build) | 10000.00 | root | | data:TableFullScan_20 |
| │ └─TableFullScan_20 | 10000.00 | cop[tikv] | table:s | keep order:false, stats:pseudo |
| └─TableReader_19(Probe) | 10000.00 | root | | data:TableFullScan_18 |
| └─TableFullScan_18 | 10000.00 | cop[tikv] | table:t | keep order:false, stats:pseudo |
+-------------------------------+----------+-----------+---------------+--------------------------------------------+
6 rows in set (0.00 sec)
TopN 无法下推过 Inner Join。以上面的查询为例,如果先 Join 得到 100 条记录,再做 TopN 可以剩余 10 条记录。而如果在 TopN 之前就过滤到剩余 10 条记录,做完 Join 之后可能就剩下 5 条了,导致了结果的差异。
同理,TopN 无法下推到 Outer Join 的内表上。在 TopN 的排序规则涉及多张表上的列时,也无法下推,如  t.a+s.a 。只有当 TopN 的排序规则仅依赖于外表上的列时,才可以下推。

示例 4:TopN 转换成 Limit 的情况

create table t ( id int primary key , a int not null ) ; create table s ( id int primary key , a int not null ) ; explain select * from t left join s on t . a = s . a order by t . id limit 10 ;
+----------------------------------+----------+-----------+---------------+-------------------------------------------------+
| id | estRows | task | access object | operator info |
+----------------------------------+----------+-----------+---------------+-------------------------------------------------+
| TopN_12 | 10.00 | root | | test.t.id, offset:0, count:10 |
| └─HashJoin_17 | 12.50 | root | | left outer join, equal:[eq(test.t.a, test.s.a)] |
| ├─Limit_21(Build) | 10.00 | root | | offset:0, count:10 |
| │ └─TableReader_31 | 10.00 | root | | data:Limit_30 |
| │ └─Limit_30 | 10.00 | cop[tikv] | | offset:0, count:10 |
| │ └─TableFullScan_29 | 10.00 | cop[tikv] | table:t | keep order:true, stats:pseudo |
| └─TableReader_35(Probe) | 10000.00 | root | | data:TableFullScan_34 |
| └─TableFullScan_34 | 10000.00 | cop[tikv] | table:s | keep order:false, stats:pseudo |
+----------------------------------+----------+-----------+---------------+-------------------------------------------------+
8 rows in set (0.00 sec)
在上面的查询中,TopN 首先推到了外表 t 上。然后因为它要对  t.id  进行排序,而  t.id  是表 t 的主键,可以直接按顺序读出 ( keep order:true ),从而省略了 TopN 中的排序,将其简化为 Limit。
 
 

2.8Join Reorder

在实际的业务场景中,多个表的 Join 语句是很常见的,而 Join 的执行效率和各个表参与 Join 的顺序有关系。如  select * from t1, t2, t3 where t1.a=t2.a and t3.a=t2.a ,这个 SQL 中可能的执行顺序有“t1 和 t2 先做 Join,然后再和 t3 做 Join”以及“t2 和 t3 先做 Join,然后再和 t1 做 Join”两种情况。根据  t1  和  t3  的数据量及数据分布,这两种执行顺序会有不同的性能表现。
因此优化器需要实现一种决定 Join 顺序的算法。目前 TiDB 中使用的算法是 Join Reorder 算法,又称贪心算法。

Join Reorder 算法实例

以三个表 t1、t2、t3 的 Join 为例。首先获取所有参与 Join 的节点,将所有节点按照行数多少,从少到多进行排序。
之后选定其中最小的表,将其与其他两个表分别做一次 Join,观察输出的结果集大小,选择其中结果更小的一对。
然后进入下一轮的选择,如果这时是四个表,那么就继续比较输出结果集的大小,进行选择。这里只有三个表,因此就直接得到了最终的 Join 结果。
以上就是当前 TiDB 中使用的 Join reorder 算法。

Join reorder 算法限制

当前的 Join Reorder 算法存在如下限制
  • 目前并不支持 Outer Join 的 Join Reorder
  • 受结果集的计算算法所限并不会保证一定会选到合适的 Join order
目前 TiDB 中支持使用  STRAIGHT_JOIN  语法来强制指定一种 Join 顺序,参见 语法元素说明
 
 
 
 
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值