作者介绍:飞你莫属
原文链接:Hekaton内存引擎
Hekaton是MS-SQLServer专门为基于内存的OLTP工作负载优化的存储引擎。该内存引擎采用无锁数据结构和乐观的多版本并发控制技术,从而实现了非常高的并发和10倍以上的性能提升。
MS-SQLServer团队分析发现,仅仅优化现有的SQL Server无法实现10-100倍的性能提升。而提升性能主要有3种方式:提升可扩展性、提高CPI(即每条指令所需要的CPU时钟)、减少每个请求所执行的指令数目。分析还发现,即使是最优的情况下,提升可扩展性和提高CPI都只能带来3-4倍的性能提升。所以希望寄托在减少每个请求所执行的指令数目上,如果想要10倍的性能提升,则必须减少90%的指令,如果想要100倍的性能提升,则必须减少99%的指令,直接在现有的存储引擎上去优化是做不到的。
现在主流的数据库系统都是面向硬盘的存储结构,记录都按page保存在硬盘上,在需要的时候加载到内存中。这需要维护复杂的buffer pool,在访问一个page的时候必须加latch锁保护。即使所有的pages都在内存中,在B-tree索引中执行一个简单的查询也需要数千条指令。
所以Hekaton重新设计实现了常驻内存的数据的索引,索引相关的操作则不记录到日志,通过日志和检查点机制来保证数据的durability。在恢复的时候,Hekaton通过最新的检查点和日志来重建表和索引。
数十甚至上百核心的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。
解释执行是RDBMS的标准且通用的做法,解释执行的优点是灵活,缺点是执行慢。一个简单的查询语句,如果是使用解释执行的话,大概要执行10万量级的指令。
Hekaton解决方法是,对于T-SQL编写的语句和存储过程,Hekaton会将其编译成高度优化的机器码,编译会花一些时间,但执行期间效率很高。
HStore/VoltDB这类内存数据库会将数据按照CPU core进行分区,但是经过认真评估分区方式之后,Hekaton最后拒绝了它。因为在应用可以分区的情况下,分区的确很好,但如果应用分区做的不好,平均下来每个事务都需要访问多个分区的话,性能退化非常严重。因此,Hekaton不对数据库进行分区,任何线程都可以访问该数据库的任何一部分。
Hekaton主要由3部分组成:
Hekaton和SQL Server主要在以下几个方面进行集成:
Hekaton按行存储,每一行数据存在多个版本,每个版本的数据的布局如下:
每一行数据主要包括2部分:Row Header和Payload。Row Header中主要保存该版本数据的元数据,Payload中则存储的是这一行的用户数据。
其中RowHeader包括以下字段:
关于事务的BeginTs和CommitTs的说明:Hekaton中每个事务都包含2个时间戳:BeginTs和CommitTs,分别表示事务开始的时间戳和事务commit的时间戳
如果EndTs是无穷大,则表明该版本的记录依然有效
Hekaton支持两种索引类型:hash索引和range索引。
hash索引通过lock-free的hash table实现,range索引则通过无锁的Bw-tree实现。
一个表可以有多个索引,记录总是通过索引来访问。Hekaton使用多版本,更新总是创建一个新的版本。
为了支持多版本,Hekaton内部维护了两个计数器:
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个阶段:Processing阶段,Validation阶段和Post-processing阶段。
下面我们以一个例子来讲述Hekaton在这3个阶段都分别做了些什么。
假设在事务开始之前,Hekaton中包含如下2条记录:
BeginTs | EndTs | … | Name | City |
---|---|---|---|---|
20 | Infinite | … | Susan | Bogota |
20 | Infinite | … | Greg | Beijiing |
现在用户发起一个事务,TransactionId为Tx1,事务开始时间为90,事务执行如下操作:
下面分析在事务的不同阶段,这些记录的版本变迁,以及不同版本的数据对于并发事务的可见性。
对于事务Tx1中的delete语句,Hekaton会通过索引找到Susan对应的记录,并且将记录中的EndTs设置为Tx1。
对于事务Tx1中的update语句,Hekaton会创建一个新的记录
对于事务Tx1中的insert语句,Hekaton会创建一个新的记录
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中的所有更新对其它事务可见。
假设另外一个事务Tx2,事务开始时间戳为100,此时事务Tx1已经开始了,但是事务Tx1还没有接收到commit请求。对各记录的处理如下:
当事务Tx2读取到
当事务Tx2读取到
当事务Tx2读取到
当事务Tx2读取到
假设事务Tx2的事务开始时间戳为121,此时事务Tx1已经接收到了commit请求,但是事务Tx1还没有完成Validation阶段的工作。此时,对各记录的处理如下:
当事务Tx2读取到
当事务Tx2读取到
当事务Tx2读取到
当事务Tx2读取到
因为Hekaton只是乐观的认为事务Tx1最终会commit成功,而事务Tx1最终是否commit成功,还是未知的,所以Hekaton会将事务Tx2注册到事务Tx1的commit-dependency列表中(称Tx1是Tx2依赖提交的事务),表示直到事务Tx1完成Validation阶段之后,事务Tx2才可以完成Validation阶段。
假设事务Tx2的事务开始时间是100,它不仅会读取事务Tx1修改的记录,还会将记录
一旦事务Tx1接收到commit请求,并且Hekaton为事务Tx1分配了commit时间戳,事务Tx1就将进入Validation阶段。
在Validation阶段主要执行以下逻辑:
Hekaton通过乐观的多版本并发控制(MVCC),无需锁就可以支持snapshot,repeatable read和serializable隔离级别。
如果事务的读和写逻辑上发生在同一时刻,则事务就是可串行化的。SI(Snapshot Isolation)隔离级别并不满足这个情况,SI的读实际上是发生在事务开启时(事务开启时取快照,快照一旦确定了,即使读请求是过一段时间之后才发起的,仍然等价于是去快照后立刻执行的),写实际上发生在事务提交时(事务提交前,所有写操作都不生效)。
如果在事务结束时我们能够确认该事务之前发起的读操作,再次发起时返回相同的结果,那么等价于读操作和写操作是同时发生在事务结束这个时间点上,因此事务就是可串行化的。
基于上述观察,如果要实现Serializable隔离级别,则如下两个条件都要满足:
相比较于Serializable隔离级别,如果要实现其它更低的隔离级别,就容易的多:
在事务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 |
在snapshot隔离级别下,需要检查是否存在如下的破坏snapshot隔离级别要求的情形:
在repeatable read隔离级别下,需要检查是否存在类似于“破坏snapshot隔离级别要求”的情形。
在repeatable read隔离级别下,还需要检查事务的read-set中的记录是否具有更新的版本(也就是说,其它事务在当前事务commit之前更新了该记录),如果是,则当前事务必须abort。
在serializable隔离级别下,除了需要检查是否存在类似于“破坏repeatable read隔离级别要求”的情形之外,还需要检查事务的scan-set中的记录是否存在下面2种情况:
如果发生上述情况中的任意一种,则当前事务必须abort。
该阶段比较简单,将当前事务所涉及的所有旧版本记录中的EndTs字段更新为它的commit时间戳,同时将所有新版本记录中的BeginTs字段更新为它的commit时间戳。
如果事务失败或者显式回滚,则新版本的记录将被标记为garbage,同时旧版本的记录中的EndTs将被修改为infinite。被标记为garbage的记录将由garbage collector负责回收。
Hekaton采用事务日志(transaction log)和checkpoint来保证记录的durability。
Hekaton中索引更新不会写log,而是在恢复的时候进行重建。
transaction log, checkpoint和recovery组件的设计原则:
Hekaton事务日志的要点:
执行checkpoint的目的是为了减少恢复时间。
Checkpoint的设计主要围绕两个需求:
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中。
一个checkpoint任务会将transaction log中的一部分内容转换为data files和delta files,并更新inventory。
持续一段时间之后,checkpoint文件就会逐渐变多,导致recovery时间变长。为此,Hekaton支持当data file中未删除的记录所占的比例达到某个阈值时对相邻的data files进行合并。
Hekaton根据记录的可见性来决定它是否是garbage,如果一条记录不再对任何活跃的事务可见,则该记录就是garbage。GC线程周期地扫描全局活跃事务map,得到最老的活跃事务的BeginTs作为gc-timestamp。EndTs小于gc-timestamp的事务产生的旧版本都可以回收。
Hekaton的garbage collection具有以下特点:
Hekaton: SQL Server’s Memory-Optimized OLTP Engine
SQL Server Internals: In-Memory OLTP Inside the SQL Server 2016 Hekaton Engine
MemFireDB,带你体验不一样的云端飞翔。