TiDB 源码阅读系列文章(十)Chunk 和执行框架简介

什么是 Chunk

TiDB 2.0 中,咱们引入了一个叫 Chunk 的数据结构用来在内存中存储内部数据,用于减少内存分配开销、下降内存占用以及实现内存使用量统计/控制,其特色以下:git

  • 只读
  • 不支持随机写
  • 只支持追加写
  • 列存,同一列的数据连续的在内存中存放

Chunk 本质上是 Column 的集合,它负责连续的在内存中存储同一列的数据,接下来咱们看看 Column 的实现。github

1. Column

Column 的实现参考了 Apache Arrow,Column 的代码在 这里。根据所存储的数据类型,咱们有两种 Column:golang

  • 定长 Column:存储定长类型的数据,好比 DoubleBigintDecimal
  • 变长 Column:存储变长类型的数据,好比 CharVarchar

哪些数据类型用定长 Column,哪些数据类型用变长 Column 能够看函数 addColumnByFieldType 。数据库

Column 里面的字段很是多,这里先简单介绍一下:缓存

  • length

    用来表示这个 Column 有多少行数据。session

  • nullCount

    用来表示这个 Column 中有多少 NULL 数据。数据结构

  • nullBitmap

    用来存储这个 Column 中每一个元素是不是 NULL,须要特殊注意的是咱们使用 0 表示 NULL,1 表示非 NULL,和 Apache Arrow 同样。app

  • data

    存储具体的数据,无论定长仍是变长的 Column,全部的数据都存储在这个 byte slice 中。框架

  • offsets

    给变长的 Column 使用,存储每一个数据在 data 这个 slice 中的偏移量。函数

  • elemBuf

    给定长的 Column 使用,当须要读或者写一个数据的时候,使用它来辅助 encode 和 decode。

1.1  追加一个定长的非 NULL 值

追加一个元素须要根据具体的数据类型调用具体的 append 方法,好比: appendInt64appendString 等。

一个定长类型的 Column 能够用以下图表示:

咱们以 appendInt64 为例来看看如何追加一个定长类型的数据:

  • 使用 unsafe.Pointer 把要 append 的数据先复制到 elemBuf 中;
  • 将 elemBuf 中的数据 append 到 data 中;
  • 往 nullBitmap 中 append 一个 1。

上面第 1 步在 appendInt64 这个函数中完成,第 二、3 步在 finishAppendFixed 这个函数中完成。其余定长类型元素的追加操做很是类似,感兴趣的同窗能够接着看看 appendFloat32appendTime 等函数。

1.2  追加一个变长的非 NULL 值

而一个变长的 Column 能够用下图表示:

咱们以 appendString 为例来看看如何追加一个变长类型的数据:

  • 把数据先 append 到 data 中;
  • 往 nullBitmap 中 append 一个 1;
  • 往 offsets 中 append 当前 data 的 size 做为下一个元素在 data 中的起始点。

上面第 1 步在 appendString 这个函数中完成,第 二、3 步在 finishAppendVar 这个函数中完成。其余边长类型元素的追加操做也是很是类似,感兴趣的同窗能够接着看看 appendBytesappendJSON 等函数。

1.3  追加一个 NULL 值

咱们使用 appendNull 函数来向一个 Column 中追加一个 NULL 值:

  • 往 nullBitmap 中 append 一个 0;
  • 若是是定长 Column,须要往 data 中 append 一个 elemBuf 长度的数据,用来占位;
  • 若是是变长 Column,不用往 data中 append 数据,而是往 offsets 中 append 当前 data 的 size 做为下一个元素在 data 中的起始点。

2. Row

如上图所示:Chunk 中的 Row 是一个逻辑上的概念:Row 中的数据存储在 Chunk 的各个 Column 中,同一个 Row 中的数据在内存中没有连续存储在一块儿,咱们在获取一个 Row 对象的时候也不须要进行数据拷贝。提供 Row 的概念是由于算子运行过程当中,大多数状况都是以 Row 为单位访问和操做数据,好比聚合,排序等。 

Row 提供了获取 Chunk 中数据的方法,好比 GetInt64GetStringGetMyDecimal 等,前面介绍了往 Column 中 append 数据的方法,获取数据的方法能够由 append 数据的方法反推,代码也比较简单,这里就再也不详细介绍了。

3. 使用

目前 Chunk 这个包只对外暴露了 Chunk, Row 等接口,而没有暴露 Column,因此,写数据调用的是在 Chunk 上实现的对 Column 具体函数的 warpper,好比 AppendInt64;读数据调用的是在 Row 上实现的 Getxxx 函数,好比 GetInt64

执行框架简介

1. 老执行框架简介

在重构前,TiDB 1.0 中使用的执行框架会不断调用 Child 的 Next 函数获取一个由 Datum 组成的 Row(和刚才介绍的 Chunk Row 是两个数据结构),这种执行方式的特色是:每次函数调用只返回一行数据,且不论是什么类型的数据都用 Datum 这个结构体来封装。

这种方法的优势是:简单、易用。缺点是:

  • 若是处理的数据量多,那么框架上的函数调用开销将会很是大;
  • Datum 占用的无效内存太大,内存浪费比较多(存一个 8 字节的整数须要 56 字节);
  • Datum 没有重用,golang 的 gc 压力大;
  • 每一个 Operator 一次只输出一行数据,要进行更加缓存友好的计算、更充分的利用 CPU 的 pipeline 很是困难;
  • Datum 中的 interface 类型的数据,统计它的内存使用量比较困难。

2. 新执行框架简介

在重构后,TiDB 2.0 中使用的执行框架会不断调用 Child 的 NextChunk 函数,获取一个 Chunk 的数据。

这种执行方式的特色是:

  • 每次函数调用返回一批数据,数据量由一个叫 tidb_max_chunk_size 的 session 变量来控制,默认是 1024 行。由于 TiDB 是一个混合 TP 和 AP 的数据库,对于 AP 类型的查询来讲,由于计算的数据量大,1024 没啥问题,可是对于 TP 请求来讲,计算的数据量可能比较少,直接在一开始就分配 1024 行的内存并非最佳的实践( 这里 有个 github issue 讨论这个问题,欢迎感兴趣的同窗来讨论和解决)。
  • Child 把它产出的数据写入到 Parent 传下来的 Chunk 中。

这种执行方式的好处是:

  • 减小了框架上的函数调用开销。好比一样输出 1024 行结果,如今的函数调用次数将会是之前的 1/1024。
  • 内存使用更加高效。Chunk 中的数据组织很是紧凑,存一个 8 字节的整数几乎就只须要 8 字节,没有其余额外的内存开销了。
  • 减轻了 golang 的 gc 压力。Chunk 占用的内存能够不断地重复利用,不用频繁的申请新内存,从而减轻了 golang 的 gc 压力。
  • 查询的执行过程更加缓存友好。如咱们以前所说,Chunk 按列来组织数据,在计算的过程当中咱们也尽可能按列来计算,这样既能让一列的数据尽可能长时间的待在 Cache 中,减轻 Cache Miss 率,也能充分利用起 CPU 的 pipeline。这一块在后续的源码分析文章中会有详细介绍,这里就再也不展开了。
  • 内存监控和控制更加方便。Chunk 中没有使用任何 interface,咱们能很方便的直接获取一个 Chunk 当前所占用的内存的大小,具体能够看这个函数:MemoryUsage。关于 TiDB 内存控制,咱们也会在后续文章中详细介绍,这里也再也不展开了。

3.  新旧执行框架性能对比

采用了新的执行框架后,OLAP 类型语句的执行速度、内存使用效率都有极大提高,从 TPC-H 对比结果 看,性能有数量级的提高。

做者:张建
相关文章
相关标签/搜索