TiDB源码分析

关于TiDB与TiKV学习总结

TiDB

这是一篇总结篇,是读了PingCAP系列文章以及部分源码对该块知识点进行的总结。
本篇着重对TiDB的执行路径进行分析,很多SQL相关的细节后续再补。

TiDB知识点

TiDB的主要工作是负责解析SQL协议,将其转换为所需查询的数据所在Region下传读写操作至TiKV,通过TiKV来查找具体的数据,TiKV负责Raft管理Log日志进行分布式kv的管理,以及基于Percolator进行事务一致性处理,MVCC实现多版本读取提供SI隔离级别,

  1. Google F1
  2. Nested Loop Join、Hash Join、Merge Sort Join
  3. Lexer&&Yacc Parse、LL(1)/LR(n)、AST
  4. Optimize RBO、CBO、Logical Plan、Physical Plan
  5. ExecStmt生成执行器、Chunk/NextChunk(接TiKV、行存列存转换)
  6. 火山模型/向量模型
  7. RecordSet返回结果集

考虑Parser,Optimizer,Executor,DistSQL,TiKV-Client这几层,接下来会从下往上介绍,从与TiKV的交互开始拆分一下源代码。

1.关于TiDB和TiKV之间是如何发起查询的:(RegionRequestSender)
PS:此处出现的Client全部属于internal/tikv/tikv
结构体RegionRequestSender在封装了Cilent和Region Cache的基础上还进行了网络异常处理
普通请求:
Sender.SendReq->SendReqToRegion->Client.SendRequest->TiKV.CallRPC(发送给kvproto中暴露的对不同请求的地址)
调用RequestBuilderSender的SendReq方法->转换Region对应的StoreAddr地址->调用RequestBuilderSender中TiKVClient发送请求->调用封装的发送接口。
其中会存在参数tikvrpc.Request(调用tikvrpc.NewRequest构建请求)用于记录所有可能发给TiKV的命令,将这些命令和KVRange以及一些Options封装成一个Request,找寻到Region地址以后发送即可。
CopTask:
存在参数coprocessor.Request,tikvrpc.CmdCop,将需要的Task放入copTasks数组
Send->buildCopTask->handleTask->handleTaskOnce->client(tikvtxnSnapshot.ClientHelper->client.Client).SendReqCtx->SendReqToRegion,后续从RespCh中读取resp结果。
这里的请求通过tikvrpc.NewReplicaReadRequest进行构建,也就是指定了版本的读写请求。一个请求可以发给多个TiKV.Store同步处理,之后将返回数据行统一在TiDB进行处理。
关于kvStore这个结构体:
KVStore contains methods to interact with a TiKV cluster.
其中封装与TiKV的各种接口如GetSnapshot()、SendReq,client中封装了RPCClient(维护连接池、gRPC等)有SendRequest,因此在TiDB上获取到了store,就可以接着获取其中的client以及对应的Addr,从而进行请求调用。所以在TiDB中需要与TiKV交互的部分可以在结构体中引用kvStore结构。
关于RequestBuildSender也封装了Client,不再赘述。

//其中封装了client Client
type KVStore struct {
	clusterID uint64
	uuid      string
	oracle    oracle.Oracle
	clientMu  struct {
		sync.RWMutex
		client Client
	}
	pdClient     pd.Client
	regionCache  *locate.RegionCache
	lockResolver *txnlock.LockResolver
	txnLatches   *latch.LatchesScheduler
	mock bool
	kv        SafePointKV
	safePoint uint64
	spTime    time.Time
	spMutex   sync.RWMutex // this is used to update safePoint and spTime
	// storeID -> safeTS, stored as map[uint64]uint64
	// safeTS here will be used during the Stale Read process,
	// it indicates the safe timestamp point that can be used to read consistent but may not the latest data.
	safeTSMap sync.Map
	replicaReadSeed uint32 // this is used to load balance followers / learners when replica read is enabled
	ctx    context.Context
	cancel context.CancelFunc
	wg     sync.WaitGroup
}
type mppIterator struct {
	store *kvStore
	tasks    []*kv.MPPDispatchRequest
	finishCh chan struct{}
	startTs uint64
	respChan chan *mppResponse
	cancelFunc context.CancelFunc
	wg sync.WaitGroup
	closed uint32
	vars *tikv.Variables
	mu sync.Mutex
}
m *mppIterator
sender := NewRegionBatchRequestSender(m.store.GetRegionCache(), m.store.GetTiKVClient())

rpcResp, err = m.store.GetTiKVClient().SendRequest(ctx, req.Meta.GetAddress(), wrappedReq, tikv.ReadTimeoutMedium)

2.如何构建可发送的请求以及如何向TiKV发起查询?(RequestBuilder/distSQL.Select)
构建范围+发起查询请求
(需要下推分布式计算需要通过DistSQL层暴露Select接口发起与TiKV的连接,如果不需要分布式计算Coprocessor进行下推,则直接简单的KV Set、Get就好)
PS:此处会有tidb/kv中的Client与internal/tikv中的Client两种进行交互
在TiDB中有一个kv结构体,在TiKV-Client中有一个tikv结构体,我们将CRUD最终转换为KV Range+CmdType命令,用RequestBuilder构建出一个kv结构体Request,之后通过RequestBuilder的Select发起Send,调用kv.Storage接口将请求数据交付给tikv.Storage接口,之后调用tikv.Storage中的SendReqToAddr实现1中提到的TiDB与TiKV通过TiKV-Client发送请求。
其中的kvRanges就是一堆LowKey/HighKey的集合,由Executor层使用Ranges等对kvReq(kv.Request)使用RequestBuilder进行Build,这里涉及到的Executor层信息将在第三点中提及,先了解如何构建请求以及RequestBuilder的使用就好。

func (e *TableReaderExecutor) buildKVReqSeparately(ctx context.Context, ranges []*ranger.Range) ([]*kv.Request, error) {
	pids, kvRanges, err := e.kvRangeBuilder.buildKeyRangeSeparately(ranges)
	if err != nil {
		return nil, err
	}
	var kvReqs []*kv.Request
	for i, kvRange := range kvRanges {
		e.kvRanges = append(e.kvRanges, kvRange...)
		if err := updateExecutorTableID(ctx, e.dagPB.RootExecutor, pids[i], true); err != nil {
			return nil, err
		}
		var builder distsql.RequestBuilder
		reqBuilder := builder.SetKeyRanges(kvRange)
		kvReq, err := reqBuilder.
			SetDAGRequest(e.dagPB).
			SetStartTS(e.startTS).
			SetDesc(e.desc).
			SetKeepOrder(e.keepOrder).
			SetStreaming(e.streaming).
			SetTxnScope(e.txnScope).
			SetFromSessionVars(e.ctx.GetSessionVars()).
			SetFromInfoSchema(e.ctx.GetInfoSchema()).
			SetMemTracker(e.memTracker).
			SetStoreType(e.storeType).
			SetAllowBatchCop(e.batchCop).Build()
		if err != nil {
			return nil, err
		}
		kvReqs = append(kvReqs, kvReq)
	}
	return kvReqs, nil
}

构建好RequestBuilder后就可以使用SelectSelectWithRuntimeStats方法将kvReqs封装起来发送DAGRequest,之后等待SelectResult返回结果。可以看到Distsql.Select里的Send用的是kv.Client,Distsql.Select->Send->SendBatch->放入CopIteratpr/batchCopIterator(内部封装了tikvStore,里面可以访问到tikv.Client)->run->handleTask->handleTaskOnce->构建tikvrpc.NewRequest->调用Sender.SendReqToAddr实际发起请求。
所以对于TiDB层面不考虑实际的与TiKV的交互等,只需要考虑对上层暴露出的Select接口即可。对于其上层的只需要设计好RequestBuilder并且调用一次Select就好。

func Select(ctx context.Context, sctx sessionctx.Context, kvReq *kv.Request, fieldTypes []*types.FieldType, fb *statistics.QueryFeedback) (SelectResult, error) {
	if span := opentracing.SpanFromContext(ctx); span != nil && span.Tracer() != nil {
		span1 := span.Tracer().StartSpan("distsql.Select", opentracing.ChildOf(span.Context()))
		defer span1.Finish()
		ctx = opentracing.ContextWithSpan(ctx, span1)
	}//GetClient返回的是kv.Client
	resp := sctx.GetClient().Send(ctx, kvReq, sctx.GetSessionVars().KVVars, sctx.GetSessionVars().StmtCtx.MemTracker, enabledRateLimitAction)
--------------------------------------------------
// The difference from Select is that SelectWithRuntimeStats will set copPlanIDs into selectResult,
// which can help selectResult to collect runtime stats.
func SelectWithRuntimeStats(ctx context.Context, sctx sessionctx.Context, kvReq *kv.Request,
	fieldTypes []*types.FieldType, fb *statistics.QueryFeedback, copPlanIDs []int, rootPlanID int) (SelectResult, error) {
	sr, err := Select(ctx, sctx, kvReq, fieldTypes, fb)
	if err == nil {
		if selectResult, ok := sr.(*selectResult); ok {
			selectResult.copPlanIDs = copPlanIDs
			selectResult.rootPlanID = rootPlanID
		}
	}
	return sr, err
}

3.执行语句如何转换为可发送请求(关于Executor层如何构建Exec)
SPJ算子被称为基本算子,也就是Selection、Projection、Join算子以及DataSource算子,每个算子在Executor层会实现一个Exec,其中还有像IndexScanExec算子,是向TiPB发送DAGRequest请求,这个后续补全。

var (
	_ Executor = &baseExecutor{}
	_ Executor = &CheckTableExec{}
	_ Executor = &HashAggExec{}
	_ Executor = &HashJoinExec{}
	_ Executor = &IndexLookUpExecutor{}
	_ Executor = &IndexReaderExecutor{}
	_ Executor = &LimitExec{}
	_ Executor = &MaxOneRowExec{}
	_ Executor = &MergeJoinExec{}
	_ Executor = &ProjectionExec{}
	_ Executor = &SelectionExec{}
	_ Executor = &SelectLockExec{}
	_ Executor = &ShowNextRowIDExec{}
	_ Executor = &ShowDDLExec{}
	_ Executor = &ShowDDLJobsExec{}
	_ Executor = &ShowDDLJobQueriesExec{}
	_ Executor = &SortExec{}
	_ Executor = &StreamAggExec{}
	_ Executor = &TableDualExec{}
	_ Executor = &TableReaderExecutor{}
	_ Executor = &TableScanExec{}
	_ Executor = &TopNExec{}
	_ Executor = &UnionExec{}
	// GlobalMemoryUsageTracker is the ancestor of all the Executors' memory tracker and GlobalMemory Tracker
	GlobalMemoryUsageTracker *memory.Tracker
	// GlobalDiskUsageTracker is the ancestor of all the Executors' disk tracker
	GlobalDiskUsageTracker *disk.Tracker
)

每个Exec提供如下接口:

type Executor interface {
	base() *baseExecutor
	Open(context.Context) error
	Next(ctx context.Context, req *chunk.Chunk) error
	Close() error
	Schema() *expression.Schema
}

Executor层与下一层的关系也就是通过Open或Next来与DistSQL层进行关联,像Set/Get有更直接地请求发送方式,但也都是从SQL算子中的最后一层Executor向下层发起连接。
关于火山模型、向量模型与算子间的封装:

func newBaseExecutor(ctx sessionctx.Context, schema *expression.Schema, id int, children ...Executor) baseExecutor {
	e := baseExecutor{
		children:     children,
		ctx:          ctx,
		id:           id,
		schema:       schema,
		initCap:      ctx.GetSessionVars().InitChunkSize,
		maxChunkSize: ctx.GetSessionVars().MaxChunkSize,
	}
	if ctx.GetSessionVars().StmtCtx.RuntimeStatsColl != nil {
		if e.id > 0 {
			e.runtimeStats = &execdetails.BasicRuntimeStats{}
			e.ctx.GetSessionVars().StmtCtx.RuntimeStatsColl.RegisterStats(id, e.runtimeStats)
		}
	}
	if schema != nil {
		cols := schema.Columns
		e.retFieldTypes = make([]*types.FieldType, len(cols))
		for i := range cols {
			e.retFieldTypes[i] = cols[i].RetType
		}
	}
	return e

当我们要运行到下一个算子的管辖区域时,我们可以把上一个算子中得到的结果记录存放在参数children中,就这样一层一层向下构建即可。如下Limit算子所示:

//对于set和get不需要用到复杂的算子,直接构建v.Schema/v.ID即可完成executor,之后直接走set_Config发起请求,走测试包使用testkit中的Mustexec或MustQuery发起请求即可。
//buildBatchPointGet同理
func (b *executorBuilder) buildSet(v *plannercore.Set) Executor {
	base := newBaseExecutor(b.ctx, v.Schema(), v.ID())
	base.initCap = chunk.ZeroCapacity
	e := &SetExecutor{
		baseExecutor: base,
		vars:         v.VarAssigns,
	}
	return e
}
//举例Limit算子:
//v.Children()[0]里记录了上一步操作,上一步估计可能是DataSource算子,通过build构建为上一步的算子作为limit的基础算子,buildLimit以此继续构建Limit算子。
func (b *executorBuilder) buildLimit(v *plannercore.PhysicalLimit) Executor {
	childExec := b.build(v.Children()[0])
	if b.err != nil {
		return nil
	}
	n := int(mathutil.MinUint64(v.Count, uint64(b.ctx.GetSessionVars().MaxChunkSize)))
	base := newBaseExecutor(b.ctx, v.Schema(), v.ID(), childExec)
	base.initCap = n
	e := &LimitExec{
		baseExecutor: base,
		begin:        v.Offset,
		end:          v.Offset + v.Count,
	}

	childUsedSchema := markChildrenUsedCols(v.Schema(), v.Children()[0].Schema())[0]
	e.columnIdxsUsedByChild = make([]int, 0, len(childUsedSchema))
	for i, used := range childUsedSchema {
		if used {
			e.columnIdxsUsedByChild = append(e.columnIdxsUsedByChild, i)
		}
	}
	if len(e.columnIdxsUsedByChild) == len(childUsedSchema) {
		e.columnIdxsUsedByChild = nil // indicates that all columns are used. LimitExec will improve performance for this condition.
	}
	return e
}

算子间的关联可以看关于算子间的一些关系

最顶层的应该是UnionScanExec,会合并脏表的行和distsql中的行一起发送出去?该Exec的children[0]为dataSource算子,也就是Reader。UnionscanExec的Next会使用reader的Next方法得到返回的结果数据,不断的向上返回其中的一行。
先考虑DataSource算子,也就是From字段后接着的表数据源,数据源主要是通过三个读算子(TableReader、IndexReader、IndexLookupReader方法),由于每个算子都是一个Executor,因此每个算子都有实现如上方法,主要考虑Open和Next。
代码中主要实现了Index_Merge_Reader.go和Table_Reader.go,对于TableReaderExecutor,其要实现的操作是读TiKV中的全表数据,因此在Open中根据TableReaderExecutor中储存的plans和ctx构建出e.dagPB.Executors,再根据distsql.SplitRangesAcrossInt64Boundary得到本次下发查询中表数据对应的ranges,接着就可以Open->buildKVReq、buildResp->buildKVReqSeparately/buildKVReq->SelectResult->distsql.SelectWithRuntimeStats去到第二步中继续向TiKV发起请求。Open中构建好相应的请求并发送出去即可,Next中则是将返回的结果封装在SelectResult中,构建chunk不断地获取返回的SelectResult。
在TableReader中是读全表,IndexMergeReader则被称为索引合并,指的是有的查询请求需要一个或多个索引同时作用于对应的表,最终整合每个索引中可以查询到的结果,作为数据源算子。
Open->buildKeyRangesForTable->distsql.IndexRangesToKVRanges根据index构建出KVRange->预先设置好resultCh等待Next返回结果。
Next->startWokers==>
startIndexMergeProcessWorker->运行fetchLoop,提前设置好将获取的Task从fetchCh中取出并加上句柄放入workCh和resultCh==>
startPartialIndexWorker->启动partialIndexWorker(管理的是fetchCh)->每个worker处理如下:(每个PartialIndex得到所有可能的范围,并用这些范围去构建requestBuilder->distsql.SelectWithRuntimeStats==>fetchHandles->extractTaskHandles->将Task放入fetchCh)==>
waitPartialWorkersAndCloseFetchChan对worker进行wait
=>startIndexMergeTableScanWorker执行pickAndExecTask(在IndexMergeTableScanWorker中处理的是workCh)->executeTask->buildFinalTableReader获取tableReader->不断的对tableReader执行Next将结果放入chunk中。这里为什么会在IndexMergeReader中涉及到TableReader?IndexMergeReader最终的返回结果需要以TableReader的next来进行处理吗?
逻辑大概是先将Task放入fetchCh,在fetchLoop中不断的将Task加上handles封装为一个新的lookUpTableTask放入workCh中,最终在workCh中处理最终的结果。关于句柄:根据返回的Task得到对应的Table Id,根据distinctHandles[tblId]得到Table Id对应的hMap,再通过hMap得到对应的handles即可,得到handles放入workCh。也就是将结果封装一个句柄返回给对应的数据表进行处理。
在IndexMerge的Next路径中如果不涉及到index也有启用worker去运行TableReader的Open方法,不再详述。
考虑Join算子有HashJoinExec、NestedLoopAppyExec、MergeJoinExec分别在join.go和merge_join.go中。
举例MergeJoinExec,其数据来源为baseExecutor中使用Open获得到的ctx,分别获得其中对应的innerTable/outerTable。其baseExecutor可以是DataSource来源的读算子。可以看到读算子的Open会向distsql发起请求,而Join的算子的Open仅是从DataSource中获取数据。这也就是算子间一层层的关系,也就是火山模型一层一层的概念。

func (e *MergeJoinExec) Open(ctx context.Context) error {
	if err := e.baseExecutor.Open(ctx); err != nil {
		return err
	}

	e.memTracker = memory.NewTracker(e.id, -1)
	e.memTracker.AttachTo(e.ctx.GetSessionVars().StmtCtx.MemTracker)
	e.diskTracker = disk.NewTracker(e.id, -1)
	e.diskTracker.AttachTo(e.ctx.GetSessionVars().StmtCtx.DiskTracker)

	e.innerTable.init(e)
	e.outerTable.init(e)
	return nil
}

buildUnionScanForIndexJoin->buildExecutorForIndexJoinInternal->buildTableReaderForIndexJoin->buildTableReaderFromHandles/ buildTableReaderFromKVRanges->buildTableReaderBase->tableReaderBuilder.SelectResult->distsql.SelectWithRuntimeStats

后续的AST、Parser、Optimizer后续再补,想先接触一下整体的框架。
Executor前面的一层会考虑析取(OR)和合取(AND),主要是where语句后面那部分,将语句转换为表里的KVRange,接Executor的KVReq,再往前一层的优化有索引相关的考虑,比如列裁剪就可以好好设计避免索引的回表,还有执行计划的重建,数据库中数据达到一定程度后有时候走别的索引会更好的提升查询速度。
最前面就是Parser,也就是一个语法词法编译器,将SQL语句中的主要标识符提取出来,想之前代码里提到的PlannerCore中就存储了这些数据。其中含有AST树,也是语法构建的一部分。
这就是TiDB整体的思路,主要还是火山模型的一个感觉学到了很多,不同算子不断叠起滤掉相应数据。

(调用Parser后会将文本解析成一个抽象语法树AST,解析其实是通过Lexer将文本转换成token,交付给Parser,Parser根据yacc语法生成,会有SelectStmt等规则将最终的语句抽象为一个ast.StmtNode是一个AST树。
获得了ast.StmtNode以后可以进行Compile制定查询计划Plan。最终将查询计划交给Executor.ExecStmt结构持有查询计划Plan进行执行(ExecStmt.buildExecutor()),Executor每一层通过调用下一层Next/NextChunk,生成执行器后封装在一个RecordSet结构中(含有txnStartTS)。
parser(AST)、plan(AST)、execute(Plan)、recordset

通过Parser后会将文本查询语句依据各种规则进行Lexer的token匹配、如SelectStmt、InsertStmt等,最终可以得到一棵AST树。
运行Parser后我们会得到一棵AST树。

此时我们得到了一个AST树,这个树我们开始对其生成一个Plan,对于plan其实是有preprocessor和optimize,执行计划最终会得到一些算子如全表扫描+过滤。执行计划有逻辑计划RBO,物理计划CBO。逻辑优化包括列裁剪、最大最小消除、投影消除、谓词下推、TopN下推。
逻辑算子Join两表连接、Projection投影取出对应的列、Selection做where的过滤条件、DataSource取From语句后的表、Sort进行Order By、Aggregation做一些聚合操作、Apply做一些子查询操作、Limit算子取相应数量的数据。选择、投影、连接SPJ是最基本的算子。其中的最大最小消除对于max/min转化为limit 1,将一个聚合操作改为一个Limit,节省IO。谓词下推会直接把过滤条件加入到CopTask中,最终通过Coprocessor推给TiKV去做,这里推给TiKV可以是单表上的一些操作,从而该操作可以在TiKV上直接完成并返回结果再由TiDB进行二次聚合返回实际结果。CBO则是依赖于当前统计信息的准确性和及时性更改执行计划,例如对于DataSource会有IndexReader(通过索引读取数据)、TableReader(通过RowID读取数据)、IndexLookupReader(通过索引得到RowID最终读取数据,类似二级索引)的物理执行计划,对于Aggregation有StreamAgg、HashAgg,对于Join的数据有HashJoin、IndexJoin(NestedLoopJoin)、SortMergeJoin等。CBO会挑选最小执行计划的路径,依据DagPhysicalOptimize函数采集对应表的统计信息得到每条路径上算子的代价、再依据各路径算子的代价得到代价最小的路径。关于评估Task有三种类型:Cop Single、Cop Double、Root。Cost会计算网络开销+内存开销+CPU开销、DataSource中可以获得对应表的主键和索引信息,依据此算子的Properties可以进行一些数据统计信息的剪枝。

此时我们得到了一个优化过的执行计划,就可以开始找执行引擎对其进行执行操作了。执行引擎会有火山模型、向量模型等构成一个树状结构,这棵树的每下面一层都是调用Next/NextChunk的方法获取上一个算子的结果。
最终对于Select操作会构造RecordSet不断地调用Next驱动方法获取Row,对于Insert等操作则在构造RecordSet就会返回。)

TiKV知识点

  1. Google Spanner、TrueTime API
  2. Service:
  • gRPC(接TiDB)(kv_get/kv_scan/kv_prewrite/kv_commit API、coprocessor API、raw KV API、future下接Storage)
    (接其他TiKVNode)(Raft&&Batch_Raft)
  • Raft(step/propose/tick/ready/advance、Leader/Follower)
  • MultiRaft、状态机
  • Log与Raft RocksDB、Region
  1. Storage:
  • Percolator(Prewrite+Commit)
  • MVCC
  • 解析Log写落盘KV RocksDB
  • Coprocessor(TiDB单表聚合算子计算下推)
  • Snapshot
    下一篇继续,花点时间理解下rust,里面的future、fsm状态机、mailbox等看着很有意思。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值