简易分布式计算系统设计 | 青训营笔记

191 阅读4分钟

这是我参与「第四届青训营 」笔记创作活动的第21天

这次继续记录一下关于项目中SQL解析与验证部分的学习笔记,这是第二部分

  • Visitor

Visitor是遍历AST的手段,是处理AST最方便的模式,Visitor是一个接口,有缺省什么都没做的实现VistorAdapter。

Druid内置提供了如下Visitor:

OutputVisitor用来把AST输出为字符串 WallVisitor 来分析SQL语意来防御SQL注入攻击 ParameterizedOutputVisitor用来合并未参数化的SQL进行统计 EvalVisitor 用来对SQL表达式求值 ExportParameterVisitor用来提取SQL中的变量参数 SchemaStatVisitor 用来统计SQL中使用的表、字段、过滤条件、排序表达式、分组表达式 SQL格式化 Druid内置了基于语义的SQL格式化功能

  • SQL Compile

SQL Compiler 处理层完成 SQL 语意检查(Preprocess)、编译执行计划(Logical Optimize、Physical Optimize) 工作,将 SQL 编译成可执行的物理执行计划。

1、 首先,从 MySQL Protocol Layer 串联起 “解析”、“执行” 操作,并在 func (s *session) ExecuteStmt(…) 中调用 func (c *Compiler) Compile(…) 进行真正的编译处理。

2、其次,在 Compile 内部调用 func Preprocess(…),进行 Preprocess 完成前置检查,如:语义检查。具体实现流程为,通过 AST 的 Accept 方法 方法, 构造一个 Vistor 实现对 AST 的遍历。 每个 Visitor 接口包含 Enter、Leave 方法,并在 Enter 或 Leave 时,依据 SQL 类型进行判断。本例 point get 会跳到 func (n *SelectStmt) Accept(v Visitor) 中,不断分支处理完成遍历。 3、最后,进入 Optimizer 处理,本例中因为是点查会越过大量优化器处理过程,直接进入 func TryFastPlan(…) 进行简单的 “权限检查” 及 “数据库名检查”。

// TryFastPlan tries to use the PointGetPlan for the query.
func TryFastPlan(ctx sessionctx.Context, node ast.Node) (p Plan) {
    ......
	case *ast.SelectStmt:
		if fp := tryPointGetPlan(ctx, x, isForUpdateReadSelectLock(x.LockInfo)); fp != nil {
			if checkFastPlanPrivilege(ctx, fp.dbName, fp.TblInfo.Name.L, mysql.SelectPriv) != nil {
				return nil
			}
			if tidbutil.IsMemDB(fp.dbName) {
				return nil
			}
			if fp.IsTableDual {
				return
			}
			p = fp
			return
		}
	}
	return nil
}

优化部分

此模块负责进行合法性检查及名字绑定、由AST生成逻辑执行计划,并基于一系列优化规则进行优化,生成物理执行计划并返回。

模块的入口为:

// executor/compiler.go
// 将AST结点转化为物理执行计划
func (c *Compiler) Compile(ctx context.Context, stmtNode ast.StmtNode) (*ExecStmt, error) {
    // 进行合法性检查及名字绑定
    infoSchema := infoschema.GetInfoSchema(c.Ctx)
    if err := plannercore.Preprocess(c.Ctx, stmtNode, infoSchema); err != nil {
        return nil, err
    }

    // 优化,生成物理执行计划
    finalPlan, names, err := planner.Optimize(ctx, c.Ctx, stmtNode, infoSchema)
    if err != nil {
        return nil, err
    }

    return &ExecStmt{
        InfoSchema:  infoSchema,
        Plan:        finalPlan,
        Text:        stmtNode.Text(),
        StmtNode:    stmtNode,
        Ctx:         c.Ctx,
        OutputNames: names,
    }, nil
}

Optimize函数中,首先由AST构造逻辑执行计划:

// planner/optimize.go
builder := plannercore.NewPlaBuilder(sctx, is)
p, err := builder.Build(ctx, node)
if err != nil {
    return nil, nil, err
}
logic, isLogicalPlan := p.(plannercore.LogicalPlan)

再进行优化,生成最终执行计划:

// planner/optimize.go
finalPlan, err := plannercore.DoOptimize(ctx, builder.GetOptFlag(), logic)

DoOptimize函数中,会进行逻辑优化:

// planner/core/optimizer.go
logic, err := logicalOptimize(ctx, flag, logic)
if err != nil {
    return nil, err
}
// planner/core/optimizer.go
// flag为掩码,代表需要应用哪些优化规则
func logicalOptimize(ctx context.Context, flag uint64, logic LogicalPlan) (LogicalPlan, error) {
    var err error
    for i, rule := range optRuleList {
        if flag&(1<<uint(i)) == 0 {
            continue
        }
        // 遍历优化规则,调用rule.optimize进行优化
        logic, err = rule.optimize(ctx, logic)
        if err != nil {
            return nil, err
        }
    }
    return logic, err
}

其中,optRuleList为优化规则列表:

// planner/core/optimizer.go
var optRuleList = []logicalOptRule{
    &columnPruner{},
    &buildKeySolver{},
    &aggregationEliminator{},
    &projectionEliminator{},
    &maxMinEliminator{},
    &ppdSolver{},
    &outerJoinEliminator{},
    &aggregationPushDownSolver{},
    &pushDownTopNOptimizer{},
    &joinReOrderSolver{},
}

类型logicalOptRule为优化规则:

// planner/core/optimizer.go
type logicalOptRule interface {
    optimize(context.Context, LogicalPlan) (LogicalPlan, error)
    name() string
}

列表中的每种规则均有相应的optimize方法实现,位于planner/core/rule*中。

列裁剪

列裁剪算法位于planner/core/rule_column_prunning.go中。逻辑执行计划LogicalPlan接口包含列裁剪PruneColumns方法,而每种逻辑执行计划结点均实现了LogicalPlan接口。planner/core/rule_column_prunning.go则包含了每种逻辑执行计划结点对应的列裁剪PruneColumns方法实现。

列裁剪目的为裁剪掉不需要读取的列,以节约IO资源;算法实现为自顶向下遍历逻辑执行计划树,并调用每个结点所实现的PruneColumns方法:某个结点需要用到的列,等于它自己需要用到的列,加上父节点需要用到的列:

// lp为逻辑执行计划树的根结点
func (s *columnPruner) optimize(ctx context.Context, lp LogicalPlan) (LogicalPlan, error) {
    err := lp.PruneColumns(lp.Schema().Columns)
    return lp, err
}

例如对于Select算子,PruneColumns方法实现为:

func (p *LogicalSelection) PruneColumns(parentUsedCols []*expression.Column) error {
    child := p.children[0]
    // 父节点用到的列 <= 父节点用到的列 + 当前结点用到的列
    parentUsedCols = expression.ExtractColumnsFromExpressions(parentUsedCols, p.Conditions, nil                )
    // 调用子节点的PruneColumns方法,传入父节点用到的列
    return child.PruneColumns(parentUsedCols)
}

Predicate及Limit下推

planner/core/rule_predicate_push_down中实现了将Predicate下推到Project与Join算子下面。

谓词下推目的为将能下推的条件尽量下推,使得提前过滤更多的记录,减小参与Join等算子的数据量。

谓词下推接口函数为:

func (p *baseLogicalPlan) PredicatePushDown(predicates []expression.Expression) ([]expression.Expression, LogicalPlan)

其处理当前的执行计划p,参数predicates表示要添加的过滤条件;函数返回值为无法下推的条件以及新生成的执行计划。

例如,对于Join算子的谓词下推,首先会尽可能将左外连接和右外连接简化为内连接;再收集所有过滤条件,区分哪些是 Join 的等值条件,哪些是 Join 需要用到的条件,哪些全部来自于左子节点,哪些全部来自于右子节点;区分之后,对于内连接,可以把左条件和右条件分别向左右子节点下推。等值条件和其它条件保留在当前的 Join 算子中,剩下的返回。

case InnerJoin:
    tempCond := make([]expression.Expression, 0, len(p.LeftConditions)+len(p.RightConditions)+len(p.EqualConditions)+len(p.OtherConditions)+len(predicates))
    tempCond = append(tempCond, p.LeftConditions...)
    tempCond = append(tempCond, p.RightConditions...)
    tempCond = append(tempCond, expression.ScalarFuncs2Exprs(p.EqualConditions)...)
    tempCond = append(tempCond, p.OtherConditions...)
    tempCond = append(tempCond, predicates...)
    tempCond = expression.ExtractFiltersFromDNFs(p.ctx, tempCond)
    tempCond = expression.PropagateConstant(p.ctx, tempCond)
    dual := Conds2TableDual(p, tempCond)
    if dual != nil {
        return ret, dual
    }
    equalCond, leftPushCond, rightPushCond, otherCond = p.extractOnCondition(tempCond, true, true)
    // 把左条件和右条件分别向左右子节点下推
    p.LeftConditions = nil
    p.RightConditions = nil
    // 等值条件和其它条件保留在当前的 Join 算子中
    p.EqualConditions = equalCond
    p.OtherConditions = otherCond
    leftCond = leftPushCond
    rightCond = rightPushCond
}
leftCond = expression.RemoveDupExprs(p.ctx, leftCond)
rightCond = expression.RemoveDupExprs(p.ctx, rightCond)
leftRet, lCh := p.children[0].PredicatePushDown(leftCond)
rightRet, rCh := p.children[1].PredicatePushDown(rightCond)
addSelection(p, lCh, leftRet, 0)
addSelection(p, rCh, rightRet, 1)
p.updateEQCond()
for _, eqCond := range p.EqualConditions {
    p.LeftJoinKeys = append(p.LeftJoinKeys, eqCond.GetArgs()[0].(*expression.Column))
    p.RightJoinKeys = append(p.RightJoinKeys, eqCond.GetArgs()[1].(*expression.Column))
}
p.mergeSchema()
buildKeyInfo(p)
return ret, p.self

谓词下推算法的执行流程与列裁剪类似:自顶向下遍历执行计划树,在当前结点的PredicatePushDown方法中处理谓词下推并调用子节点的PredicatePushDown方法。

planner/core/rule_topn_push_down中,还实现了将Limit下推到Project与Join算子下面。例如:

func (p *LogicalProjection) pushDownTopN(topN *LogicalTopN) LogicalPlan {
    for _, expr := range p.Exprs {
        if expression.HasAssignSetVarFunc(expr) {
            return p.baseLogicalPlan.pushDownTopN(topN)
        }
    }
    if topN != nil {
        for _, by := range topN.ByItems {
            by.Expr = expression.ColumnSubstitute(by.Expr, p.schema, p.Exprs)
        }

        // 删除无意义的常量排序项
        for i := len(topN.ByItems) - 1; i >= 0; i-- {
            switch topN.ByItems[i].Expr.(type) {
            case *expression.Constant:
                topN.ByItems = append(topN.ByItems[:i], topN.ByItems[i+1:]...)
            }
        }
    }
    p.children[0] = p.children[0].pushDownTopN(topN)
    return p
}

支持的算子

  • UnionScan
type PhysicalUnionScan struct {
   basePhysicalPlan

   Conditions []expression.Expression

   HandleCol *expression.Column
}

通过下推 API ,把一部分简单的 SQL 层的执行逻辑下推到 KV 层执行,减少 RPC 的次数和数据传输量,从而提升性能

为了解决对脏数据的读取,SQL 层实现了 UnionStore 的结构,UnionStore 对 SQL 层的 Buffer 和 KV 层接口做了一个封装,事务对 KV 层的读写都经过 UnionStore 。

UnionStore 收到请求时会先在 Buffer 里寻找,找不到时才会调用 KV 层的接口

当需要遍历数据的时候 UnionStore 会创建 Buffer 和 KV 的迭代器,并合并成一个

type UnionStore interface {
	MemBuffer
	// GetKeyExistErrInfo gets the key exist error info for the lazy check.
	GetKeyExistErrInfo(k Key) *existErrInfo
    
	// DeleteKeyExistErrInfo deletes the key exist error info for the lazy check.
	DeleteKeyExistErrInfo(k Key)
    
	// WalkBuffer iterates all buffered kv pairs.
	WalkBuffer(f func(k Key, v []byte) error) error
    
	// SetOption sets an option with a value, when val is nil, uses the default value of this option.
	SetOption(opt Option, val interface{})
    
	// DelOption deletes an option.
	DelOption(opt Option)
    
	// GetOption gets an option.
	GetOption(opt Option) interface{}
    
	// GetMemBuffer return the MemBuffer binding to this UnionStore.
	GetMemBuffer() MemBuffer
}

为了解决 SQL 层脏数据的可见性问题,定义了Union Scan 算法以 Row 为单位,创建一个 DirtyTable 保存事务的修改

addedRows 保存新写入的 row, deleteRows 保存删除的 row

  1. 对于 INSERT,我们需要把 row 添加到 addedRows 里。
  2. 对于 DELETE,我们需要把 row 从 addedRows 里删掉,然后把 row 添加到 deleteRows 里。
  3. 对于 UPDATE,相当于先执行 DELETE, 再执行 INSERT。

对于每一条下推 API 得到的结果集里的 Row,在 deleteRows 里查找,如果有,那么代表这一条结果已经被删掉,那么把它从结果集里删掉,得到过滤后的结果集。

把 addedRows 里的所有 Row,放到一个 slice 里,并对这个 slice 用快照结果集相同的顺序排序,生成脏数据结果集。

返回结果的时候,将过滤后的快照结果集与脏数据结果集进行 Merge。

type DirtyTable struct {bl
	tid int64
	
	addedRows   map[int64]struct{}
	deletedRows map[int64]struct{}
}