7.深入TiDB:range 范围计算优化
本文基于 TiDB release-5.1进行分析,需要用到 Go 1.16以后的版本
这篇文章首先会回顾一下整个 SQL 的执行过程,用来说明为什么要范围计算,最后从源码的角度讲解一下 析取范式 DNF (disjunctive normal form) 和 合取范式 CNF (conjunctive normal form) 是如何转化为范围区间。
优化过程解析
TiDB 在进行表扫描前会对查询条件,也就是 Selection 算子的过滤条件化简, 转为区间扫描。可以尽早的将无关的数据过滤掉,提升整个 SQL 的执行效率。
例如:
CREATE TABLE test1 (a int primary key, b int, c int,index (b));
explain select * from test1 where b=5 or ( b>5 and (b>6 or b <8)  and b<12) ;
在上面的查询中,会对查询条件进行优化,将索引搜索的返回缩小。对于上面的 where 条件中的表达式区间,最终会优化为:
b=5 or ( b>5 and (b>6 or b <8)  and b<12)=> [5,12)
我们从 explain 中也可以看到优化结果:
+-------------------------+-------+---------+-----------------------+--------------------------------------------+
|id                       |estRows|task     |access object          |operator info                               |
+-------------------------+-------+---------+-----------------------+--------------------------------------------+
|IndexLookUp_10           |250.00 |root     |                       |                                            |
|├─IndexRangeScan_8(Build)|250.00 |cop[tikv]|table:test1, index:b(b)|range:[5,12), keep order:false, stats:pseudo|
|└─TableRowIDScan_9(Probe)|250.00 |cop[tikv]|table:test1            |keep order:false, stats:pseudo              |
+-------------------------+-------+---------+-----------------------+--------------------------------------------+
在正式进入探究之前,我们先来看看 TiDB 的几个优化步骤,让不了的同学也能很好的掌握整个 SQL 优化过程。
对于上面我们的 SQL:
select * from test1 where b=5 or ( b>5 and (b>6 or b <8)  and b<12) ;
首先会生成执行计划:

在执行完 logicalOptimize 逻辑优化之后,执行计划变为下面这样:

Selection算子被下推到了 DataSource 算子中,在 DataSource 的 pushedDownConds 中保存着下推的过滤算子:

对于我们的 pushedDownConds 展开来是一个二叉树结构:

因为索引底层是顺序排列的,所以要将这颗树转为扫描区间。
然后在执行 physicalOptimize 然后进行物理优化的时候会遍历 DataSource 算子的 possibleAccessPaths
...
for _, path := range ds.possibleAccessPaths {
   if path.IsTablePath() {
      continue
   }
   err := ds.fillIndexPath(path, ds.pushedDownConds)
   if err != nil {
      return nil, err
   }
}
...
fillIndexPath 会调用 DetachCondAndBuildRangeForIndex 来生成扫描区间,这个函数会递归的调用如下 2 个函数:
detachDNFCondAndBuildRangeForIndex:展开 OR 条件连接也叫析取范式 DNF (disjunctive normal form),生成扫描区间或合并扫描区间;
detachCNFCondAndBuildRangeForIndex:展开 AND 条件连接也叫合取范式 CNF (conjunctive normal form),生成扫描区间或合并扫描区间;
整个执行过程如下:

上面的表达式树最终生成了这样的区间: [5,12)。
然后 physicalOptimize 会递归所有的算子调用 findBestTask 函数,最后调用到 DataSoure 算子使用 Skyline-Pruning 索引裁剪,它会从 possibleAccessPaths 获取最优的执行计划:
func (ds *DataSource) skylinePruning(prop *property.PhysicalProperty) []*candidatePath {
	candidates := make([]*candidatePath, 0, 4)
	for _, path := range ds.possibleAccessPaths {
		var currentCandidate *candidatePath
		...
		pruned := false
		for i := len(candidates) - 1; i >= 0; i-- {
			// 比较索引代价,判断是否进行裁剪
			result := compareCandidates(candidates[i], currentCandidate)
			if result == 1 {
				pruned = true
				break
			} else if result == -1 {
				candidates = append(candidates[:i], candidates[i+1:]...)
			}
		}
		if !pruned {
			candidates = append(candidates, currentCandidate)
		}
	}
	...
	return candidates
}
compareCandidates 函数会从下面三个方面进行判断一个索引的优劣:
索引的列涵盖了多少访问条件。“访问条件”指的是可以转化为某列范围的
where条件,如果某个索引的列集合涵盖的访问条件越多,那么它在这个维度上更优。选择该索引读表时,是否需要回表(即该索引生成的计划是 IndexReader 还是 IndexLookupReader)。不用回表的索引在这个维度上优于需要回表的索引。如果均需要回表,则比较索引的列涵盖了多少过滤条件。过滤条件指的是可以根据索引判断的
where条件。如果某个索引的列集合涵盖的访问条件越多,则回表数量越少,那么它在这个维度上越优。选择该索引是否能满足一定的顺序。因为索引的读取可以保证某些列集合的顺序,所以满足查询要求顺序的索引在这个维度上优于不满足的索引。
例如:如果索引 idx_a 在这三个维度上都不比 idx_b 差,且有一个维度比 idx_b 好,那么 TiDB 会优先选择 idx_a。
排除了不合适的索引之后,会根据下面的规则来选择一个代价最低的索引进行读表:
- 索引的每行数据在存储层的平均长度。
 - 索引生成的查询范围的行数量。
 - 索引的回表代价。
 - 索引查询时的范围数量。
 
最后生成的执行计划为:PhysicalIndexLookUpReader。
范围计算源码分析
在上面中我也说到了 DetachCondAndBuildRangeForIndex 会根据 where 条件来生成扫描区间。

detachDNFCondAndBuildRangeForIndex 析取范式
func (d *rangeDetacher) detachDNFCondAndBuildRangeForIndex(condition *expression.ScalarFunction, newTpSlice []*types.FieldType) ([]*Range, []expression.Expression, bool, error) {
	sc := d.sctx.GetSessionVars().StmtCtx
	firstColumnChecker := &conditionChecker{
		colUniqueID:   d.cols[0].UniqueID,
		shouldReserve: d.lengths[0] != types.UnspecifiedLength,
		length:        d.lengths[0],
	}
	rb := builder{sc: sc}
	// 递归拉平 or 子项 Expression
	dnfItems := expression.FlattenDNFConditions(condition)
	newAccessItems := make([]expression.Expression, 0, len(dnfItems))
	var totalRanges []*Range
	hasResidual := false
	for _, item := range dnfItems {
		// 如果该子项 Expression 包含了 AND
		if sf, ok := item.(*expression.ScalarFunction); ok && sf.FuncName.L == ast.LogicAnd {
			// 递归拉平 and 子项 Expression
			cnfItems := expression.FlattenCNFConditions(sf)
			var accesses, filters []expression.Expression
			res, err := d.detachCNFCondAndBuildRangeForIndex(cnfItems, newTpSlice, true)
			if err != nil {
				return nil, nil, false, nil
			}
			ranges := res.Ranges
			accesses = res.AccessConds
			filters = res.RemainedConds
			if len(accesses) == 0 {
				return FullRange(), nil, true, nil
			}
			if len(filters) > 0 {
				hasResidual = true
			}
			totalRanges = append(totalRanges, ranges...)
			newAccessItems = append(newAccessItems, expression.ComposeCNFCondition(d.sctx, accesses...))
		} else if firstColumnChecker.check(item) {
			if firstColumnChecker.shouldReserve {
				hasResidual = true
				firstColumnChecker.shouldReserve = d.lengths[0] != types.UnspecifiedLength
			}
			// 计算逻辑区间
			points := rb.build(item)
			// 将区间转化为外暴露的 range 结构
			ranges, err := points2Ranges(sc, points, newTpSlice[0])
			if err != nil {
				return nil, nil, false, errors.Trace(err)
			}
			totalRanges = append(totalRanges, ranges...)
			newAccessItems = append(newAccessItems, item)
		} else {
			//生成 [null, +∞) 区间
			return FullRange(), nil, true, nil
		}
	}
	// 区间并
	// 例如区间:[a, b], [c, d],表示的是a <= c. If b >= c
	// 那么这两个区间可以合并为:[a, max(b, d)].
	totalRanges, err := UnionRanges(sc, totalRanges, d.mergeConsecutive)
	if err != nil {
		return nil, nil, false, errors.Trace(err)
	}
	return totalRanges, []expression.Expression{expression.ComposeDNFCondition(d.sctx, newAccessItems...)}, hasResidual, nil
}
detachDNFCondAndBuildRangeForIndex 方法中会拉平 or 子项,然后进行遍历,因为子项中可能嵌套子项,例如:where b=5 or ( b>5 and (b>6 or b <8)  and b<12) 经过 FlattenDNFConditions 拉平之后会变成两个子项:EQ 和 AND

那么,对于 AND 子项来说会继续调用 FlattenCNFConditions 拉平,之后进入到 detachCNFCondAndBuildRangeForIndex 进行范围区间的提取,这个我们后面再说。先看看 EQ 这个子项的处理。
EQ 子项会进入到 build 方法中,根据类型判断构建 point :
func (r *builder) buildFromScalarFunc(expr *expression.ScalarFunction) []*point {
	switch op := expr.FuncName.L; op {
	case ast.GE, ast.GT, ast.LT, ast.LE, ast.EQ, ast.NE, ast.NullEQ:
		return r.buildFormBinOp(expr)
	...
	case ast.In:
		retPoints, _ := r.buildFromIn(expr)
		return retPoints
	case ast.Like:
		return r.newBuildFromPatternLike(expr)
	case ast.IsNull:
		startPoint := &point{start: true}
		endPoint := &point{}
		return []*point{startPoint, endPoint}
	case ast.UnaryNot:
		return r.buildFromNot(expr.GetArgs()[0].(*expression.ScalarFunction))
	}
	return nil
}
buildFromScalarFunc 中包含了很多 buildFromXXX 方法,它们是计算一个具体函数的 range 的方法。比如 buildFromIn 便是处理 in 函数的方法。
每个 point 代表区间的一个端点:
type point struct {
	value types.Datum
	excl  bool // exclude
	start bool
}
value 表示端点的值, excl 表示端点为开区间的端点还是闭区间的端点,start 表示这个端点是左端点还是右端点。
我们这里的 EQ 子项会进入到 buildFormBinOp 方法中。
func (r *builder) buildFormBinOp(expr *expression.ScalarFunction) []*point {
	...
	var col *expression.Column
	var ok bool
	// 因为有的人喜欢这样写表达式:where 5=b,所以这里需要获取表达式中的列名和值
	// 判断第一个参数是否是列字段
	if col, ok = expr.GetArgs()[0].(*expression.Column); ok {
		ft = col.RetType
		// 获取值
		value, err = expr.GetArgs()[1].Eval(chunk.Row{})
		if err != nil {
			return nil
		}
		op = expr.FuncName.L
	} else {
		// 参数的第二个是列
		col, ok = expr.GetArgs()[1].(*expression.Column)
		if !ok {
			return nil
		}
		ft = col.RetType
		value, err = expr.GetArgs()[0].Eval(chunk.Row{})
		if err != nil {
			return nil
		}
		// 因为表达式是这样写的:where 5=b 所以需要将表达式中的符号做一下反转
		switch expr.FuncName.L {
		case ast.GE:
			op = ast.LE
		case ast.GT:
			op = ast.LT
		case ast.LT:
			op = ast.GT
		case ast.LE:
			op = ast.GE
		default:
			op = expr.FuncName.L
		}
	}
	if op != ast.NullEQ && value.IsNull() {
		return nil
	}
	...
	//处理unsigned列
	value, op, isValidRange := handleUnsignedCol(ft, value, op)
	if !isValidRange {
		return nil
	}
	// 处理越界情况
	value, op, isValidRange = handleBoundCol(ft, value, op)
	if !isValidRange {
		return nil
	}
	// 构建区间端点
	switch op {
	case ast.NullEQ:
		if value.IsNull() {
			return []*point{{start: true}, {}} // [null, null]
		}
		fallthrough
	case ast.EQ:
		startPoint := &point{value: value, start: true}
		endPoint := &point{value: value}
		return []*point{startPoint, endPoint}
	case ast.NE:
		startPoint1 := &point{value: types.MinNotNullDatum(), start: true}
		endPoint1 := &point{value: value, excl: true}
		startPoint2 := &point{value: value, start: true, excl: true}
		endPoint2 := &point{value: types.MaxValueDatum()}
		return []*point{startPoint1, endPoint1, startPoint2, endPoint2}
	...
	}
	return nil
}
buildFormBinOp 主要是对一些异常情况进行处理,如:unsigned列、越界、特殊列的值,然后构建区间端点 Point 数组。
然后就是调用 points2Ranges 将 Point 数组转为 range:
func points2Ranges(sc *stmtctx.StatementContext, rangePoints []*point, tp *types.FieldType) ([]*Range, error) {
	ranges := make([]*Range, 0, len(rangePoints)/2)
	for i := 0; i < len(rangePoints); i += 2 {
        startPoint := rangePoints[i]
		...
		endPoint := rangePoints[i+1]
		...
		ran := &Range{
			LowVal:      []types.Datum{startPoint.value},
			LowExclude:  startPoint.excl,
			HighVal:     []types.Datum{endPoint.value},
			HighExclude: endPoint.excl,
		}
		ranges = append(ranges, ran)
	}
	return ranges, nil
}
上面的代码形态我做了一些处理方面理解这段代码的意思,主要就是获取端点的开闭区间构建 Range。
detachCNFCondAndBuildRangeForIndex 合取范式
func (d *rangeDetacher) detachCNFCondAndBuildRangeForIndex(conditions []expression.Expression, tpSlice []*types.FieldType, considerDNF bool) (*DetachRangeResult, error) {
	var (
		eqCount int
		ranges  []*Range
		err     error
	)
	...
	res := &DetachRangeResult{}
	// accessConds 用于抽出 eq/in 可以用于点查的条件构建范围查询
	// newConditions 用来简化同字段出现多次的 eq 或 in 条件的情况,如:a in (1, 2, 3) and a in (2, 3, 4) 被简化为 a in (2, 3)
	accessConds, filterConds, newConditions, emptyRange := ExtractEqAndInCondition(d.sctx, conditions, d.cols, d.lengths)
	eqOrInCount := len(accessConds)
	// 根据access构建范围区间
	ranges, err = d.buildCNFIndexRange(tpSlice, eqOrInCount, accessConds)
	if err != nil {
		return res, err
	}
	res.Ranges = ranges
	res.AccessConds = accessConds
	checker := &conditionChecker{
		colUniqueID:   d.cols[eqOrInCount].UniqueID,
		length:        d.lengths[eqOrInCount],
		shouldReserve: d.lengths[eqOrInCount] != types.UnspecifiedLength,
	}
	if considerDNF {
		...
		if eqOrInCount > 0 {
			newCols := d.cols[eqOrInCount:]
			newLengths := d.lengths[eqOrInCount:]
			tailRes, err := DetachCondAndBuildRangeForIndex(d.sctx, newConditions, newCols, newLengths)
			if len(tailRes.AccessConds) > 0 {
				res.Ranges = appendRanges2PointRanges(res.Ranges, tailRes.Ranges)
				res.AccessConds = append(res.AccessConds, tailRes.AccessConds...)
			}
			res.RemainedConds = append(res.RemainedConds, tailRes.RemainedConds...)
			...
			return res, nil
		}
		// 到这里,说明eqOrInCount = 0
		// 遍历所有 conditions ,如果该condition是LogicOr Scalar Function类型的,则调用 DNF 相关函数进行处理
		res.AccessConds, res.RemainedConds = detachColumnCNFConditions(d.sctx, newConditions, checker)
		// 获取 AccessConds 的范围 range
		ranges, err = d.buildCNFIndexRange(tpSlice, 0, res.AccessConds)
		if err != nil {
			return nil, err
		}
		res.Ranges = ranges
		return res, nil
	}
	...
	return res, nil
}
AND 表达式中,只有当之前的列均为点查的情况下,才会考虑下一个列。
例如:对于索引 (a, b, c),有条件 a > 1 and b = 1,那么会被选中的只有 a > 1。对于条件 a in (1, 2, 3) and b > 1,两个条件均会被选到用来计算 range。
所以在这个方法中,首先会调用 ExtractEqAndInCondition 函数抽离出 eq/in 可以用于点查的条件构建范围查询赋值到 accessConds 中,剩余的条件被抽离到 newConditions 中。
然后对于联合索引中,如果第一个字段是 eq/in 点查询,那么 eqOrInCount 不为0,就可以继续向后获取其他字段的范围。所以接下来会调用 DetachCondAndBuildRangeForIndex 获取其他字段的范围。
对于 eqOrInCount 等于0的条件,说明字段中不存在 eq/in 点查询,或者联合索引中左边的字段查询不为点查询,那么会调用 detachColumnCNFConditions 对单列索引进行处理。
Reference
https://pingcap.com/zh/blog/tidb-source-code-reading-13
https://github.com/xieyu/blog/blob/master/src/tidb/range.md
https://www.youtube.com/watch?v=OFqkfJTVIc8
https://www.cnblogs.com/lijingshanxi/p/12077587.html
https://zh.wikipedia.org/wiki/逻辑运算符
https://docs.pingcap.com/zh/tidb/stable/choose-index

7.深入TiDB:range 范围计算优化的更多相关文章
- [转]MySQL源码:Range和Ref优化的成本评估
		
MySQL源码:Range和Ref优化的成本评估 原文链接:http://www.orczhou.com/index.php/2012/12/mysql-source-code-optimizer-r ...
 - (转)支持Multi Range Read索引优化
		
支持Multi Range Read索引优化 原文:http://book.51cto.com/art/201701/529465.htm http://book.51cto.com/art/2016 ...
 - 2019牛客多校第二场F Partition problem 暴力+复杂度计算+优化
		
Partition problem 暴力+复杂度计算+优化 题意 2n个人分成两组.给出一个矩阵,如果ab两个在同一个阵营,那么就可以得到值\(v_{ab}\)求如何分可以取得最大值 (n<14 ...
 - 递归、尾递归和使用Stream延迟计算优化尾递归
		
我们在学数据结构的时候必然会接触栈(Stack),而栈有一个重要的应用是在程序设计语言中实现递归.递归用途十分广泛,比如我们常见的阶乘,如下代码: 1234 public static int (in ...
 - 使用 CUDA 进行计算优化的两种思路
		
前言 本文讨论如何使用 CUDA 对代码进行并行优化,并给出不同并行思路对均值滤波的实现. 并行优化的两种思路 思路1: global 函数 在 global 函数中创建出多个块多个线程对矩阵每个元素 ...
 - 第七篇:使用 CUDA 进行计算优化的两种思路
		
前言 本文讨论如何使用 CUDA 对代码进行并行优化,并给出不同并行思路对均值滤波的实现. 并行优化的两种思路 思路1: global 函数 在 global 函数中创建出多个块多个线程对矩阵每个元素 ...
 - PARTITION RANGE ALL 的优化
		
建议如下: 检查数据库的cpu 消耗 ,Sql_id :***** 消耗过多资源,这个新上线sql, 20号才上线,是对log 进行分析,平均每次执行时间300s.,使用的是PARTITION RAN ...
 - STM32 MCU一次计算优化和提速
		
1.背景 STM32 MCU对25.6Kb数据进行压缩,丢掉每个数据的低4位然后2个字节拼接为1个字节.发现处理耗时竞达1ms以上,于是开始进行优化,最后达到200us的效果,提速5倍以上. 2.优化 ...
 - 记一次 Sedona(GeoSpark) 空间计算优化
		
项目需求需要空间计算能力,开始选型Sedona(GeoSpark)来完成, 需求需要每一条数据在满足某条件的情况下,去查找某张表进行空间匹配,找到离这个点(point)最近的一条道路(lineStri ...
 
随机推荐
- 项目配置shiro原缓存注解失效
			
项目用springboot + shiro + ehcache @cacheable 注解不起作用原因 Shiro框架初始化比Spring框架的某些部件早,导致使用@Autowire注入Shiro框架 ...
 - Redis基础数据结构-基于2.8
			
SDS SDS是Redis中String的底层数据结构,数据结构如下,SDS保留了传统的C字符串表达方式即数组的最后一个元素是'/0'结尾.此外还添加了两个字段len和free,其中len表示字符串长 ...
 - Vulnhub实战-JIS-CTF_VulnUpload靶机👻
			
Vulnhub实战-JIS-CTF_VulnUpload靶机 下载地址:http://www.vulnhub.com/entry/jis-ctf-vulnupload,228/ 你可以从上面地址获取靶 ...
 - centos6.5 oracle 卸载
			
1.使用SQL*PLUS停止数据库 [oracle@OracleTest oracle]$ sqlplus / as sysdba SQL> shutdown immediate; SQL> ...
 - ApsNetCore打造一个“最安全”的api接口
			
Authentication,Authorization 如果公司交给你一个任务让你写一个api接口,那么我们应该如何设计这个api接口来保证这个接口是对外看起来"高大上",&qu ...
 - Oracle基础命令操作总结
			
第1章 oracle命令集 1.1 重启数据库 1.1.1 启动数据库 su -root 切换到oracle安装用户下,我的是root source .bash_pro ...
 - L1-027 出租 (20 分) java题解
			
下面是新浪微博上曾经很火的一张图: 一时间网上一片求救声,急问这个怎么破.其实这段代码很简单,index数组就是arr数组的下标,index[0]=2 对应 arr[2]=1,index[1]=0 对 ...
 - selenium去特征
			
code from selenium import webdriver chrome_options = webdriver.ChromeOptions() chrome_options.add_ex ...
 - 【数据结构与算法Python版学习笔记】目录索引
			
引言 算法分析 基本数据结构 概览 栈 stack 队列 Queue 双端队列 Deque 列表 List,链表实现 递归(Recursion) 定义及应用:分形树.谢尔宾斯基三角.汉诺塔.迷宫 优化 ...
 - 生产环境全链路压测平台 Takin
			
什么是Takin? Takin是基于Java的开源系统,可以在无业务代码侵入的情况下,嵌入到各个应用程序节点,实现生产环境的全链路性能测试,适用于复杂的微服务架构系统. Takin核心原理图 Taki ...