SQL Server In Memory OLTP - 存储内幕浅谈

在启用了memory optimized OLTP的第一个版本sql server 2014中,memory optimized table的memory分配是无规则的,不像disk-based table一样,有一个page专门用来记录已经分配的表数据页的物理地址,这样一张表的数据,直接从这个page查询就能找到了。这种page叫做indexed allocated map page. 而在memory optimized table层面来讲,在sql server 2014中是没有对应的数据结构来执行IAM Page一样的功能。直到sql server 2016开始,有一个结构varheap专门为memory optimized table分配内存,此时才有些系统化的样子。

在sql server 2014中,没有varheap的时代,怎么做表数据row的映射呢?通过每一个row,存储下一个row的物理内存地址来串联起一个表的数据。这里的功劳是index的。memory optimized table的index,会将所有的数据行,按照索引列,存储在一个row array里面,同一个row array里面存储的则是这些拥有相同索引值的row,每一个索引条目(索引row)里面,有3个不同的array.

存储相同索引值的row array里面,一个接一个的存储了相同索引值的row.假设row array是个列表,列表会通过存储一个指针,指向下一个列表项,而这个列表项代表的就是row的物理内存地址。

索引结构,除了存储了索引字段的值,还存储了其他不是索引字段的列的值,这样在查询表的时候,如果用到的是全表扫描,或者不是索引列的字段,那么执行计划还是会找一个索引来扫描,而不是直接去读表。

那么问题来了,既然索引存储了row的物理内存地址,那么为什么还要将非索引列的其他列都存放在索引页上呢?

这里有可能是对row存储理解不清晰造成的:一个memory optimized table的row,有下面几部分组成:

Begin TimeStamp
End TimeStamp
Index Counter
A set of Index Pointers: point to the next row based on the index
PayLoad: the actual data stored

其实,memory optimized table的row,本身就是索引(无论是hash index还是range index)的一部分。row在Index Pointer里面存储了下一个row的物理地址,假设某个表有2个index,在一个row里面就存储了每一个index,对应的下一条row的物理地址。

比如有一个memory optimized table, dbo.Person, 有name, city 两列。我们对这两列,分别作了index.那么在这个表的每一条row里面,分别存储了以name为索引的下一条row的物理内存地址, 和以city为索引的下一条row的物理内存地址。Index Counter为2.假如Index Counter变为0了,那么说明这条数据没有被索引引用,是条已经被删除的数据,可以被回收。因为某条数据如果是最后一条数据,那么可能就没有存储其他数据行的内存地址,pointers set可能是null,因此并不能依靠pointers set来判断数据行是否有用或者可以被删除。

这里还有个问题,pointers set里面存储的下一条数据行的物理内存地址,怎么知道是属于哪个index?
一个row里面,Pointers set里面存储的本身是一堆有序的pointers,那么从第一到最后一个index的pointer在这个集合里面都是有序列编码的,比如1,表示第一个index的pointer;2,表示第二个index的pointer,以此类推!

每种index的存储都不一样,hash index的page,仅仅是一个bucket array, 每一个bucket的起始地址是具有相同hash值的第一个row的物理地址。range index的page,和b-tree索引页一样,有层级关系,上层索引页会存储指向下一层索引页的物理地址,而子页存储的是指向相同索引值的第一个row的物理地址。

如何理解每个index page是固定不可变的:

memory optimized table的range index,最终存储的方式是以一条条相同索引值组成的链条,存储了表中的数据。因此只要存入的数据是原先已经有的索引值,那么只是在链条上加了一环而已,并不需要重新修改索引页。唯一需要重新修改索引页的情景是更新一个索引值或者删除一个索引值所对应的数据。此时,需要做的操作就是新建一个索引页,然后让中间层节点存储指向这个新建索引页的物理内存地址,并将旧索引页标记为删除即可。

memory optimized table共支持三种不同类型的索引:
Hash index;
Range Index;
ColumnStored Index;

hash index是hash函数计算索引值的索引。 由hash函数计算出来的,具有相同索引值的多行数据行,其实并不具有唯一性。也就是说,有可能UnitedStatus和KenberleyKing计算得到的hash值完全是一样的(这里只是随便举个例子,hash值最终的取得,需要依赖于hash方法的实现)。考虑只有一个列的hash index,具有相同hash值的这些数据行,会被放在同一个bucket里面。这样,依靠hash索引得到的数据行,其实并不是需要的那行数据。怎么解释?

解释这种原因,在red-gate上有个很好的帖子,也就是说:在创建hash index的时候,我们需要指定一个Bucket的大小。本来有多少个不同的唯一值(字段得到的hash值),就有多少个不同的bucket.但实际上,bucket数量并不是随着数据的增加,更新而改变的,是在一开始创建hash index的时候,就被指定了一个大小。这个大小是根据基数(2)倍增到一个具体的值(2的N次方)。比如你指定了100个bucket,实际上会被指定2的7次方,即128个bucket.所有表数据,都会被hash到这些指定的bucket里面去。那么只要数据量达到一定的规模,hash bucket能承载的唯一值都已经用完,后来进来的数据,必然是会有不同字段值产生了相同hash值。故也就有了上面的可能性:UnitedStatus和KenberleyKing计算得到的hash值完全是一样的。

microsoft对这一现象也有一定的容忍度,当大于30%的重复使用Bucket的时候,就会有性能问题。这主要是因为bucket的数量指定太小而造成的。那我们把bucket数量指定的足够大,总行了吧。也不是那么回事。刚才说到,Bucket的大小会是2的n次方,而不是创建hash index时候指定的数字,只是比你指定的bucket大小稍微大一些。而管理这些Bucket是需要8bytes内存的,所以大数字的bucket,必然带来很大内存开销。

在建立primary key的时候,如果不明确指定使用hash index, 那么默认是会用range index的。我认为这也是合理的,毕竟primary key的唯一值太多了,用hash index不好控制量。一个hash index的创建语法:

create table Customers(
id int not null
, name varchar(200) not null
primary key nonclustered hash (name) with (bucket_count = 124)
) with ( memory_optimized=on, durability = schema_and_data)

nonclustered index,名字相同,但是意义和disk-based table的nonclustered index,却不一样。hash index for memory optimized table,其实也不是clustered index。memory optimized table的nonclustered index, 使用的是b-tree的变种 – bw-tree。 最大的不同在于子节点存储的数据项目不一样。disk-based table的nonclustered index,子节点按照索引值存储的要么是原数据行的row id, 要么是聚集索引的索引值,有多少个就在子节点上存多少个,这样的后果是子节点页面也无限增大。对于memory optimized table, nonclustered index 在子节点页面存储的就不再是具体的一个个索引值对应的row id或者聚合索引值,而仅仅就是一个索引值。任何具有相同索引值的数据行,都被放在这一个索引值开辟的地址空间数组内。在这个数组内,存储的是一系列具有相同索引值的数据行组成的chain.而且每项都是索引字段和非索引字段的全部字段。这么看来memory optimized table上建立的索引,都将是每一个Memory optimized table的副本数据。

ROW Page存储的是row header 和payload.这是存储真实数据的结构。Index Page也尤其存储结构,包含了:
PID
Page Type
Right PID
Height
Page Statistics
Max Key
最重要的是他的可变长的数组:
Keys: 索引值,所有在这一页上存储的不尽相同的索引值
Values:中间页:下一级页的PID;叶子页:数据行(row 的内存物理地址),这行数据是相同索引值的行链上的第一条数据;
Offsets:当组成索引的索引列,是变长的情况,offsets存储了起始位置。

Memory optimized table也支持columnstore index. 在disk-based table上建立columnstored index, 是可以建立成为聚集索引,也可以建成非聚集索引。两者最大的区别,在与clustered columnstored index随着数据的更新而更新,但是nonclustered columnstored index则是只读的。需要的时候,重建索引。

假设columnstore index有2个列构成,这两个列按照指定顺序,先将第一个列排序,第一个列拥有相同值的,再按照第二个列排序。(这个假设不成立,columnstore index是以BLOB Allocation Unit分配的,存储在segment里面的数据只是一个编码值,不能根据编码值确定字段值的顺序。除非columnstore index包含的是clustered index的列或者partition的列)第一个列的前N条记录,肯定拥有相同的值,假如将这些值一条一条存储下来,肯定也占用不少空间,效率无法提高。我认为保存了一个唯一值作为第一个列的key值,然后将第二列对应列值的segment信息存在第一个列的value里面。这样通过限制第一个列的索引值,就间接找到了第二个列的segment,这样就可以做筛选了。那么columnstore index到底是怎么去做和table row的映射呢?普通索引在页级节点就存放了row id或者聚集索引值,对应的columnstore index怎么存储呢 ?其实columnstore index不再存储表级数据了。它本身就是一个微型表。

Columnstore index 的内幕:

传统的非columnstore index,将所有的索引列都存储在一个数据页(叶子节点)上面。当需要读取第一个列的数据时,需要将一个数据页的数据都读取出来。所以读取10000行第一个列的数据会造成读取多个数据页,同时读取了其他索引列的数据。如果我们仅仅需要第一列的索引列数据,那么其他列的数据就是白白读取了。

使用columnstore index就可以解决这类浪费读取数据的问题。在一个数据页里面,就只存储了第一个索引列的数据,而且是单一值。单一值的意思,就是相同索引值只会出现一次。这样一页columnstore index data page上,不仅能存储比原先的存储方式多的索引值,还能极大缩减原先的无用的索引列数据。
使用columnstore index查询时,读取的仅仅是索引上的各列的值,columnstore index并不存储指向原表数据的指针,不管是ROW ID也好,还是Primary Key值也好,通通不存储。所有的数据都会直接从columnstore index读取。最符合columnstore index的场景是,数据仓库/BI中对事实表的多维度查询,比如:

select Product, OrderMonth, Sum(Amount) As Amount
from FctMonthlySales
Group by Product, OrderMonth

假设我们对FctMonthlySales的Product, OrderMonth,Amount做了columnstore index, 那么在这个查询中,将只使用这个columnstore index来查询。columnstore index 分成了3个segment来存储,分别是Product, OrderMonth,Amount. Product segment中的第一行数据,对应了OrderMonth,Amount segment中的第一行数据。

在Product, OrderMonth, Amount三个segment中,他们的数据类型不一致,导致的存储空间也不一致,怎么保证一行一行逐一对应?虽然在数据仓库的事实表中,前面两列通常会是维度表的代理键,都会是Bigint类型,理论上完美的数据结构中,他们的存储是完全一致的。但是Amount就不一样了,有可能是浮点类型。所以至少他们中有2种数据类型,那么这两种数据类型怎么保证只存储了一样条目数的数据?每个segment的行号都一一对应?

Columnstore index的存储结构,是一种BLOB结构,同样是由allocation unit分配。这个BLOB结构里包含了所有的segment数据。在同一个columnstore index的column segment中,使用的都是同一种压缩编码方法:

Dictionary Encoding
Huffman Encoding
Run Length Encoding
Lempel-Ziv-Wlch

columnstore index的存储结构,除了BLOG存储的是真实数据之外,还有包含了一类数据,就是各种数据字典:
Primary Dictionary
Secondary Dictionary

SQL Server In Memory OLTP - 存储内幕浅谈_第1张图片

column segment里面存储的并不是真实的字段值,而是已经编码过(将字段值编译成简单的数字)的码值。按照表数据行的顺序进行了存储。真实的字段值由字典来保存。

所有的字段唯一值,做成的编码表,称为Primary Dictionary, 所有字段值横跨多个segment,被存在Secondary Dictionary里面,可以有多个Secondary Dictionary. BLOB中存储的是编码值,这些编码值对应的字段值被存在dictionary里面,那么这些dictionary物理上又被存在哪里?

Sys.column_store_dictionaries, sys.column_store_segments存储了segment与dictionary的对应关系,假设sys.column_store_segments存储了columnstore index的所有segment BLOB ,以varchar(max)格式存储;那么dictionaries也可以看做是sys.column_store_dictionaries的varchar(max) BLOB 列。

Columnstore index带来的好处, segment elimination. 根据排序,跳过越多的segment,相当于跳过n个百万条数据。实现segment elimination的前提是,columnstore index选用的列,本身是要按顺序排列存储的。这样编码出来的编码值就自带顺序,并且columnstore index 的column segment,本身不会按照字段值排序,但是如果columnstore index的column是建立在本身排序的字段上,那么在segment建立的时候,也就按照顺序排列存储了。

Clustered Columnstore index与partition table (聚集列式索引和分区表):

columnstore index的存储结构包含了segment与dictionary,与Row Store Index不同,segment在数据页上的存储并非是一行一行存储了实际数据,而是存储了字段值的不同编码值。在一个segment也称之为row group里面,最多存储了2^20条数据,也就是1,048,576条数据。一个partition规定的是在某一个范围内的数据,segment里面存储的是在这个segment里面从最小到最大值的编码值。partition是外层限制,如果segment的数据不在某一个partition范围之内,则需要重新启动一个segment来存放。

装载columnstore index的时候,多个线程可以并行输入数据,因此在多个segment里面存在没有完全填满一个segment的情况,比如10million记录,理论上需要的是9 segments(每segment 1048576条)加1 segment(装载剩下的562816条数据). 实际上,存储10million记录可能用了15个segments,有部分segment可能没有完全写满1 million条记录。

理论上每segment能存储1million的数据,那么最大值是1048567既2^20,物理上用来存储这个值的byte必须有3个,也可以是20bit.如果为每一个编码值都用20bit来存储显然是浪费空间,为1million的数据实际物理byte放1个符号来分割,可以极大减少空间存储。如果将columnstore index的索引列值打乱重新排序,这样让压缩效率更加高一些,就会使columnstore index的segment elimination发挥作用。

欢迎关注个人微信公众号【有关SQL】

SQL Server In Memory OLTP - 存储内幕浅谈_第2张图片

你可能感兴趣的:(sql,server,SQL,Server,实战笔记)