join 子查询 效率_Calcite 子查询处理 - I (RemoveSubQuery)

之前文章的中介绍 SQL 执行流程中没有关注子查询相关的处理,这篇文章中我们回来看一下查询优化中的经典问题---子查询处理在 Calcite 中的实现。

本文主要先介绍了下子查询从 AST 到 rel 并被消除即 SubQueryRemove 的过程(转换成 Correlate 或 Join), 下一篇会继续介绍子查询去关联 Decorrelate 的实现(本来打算一篇发现太长了)

SQL 子查询

在编写复杂 SQL 的时候,有时通过子查询比直接写 join 清晰些,一般子查询可以被分为关联子查询和非关联子查询。

  • 关联子查询: 子查询中引用了外层查询的列数据,执行时外层查询的每一行都需要重新跑一遍子查询
  • 非关联子查询: 子查询是独立的,执行时子查询只需要一次求值,外查询可以复用

关联就是 correlation,非关联子查询只需要执行一次还好,但对于关联子查询每个外层结果重新计算子查询结果很低效, 现代的几乎所有数据库都会尝试将关联子查询优化执行。

SubQuery AST

我们看下子查询会在一个 SQL 中可能出现的位置:

  • FROM 后面或 Join 做参与表
  • JOIN 条件中
  • Projection
  • Filter 条件中
  • Group 条件中
  • ......

首先,对于在 FROM 或者 JOIN 中的 join 是作为存在的情况,子查询无法引用外部的变量所以会被直接 Convert(只有一个表, 直接向下接子查询的root)或被 Convert 成 Join(From 或 Join 多个表, 转换为 Join 下接子查询的 root) 。

而其他几种情况, 本质上子查询都是位于 Project 或 判断 Condtion 中的表达式存在, 表达式有可能和外部查询有关联也可能和外面没有关联, 对于表达式中的子查询根据操作符的不同又有几种写法, 直接看几个例子

  • Exists/Not exists: select * from emps e where exists (select 1 from depts d where e.deptno = d.deptno and d.name = 'Sales'), 子查询主要检查条件存在性(或不存在性), 可以放任何有 bool 的地方, 其实就是 semi-join(anti-semi-join)
  • In/Not In/Some/ALL: select * from emps e where (e.deptno, 1) in (select d.deptno, 1 from depts d where d.deptno = e.deptno) (这里演示了 row constructor 的用法), select * from emps e where e.deptno > some (select 1 from depts d limit 1) 子查询返回集合结果,并做集合比较, 同样可以用在任何需要 bool 的地方
  • ScalarSubQuery: select * from emps e where e.deptno = (select d.deptno from depts d where d.name = 'Sales'), 只能出现在要求子查询只能返回一列且最多一行结果, 对于表达式 scalar 的支持可以看下 argumentMustBeScalar
  • Cursor: select * from * TABLE(udx(CURSOR(select ...), x, y, z))
  • Multiset: multiset(select ...)

对于前面 2 种类型的子查询表达式 Calcite 会在 parse 阶段生成对应的 operator 并标记相应的Kind; 而 ScalarSubQuery 比较特殊,会在 SqlNode 转 Rel 开始的 validate 阶段做在转 rel 之前是识别出叫 SCALAR_QUERY 的特殊 internal operator 并指定 Kind 为 SCALAR_QUERY;这样在之后可以通过上 Kind 识别出这些子查询的特征(Cusor, multiset 我们后面再看)。

Expand Subquery Option

Calcite 目前看子查询有两种选择,通过 isExpand() 来决定是否在 convert 阶段展开,目前默认 false(因为这个issue没解决):

  • 如果为 false(当前默认) 则在 sqlNode -> rel, 不会对子查询节点展开,通过kind找到的子查询节点会等价的 convert 成 RexSubQuery Rex表达式, 并等到后面 planner 中再用 rule 将表达式中的子查询提出来并优化
  • 如果为 true 则在 sqlNode -> rel 就会对子查询进行展开生成 LogicalCorrelate,在执行完 convert 后 setRoot 到 planner 之前用 RelDecorrelator 进行优化再进入 Planner 阶段

本文主要关注的 isExpand = false, 也就是目前 calcite 默认的子查询处理方式, 对于 isExpand = true 的情况后面有机会再来介绍~,所以下面我们都假设 isExpand = false

SubQuery SqlNode To RelSubQuery

因为 isExpand 为 false, 在转换 Rel 的时候只会生成 RexSubQuery 作为表达式并不会进行去关联处理, 所以在 SqlToRelConverter 的各种 convertXX 方法在在调用到 convertExpression 会进入到这个判断, 首先会对 子查询的内容先递归转换成 rel,之后用这个 rel 根据不同 kind 处理

  • 集合操作(IN/NOT_IN/SOME/ALL): 之后就是调用 RexSubQuery 的 in 和 some 并组合 not, 来生成相应属性的 RexSubQuery, 子查询内容 rel 作为 child(对于集合操作一个 in 后面可能有多个子查询, 另外还有 row constructor )
  • 存在检查操作(EXISTS): 也是简单的调用 RexSubQuery 的 exists 创建 RexSubQuery, 但这里会把子查询最上面的没用的 project 和 limit 去掉(这些不影响存在判断的结果所以没用)
  • 标量子查询(SCALAR_QUERY): 用子查询内容 rel 做 child 生成 scalar 的 RexSubQuery

生成的 RexSubQuery 中对外部变量的引用会被修改为引用 variable 的方式,举个例子:

select * from emps e where e.deptno = (select d.deptno from depts d where d.deptno = e.deptno)

转换成 rel 后类似这样:(通过 hep 的日志输出会包 HepRelVertex 不不影响逻辑)

Breadth-first from root:  {
    HepRelVertex#12 = rel#11:LogicalProject.NONE.[](input=HepRelVertex#10....), rowcount=15.0, cumulative cost={..}
    HepRelVertex#10 = rel#9:LogicalFilter.NONE.[](input=HepRelVertex#8,condition==($2, $SCALAR_QUERY({
LogicalProject(DEPTNO=[$0])
  LogicalFilter(condition=[=($0, $cor0.DEPTNO)])
    CsvTableScan(table=[[SALES, DEPTS]], fields=[[0, 1]])
})),variablesSet=[$cor0]), rowcount=15.0, cumulative cost={115.0 rows, 201.0 cpu, 0.0 io}
    HepRelVertex#8 = rel#0:CsvTableScan.ENUMERABLE.[](table=[SALES, EMPS],fields=[0,..9]), rowcount=100.0, cumulative cost={..}
}

可以看到 rel#9 的 filter 的 condition 是一个 scalar_query 的 RexSubQuery, 它引用外层 query 使用的是 $cor0.DEPTNO, 并且 filter 的 variablesSet 中可以看到 variablesSet 属性中有 $cor0 标记,除了 filter 对于 join 和 filter 也会做类似的处理。 这个的实现过程是:

  • 在 convertIdentifier 过程中做 lookupExp 时发现标识符引用了其他 resolve scope 的变量时
  • 会将新建 correlateId 到原引用 rel 的名字(e.g. 这里的 e)的 lookup 的映射并保存到 converter 的 mapCorrelToDeferred
  • 之后生 RexCorrelateVariable 并最终对列引用生成 RexFieldAccess 而不是普通的 RexInputRef
  • 在 convertWhere 生成 LogicFilter 之前会 getCorrelationUse 查找第二步保存的 mapCorrelToDeferred 来生成 CorrelationUse 并保存到 LogicFilter 中

后面的规则处理会用这些关联变量信息来识别出子查询中管理外部查询的引用列。

Three-values logic & Simplify

在后续子查询优化中,对于是三值逻辑中 Unknow (也就是 nil 或 结果) 的处理会决定我们能否使用某些优化, 这里介绍下三值逻辑处理和表达式 simplify (其实这部分对于后续 join 等其他优化也有用, 前面的文章没细看这部分这里补充下~)

对于一整颗 Rex/Rel 树, 他最终是否有用三值逻辑,由以下因素决定:

  1. 一开始我们可以假设所有 rel 开始都是三值(true/false/unknown)
  2. 在 convertExpression 创建 rex 时会推断 type, 如果当表定义有非空字段可以自底向上推断出引用这些字段 type 的 null 属性, 所以查看 rel 的 getType 如果 isNullable 则只能 TRUE_FALSE 两值
  3. 另外除了自底向下能推断出 rel 的 nullable 外, 上层算子会向下提供一些上下文约束(e.g. 比如 filter 是在一个 where 过滤中, 过滤表达式的 rex 都可以以 unknown as false 来处理),calcite 中是通过在 simplify 的 RexUnknownAs 参数的传值来向下提供上下文,这样 simplify 就根据上层提供上下文做更多消除(比如上下文是 unknown as false 下游返回了消除后返回 null 则可以直接转换成 false)
  4. 对于自身就是逻辑运算符的的 operator (e.g. and, or, not, is not true , is null, >, < 等) 会在 parse 成 ast 的阶段直接生成有 kind 的 operator, 并在 simplify 根据不同步 kind 做相应的逻辑转换处理
  5. 除了逻辑组合运算符,有一些运算符在定义上有明确的 null 输出属性,calcite 使用 Strong#Policy 来描述 operator 的 null-rejecting, 维护了一个不同 kind -> policy 的映射,所以 simplify 会获取当前的 kind 的 policy 来尝试判断运算符结果 isNull 或 isNotTrue (e.g. (exists select ...) is null 永远为 false, 因为 exists 的 policy 是 not_null)

所以在转换 sqlNode 为 rel 的过程中, convertExpression 函数会推断并维护 RelReturnType 和其中的 nullable 属性,并在 build rex 的过程中根据不同上下文用不同的 RexUnknownsAs 来调用 RexSimplify#simplify 来根据: 上下文,类型推断的nullable,根据 operator 的 kind 根据组合类型进行不同的递归处理, 考虑 operator 的 strong 属性, 这样就可以在 simplify 阶段消除无用的条件简化三值逻辑。

在 simplify 后理论上会将可以简化短路的 IS_TRUE, IS_FALSE 向上提升和简化,在 expand = false 的情况下, 后面我们会看到在消除规则执行阶段会用 LogicVisitor.find 方法寻找一个更少可能性的 logic(比如不需要判断 unknown), LogicVistitor 的实现依赖于结合上下文使用上面提到的 simplify 提升后的结果进行推导, 他仅仅查找 IS_XXX 和逻辑组合运算符(and, case) 和 RelReturnType 的 isNullable 情况。

SubQuery remove

到这里我们已经得到一颗 rel 树, 其中一些节点的表达式是代表子查询的 RexSubQuery, 另外已经完成对 rel 树和相关表达式的类型推导和逻辑简化, 接下来需要进行的是将 RexSubQuery 从表达式中提出来并转换成 join(准确说是 correlate 一种特殊的 join), 因为子查询在表达式中执行如前面所说每一个记录 eval 一次效率很低,另外在 Rex 中我们的各种对 Rel 的优化 rule 没法用上,所以先做一次 SubQuery remove。

在 isExpand = false 时去关联是用 Program#standard 中的前两个 Hep Program 做去关联处理。

第一个 Program 正是做 SubQuery Remove 的,他会用 HepPlanner 一组执行三个 SubQueryRemoveRule 分别针对 Filter, Project 和 Join 的 SubQuery 做 remove。

I. 整体流程

SubQueryFilterRemoveRule 为例, 对 condition 表达式中 SubQueryFinder::containsSubQuery 的 filter 算子会进入 onMatch 进行转换,这个过程过程利用 RelBuilder(对于 builder 可以理解为就是一个帮助构建的 stack):

  1. 将 filter 下的 input 算子先原样放入 builder
  2. 找到 filter condition 中的 RexSubQuery(实现又是一个 visitor)
  3. 使用 LogicVistor.find 获取子查询表达式的三值 logic(实现是 visit 找 kind 为 IS_TRUE/IS_NULL... 的 rex 前面介绍过~)
  4. 获取子查询中引用外层的 correlate variable(实现是 visit 找 RexFieldAccess/RexCorrelateVariable)
  5. 调用 apply 其中会根据 kind 做分发,以 scalar 为例, 会调用 rewriteScalarQuery
  6. rewriteScalarQuery 首先将子查询的 rel 推入 builder
  7. 查看刚才推入的子查询(peek)是不是 unique 的, 如果不是则 pop 在子查询上加一个 SINGLE_VALUE 的 agg
  8. 然后用一个 left 的 correlate 对 stack 顶的 agg 和 开始 filter 下的rel 做 join, 并返回用于代替 RexSubQuery 的 rel(一般比较是join后另一个表的列)
  9. 用上一步返回的代替 rel 用 ReplaceSubQueryShuttle 做 visit 并替换子查询表达式为新 rel (比如 join 后对另一个表的比较),将新 filter 推入 builder
  10. 之后需要再用一个 project 在 filter 之后做对 join 结果列限制为开始的输出列(这个子查询可能不是必须但后续会有别的规则消除)
  11. 最后就是 builder 生成新的 rel 并用 hep 的 transform 对原来的 filter 做替换.

对于位于其他两个位置的子查询表达式, 处理整体流程也类似:

  • 对于 project 中子查询: LogicVisitor.find 用的是 TRUE_FALSE_UNKNOWN, 且不关心 correlateVariable(?),最后在 join 结果上做 project
  • 对于 join 条件中子查询: LogicVisitor.find 和 filter 一样用的是 TRUE, 也是先将子算子(左右节点入 stack), 然后右节点先和子查询join,最后再和原来左节点 join 并加一个 project

II . Apply

对于查找 RexSubQuery 和 判断 Logic 前面有介绍, 这个 remove 中比较复杂的是 apply 调用, 会根据不同的在查询条件(scalar,some, in, exists) 做不同处理, 这里我们详细看下~

1) Rewrite some to join

首先看下 some 的 apply,比如目前有这样一个 sql

select e.deptno, e.deptno < some (select deptno from emp) as v from emp as e

这是一个在 project 中 < some 子查询没有关联变量, 在 apply 后因为 kind 为 some 直接进入 rewriteSome 方法处理。

如前面基本流程中介绍的, apply 过程主要依赖类似 stack 的 RelBuilder

  • 首先在 apply 外面已经将 project 下的算子推到 builder了, 所以开始先将子查询 (select depno from emp[project-tablescan]) 推入 builder
  • 添加添加 agg 算子执行 3 个 func: 如果是 >>= 添加 min(deptno) 否则 max(deptno), count(*)count(deptno)
  • 然后将 project 的 input 和添加了 agg 的子查询做没有过滤条件的 inner join(其实就是 cross join), 对于集合比较 some 操作没有关联变量会直接调用 joinFactory生成 LogicalJoin 算子.
  • 然后添加一个 生成一个 case 表达式作为 apply 返回(rule 会用 apply 返回去替换 rexSubQuery )
case
when count(*) = 0 then false // 子查询为空一定 false
when (e.deptno < max(depno)) is true then true // 比子查询最大值小则返回 true
when count(*) > count(depno) then unknown // 子查询有一个 null(unknown) 则 unknow
else e.deptno < max(depno) 
end as v

总结下: 对于 some 会对子查询生成一个 agg 计算 max/min(根据比较类型), count(*), count(col) (用于寻找空值), 之后再和原算子的 input 做 inner join, 并生根据 max/min/count 的比较表达式来作为比较结果。

2) rewrite scalar query to join

如果是 scalar 子查询,apply 处理位于 rewriteScalarQuery, 前面流程介绍部分提到过:

  • 根据比较列是否是 unique 决定是否先给子查询加一个 SINGLE_VALUE (如果返回多个记录就报错的func) 的 agg
  • 之后生成 Left join, 如果是关联子查询(有关联变量),这里会调动 correlateFactory 生成 LogicalCorrelate , 一种每次设置变量然后重新执行右 input 的 nested-loop join(有的地方也叫 apply, 没 on 条件), 如果是非关联子查询还是继续生成 LogicalJoin;这选择这里不做更多优化先选择 Left Join 假设可能关联子查询可能没有结果,后续会有 Join 的规则优化这个 Left Join; 另外这里的 join condition 也为 true

总结: 对于 scalar query 会转换成 left join, 如果有 correlateVariable 会被放到生成的 LogicalCorrelate 中, 子查询的 filter 继续使用 $cor,等待其他规则进一步优化

    HepRelVertex#25 = rel#24:LogicalCorrelate.NONE.[](left=HepRelVertex#8,right=HepRelVertex#23,correlation=$cor0,joinType=left,requiredColumns={2}), rowcount=1.0, cumulative cost={inf}

3) rewrite exists to join

对于 exists 存在检测子查询处理位于 rewiteExists:

  • 首先对子查询添加一个返回 true 的 project(有数据返回就变成 true)
  • 然后根据 filter condition 整体的 Logic(如果只有一个 exists 就是 True,filter 中除了 exists 还有其他条件则就不一定是 true, 需要看三值逻辑情况)
  • 如果 logical 为 true 则生成 semi-join, 生成 group 输入第一列的 LogicalAggregate, 然后带关联变量生成 condition 继续为 true 的 inner join, 返回 true 去替换 RexSubQuery
  • 如果 logical 为其他则对所有输入列做 group 生成 LogicalAggregate 然后生成 Left-Join, 并返回 `IS NOT NULL`作为替换 RexSubQuery 的表达式

总结: 对于 exists 会根据 condition logic 选择转换成 agg 一列 + inner 的 semi-join, 还是 distinct 所有输入在 left-join 最后检查 not null 的 left-join; 需要注意的是这里不会直接生成 semi-join 而是生成 inner join + agg 的逻辑上有些像 semi-join 的算子, 后面会有 rule 会在 volcano 中是不是转 semi-join 还是这样继续 apply 下去更好?

4) rewrite in to join

这部分处理逻辑位于 rewriteIn, 就以注释里的那个 sql 为例(这个方法的注释很不错)~

select e1.deptno, e1.deptno in (select e2.deptno from emps e2) from emps as e1

这部分相比别的挺复杂, 所以先流水账过一下代码(也许可以跳过)...

  • 首先检查下 in 的被比较 rel (比如上面 sql 中的 e1.deptno) 是不是 const
    • 如果是都是 const 比如上面 sql 变成 select e1.deptno, 233 in (select e2.deptno from emps e2) from emps as e1
      • 根据 in 所做 condition 的 logic, 如果是 true 或 true_false 时会在子查询上添加 filter (e2.deptno = 233) 和 true project 并 distinct
      • 如果其他 logic 有 null 则会添加可以留下 null 的 filter (e2.deptno = 233 or is null(e2.deptno)) 和 可以和判断是否为 null 的 project (is null (e2.deptno) as cs)
      • 如果没有关联变量则对上面的 project 的返回的是否为 null 列做 group by count, 并且 desc sort limit 1, 即有 true 取 true, 否则取 false; 如果有关联变量则对输出列 distinct 下
    • 如果不是 in 的不是 const, 比如上面的 sql
      • 根据 condition 的 logic, 如果是 true 则子查询的下游输出所有列做一次 distinct agg
        • 如果 logic 是 unknow_as_false 或 true_false_unknown 则先查下子查询的 count(e2.depno) as c 和 count(*) as ck 作为 ct 表
        • 根据是是否有关联变量,选择 inner 或 left 将 ct 作为 right 表先和母查询输入进行 join 将结果作为之后 join 的左表, 然后推一个子查询 rel,因为左表加了了2列,所以 offset 也 +2 下
      • 对子查询生成加一列 true, 然后对 (e1.deptno, true) 做 distinct
    • 如果 logic 是 true, 则生成 inner join, 并返回 true 去替换 rexSubQuery
    • 其他 logic 则会用 left join 连接, 并生成一个 case 多个 when 的表达式去替换 rexSubQuery
      • 如果 in 前面都是 const
        • 如果没有关联变量则添加 when count(*) is null then false(快速处理子查询结果为空)
        • 根据 logic 选择 when dt.cs is false then false 还是 then null (三值)
      • 如果 in 前面不是 const
        • 则添加 when ct.c = 0 then false(子查询结果为空)
      • 如果 in 前面的 rel 有是 nullable 的(比如 in 前面的 e1.deptno), 则添加 when e1.deptno is null then null, 如果是多个(row constructor)则每个都 is null 然后 or 连接
      • 如果 in 前都是 const 添加 when dt.cs is not null then true, 否则添加 when lastCol is not null then true
      • 最后如果不是 in 前都 const,如果 logic 是 TRUE_FALSE_UNKNOWN 添加 when count(*) > count(deptno) then null , 如果是 UNKNOWN_AS_TRUEthen true
      • 其他情况返回 false

这段逻辑相比其他几个复杂,主要是在 in 前面是否是 const, logic, 是否是关联子查询等多个因素做不同处理, 比较难表述代码 - - 所以还是看几个例子~

比如最通常的情况: in 左右两边的 rel 都可以为 null 且上下文要求 logic 是三值逻辑 TRUE_FALSE_UNKNOWN, 这个 sql

select e1.deptno, e1.deptno in (select e2.deptno from emps e2) from emps as e1

逻辑上会被转换为的这样:

select e1.deptno, 
   case
   when ct.c = 0 then false // 子查询为空
   when e1.deptno is null then null // 如果 in 左边是 unknow 任何比较都是 unknow
   when dt.i is not null then true // 如果子查询能查到记录就是则算匹配成功
   when ct.ck < ct.c then null // 如果子查询没匹配的,看下是子查询结果中有一个 unknow,如果是则 unknow
   else false // 其他情况才是真正的 false
   end
from (
   emps as e1
   cross join ( // 如果是关联子查询这里需要换成 left join
     (select count(e2.*) as c, count(e2.deptno) as ck from emps e2 // 主要为了帮助判断子查询是不是返回了null
   ) as ct
)
left join (
   select distinct e3.deptno, true as i from emps as e3
) as dt
on e1.deptno = dt.deptno // dt 的 offset = 原offset + 2

如果上下文要求的 logic 为不是 TRUE_FALSE_UNKNOWN 也不是 UNKNOWN_AS_TRUE, 即不接受 unknow (返回 null 了外面也会忽略,只要求 true/false) 则不需要做那个 count 和 cross join 可以这样

select e1.deptno, 
   case
   when dt.i is not null then true // 如果子查询能查到记录就是则算匹配成功
   else false // 其他情况都是 false
   end
from 
emps as e1   
left join (
   select distinct e3.deptno, true as i from emps as e3
) as dt
on e1.deptno = dt.deptno 

更进一步如果上下文 logic 是 TRUE (只有 true 才有用), 则可以进一步简化为 inner join

select e1.deptno, true
from 
emps as e1   
inner join (
   select distinct e3.deptno from emp as e3
) as dt
on e1.deptno = dt.deptno 

calcite 还对 in 左边是数字的情况做了一些优化, 比如这个 sql, 还是左右可以 nullable,且上下文三值逻辑 TRUE_FALSE_UNKNOW:

select e1.deptno, 3 in (select e2.deptno from emps e2) from emps as e1

会被转换为:

select e1.deptno
  case
  when dt.c is null then false // 子查询返回空结果, 如果有关联变量不要这个 when
  when dt.cs is false then null // 子查询返回不为空结果但是有 null
  when dt.cs is not null then true
  else false
  end
from emps as e1
cross join (
  select distinct e2.deptno is not null as cs, count(*) as c // 如果 logic 是 TRUE 或 TRUE_FALSE, 不需要 count 只需要 distinct e2.deptno
  from emps as e2
  where e2.deptno = 3 or e2.deptno is null // 将 3 放到子查询中过滤且保留 null, 如果 logic 是 TRUE 或 TRUE_FALSE 则没必要 or
  order by cs desc limit 1 // 如果有关联变量不需要这个, 这里 top1 主要为先找 true, 找不到再找 false(结合 distinct is not null 和  project 中的 is false)
) as dt

上面所有的 join 如果有关联变量都会生成 LogicCorrelate, 没有关联变量则 LogicalJoin。

小结: 对于 in 子查询, 如果是 in 左边不都是常量的情况,会用外表对子查询做 left join , 然后在处理左边 null, 子查询返回空等情况后找到匹配即为匹配,对于匹配不上的情况如果上下文有 unknow 则需要进一步通过获取子查询的 count(*) 和 count(col) 来确定是 unknow 还是 false; 如果 in 左边是常量,则将常量比较放到子查询中新加的 filter 中,当然为了处理 null 和关联变量会根据情况添加 is null 和 count(*) 判断。

小结

到这里通过 Program.standard 中的第一个 Program 执行上面的 SubQueryRemoveRule 已经将表达式中的 RexSubQuery remove 掉并转换成 LogicalCorrelate 或 LogicalJoin。对于有关联变量的情况,这里虽然将子查询从表达式中提出来,但关联表达式 $core 依然存在,另外上面的转换中可以看到 calcite 实现中无脑添加了很多 agg, 所以下一篇我继续学习下 Decorrelate , 再来全局看下为啥这么做哈 - -

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值