浅析内存事务

基础简介

事务必须具备四个特性:

  • 原子性(Atomic): 事务中的多个操作,不可分割,要么都成功,要么都失败
  • 一致性(Consistency): 事务操作之后, 数据库所处的状态和业务规则是一致的; 比如a, b账户相互转账之后,总金额不变
  • 隔离性(Isolation): 多个事务之间就像是串行执行一样,不相互影响
  • 持久性(Durability): 事务提交后被持久化到永久存储

通常来说事务隔离级别有以下几种(由弱到强):

  • READ UNCOMMITTED:可以读取脏数据(未提交的数据),又称脏读。
  • READ COMMITTED:只能读取已经提交的数据。允许幻读和不可重复读,不允许脏读。通常实现为读不加锁,写加锁,且实现方式有很多种。
  • REPEATABLE READ:同一个事务中多次执行同一个select,读取到的数据没有发生改变,InnoDB默认级别。允许幻读,但不允许不可重复读和脏读。通常实现为读加共享锁,写加排他锁,事务提交后释放所有锁。但只能锁住本事务涉及的行,对于新加入的行无法奏效,因此幻读不可避免。
  • SNAPSHOT ISOLATION:快照隔离,和RR差不多是同一级别,通常由多版本并发控制(MVVC)实现。RR有幻读问题,SI没有,而SI有写偏(write skew)问题,RR没有。
    • 写偏问题:假设事务1读x写y,事务2读y写x,存在约束c := a+b<=100。两个事务commit时各自都满足c,但它们共同作用的结果却破坏了c。一种解决方案就是通过对版本依赖构成有向图,检查并破坏环的构成。t2对t1构成了一个先读后写的rw(x)依赖,t1对t2构成了一个先读后写的rw(y)依赖。
      依赖
      造成写偏的条件是成环,并且环中有两个连续的rw依赖,如下。
      环
      但是环检测会影响性能,所以可以放宽构成环的条件,只要有两个连续的rw依赖就会放弃提交,即使没有成环。
    • 可串行化快照隔离(serializable snapshot isolation,SSI):通过破坏写偏的形成条件以达到可串行化的目的
  • SERIALIZABLE: 幻读,不可重复读和脏读都不允许。对事务并发最不友好,每次读都需要获得表级共享锁,读写相互都会阻塞。

事务引擎

很多的数据存储引擎都采用在线数据(memtable)和离线数据(sstable)分离存储的模式。当在线数据到达一定阈值后,dump成离线数据写入磁盘。MVCC可实现读写相互不阻塞;两阶段行锁可实现写并发;主备记录redolog来持久化事务;多副本同步保证服务持续可用;批处理、并发提交等方式可提升性能。

OceanBase在事务并发的性能上相当不错,下文参考官方发布的资料对其实现进行分析,并在部分内容上进行扩展。

内存事务在执行分为3个阶段

  • 预提交:由多线程对并发的事务执行预处理,包括加行锁和进行逻辑判断(如能否执行insert|update|delete语句,以及上述语句中where条件的判断和数据读取),然后将待更新数据写入事务上下文的临时空间。
  • 提交:确定事务要提交(收到commit或单条autocommit)时,申请事务版本号、提交对数据的修改和释放行锁。
  • 发布:提交操作完成后,由单个线程执行发布操作:写redolog到磁盘并同步到备机,原子性地发布当前事务。发布阶段完成后,事务对数据的修改才可以被后续开始的其他事务读取到。

并发索引

内存引擎通过聚集索引提供针对行主键的单行和范围查询(此处暂不考虑二级索引)。为了提升查询性能,范围查询通过B+Tree实现,单行随机查询则通过Hash实现。

并发B+Tree

并发B+Tree可通过copy on write来实现读写互不阻塞。不支持物理删除、合并,支持插入、更新、查询、逻辑删除,节点可以分裂,具体的k-v数据只存于叶子节点。

写操作的流程如下:

  • 从树根开始向下查询目标叶子节点C,对路径上的节点加共享锁
  • 升级C的锁为排他锁,升级失败则释放所持有的共享锁,因为此时可能有另一个写操作尝试自下向上加排他锁
  • 拷贝C得到C’,并插入新数据到C’
    • C’满
      1. C’分裂出新节点
      2. 升级父节点的锁为排他锁,升级失败则降级所持有的排他锁为共享锁,然后释放所持有的共享锁,表示此次写操作失败,再进行重试
      3. 拷贝父节点,尝试将新节点插入其中,如果还需分裂,则递归处理
    • C’不满
      1. 原子操作修改父节点指向C的指针,另其指向C’
  • 从上往下释放共享锁,再释放C的排他锁

需要注意两点:

  • 所有加共享锁的操作均使用try_lock的方式,失败则释放所有已加的锁,再重试
  • 上述的锁可以用一个读写锁数据结构表示:写标志位、拥有写锁的线程标识、读引用。对于读锁升级为写锁或者是直接加写锁等操作,首先多线程互斥设置写标志位和线程标识,设置成功的线程等到读引用为0后才表示升级或加锁成功

并发Skiplist

并发Skiplist属于Rocksdb、Leveldb等数据库的核心数据结构之一,且数据结构相对简单。以Leveldb中Skiplist的插入函数为例

template<typename Key, class Comparator>
void SkipList<Key,Comparator>::Insert(const Key& key) {
  Node* prev[kMaxHeight];
  Node* x = FindGreaterOrEqual(key, prev);

  assert(x == nullptr || !Equal(key, x->key));

  int height = RandomHeight();
  if (height > GetMaxHeight()) {
    for (int i = GetMaxHeight(); i < height; i++) {
      prev[i] = head_;
    }
    max_height_.NoBarrier_Store(reinterpret_cast<void*>(height));
  }

  x = NewNode(key, height);
  for (int i = 0; i < height; i++) {
    x->NoBarrier_SetNext(i, prev[i]->NoBarrier_Next(i));
    prev[i]->SetNext(i, x);
  }
}

无锁式的结构可以提升程序性能,其中内存乱序访问的问题可通过Memory barrier(主要为2类:Compiler BarrierCPU Memory Barrier)解决,如函数NoBarrier_Store和NoBarrier_SetNext。对于新增的节点x进行自下而上的修改确保了被修改的当前层及下层的索引指针必然已经生效,而查询操作是自上而下进行的,所以不会有读写冲突。由于Leveldb采用的是单个Writer,多个Reader,所以不考虑写写冲突。

并发Hash

并发Hash设计的难点在于如何扩容,如果不考虑扩容则可令每个bucket对应一个bit,通过原子操作来实现锁占用。

内存回收

对于copy on write结构中对象的访问可以做到无锁化,故而需要一套高效且无锁的共享对象内存回收机制。目前比较经典的算法有:Lock Free Reference Counting、Hazard Pointer、Epoch Based Reclamation等。

Lock Free Reference Counting

无锁引用计数原理相对简单,存在几个问题:

  • 在高并发场景下对全局引用计数对象的原子操作可能成为性能瓶颈
  • 工程实践相对复杂,例如在并发Btree中,涉及引用计数对象的分配、管理、更新等操作
  • 在多线程场景的架构中,很多数据结构会基于最大线程数量分配空间以降低复杂度,当共享对象较多时会耗费大量内存
Epoch Based Reclamation

算法提出者的论文:Practical lock-freedom。内存数据库memsql中使用了这种内存策略。gayhub上一个面向Rust语言的高并发库Crossbeam也实现了该算法。

实现需要以下部分:

  1. 全局对象global_epoch(取值为0,1,2)
  2. 每个epoch对应一个垃圾回收列表retire_list
  3. 每个线程对应一个active的标志和自身的epoch

一个多读单写实例的伪代码如下,不考虑写写冲突,内存乱序访问的问题不显式地解决:

read(thread_id):
    active[thread_id] = true
    epoche[thread_id] = global_epoch
    read_begin()
    read_end()
    active[thread_id] = false

logically_delete(thread_id):
    active[thread_id] = true
    epoche[thread_id] = global_epoch
    retire_list[global_epoch].append(node_to_del)
    active[thread_id] = false
    try_gc()

try_gc():
    for thread_id in range(THREAD_NUM):
        if active[i] && epoche[i] != global_epoch:
            free((global_epoch+1)%3)
            return
    global_epoch = (global_epoch+1)%3
    free((global_epoch+1)%3)

free(epoch):
    for p in retire_list[epoch]:
        physically_delete(p)

上述过程中global_epoch的更新机制可以保证读线程的epoch等于global_epoch或global_epoch-1,也就意味着retire_list[global_epoch+1]是可安全回收的。该例子相对简单,在生产环境中解决多线程写的问题还需要一套与之对应的数据同步机制。

Hazard Pointer

算法论文
Hazard pointers: Safe memory reclamation for lock-free objects

共享变量的并发读写使用Hazard Version实现的无锁Stack与Queue这两篇文章对该算法做了详尽的介绍并给出了一种改进后的版本:HazardVersion。我本人也基于此设计实现了一个面向Rust语言的LockFree库rs-lockfree,且已发布在Crates.io

HazardVersion本身使用了和EpochBasedReclamation相似的理念,通过一个全局的标志划定共享对象所持有的有效域,并适时回收失效部分产生的内存垃圾。HazardVersion解决了写写冲突,无需额外的同步机制。

内存事务并发

MVCC机制按照事务版本号识别并保存修改历史,为防止内存占用过大,需要适时地合并Snapshot。MVCC可用于实现SI隔离级别,遇到写冲突时,则可abort掉较新的事务然后重试。

RC事务隔离级别则仅需保证:事务中每条语句都能够读到该语句开始前其他事务已经提交的修改。每条语句需运行在语句开始前的快照之上,当一条语句涉及多次和事务引擎交互,运行过程中发现行已被其他事务修改,则执行语句回滚,然后在最新的快照上重试直到失败次数超过某个阈值。
事务需要满足以下几个关键特性:事务原子性提交,事务级别回滚,语句级别回滚,读取事务内未提交数据。
在内存中,每一行包含多个历史事务的修改记录,用以实现回滚。

                       +---------+     +---------+
   +------------+      |TransID=1|     |TransID=2|
   |UndoListHead+----> |col1=1   +---> |col2=3   |
   +------+-----+      |col2=1   |     |         |
          ^            +---------+     +---------+
          |
   +------+-----+      +---------+     +---------+
   |UndoListHead+----> |TransID=2+---> |TransID=3|
   +------+-----+      |col1=1   |     |col1=4   |
          ^            |col2=3   |     |         |
          |            +---------+     +---------+
+-----------------+
|RowValue |       |
|  +------+-----+ |
|  |UndoListHead| |
|  +------------+ |    +---------+     +---------+
|  +------------+ |    |TransID=3|     |TransID=3|
|  |DataListHead+----> |col1=4   +---> |col2=5   |
|  +------------+ |    |col2=3   |     |         |
|  +------------+ |    +---------+     +---------+
|  |DataListTail+----------------------------^
|  +------------+ |
+-----------------+

每个事务含有其有关的上下文信息,用于保存事务执行过程中的未提交数据、锁信息等。每行的修改对应一个UncommittedInfo。

    +-------------+
    |TransContext |
    +------+------+
           |
           |
+----------v------------+     TransInfoNode
|UncommittedInfo        |     +----------+    +----------+
| +-------------------+ |     |TransID=? |    |TransID=? |
| |UncommittedListHead+-----> |col1=100  +--> |col2=200  |
| +-------------------+ |     +----------+    +------^---+
| +-------------------+ |                            |
| |UncommittedListTail+------------------------------+
| +-------------------+ |
| +-----------+         |     +--------+
| |RowValuePtr|         |     |RowValue|
| +-----------+         |     +----+---+
+-----------------------+          |
           |      ^----------------+
           |
 +---------v-----+
 |UncommittedInfo|
 |    ...        |
 +---------------+

事务操作:

  • 维护一个全局最大的已提交事务版本号g_pub_id,事务开始时获取g_pub_id作为快照点,事务提交时需要生成版本号并写入到TransInfoNode,事务发布后原子更新g_pub_id,需要保证g_pub_id更新的顺序与获取版本号的顺序一致。
  • 事务回滚则删除TransContext以及UncommittedInfo;
  • 事务每条语句开始前需要记录UncommittedInfo链表的状态以便于语句回滚;
  • 事务执行过程中会将TransInfoNode接到RowValue的DataList链表后但不修改DataListTail。而事务内读数据则遍历整个链表;
  • 事务内修改同一行,单条语句对一行的修改作为一个TransInfoNode,且需要保证一行内多个未提交的TransInfoNode连接在一起。

事务锁机制

事务在加锁时有多种方式:一次性锁协议、两阶段锁协议、树形协议、时间戳排序协议等

两阶段锁协议属于悲观并发控制中能够保证事务可串行化的协议,将事务的获取锁和释放锁划分成了增长(Growing)和缩减(Shrinking)两个不同的阶段。
在增长阶段,事务只能加锁,也可以操作数据,但不能解锁,直到事务释放第一个锁,就进入缩减阶段,此过程中事务只能解锁,也可以操作数据,不能再加锁。两阶段锁协议使得事务具有较高的并发度,因为解锁不必发生在事务结尾。不足是没有解决死锁的问题,因为在加锁阶段没有顺序要求。

基于MVCC实现RC隔离级别,无需共享锁,事务DML语句执行过程中,涉及读或改的行都被加上排他锁,事务提交后再全部释放。

为了提升性能,线程数应该尽可能与CPU数相符,并减少阻塞。因此,事务操作涉及的一系列任务也应尽可能队列化。在加锁失败时,则回滚请求并重新添加到任务队列中以避免锁等待。

事务持久化

事务提交时,需要将操作日志写到磁盘上以实现持久化,如此之后事务才算真正完成。出于性能考虑,通常数据库会建立一个日志缓冲区,每次写入一批事务日志,由另一个定时线程执行刷盘操作并同步到备机。另一种方式则是根据当前负载和缓冲的数据量来决定刷盘时机。

内存事务架构中无需undolog,备机通过回放redolog日志来同步主机状态。数据同步的协议可采用multi-paxos、raft、zab(貌似不是主流)等。备机多线程并发回放事务需要用行锁隔离,并使行内多版本保持有序。

写日志优化

事务的并发度取决于程序架构和事务间的耦合度,对于并发修改同一行的事务,应尽可能减少行锁的粒度。
限定每个事务日志大小的上限U,日志缓冲区按U划分block。刷盘由单线程执行,用一个128位的内存对象表示缓冲区占用:block_id:64bit(连续递增编号)、offset:32bit(事务日志在block内偏移量)、id:32bit(事务日志在block内的连续递增编号)。
block内占位可用64位的CAS指令实现offset+=log_size、id+=1,若block剩余空间不足,则可用128位的CAS(CMPXCHG16B)实现block_id+=1、offset=0、id=0,然后再占位。

事务线程在日志缓冲区中占位后即可解锁(此时该事务尚未发布,令其他读同一行的事务不能获取本次修改,而修改同一行的事务则可以),然后再将事务上下文序列化并写入对应的block,block由最后一个写入的事务向刷盘线程发起持久化任务。但如果事务发布失败,该事务及其后修改过相同行的事务都要回滚。

事务版本号

事务版本号旨在约束事务间的偏序关系,通常为时间戳,有些数据库在事务开始时分配版本号,并以此作为写冲突时退让的判断条件。这种做法使得维护事务版本号变得简单,但使处理快照读的逻辑变得复杂。而且版本号没有反应事务的提交顺序,增加了主备间同步事务的难度。

内存事务的版本号可在提交时分配,根据当前时间事务日志在block内的编号计算。

CheckPoint

内存不足时memtable会dump成sstable存到磁盘上,并生成一个checkpoint记录redolog的偏移量。

并发锁优化

针对读多写少的读写锁,一种优化方式就是每个线程维护一个局部的读写锁。全局读锁的加锁解锁都只修改局部变量;加全局写锁时,遍历所有线程为加局部写锁,全部加锁完毕表示持有全局写锁;解全局写锁即解每个线程的局部写锁。

对于长久不需要释放且读引用频繁的引用计数对象,每个线程维护局部的引用计数,全局维护一个标志位f。加全局引用原理同上;减全局引用时,先减局部引用,然后判断若为0,则遍历全部线程确认引用都为0后,用原子操作抢占f,抢占成功后释放对象。

乱序log同步

事务日志之间是有序的,但同步备机的过程可以是乱序的。当然这种优化方向有以下几个前提:

  • 需使用支持乱序提交的分布式协议才能发挥性能优势(最典型的就是multi-paxos)
  • 日志乱序接收和存储,需建立索引机制

basic-paxos算法的基本流程可参考《PaxosCommit TLA+形式化验证》,一旦某个value被多数Acceptor接受,后续的决议都会在Phase2a阶段使用该值,并使其最终被所有Acceptor接受。算法的目的是达成最终一致,因为其间可能发生消息丢包、重复、乱序等状况。

在multi-paxos中,每条日志被分配一个连续递增id来对应一个paxos实例,集群中的每个节点同时作为Proposer和Acceptor。由各个节点自行为日志分配id会产生冲突(如集群冷启动),因此工程实践上会选出一个节点作为Leader,且使得在有效期内只有Leader能提出决议。
日志id由Leader分配,每次Leader选出来后会向所有成员查询最大日志id,收到多数响应后选取最大值(max_log_id)作为任期内日志id的起始值。
proposal id要保证全局唯一,可用 时间戳+节点ip 生成。
与raft不同,Leader选举没有特殊要求,可以用一轮完整的paxos流程选出或直接指定。

为每个paxos实例都运行完整的算法流程会造成很多冗余,因此可以使一批决议的Phase1a共用一个proposal id。这批决议可表示为:[start, 正无穷),start指本地已形成多数派的连续paxos实例最大id,即如果本地已形成多数派的paxos实例为(1,2,5),未形成多数派的是(3,4),那么start为2。
这种模式下,Leader可以用滑动窗口来收发消息并更新start,同时配合超时机制,批量发送,批量刷盘等手段。

Phase1b阶段,Acceptor会返回已接受的proposal id和value。为减少网络流量,获取value可以做成异步推拉。

成员变更的方式可参考raft,关键点在于保证成员配置更新前所产生的决议都已形成多数派。工程实践上可参考etcd,详见《Raft TLA+形式化验证》,即每次只变更一个节点,使得前后的多数派有交集。

Confirm机制

Leader持久化一条日志并得被多数派接受后,为该日志写一条confirm日志,即表示确认。日志恢复时,若有对应的confirm日志则直接读本地,否则执行一轮paxos(重新确认)。
Leader批量同步confirm日志到备机(备机也可向Leader拉取),备机根据confirm日志来进行日志回放。
每次选主成功后,Leader会对本地未确认的日志(id [, max_log_id])进行重新确认。

Leader变更

乱序日志确认会带来以下问题

RoundLeaderlog_Alog_Blog_C
1A1,2,311
2Bdown11
3A1,2,3down1,2

上层应用未做任何操作,在round_1无法读到日志2,却在round_3读到了。其原因在于新Leader没有区分上一任的遗留日志。解决办法:

  • 在每条日志中记录其生成时的proposal id作为generated id
  • Leader提供服务前都要写一条start working日志并确保其已形成多数派
  • 按日志id顺序回放时,start working日志后面generated id比它小的设为空日志,即保证日志的生成顺序是递增的

小结

相对于raft,multi-paxos在Leader变更后的处理会相对复杂,但其优势在于更高的网络抖动容错度。实现一个高效且可靠的multi-paxos库需要考虑的细节也是相当多的。epaxos给出了部分日志有序场景下的paxos解决方案,但貌似没有工业级别的案例。在一个IDC内,带宽和网络都得到保障的情况下,上层应用划分group且保证不同group之间的事务日志没有顺序依赖,multi-raft算得上一种性价比较高的方案。

总结

想实现一个自己的内存事务框架还是相当困难的,尤其是考虑选用Rust语言作为主体时,很多模块不知道该如何抽象(游走在safe和unsafe的边缘)。估计又要拖成有生之年系列了。。。

// TODO

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值