----------------------------我是分割线-------------------------------
本文翻译自微软白皮书《SQL Server In-Memory OLTP Internals Overview》:
译者水平有限,如有翻译不当之处,欢迎指正。
----------------------------我是分割线-------------------------------
行和索引存储
内存中OLTP的内存优化表与表上的索引采用了与基于磁盘的表非常不同的方式进行存储。内存优化表并不像基于磁盘的表那样存储在数据页或者从盘区中分配的空间中,这是由于内存优化表是为按字节寻址的内存而不是按块寻址的磁盘所优化的设计原则决定的。
数据行
数据行是从被称为堆的结构中分配的,这些堆与SQL Server为基于磁盘的表所支持的堆的类型不同。单表上的数据行不一定存放同一张表的其他数据行附近, SQL Server知道哪些数据行属于同一个表的唯一方法是因为它们都是用这张表的索引连接在一起。这就是为什么内存优化表必须拥有至少一个创建在其中的索引的要求。索引为表提供了结构。
数据行本身的结构与基于磁盘的表使用的数据行结构有很大不同。每个数据行包含一个标题和含有这个数据行属性的有效负载。图2展示了这样的结构,以及扩展了标题区域中的内容。
图2 内存优化表中一个数据行的结构
数据行标题
标题包含了两个8字节的字段,这两个字段记录了内存中OLTP的时间戳:开始时间戳和结束时间戳。每个支持内存优化表的数据库都会管理两个用于生成这些时间戳的内部计数器。
- 事务ID计数器是一个全局的唯一值,当SQL Server实例重新启动时,这个值会被重置。每次新的事务启动时它会递增。
- 全局事务时间戳也是全局和唯一的,但在重新启动时不会被重置。这个值在每个事务结束后并开始验证处理过程时增加。新的值则为当前事务的时间戳。在使用从恢复记录中找到的最大事务时间戳进行恢复时,全局事务时间戳的数值会被初始化。 (我们稍后将在本白皮书中看到更多关于恢复的内容。)
开始时间戳的值是插入数据行的事务的时间戳,结束时间戳的值是删除数据行的事务的时间戳。一个特殊值(称为“无穷大”)被用作为尚未被删除行的结束时间戳。但是,当数据行第一次被插入时,在插入事务完成之前,事务的时间戳是未知的,因此在事务提交前开始时间戳会使用全局的事务ID值。类似地,对于删除操作,事务的时间戳是未知的,所以对于被删除的数据行的结束时间戳则使用全局的事务ID值,一旦确定了事务实际的时间戳,这个全局的事务ID值则会被更换。正如在讨论数据操作时我们所看到的,开始时间戳和结束时间戳确定了哪些其它的事务将能够看到这个数据行。
标题还包含了一个四字节的语句ID值。一个事务中的每条语句都有一个唯一的StmtId值,当数据行被创建时,它会为存储创建这个数据行语句的StmtId。如果相同的语句再次访问同一数据行,它的StmtId则会被跳过。
最后,标题包含一个双字节值(idxLinkCount),这个引用计数表明了有多少索引引用了这一数据行。接下来的idxLinkCount值是一组索引指针,在下一节中将进行介绍。指针的数量等于索引的数量。数据行的引用值需要从1开始,这样即使该数据行不再连接到任何索引,数据行仍可以被垃圾收集(garbage collection ,GC)机制所引用。垃圾收集被认为是初始引用的“所有者”。
如之前所提到的,表上的每个索引都有一个指针,并且是这些指针加上索引数据结构将数据行连接在一起。除了索引指针将数据行连接在一起之外,并没有其他结构将数据行合并成一张表。正是这个原因才导致了所有内存优化表中都必须至少有一个索引的要求。另外,由于指针的数量是数据行结构的一部分并且数据行从来都不能被修改,因此所有索引都必须在创建内存优化表时进行定义。
有效负载数据
有效负载就是数据行本身,包含键值列加上数据行中所有其他的列。(这就意味着,在内存优化表上的所有索引实际上都是覆盖索引)有效负载的格式依据表进行变化。正如在前面创建表的章节所提到的,内存中OLTP编译器为表操作生成了DLL文件,只要它知道在数据行插入表时所使用的有效负载的格式,它就可以为所有的数据行操作生成适当的命令。
内存优化表中的索引
所有内存优化表必须至少有一个索引,因为正是索引将所有数据行连接在一起。正如前面提到的,数据行并不存储在数据页中,所以没有数据页或盘区的集合,也没有分区或分配单元可以被引用来获取表上所有的数据页。各类索引中一类索引有索引页的一些概念,但这些索引页存储的方式不同于基于磁盘的表的索引。
内存中OLTP的索引,以及在数据处理过程中对它们所做的更改,都不会被写入磁盘。只有数据行以及数据的变化,才会被写入到事务日志中。内存优化表的所有索引都在数据库恢复期间基于索引的定义进行创建。我们将在接下来的检查点和恢复章节中讨论相关的细节。
哈希索引
哈希索引由一个指针数组组成,数组中的每个元素被称为一个哈希桶。每个数据行中的索引键值列都有一个对其应用的哈希函数,并且这个函数的结果确定了这个数据行使用哪个哈希桶。具有相同哈希值的所有键值(即拥有通过哈希函数所得出的相同的结果)通过哈希索引中同一个指针进行访问,并且连接成一条链。当某一数据行被添加到表中时,哈希函数被应用到该数据行中的索引的键值上。由于重复键值将总是产生相同的函数结果,因此如果存在重复的键值,键值将始终位于同一条链中。
图3显示了在Name列上一个哈希索引的一个数据行。对于这个例子,假定有一个非常简单的哈希函数,这个函数产生与该字符串中索引键值列长度相等的一个数值。第一个值“Jane”的哈希值为4,目前为止,这个值在哈希索引的第一个哈希桶中。(请注意,真正的哈希函数是更加随机和不可预测的,不过现在使用的这个长度的例子使其更加易于解释说明。)你可以看到从哈希表中值为4的条目到Jane所在数据行的指针。这一数据行并不指向任何其他的数据行,所以这条记录的索引指针为NULL。
图3 拥有单个数据行的一个哈希索引
在图4中,一个Name列值为Greg的数据行已经被添加到表中。由于我们假设Grag的哈希值也映射到4上,因此Grag与Jane在同一个哈希桶中,并且数据行被链接到与Jane所在数据行的同一条链中。Greg所在数据行有一个指向Jane所在数据行的指针。
图4拥有两个数据行的一个哈希索引
在City列上,表定义中包含的第二个哈希索引创建了第二个指针字段。现在,表中的每一个数据行都有两个指针(每个索引各有一个)指向它,并具备指向两个以上数据行的能力。每一个数据行的第一个指针为Name列上的索引指向链中的下一个值;第二个指针为City列上的索引指向链中的下一个值。图5显示了在Name列上同一个哈希索引,现在这个索引拥有了哈希值为4的三个数据行,以及哈希值为5 的两个数据行,等于5的哈希值使用了Name列上索引的第二个哈希桶。在City列上的第二个索引使用了三个哈希桶。哈希值为6对应的哈希桶在链中拥有三个值,哈希值为7对应的哈希桶在链中拥有一个值,而哈希值为8对应的哈希桶在链中也拥有一个值。
图5 在同张表上的两个哈希索引
如此前在CREATE TABLE的示例所示,当创建一个哈希索引时,必须指定哈希桶的数量。我们建议选择哈希桶的数量等于或大于索引键值列预期的基数(即唯一值的数量),这样每个哈希桶只拥有链中单一值的数据行的可能性更大一些。不过要注意不要选择一个过大的数量,因为每个哈希桶都需要消耗内存。你提供的数字会被凑整为2的下一个乘方,所以50000的值将被凑整至65536。拥有额外的哈希桶并不会提高性能,但很明显会浪费内存并且有可能减少降低扫描的性能,因为扫描会为数据行检查每一个哈希桶。
在决定建立一个哈希索引时,记住实际使用的哈希函数是基于所有的键值列。这就是说,如果在一个employees表中的lastname和firstname列上有一个哈希索引,值为“Harrison”和“Josh”的数据行可能会被哈希到与值为“Harrison”和“John”的数据行不同的哈希桶中。只提供一个lastname值或者一个不精确的firstname值(例如,“Jo%”)的查询将完全无法使用这个索引。
内存优化的非聚集索引
如果你不知道某个列所需的哈希桶的数量,或者如果你知道你会根据值的范围来查找数据,你应该考虑创建一个内存优化的非聚集索引,而不是哈希索引。这些索引都是通过采用被称为Bw树的新型数据结构来实现的,Bw树最初是由微软研究院在2011年设想并定义的。内存优化的非聚集索引是B树的一个没有锁和闩锁的变型。
除了索引页没有固定的大小之外,这个非聚集索引的大体结构类似于SQL Server的常规B树,并且一旦建立,它们是不可改变的。像一个普通B树页一样,每个索引页包含了一组有序的键值,并为每个值都有一个对应的指针。在索引的上层,在所谓的内部页上,指针指向索引树下一级的索引页,而在叶级别,指针指向一个数据行。就像内存中OLTP的哈希索引那样,多个数据行可以被连接到一起。对于非聚集索引,具有相同索引键值的数据行将会被连接起来。
内存优化的非聚集索引和SQL Server的B树之间的很大的一个区别是页指针是一个逻辑页ID(PID),而不是一个物理页号。 PID标志着映射表中的一个位置,映射表中通过物理内存地址连接到每个PID上。索引页永远不会被更新,而是被替换为一个新的页并更新映射表,这样,相同的PID就指示到了一个新的物理内存地址上。
图6表明了一个内存优化的非聚集索引的大体结构,以及页映射表。
图6 一个内存优化的非聚集索引的大体结构
在图6中并没有将所有的PID值都标记出来,而且映射表也没有显示出在使用中的所有PID值。索引页显示了索引引用的键值。内部索引页中的每个索引行包含了一个键值(如图所示),和下一个级别页的PID。键值为所引用的页上可能的最大值。 (注意,这与常规的B树索引不同,对于常规的B树索引,索引行存储了在下一级别页上的最小值。)
叶级索引页也包含键值,但不是一个PID,它们包含一个数据行的实际内存地址,这个实际内存地址可能位于键值相同的数据行的链中的第一位。
内存优化的非聚集索引和SQL Server的B树的另一大区别是在叶级节点,使用一组增量值来记录对数据更改的跟踪。对于每次更改,叶级页本身并不被替换。每次对一个页的更新,可以是在页中插入或删除一个键值,都会生成一个包含了表明所执行更改的增量纪录的页。一个更新则是由两个新的增量记录来表示,一个是删除的原始值,一个是插入的新值。当添加每个增量记录时,映射表都会用包含新增增量记录的页的物理地址进行更新。图7说明了这种行为。映射表只显示了逻辑地址为P的单个页。如P页所示,在映射表中的物理地址原本是对应的叶级索引页的内存地址。在索引键值为50的新行(假设在表的数据中这个值此前并没有发生过)被添加到表中之后,内存中的OLTP将delta记录增加到P页,这表明了新值的插入,并更新了P页的物理地址,以表明第一个增量记录页的地址。假设然后索引键值为48的唯一行从表中被删除。那么内存中OLTP必须删除键值为48的索引行,因此创建了另一个增量记录,并且P页上的物理地址再次被更新。
图7 链接到一个叶级索引页的增量记录
索引页结构
尽管最大的索引页的大小仍然是8KB,但与基于磁盘的表上的索引不同,内存中OLTP的非聚集索引页并没有一个固定的大小。
内存优化的所有非聚集索引页都拥有包含以下信息的标题区:
- PID -到映射表的指针
- 页类型 - 叶级页,内部页,增量页或特殊页
- 右页PID -当前页面右侧页面的PID
- 高度 - 从当前页到叶级页的垂直距离
- 页面的统计数据 – 增量记录的数量加上页上记录的数量
- 最大键值 - 页上数值的上限
此外,叶级页及内部页都包含两个或三个固定长度的数组:
- 数值 - 这事实上是一个指针数组。数组中的每个条目为8字节长。内部页的条目包含了下个级别中一个页的PID,而叶级页的条目包含了具有相等键值的数据行所在链中第一个数据行的内存地址。(需要注意的是从技术的角度说,PID可以存储在4个字节中,但为了对所有的索引页都采用相同的数值结构,因此数组允许每个条目为8个字节)。
- 偏移 - 这个数组只存在于拥有可变长度键值的索引的页中。每个条目为2个字节,并包含了对应键值在页上的键值数组中起始位置的偏移。
- 键值 - 这是键值的数组。如果当前页是一个内部页,键值表示PID所引用的页上的第一个值。如果当前页是叶级页,键值则是数据行所在链中的值。
最小的页通常是增量页,增量页拥有一个包含了与内部页或叶级页大部分相同信息的标题。但是增量页的标题没有叶级页或内部页所介绍的数组信息。一个增量页只包含一个操作码(插入或删除)和一个数值,就是数据行所在链中第一个数据行的内存地址。最后,增量页也还包含用于当前增量操作的键值。实际上,你可以把一个增量页看作是一个小型的保存单个元素的索引页,而普通索引页则存储了N个元素的一个数组。
非聚集内部重构操作
有三种不同的操作可以被用于管理这个索引的结构:汇总,拆分和合并。对于所有这些操作,都不会更改现有的索引页。而是更改映射表来更新一个PID值所对应的物理地址。如果一个索引页需要添加一个新的数据行(或者删除一个数据行),则会创建一个全新的页,并在映射表中更新PID值。
增量记录的汇总
一条增量记录的长链最终会导致搜索性能的降低,因为当SQL Server通过索引进行搜索时,它必须考虑在增量记录中的更改以及索引页的内容。如果内存中OLTP尝试将一个新的增量记录添加到一个已经有16个元素的链中时,增量记录中的更改将被汇总为一个引用的索引页,并且该页将被重建,并包含了触发汇总的那条新的增量记录所指示的更改。新的重建页的PID值和原有的值相同,但是拥有一个新的内存地址。旧的页(索引页加上增量页)将被标记为垃圾回收。
一个完整索引页的拆分
一个索引页根据按需的原则增长,可以从存储单行开始到最多存储8K字节。一旦索引页增长到8K字节,一条新插入的数据行将会导致索引页进行拆分。对于一个内部页,这就意味着没有更多的空间来添加另一个键值和指针,而对于一个叶级页,则表示一旦合并所有的增量记录,这个数据行则太大而无法放在这个页中。叶级页的页标题中的统计信息持续对汇总增量记录所需空间进行跟踪,每添加一个新的增量记录时,这个信息都会进行调整。拆分操作由接下来介绍的两个原子步骤完成。假设Ps是拆分成页P1和P2的页,而PP则是父级页,其拥有指向Ps的一个数据行。
- 步骤1:分配两个新的页P1和P2,并将数据行从Ps页拆分到这些页上,包括新插入的行。在页映射表中的一个新的位置用来存储 P2页的物理地址。P1和P2这两个页此时还不能被任何并发的操作访问到。另外还设置了从P1到 P2的“逻辑”指针。这个操作完成后,在同一个原子操作内对页映射表进行更新,将指针从指向Ps更改为指向P1。这个操作之后,就没有指向Ps页的指针了。
- 步骤2:步骤1后,父级页PP指向了P1,但没有一个直接的指针从父级页指向页P2。页P2只能通过页P1访问。为了创建一个从父级页到页P2的指针,需要分配一个新的父级页PNP,从页PP复制所有的数据行,并添加一个新的数据行指向页P2。这个操作完成后,在同一个原子操作内对页映射表进行更新,将指针从PP改变到PNP。
相邻索引页的合并
当删除操作使得一个索引页P只剩下少于最大页面大小(目前为8K)的10%,或者在页上只有单个数据行时,页P将会被合并到其邻近的页面。类似于拆分,这也是一个多步骤的操作。对于这个例子,假设我们将一个页及其左邻的页进行合并,左邻的页也就是拥有更小数值的那个页。当一个数据行从页P中删除时,标识删除的增量记录照常添加。此外,还执行了一个检查以确定页P是否符合合并的条件(比如,删除数据行后剩余的空间会小于最大页面大小的10%)。如果符合条件,合并则会按照以下的介绍在三个原子步骤中执行。对于这个例子,假设页PP是拥有一个指向页P的数据行的父级页,Pln表示左邻的页面,并且我们假设它的最大值是5。这就意味着在父级页PP中指向Pln的数据行中包含为5的值。我们将在页P中删除键值为10的数据行。删除后,将只有一个键值为9的数据行保留在页P中。
- 步骤1:创建了一个表示键值为10的增量页DP10,并且将其指针设置为指向P。另外创建了一个特殊的“合并增量页”DPM,将其指向DP10。请注意,在这个阶段,页DP10和DPM对于任何并发事务都还是不可见的。在同个原子步骤中,在页映射表中指向页P的指针被更新成指向DPM。这一步之后,在父页面PP中键值为10的条目现在指向DPM了。
- 步骤2:在这一步中,在页PP中表示键值为5的数据行被删除,并且键值为10的条目被更新为指向页Pln。为了做到这一点,分配了一个新的非叶级页PP2,并且页PP中除了表示键值为5的数据行之外,其他所有数据行都被复制到页PP2中;然后键值为10的那个数据行被更新为指向页Pln。这个操作完成后,在同一个原子操作内,指向页PP的页映射表条目被更新为指向页PP2。页PP则不能再被访问到。
- 步骤3:在这一步中,叶级页P和Pln被合并,而增量页则被删除。为了做到这一点,分配了一个新的页Pnew,并且P和Pln中的数据行进行合并,并且新的页Pnew包含了增量页中的更改。现在,在同一个原子操作内,指向页Pln的页映射表条目被更新为指向页Pnew。
数据操作
SQL Server的内存中OLTP通过维护一个以提供时间戳的目的的内部事务ID来确定哪些行版本对哪些事务可见,在本节中将称其为时间戳。时间戳是由每次事务提交时增长的一个单调递增计数器生成。一个事务的开始时间是在事务开始的那个时间点数据库中最大的时间戳,并在事务提交时,会生成一个新的时间戳,这个时间戳唯一标识了这个事务。时间戳是用来指定以下内容:
- 提交/结束时间:修改数据的每个事务提交的不同的时间点被称为事务的提交或结束时间戳。提交时间能够在序列化的历史记录中有效地标识事务所在的位置。
- 一条记录某个版本的有效时间:如图2所示,数据库中的所有记录都包含两个时间戳——开始时间戳(Begin-Ts)和结束时间戳(End-Ts)。开始时间戳是指创建版本的事务的提交时间,而结束时间戳是指删除版本(也许是用一个新版本替换)的事务的提交时间戳。一条纪录版本的有效时间是指其版本被其他事务可见的时间戳的范围。比如,在图5中,Susan的记录是在时间“90”时从Vienna被更新到Bogota。
- 逻辑读取时间:读取时间可以是事务的开始时间和当前时间之间的任意值。只有有效时间与逻辑读取时间重叠的版本,对于读操作才可见。对于除了已提交读之外的其他所有隔离级别,一个事务的逻辑读取时间对应于事务的开始。对于已提交读则对应于事务中一条语句的开始。
版本可视性的概念是在内存中OLTP中进行恰当的并发控制的基础。在逻辑读取时间RT执行的事务必须只能看到那些开始时间戳小于RT和结束时间戳大于RT的版本。
内存优化表允许的隔离级别
内存优化表上的数据操作总是使用乐观的多版本并发控制(Multi Version Concurrency Control ,MVCC)。乐观的数据访问不使用锁或闩锁来提供事务隔离。我们将介绍这个无锁和无闩锁的行为如何进行管理的细节,以及关于后面章节中提到的允许的事务隔离级别原因的详细信息。在本节中,我们将只讨论理解数据访问和修改操作基本原理所需的事务隔离级别的细节。
访问内存优化表的事务支持下列的隔离级别。
- SNAPSHOT
- REPEATABLE READ
- SERIALIZABLE
事务隔离级别可以被指定为本地编译存储过程的原子块的一部分。另外,当通过解释型Transact-SQL访问内存优化表时,可以使用表提示或者新的名为MEMORY_OPTIMIZED_ELEVATE_TO_SNAPSHOT的数据库选项来指定隔离级别,这个数据库选项透明地将较低的隔离级别(例如未提交读和已提交读)映射为快照隔离级别,从而减少将迁移部分应用程序以使用内存优化表所需的对应用程序进行的更改。更多细节请参考。
对于自动提交(单条语句)事务中的内存优化表支持READ COMMITTED隔离级别。内存优化表不支持显式或隐式的用户事务。(隐式事务是在IMPLICIT_TRANSACTIONS会话选项下调用的事务。在这个模式下,行为与显式事务一样,但不需要BEGIN TRANSACTION语句。任何DML语句将启动一个事务,并且事务必须显式地提交或者回滚。只有BEGIN TRANSACTION语句是隐式的。)自动提交事务的内存优化表支持READ_COMMITTED_SNAPSHOT隔离级别,并且仅在查询未访问任何基于磁盘的表时才支持。 此外,在 SNAPSHOT 隔离级别下通过解释型 Transact-SQL 启动的事务不能访问内存优化表。 在 REPEATABLE READ 或 SERIALIZABLE 隔离级别下使用解释型 Transact-SQL 的事务必须使用 SNAPSHOT 隔离级别访问内存优化表 。
根据之前介绍的数据行在内存中的结构,现在让我们通过一个例子来看看DML操作是如何执行的。我们将通过在尖括号中按顺序列出内容来表示数据行。假设我们有一个在SERIALIZABLE隔离级别上运行的事务ID为100的事务TX1,事务在时间戳240时开始,并执行了两个操作:
- 删除数据行<Greg , Lisbon>
- 更新<Jane, Helsinki >为<Jane, Perth>
同时,另外两个事务将读取数据行。 TX2是一个在时间戳243时运行的,自动提交的单个SELECT语句。TX3是一个显式事务,它读取一个数据行,然后基于它在Select语句中读到的值更新另一个数据行,TX3的时间戳为246。
首先我们来看看数据修改事务。事务开始时,获取了一个表明事务开始的开始时间戳,这于数据库的序列化顺序相关。在这个例子中,这个时间戳为240。
在事务运行时,事务TX1将只能够访问开始时间戳小于或等于240的记录和结束时间戳大于240的记录。
删除
事务TX1首先通过一个索引定位<Greg , Lisbon>。为了删除这一数据行,该行的结束时间戳被设置为100和一个额外的比特标志位,这个标志位表示100这个值是一个事务的ID。现在尝试访问该行的任何其它事务发现结束时间戳包含了事务ID(100),这个值表明该行可能已经被删除。然后它在事务图中找到了TX1,并检查事务TX1是否仍处于活动状态,以确定<Greg , Lisbon>的删除是否已经完成。
更新和插入
接下来,<Jane, Helsinki>的更新是通过将操作分为两个独立的操作来执行的:删除整个原始的数据行,并插入一个完整的新的数据行。这通过构建一个新的数据行<Jane, Perth>开始,数据行包括值为100的开始时间戳和表示100这个值是一个事务ID的比特标志位,然后将结束时间戳设置为∞(无穷大)。试图访问该行的任何其他事务将需要确定事务TX1是否仍处于活动状态以决定它是否可以看到<Jane, Perth>。然后通过将<Jane, Perth>连接到两个索引中来进行插入。接下来,<Jane, Helsinki>会按照上个段落所描述的删除操作进行删除。任何其他试图更新或删除<Jane, Helsinki>的事务发现,结束时间戳包含一个事务ID而不是无穷,从而确定有写写冲突,并会立即中止。
这时,事务TX1已经完成了操作,但还没有提交。处理提交的过程通过为事务获得一个结束时间戳来开始。在这个例子中,假设时间戳为250,标识其在数据库的序列化顺序中的点,在这个点上这个事务的更新逻辑上都已经完成。在获得了这个结束时间戳后,事务进入了被称为验证的状态,在这个状态下,数据库执行检查以确保事务并未违反当前的隔离级别。如果验证失败,事务则被中止。有关验证的更多细节稍后进行介绍。在验证阶段的最后,SQL Server还将会写入事务日志。
事务跟踪在一个写入集合中的所有更改,写入集合主要上是一系列的删除/插入操作以及到与每个操作相关联的版本的指针。本次事务的写入集合以及更改的数据行在图8的绿色框中显示。这个写入集合构成了事务的日志内容。事务通常只生成一个包含其ID和提交时间戳的单个日志记录,以及它删除或插入的所有记录的版本。对于受到影响的每条记录将不会像基于磁盘的表那样有单独的日志记录。然而,日志记录的大小有一个上限,如果内存优化表中的一个事务超过了这个限制,则会生成多个日志记录。一旦将日志记录已被固化到存储中,这个事务的状态被改变成已提交,并启动后续的处理。
后续的处理涉及到对写入集合的遍历,以及按如下的方式处理每个条目:
- 对于一个DELETE操作,将数据行的结束时间戳设定为事务的结束时间戳(在这种例子中是250)并清除在数据行的结束时间戳字段的类型标志。
- 对于一个INSERT操作,将受影响数据行的开始时间戳设定为事务的结束时间戳(在这种例子中是250),并清除在数据行的开始时间戳字段的类型标志。
旧数据行版本实际的取消链接和删除操作由垃圾收集系统处理,这将在之后介绍。
图8 在一张表上的事务的修改
读取
现在,让我们来看一看读的事务,TX2和TX3,这两个事务将与TX1同时进行处理。请记住,TX1正删除<Greg , Lisbon>数据行,并将<Jane, Helsinki >更新为<Jane, Perth>。
TX2是读取整个表的一个自动提交事务:
SELECT Name, City
FROM T1
TX2的会话在默认的隔离级别READ COMMITTED下运行,但如上所述,因为没有指定提示,并且T1是内存优化表,因此将使用SNAPSHOT隔离来访问数据。由于TX2在时间戳为243时运行,因此它能够读取当时已存在的数据行。时间戳为243时,数据行<Greg , Beijing>不再是有效的,所以它不能够访问该行。数据行<Greg , Lisbon>在时间戳为250时将被删除,但它在时间戳200到250之间有效,所以事务TX2可以读取到它。 TX2也会读取到数据行<Susan, Bogota >和数据行<Jane, Helsinki >。
TX3是在时间戳246开始的一个显式事务,它将读取一个数据行并基于读取的值更新另一个数据行。
DECLARE @City nvarchar(32);BEGIN TRAN TX3 SELECT @City = City FROM T1 WITH (REPEATABLEREAD) WHERE Name = 'Jane'; UPDATE T1 WITH (REPEATABLEREAD) SET City = @City WHERE Name = 'Susan';COMMIT TRAN -- commits at timestamp 255
在事务TX3中,SELECT语句将读取到数据行<Jane, Helsinki >,因为该行在时间戳243之后仍然是可访问的。然后语句将会把数据行< Susan, Bogota >更新为< Susan, Helsinki >。然而,如果在TX1已经提交之后,事务TX3尝试提交,SQL Server将会检测到数据行<Jane, Helsinki >已经被另一个事务更新了。这是违反了REPEATABLE READ隔离级别的要求,所以提交将会失败并且事务TX3将回滚。在下一节我们将介绍关于验证更多的内容。
验证
在最后提交与内存优化表相关的事务之前,SQL Server会执行一个验证步骤。因为在数据修改过程中不需要锁,所以根据所请求的隔离级别,数据更改可能会导致无效数据。因此提交处理过程的这一阶段可以确保不会有无效数据。
下面列出了在每一个可能的隔离级别中,一些可能会遇到的违反隔离级别的情况。更有可能的违反情况以及提交的依赖性,将在下一章节更加详细地描述隔离级别和并发控制时进行介绍。
如果在SNAPSHOT隔离级别下访问内存优化表,当尝试执行COMMIT时,可能会有以下验证错误:
- 如果当前事务插入了一个数据行与在当前事务之前提交的另一个事务插入的数据行,拥有同样的主键值,将会产生41325错误(“The current transaction failed to commit due to a serializable validation failure .”),并且事务将被中止。
如果在REPEATABLE READ隔离级别下访问内存优化表,当尝试执行COMMIT时,可能会有以下验证错误:
- 如果当前事务已经读取了在当前事务之前提交的另一个事务更新的任何数据行,将会产生41305错误(“The current transaction failed to commit due to a repeatable read validation failure.”),并且事务将被中止。
如果在SERIALIZABLE隔离级别下访问内存优化表,当尝试执行COMMIT时,可能会有以下验证错误:
- 如果当前事务无法读取符合指定过滤条件的任何有效数据行,或遇到了由其他事务插入的符合指定过滤条件的幻影数据行,提交将会失败。事务需要按照好像不存在任何并发事务那样执行。所有的行动逻辑上都在单个序列化的时间点上发生。如果违反了这些保障中的任意一条时,都将会产生41305错误,并且事务将被中止。
T-SQL支持
内存优化表可以通过两种不同的方式进行访问:要么通过采用解释型Transact-SQL来进行互操作,要么通过本地编译的存储过程。
解释型的Transact-SQL
当使用互操作功能时,对于使用内存优化表,几乎能够获得Transact-SQL的全部功能,但并不能获得与使用本地编译存储过程访问内存优化表相同的性能。运行即席查询,或者在将应用程序迁移到内存中OLTP时,在迁移对性能影响最大的存储过程之前,作为迁移过程中的一步,互操作都是合适的选择。解释型的Transact-SQL也应该在需要同时访问内存优化表和基于磁盘的表时使用。
使用互操作访问内存优化表时,不支持的Transact-SQL功能如下:
- TRUNCATE TABLE
- MERGE(当目标是内存优化表时)
- 动态和键集游标(这些游标都都自动降级为静态游标)
- 跨数据库查询
- 跨数据库事务
- 链接服务器
- 锁提示:TABLOCK,XLOCK,PAGLOCK等(支持NOLOCK,但是会被自动忽略)。
- 隔离级别提示READUNCOMMITTED,READCOMMITTED和READCOMMITTEDLOCK
T-SQL的本地编译存储过程
本机编译存储过程允许你以最快的方式执行Transact-SQL语句,其中包括访问内存优化表中的数据。然而,这些存储过程比起Transact-SQL语句却有更多的限制。在本地编译存储过程中,对可以访问和处理的数据类型和排序规则也有限制。支持的Transact-SQL语句,数据类型和允许的运算符的完整列表,请参阅文档。此外,在本地编译存储过程中,完全不允许访问基于磁盘的表。
这些限制的原因是由于事实上在引擎内部,必须为每个表上的每个操作创建一个单独的函数。这个接口在后续版本中将会进行扩展。
在内存中的数据行的垃圾回收
因为内存中OLTP是一个多版本的系统,删除和更新操作(以及中止的插入操作)会产生行版本,而这些行版本最终将变为失效,这意味着对任何事务它们都将不再可见。这些不需要的版本会减慢索引结构扫描的速度,还会创建未被使用的需要被回收的内存。
对于内存优化表中失效版本的垃圾收集过程类似于采用基于快照的隔离级别之一的情况下,SQL Server对于基于磁盘的表执行的版本存储清理。但是一个很大的区别是,清理不是在tempdb中进行,而是在内存表的结构本身中进行。
要确定哪些数据行可以安全删除,系统会持续跟踪在系统中运行的最早的活动事务的时间戳,并使用这个值来决定仍可能需要哪些数据行。在这个时间点之后任何无效的数据行(即,它们的结束时间戳早于这个时间点)都被认为是失效的。失效的数据行会被删除,它们的内存会被释放回系统。
垃圾收集系统的设计是非阻塞的、协同的、高效的、积极反应的和可扩展的。特别令人感兴趣的是“协同”属性。虽然有一个系统线程专门用于垃圾回收过程,但实际上用户线程做了大部分的工作。如果一个用户线程正在扫描索引(内存优化表上的所有索引访问都被认为是索引扫描),并且遇到一个失效的行版本,那么它会将这个行版本从当前的链中断开链接,并调整指针。它也将递减数据行标题区域中的引用计数。此外,当用户线程完成一个事务时,用户线程接着将有关事务的信息添加到一个待垃圾收集过程处理的事务队列中。最后,用户线程从垃圾收集线程创建的一个队列中获取一个或多个工作条目,并释放组成工作条目的数据行使用的内存。
垃圾收集线程大约每分钟检查一次已完成的事务队列,但系统可以根据等待处理的已完成的事务数量在内部调整频率。对于每一个事务,它决定哪些数据行是失效的,并建立由一组准备移除的数据行组成的工作条目。在CTP2版本中,一个组中数据行的数量为16,但这个数字在未来的版本中可能会改变。这些工作条目分布在多个队列中,每个SQL Server使用的CPU对应一个队列。一般情况下,从内存中移除数据行的实际工作是留给处理队列中这些工作条目的用户线程来处理,但是如果用户活动很少,垃圾收集线程本身会删除数据行来回收系统的内存。
动态管理视图 sys.dm_db_xtp_index_stats对于每个内存优化表中的每个索引有一条记录,rows_expired列表示在索引的扫描过程中,有多少数据行被检测为是失效的。还有一个名为rows_expired_removed的列表示多少数据行已经从索引中取消链接。如上面提到的,一旦数据行已经从一张表的所有索引中取消链接,它则可以由垃圾收集线程删除。所以,在对于内存优化表中的每个索引rows_expired计数器都已经增加了之前,rows_expired_removed的值都不会增长。
以下查询可以观察这些值。它将sys.dm_db_xtp_index_stats动态管理视图与sys.indexes目录视图连接从而能够返回索引的名字。
SELECT name AS 'index_name', s.index_id, scans_started, rows_returned, rows_expired, rows_expired_removedFROM sys.dm_db_xtp_index_stats s JOIN sys.indexes i ON s.object_id=i.object_id and s.index_id=i.index_idWHERE object_id('') = s.object_id;GO
---------------------------全文完-------------------------------