Hekaton内存引擎

作者介绍:飞你莫属

原文链接:Hekaton内存引擎

1. Hekaton简介

Hekaton是MS-SQLServer专门为基于内存的OLTP工作负载优化的存储引擎。该内存引擎采用无锁数据结构和乐观的多版本并发控制技术,从而实现了非常高的并发和10倍以上的性能提升。

2. 为什么要重新设计Hekaton内存引擎

MS-SQLServer团队分析发现,仅仅优化现有的SQL Server无法实现10-100倍的性能提升。而提升性能主要有3种方式:提升可扩展性、提高CPI(即每条指令所需要的CPU时钟)、减少每个请求所执行的指令数目。分析还发现,即使是最优的情况下,提升可扩展性和提高CPI都只能带来3-4倍的性能提升。所以希望寄托在减少每个请求所执行的指令数目上,如果想要10倍的性能提升,则必须减少90%的指令,如果想要100倍的性能提升,则必须减少99%的指令,直接在现有的存储引擎上去优化是做不到的。

3. 设计原则

3.1 为内存优化索引

现在主流的数据库系统都是面向硬盘的存储结构,记录都按page保存在硬盘上,在需要的时候加载到内存中。这需要维护复杂的buffer pool,在访问一个page的时候必须加latch锁保护。即使所有的pages都在内存中,在B-tree索引中执行一个简单的查询也需要数千条指令。

所以Hekaton重新设计实现了常驻内存的数据的索引,索引相关的操作则不记录到日志,通过日志和检查点机制来保证数据的durability。在恢复的时候,Hekaton通过最新的检查点和日志来重建表和索引。

3.2 消除latches和locks

数十甚至上百核心的CPU逐渐流行,因此scalability非常关键。如果系统中有频繁更新的共享内存和高竞争的资源,比如latches,spinlocks,lock manager,transaction log tail,last page of B-tree,可扩展性将受到严重影响。

Hekaton的所有的关键数据结构,比如内存分配器,hash/range索引,transaction map等都是latch-free(lock-free)的。在性能关键的路径上也没有任何latches或者spinlocks。

Hekaton使用新的乐观的多版本并发控制来提供隔离语义,没有locks,也没有lock table。借助于乐观的并发控制,多版本和latch-free数据结构,Hekaton线程不会stall或者wait。

3.3 将请求编译为机器码(machine code/native code)

解释执行是RDBMS的标准且通用的做法,解释执行的优点是灵活,缺点是执行慢。一个简单的查询语句,如果是使用解释执行的话,大概要执行10万量级的指令。

Hekaton解决方法是,对于T-SQL编写的语句和存储过程,Hekaton会将其编译成高度优化的机器码,编译会花一些时间,但执行期间效率很高。

3.4 无分区

HStore/VoltDB这类内存数据库会将数据按照CPU core进行分区,但是经过认真评估分区方式之后,Hekaton最后拒绝了它。因为在应用可以分区的情况下,分区的确很好,但如果应用分区做的不好,平均下来每个事务都需要访问多个分区的话,性能退化非常严重。因此,Hekaton不对数据库进行分区,任何线程都可以访问该数据库的任何一部分。

4. 架构

Hekaton主要由3部分组成:

  • Hekaton storage engine
  • 管理用户数据和索引
  • 支持事务操作
  • 支持hash和range索引
  • 支持检查点,数据恢复和高可用
  • Hekaton compiler
  • 负责将请求编译为机器码
  • Hekaton runtime system
  • 与SQL Server的资源结合,作为编译执行时所依赖功能的库

Hekaton和SQL Server主要在以下几个方面进行集成:

  • 元数据
  • Hekaton表和索引等的元数据存储在SQL Server catalog中
  • 查询优化
  • 查询算子
  • Hekaton提供一些访问Hekaton表中数据的operator,可被SQL Server的执行计划使用。另外,还提供了insert,delete和update相关的operator
  • 事务
  • SQL Server事务可以访问或者更新SQL Server表或者Hekaton表中的数据
  • 高可用
  • 检查点和日志
  • Hekaton日志被更新到SQL Server的transaction log中
  • Hekaton使用SQL Server的file stream来实现检查点

5. 存储和索引

5.1 存储

Hekaton按行存储,每一行数据存在多个版本,每个版本的数据的布局如下:
Hekaton内存引擎_第1张图片

每一行数据主要包括2部分:Row Header和Payload。Row Header中主要保存该版本数据的元数据,Payload中则存储的是这一行的用户数据。

其中RowHeader包括以下字段:

  • BeginTs:表示创建该记录的事务的commit时间戳,可能值为当前在该记录的最新版本上活跃的事务的BeginTs,或者创建该版本的事务的CommitTs

关于事务的BeginTs和CommitTs的说明:Hekaton中每个事务都包含2个时间戳:BeginTs和CommitTs,分别表示事务开始的时间戳和事务commit的时间戳

  • EndTs:表示删除或者替换该记录的事务的commit时间戳,可能值为当前在该记录的下一个版本上活跃的事务的BeginTs,或者无穷大,或者当前在该记录的下一个版本上活跃的事务的CommitTs

如果EndTs是无穷大,则表明该版本的记录依然有效

  • StmtId:表示事务中创建该版本的记录的语句的ID
  • IdxLinkCount:表示索引数目
  • Padding:用于字节对齐
  • Pointers:用于索引管理,每个索引占用一个8 Bytes的指针,有多少个索引,就有多少个8 Bytes的指针

5.2 索引

Hekaton支持两种索引类型:hash索引和range索引。

hash索引通过lock-free的hash table实现,range索引则通过无锁的Bw-tree实现。

一个表可以有多个索引,记录总是通过索引来访问。Hekaton使用多版本,更新总是创建一个新的版本。

5.3 示例

Hekaton内存引擎_第2张图片

6. 事务管理

6.1 TransactionID和时间戳

为了支持多版本,Hekaton内部维护了两个计数器:

  • TransactionID:TransactionID是全局递增的ID,用于唯一标识一个活跃的事务,每当启动一个新的事务的时候,都会为之分配一个TransactionID,在Hekaton重启的时候会重置
  • Global Transaction Timestamp:是一个全局递增的时间戳,每当事务commit的时候,会为之分配一个commit timestamp,在Hekaton重启的时候它不会重置

6.2 版本可见性

Logical Read Time:事务的read time是事务开始时间(如果是read committed隔离级别,则对应的是语句执行的时间)。

Valid Time:所有的版本都包含2个时间戳,BeginTs和EndTs,BeginTs表示创建该记录的事务的commit时间戳,EndTs表示删除或者替换该记录的事务的commit时间戳,BeginTs和EndTs就界定了该版本的Valid Time。

Hekaton每条记录中的BeginTs和EndTs这2个时间戳,决定了记录的可见性。logical read time为RT的事务,只能看到BeginTs不大于RT且EndTs大于RT的记录。

Hekaton内存引擎_第3张图片

6.3 基于MVCC的乐观并发控制

在Hekaton的乐观并发控制中事务会经历3个阶段:Processing阶段,Validation阶段和Post-processing阶段。

下面我们以一个例子来讲述Hekaton在这3个阶段都分别做了些什么。

假设在事务开始之前,Hekaton中包含如下2条记录:

BeginTs EndTs Name City
20 Infinite Susan Bogota
20 Infinite Greg Beijiing

现在用户发起一个事务,TransactionId为Tx1,事务开始时间为90,事务执行如下操作:

  • delete
  • update to
  • insert

下面分析在事务的不同阶段,这些记录的版本变迁,以及不同版本的数据对于并发事务的可见性。

6.3.1 Processing阶段

对于事务Tx1中的delete语句,Hekaton会通过索引找到Susan对应的记录,并且将记录中的EndTs设置为Tx1。

对于事务Tx1中的update语句,Hekaton会创建一个新的记录,将记录中的BeginTs设置为Tx1,将记录中的EndTs设置为infinite,然后通过索引找到Greg对应的记录,并且将记录中的EndTs设置为Tx1。

对于事务Tx1中的insert语句,Hekaton会创建一个新的记录,将记录中的BeginTs设置为Tx1,将记录中的EndTs设置为infinite。

Hekaton会通过一个特定标记位来区分BeginTs和EndTs中记录的是TransactionId还是一个时间戳。

BeginTs EndTs Name City
20 Tx1 Susan Bogota
20 Tx1 Greg Beijiing
Tx1 infinite Greg Lisbon
Tx1 infinite Jane Helsinki

假设现在接收到关于Tx1的commit请求,Hekaton会为Tx1分配一个commit时间戳,假设是120,然后将Tx1的commit时间戳保存到全局的事务表中。注意,此时虽然接收到了commit请求,但是事务Tx1仍然可能abort,Hekaton也不能向用户发送commit响应。但是,一旦接收到事务Tx1的commit请求,Hekaton会乐观的认为事务Tx1最终会commit成功,所以会让事务Tx1中的所有更新对其它事务可见。

6.3.1.1 读写冲突处理

假设另外一个事务Tx2,事务开始时间戳为100,此时事务Tx1已经开始了,但是事务Tx1还没有接收到commit请求。对各记录的处理如下:

  • 当事务Tx2读取到记录时,发现它的EndTs中记录的是TransactionId,事务Tx2会到全局的事务表中查找事务Tx1的状态信息,发现Tx1仍然处于active状态,记录尚未完成删除,所以事务Tx2可以访问记录。

  • 当事务Tx2读取到记录时,发现它的EndTs中记录的是TransactionId,事务Tx2会到全局的事务表中查找事务Tx1的状态信息,发现Tx1仍然处于active状态,记录尚未完成删除,所以事务Tx2可以访问记录。

  • 当事务Tx2读取到记录时,发现它的BeginTs中记录的是TransactionId,事务Tx2会到全局的事务表中查找事务Tx1的状态信息,发现Tx1仍然处于active状态,记录尚未完成更新,所以事务Tx2不可以访问记录,而只能访问前一个版本记录。

  • 当事务Tx2读取到记录时,发现它的BeginTs中记录的是TransactionId,事务Tx2会到全局的事务表中查找事务Tx1的状态信息,发现Tx1仍然处于active状态,记录尚未完成更新,所以事务Tx2不可以访问记录。

假设事务Tx2的事务开始时间戳为121,此时事务Tx1已经接收到了commit请求,但是事务Tx1还没有完成Validation阶段的工作。此时,对各记录的处理如下:

  • 当事务Tx2读取到记录时,发现它的EndTs中记录的是TransactionId,事务Tx2会到全局的事务表中查找事务Tx1的状态信息,发现Tx1已经分配了120这个commit时间戳,Hekaton会乐观的认为事务Tx1最终会commit成功,认为记录被删除了,所以事务Tx2不可以访问记录。

  • 当事务Tx2读取到记录时,发现它的EndTs中记录的是TransactionId,事务Tx2会到全局的事务表中查找事务Tx1的状态信息,发现Tx1已经分配了120这个commit时间戳,Hekaton会乐观的认为事务Tx1最终会commit成功,认为记录被删除了,所以事务Tx2不可以访问记录。

  • 当事务Tx2读取到记录时,发现它的BeginTs中记录的是TransactionId,事务Tx2会到全局的事务表中查找事务Tx1的状态信息,发现Tx1已经分配了120这个commit时间戳,Hekaton会乐观的认为事务Tx1最终会commit成功,进而认为记录已经完成了更新,所以事务Tx2可以访问记录。

  • 当事务Tx2读取到记录时,发现它的BeginTs中记录的是TransactionId,事务Tx2会到全局的事务表中查找事务Tx1的状态信息,发现Tx1已经分配了120这个commit时间戳,Hekaton会乐观的认为事务Tx1最终会commit成功,进而认为记录已经完成了插入,所以事务Tx2可以访问记录。

  • 因为Hekaton只是乐观的认为事务Tx1最终会commit成功,而事务Tx1最终是否commit成功,还是未知的,所以Hekaton会将事务Tx2注册到事务Tx1的commit-dependency列表中(称Tx1是Tx2依赖提交的事务),表示直到事务Tx1完成Validation阶段之后,事务Tx2才可以完成Validation阶段。

6.3.1.2 写写冲突处理

假设事务Tx2的事务开始时间是100,它不仅会读取事务Tx1修改的记录,还会将记录更新为,那么在事务Tx2执行更新操作的时候,发现记录的EndTs中记录的是TransactionId,而Hekaton会乐观的认为事务Tx1会coomit成功,所以Hekaton会立即abort事务Tx2。

6.3.2 Validation阶段

一旦事务Tx1接收到commit请求,并且Hekaton为事务Tx1分配了commit时间戳,事务Tx1就将进入Validation阶段。

在Validation阶段主要执行以下逻辑:

  • 检查是否破坏了隔离级别的要求(我们将在后面着重讲解,见“检查是否破坏了隔离级别的要求”)
  • 等待事务Tx1所依赖提交的事务数目降为0
  • 在处理并发事务的读写冲突过程中,可能会将事务Tx1注册到其它事务Txm的commit-dependency列表中,我们称Tx1依赖于Txm,Tx1必须在它所依赖的所有的事务都已经完成了Validation阶段之后,才能完成Validation阶段
  • 实现上,Hekaton每当将事务Tx1注册到其它事务Txm的commit-dependency列表中的时候,会对Tx1所依赖的事务的数目加1,当事务Txm完成Validation阶段之后,它会将它的commit-dependency列表中的每个事务所依赖的事务的数目减1
  • 写持久化的事务日志
  • 只在创建表的时候指定了SCHEMA_AND_DATA参数的情况下,才会将这些日志持久化
  • 事务Tx1会为它的所有修改维护一个write-set,其中存放的是一系列的insert或者delete操作,每个操作指向某个版本的记录
    Hekaton内存引擎_第4张图片
  • 事务日志中包含:TransactionId,commit时间戳和该事务insert或者delete的所有的记录
  • 更新全局的事务表,标记事务状态为committed
  • 减少所有依赖于Tx1提交的事务所依赖提交的事务的数目
  • 遍历Tx1的commit-dependency列表中的每个事务,将该事务所依赖的其它事务的数目减1

6.3.2.1 检查是否破坏了隔离级别的要求

Hekaton通过乐观的多版本并发控制(MVCC),无需锁就可以支持snapshot,repeatable read和serializable隔离级别。

如果事务的读和写逻辑上发生在同一时刻,则事务就是可串行化的。SI(Snapshot Isolation)隔离级别并不满足这个情况,SI的读实际上是发生在事务开启时(事务开启时取快照,快照一旦确定了,即使读请求是过一段时间之后才发起的,仍然等价于是去快照后立刻执行的),写实际上发生在事务提交时(事务提交前,所有写操作都不生效)。

如果在事务结束时我们能够确认该事务之前发起的读操作,再次发起时返回相同的结果,那么等价于读操作和写操作是同时发生在事务结束这个时间点上,因此事务就是可串行化的。

基于上述观察,如果要实现Serializable隔离级别,则如下两个条件都要满足:

  • 可重复读(Read Stability):如果T读取行的某个版本V1,那么V1直到事务结束都应该继续有效而不是被V2更新了。
  • 可以通过加读锁或者在事务结束前做validation来达到这一效果。
  • 避免幻读(Phantom Avoidance):事务的scan不会返回额外的新版本。
  • 可以通过加范围锁或者validation来实现。

相比较于Serializable隔离级别,如果要实现其它更低的隔离级别,就容易的多:

  • Read Committed隔离级别:总是读取已提交的最新数据,不需要加锁,也不需要valdation。
  • Repeatable Read隔离级别:只需要保证可重复读,而不解决幻读问题。
  • SI(Snapshot Isolation)隔离级别:总是使用事务开始时的快照,不需要加锁,也不需要validation。

在事务Processing阶段,会维护read-set,write-set和scan-set。read-set中保存该事务所读取的所有记录的指针,write-set中保存该事务所更新的所有记录的指针,scan-set中则保存谓词所涉及的所有的记录的相关信息。read-set用于检查是否可重复读,write-set用于记录事务日志,scan-set则用于检查是否发生了幻读。

当然,对于某些隔离级别来说,read-set或scan-set是不需要的,如下:

隔离级别 read-set scan-set
snapshot No No
repeatable read Yes No
serializable Yes Yes
6.3.2.2 破坏snapshot隔离级别要求

在snapshot隔离级别下,需要检查是否存在如下的破坏snapshot隔离级别要求的情形:

  • 事务Tx1开始,并且插入一个新的行
  • 事务Tx2开始,并且插入一个具有相同主键的行
  • 事务Tx2提交
  • 事务Tx1提交
  • 事务Tx1在Validation阶段,发现两行记录具有相同的主键,abort
6.3.2.3 破坏repeatable read隔离级别要求

在repeatable read隔离级别下,需要检查是否存在类似于“破坏snapshot隔离级别要求”的情形。

在repeatable read隔离级别下,还需要检查事务的read-set中的记录是否具有更新的版本(也就是说,其它事务在当前事务commit之前更新了该记录),如果是,则当前事务必须abort。

6.3.2.4 破坏serializable隔离级别要求的情况

在serializable隔离级别下,除了需要检查是否存在类似于“破坏repeatable read隔离级别要求”的情形之外,还需要检查事务的scan-set中的记录是否存在下面2种情况:

  • scan-set中的某个记录现在读取不到了,表明该记录被其它事务删除了
  • 某记录不在scan-set中,但是它满足scan-set对应的谓词,表明其它事务插入了新的记录

如果发生上述情况中的任意一种,则当前事务必须abort。

6.3.3 Post-processing阶段

该阶段比较简单,将当前事务所涉及的所有旧版本记录中的EndTs字段更新为它的commit时间戳,同时将所有新版本记录中的BeginTs字段更新为它的commit时间戳。

如果事务失败或者显式回滚,则新版本的记录将被标记为garbage,同时旧版本的记录中的EndTs将被修改为infinite。被标记为garbage的记录将由garbage collector负责回收。

6.3.4 事务durability

Hekaton采用事务日志(transaction log)和checkpoint来保证记录的durability。

Hekaton中索引更新不会写log,而是在恢复的时候进行重建。

transaction log, checkpoint和recovery组件的设计原则:

  • 顺序访问IO
  • 主流程尽量减少操作,能移到Recovery阶段的操作都移过去
  • 消除扩展性瓶颈
  • 在Recovery阶段要做IO和CPU的并发

6.3.4.1 事务日志

Hekaton事务日志的要点:

  • 每个事务记录一个日志记录,而不是事务中每个操作记录一个日志记录
  • 只在transaction commit的时候生成日志记录
  • 没有使用WAL
  • 索引更新不记录日志
  • 只支持Redo日志,不支持Undo日志
  • 支持group commit
  • 多条日志记录组成一个大的IO
  • 支持多个并发的log stream,减少log tail竞争
  • 因为事务顺序由事务的commit时间戳决定,而不是由日志记录的顺序决定

6.3.5 Checkpoint

执行checkpoint的目的是为了减少恢复时间。

Checkpoint的设计主要围绕两个需求:

  • 持续的checkpoint:采用持续的增量的checkpoint,避免突发的checkpoint
  • 流式IO:尽量保证顺序流式IO

Checkpoint触发时机:

  • 手动触发:用户显式执行checkpoint命令
  • 自动触发:当自从上次checkpoint以来,日志记录容量增加超过1.5GB的时候,就会执行checkpoint

6.3.5.1 Checkpoint文件

checkpoint数据保存在2种checkpoint files中:data files和delta files,一个完整的checkpoint包含多个data files,多个delta files以及一个用于记录该checkpoint中包含哪些files的checkpoint file inventory。

data file中只包含特定时间范围内插入的记录的版本,如果某个版本的记录的BeginTs字段所代表的时间戳在该data file的特定时间范围内,则该版本的记录就被包含在data file中

delta file中记录了某个data file中哪些版本的数据被删除了,delta file和data file之间是1:1的关系。

checkpoint file inventory中记录组成一个完整的checkpoint的所有的data和delta files。checkpoint file inventory保存在system table中。

6.3.5.2 Checkpoint过程

一个checkpoint任务会将transaction log中的一部分内容转换为data files和delta files,并更新inventory。

持续一段时间之后,checkpoint文件就会逐渐变多,导致recovery时间变长。为此,Hekaton支持当data file中未删除的记录所占的比例达到某个阈值时对相邻的data files进行合并。

7. 垃圾回收

Hekaton根据记录的可见性来决定它是否是garbage,如果一条记录不再对任何活跃的事务可见,则该记录就是garbage。GC线程周期地扫描全局活跃事务map,得到最老的活跃事务的BeginTs作为gc-timestamp。EndTs小于gc-timestamp的事务产生的旧版本都可以回收。

Hekaton的garbage collection具有以下特点:

  • 非阻塞:和事务并发运行
  • 协作执行:运行事务的工作线程在遇到garbage的时候可以自己删除它
  • 增量运行:garbage collection过程可以被限流,可以被停止或者重新启动,以避免它过度消耗CPU资源
  • 可并行化扩展

8. 参考

Hekaton: SQL Server’s Memory-Optimized OLTP Engine

SQL Server Internals: In-Memory OLTP Inside the SQL Server 2016 Hekaton Engine

MemFireDB,带你体验不一样的云端飞翔。

你可能感兴趣的:(MemFireDB,数据库,内存优化,内存数据库)