MongoDB 事务,复制和分片的关系-2

举报
geminidb_fans 发表于 2020/06/30 20:09:25 2020/06/30
【摘要】 5.WiredTiger对GSI的实现上文中我们介绍了GSI的基本概念,WiredTiger在不使用timestampAPI时,实现了CSI。它的冲突检测算法如下:Transaction's localViewTransaction -> { // Global monotonicly increasing id, allocated when transaction starts. ...

5.WiredTiger对GSI的实现


上文中我们介绍了GSI的基本概念,WiredTiger在不使用timestampAPI时,实现了CSI。它的冲突检测算法如下:

Transaction's localView
Transaction -> {
   // Global monotonicly increasing id, allocated when transaction starts.
   txnId: int
   // The local view of the global transaction list.
   snapshots: []int
}

Global Transaction List
GTL -> {
   // used for allocating one txn's txnId
txnIdAllocator: atomic<int>
// uncommitted transactions
activeTxns: List<Transaction*>
// protect GTL from concurrent visit
rwlock: RwLock
}

Transaction's mvcc prodecure
Begin -> {
    With(GTL.rwlock.wlock()) {
        txn = Transaction {
            txnId: GTL.txnIdAllocator++
             snapshots: GTL.activeTxns.copy()
        }
        GTL.activeTxns.add(txn)
    }
    // txns are naturalled sorted in this way.
}

Update(key, value)-> {
// get the updatelist of a key from the btree
// pick its header
   upd = GetUpdateList(key).header
   if (upd.txnId > self.snapshots.back()) {
       throw Conflict
   }
   if (txn.snapshots.find(upd.txnId) {
       throw Conflict
   }
   GetUpdateList(key).insertAtFront({self.txnId, value})
}

Commit -> {
    With(GTL.rwlock.wlock()) {
       GTL.activeTxns.remove(self)
    }
}
  • 在事务开始时,对将全局未提交的事务id copy 到事务的localView中的snapshots链表中。

  • 采用firstUpdateWin的冲突检测策略,在更新记录时如果违背SI,当且仅当:

    • 如果key的mvcc链头的txnId 大于localView的snapshots中的最大值,

    • 如果key的mvcc链头的txnId在本事务的localView的snapshots

  • 上述规则是经典的CSI的实现,在PG中也是类似的做法,正确性证明请见这篇文章



WiredTiger timestamp 与 GSI


WiredTiger中引入readTimestamp和commitTimestamp,readTimestamp即GSI中的snapshotTimestamp。

Transaction -> {
   // Global monotonicly increasing id, allocated when transaction starts.
   txnId: int
   // The local view of the global transaction list.
   snapshots: []int
   read_timestamp: int
   commit_timestamp: int
}

相应的冲突检测判断部分,也引入了GSI的规则

  • 即本事务的read_timestamp 小于已提交key的commit_timestamp 即认为违背GSI

  • 这里默认本事务的commit_timestamp 一定大于key的mvcc链中的最大的commit_timestamp,即对于某个特定的key,该key的commit_timestamp的顺序一定要和物理提交顺序一致,这是由application-level(mongoServer层)保证的,那么Mongo层是如何保证这一点的呢,请继续往下看。

 Update(key, value)-> {
// get the updatelist of a key from the btree
// pick its header
   upd = GetUpdateList(key).header
   if (upd.txnId > self.snapshots.back()) {
       throw Conflict
   }
   if (txn.snapshots.find(upd.txnId) {
       throw Conflict
   }
   if (txn.read_timestamp < upd.commit_timetamp) {
       throw Conflict
   }
   GetUpdateList(key).insertAtFront({self.txnId, value})
}




前缀一致的复制(PrefixConsistent Replication


MongoDB从节点拉取主节点的Oplog来实现复制,主节点上的Oplog是并行提交的,因此在Oplog的末尾存在(时间上的)乱序。如果不屏蔽乱序的话,从节点读到的Oplog就会产生空洞。举个例子:

T0: txn1.start(); txn1.setCommitTimestamp(1)
T1: txn2.start(); txn2.setCommitTimestamp(2)
T2: txn3.start(); txn3.setCommitTimestamp(3)
T3: txn3.commit()  // local.oplog.rs: [3]
T4: txn1.commit()  // local.oplog.rs: [1,3]
T5: txn2.commit()  // local.oplog.rs: [1,2,3]

从空间上来讲,oplog的顺序就是事务的commitTimestamp顺序,即[1,2,3]的顺序。但是从时间上来讲,物理提交顺序不可控,尽管三条oplog最终会生成,但是不同时间点看到的oplog,是有空洞的。比如T4时刻的oplog是[1,3],如果从节点此刻去遍历Oplog,就会丢失commitTimetamp=2的记录。



CommitQueue 与 AllCommitTimestamp的计算


本质上讲,所谓前缀一致的复制,指的是,如果我读到了某条commitTimestamp = X的数据,就不允许在我读这条数据后,对数据库做出commitTimestamp < X的更改。因此,不难想到我们可以维护一个最大的没有空洞的commitTimestamp,叫做AllCommitTimestamp, 形式化的定义如下, 也就是GSI读规则的第4点,描述了不会出现空洞:

image.png


上面我们分析过,事务有commitTimestamp属性。

在wiredTiger中,事务被按照commitTimestamp排序,即

TAILQ_HEAD(__wt_txn_cts_qh, __wt_txn) commit_timestamph;

我们知道,事务开始和设置commitTimestamp不在一个临界区中,因此事务的txnId的顺序和事务的commitTimestamp顺序并不完全一样,这也是为什么wiredTiger除了维护全局的active_transaction_list这个活跃事务链之外,还维护了一个commit_timestamph的队列。active_transaction_list可以看作按照txnId有序的队列,commit_timestamph可以看作按照commitTimestamp有序的队列。commit_timestamph 队列可以用来辅助AllCommitTimestamp的计算。相关伪代码如下:

for (auto v : commit_timestamph) {
 if (v.committed || v.rollbacked) {
   continue;
 }
 return v.commit_timestamp - 1;
}

根据上述伪代码,wiredTiger中,从左往右遍历commit_timestamph队列,找到第一个没有提交的事务T,allCommitTimestamp = T.commitTimestamp - 1; 这也很容易理解,T为commitTimestamp最小的未提交事务,那么比它更小的commitTimestamp所属的事务必然都已经提交了。这里有个假设,对commit_timestamph的插入操作必然都是插入到末尾,换句话说,commitTimestamp的分配必然是递增的,这一点是由application-level(mongodb-server层)保证的,那么Mongo层是如何保证这一点的呢,请继续往下看。

MongoDB用来做复制的,读oplog的读事务,它的readTimestamp由AllCommitTimestamp得到,因此能够做到前缀一致的复制。



遗留问题的解决


上面我们留了两个遗留问题

  1. Mongo如何做到对于某个特定的key,该key的commit_timestamp的顺序和物理提交顺序一致

  2. Mongo如何做到对commit_timestamph的插入操作必然都是插入到末尾,换句话说,commitTimestamp的分配必然是递增的

这两个问题,是wiredTiger不做任何保证的,如果违背了,数据就错乱了。MongoDB的同一段代码保证了上述两点,或者说,上述问题其实是一个问题。就是所谓的PBMA策略

 1. // the global timestamp allocator
2. GTA -> {
3.     lock     Lock
4.     globalTs int
5. }
 
6. // the timestamp allocation prodecure
7. Transaction txn
8. txn.Begin()
9. with(GTA.lock) {
10.      txn.setCommitTimestamp(globalTs++)
11. }
12. ......
13. txn.Commit()

PBMA策略的关键要素是

  • 事务先开始,再分配提交时间戳

  • 分配提交时间戳要保证全局递增(加锁)

PBMA为何能一箭双雕的保证上面两个遗留问题呢,正确性的证明在这里

  • 简单的说,对于某个特定的key,如果有两个事务都在修改, 那么根据事务冲突的规则可知, 这两个事务,一定是一个提交后,一个才开始的,满足 commit(Ti) < snapshot(Tj)的关系,对于本事务,也满足 snapshot(Tj) < commit(Tj) 的关系, 那么可知, commit(Ti) 是小于 commit(Tj)的, 即commit 顺序跟物理顺序相一致。

上面介绍了MongoDB副本集系统跟4.0的时间戳的一些实现, 底下我们需要了解一下在MongoDB 4.2 的分布式事务又是如何使用时间戳来实现的。



6.分布式系统的时钟



分布式系统,各个节点时钟的是不一致:

1,分布式各节点,时钟不一致,会导致分布式事务,无法定序。

2,对于分布式系统,一般都有时钟同步服务来保证各个节点的时间一致性。

3,但是即使存在时钟同步服务,任一时间点的绝对一致是很难达到的。这对于分布式系统来说,是不可依赖的。

4,分布式事务处理,如果不想为节点时间差专门去做逻辑处理,则需要基于一个稳定一致的时钟系统。— 即:逻辑时钟。



7.MongoDB sharding与时间戳的关系




HLC与ChangeStream


从3.6之后,MongoDB就引入了HLC的概念,在实现上的体现是,Mongos/Mongod的每次操作都会带ClusterTime,ClusterTime也作为Mongos和Client交互传递的Token。HLC在MongoDB中的体现在以下几点:

  • ClusterTime在Client/Mongos/Mongod的所有操作(读/写/命令)之间传递,每个进程收到更大的ClusterTime后就更新本地的ClusterTime。

  • Mongod侧,Oplog的分配与物理时间的递增会作为ClusterTime推进的驱动力。

  • Mongos/Mongod侧,物理时间的偏斜会作为ClusterTime推进的驱动力。

  • ClusterTime传递给Client时是被签名的,以防止被篡改使得Server端的ClusterTime无限制变大。Client本身只传递不推进ClusterTime。

相关代码在 logical_clock.h::advanceClusterTime,mongos和mongod在rpc的处理函数处均有调用。

void processCommandMetadata {
   return logicalClock->advanceClusterTime(signedTime.getTime());
}

通过上述HLC的机制,mongos/mongod各个进程之间的物理时钟不需要协调,集群的ClusterTime会随着mongos/mongod/configsvr的每次交互相互接近。如果没有HLC机制,各个shard之间的写操作的OplogTime相互独立,各个shard上的oplogTime没有totalOrder,全局的ChangeStream就无法实现。



CLOCK-SI与MongoDB 的2PC分布式事务


MongoDB的2PC和CLOCK-SI paper中的算法非常接近(由于官方并未明确说明,因此并不可以说,MongoDB的2PC是对CLOCK-SI paper的实现)。MongoDB的分布式事务流程介绍可以参考这篇文章

CLOCK-SI

  • 分布式系统中,考虑到多事务并发,对每个单个事务的开始时间是严格要求的,只有在不同节点上的单个事务都在同一个时间戳开始执行,那么多个事务在每个节点上的顺序才是一致的。考虑到分布式系统会有 clock skew (时钟偏斜) 的场景, 我们就需要在其他节点等待时间流逝到这个时间,才可以开始执行, 如下图所示:

    image.png


    • 上图中事务 T1,因为在P1节点是在 t时刻开始的, 所以在事务在 P2节点运行时,也需要等到t时刻才能运行, 如上图所示 P2 节点时间早于P1节点, 那么事务在P2节点,就需要等这个延迟时间。这样会造成事务执行过程中的延迟的增大。

  • CLOCK-SI就是为了提出了一个方法,用来解决上面事务执行过程中的延迟问题的,

    读规则如下

    image.png


    • CLOCK-SI 的读规则提供了一个多分区情况下的一致的snapshot, 主要有两点


    • 1> 如何设置事务T的SnapshotTime

    • 2> 如何延迟读操作知道每个节点都有一个一致的snapshot


    • 第2行,表示我们可以将snapshotTime适当的向前调整,这样,可以减少这个事务在远端节点的等待时间, 远端节点等待操作,见 第 21 和 22 行。

    • 第7-9行, 如果查看的oid,发现是事务T’修改的,且T’事务正在提交,并且T’事务的提交时间小于事务T(对事物T可见的话), 那么应该等到T’提交成功后,再读取。


    • 因为 commiting 也可能会提交失败, 所以事务T不能直接读取;

    • 因为 已经判断了,T’ 对事务T可见,所以事务T不能忽略。

    • 综上所述,事务T的读操作只能等待。


    • 第 11- 15行, 与上面类似,如果 T’ 已经 prepared,且 T’ 对事务T可见,那么 读操作需要等待到T’成功提交后,再读取。原因与上面描述类似

    • 21-22 行, 规定了读请求在每个节点上,都需要等到远端物理时钟到了后,才可执行。


    写规则如下

    image.png


  • 写规则针对事务是单点的,还是跨节点的,又分为 local commit, 和 distributed commit

  • 采取两阶段的提交方式

    • 先发送 prepare T 请求到所有事务参与节点

    • 等待所有事务参与节点返回 T 事务已经被 prepared的结果

    • 设置T 状态为 commiting状态

    • 选取最大的 prepare timestamps 作为 commit timestamp, 并分配给事务T

    • 记录事务T的提交时间和提交决策

    • 设置事务T为commited状态

    • 将 commit T 发送给所有事务参与节点

  • 事务参与节点,一旦收到 prepare T的请求,

    • 检查 事务 T 在本节点是否可以被修改

    • 记录事务的写操作结果和协调者的id

    • 将事务T状态设置为 prepared

    • 设置T.prepared 时间为本地时间

    • 发送 T 事务已经prepared成功的信息给事务T的协调者

  • 事务参与节点, 收到 commit T的消息后

    • 记录事务T 的commitTime

    • 将事务T 设置为 commited 状态



MongoDB如何处理读操作读取了处于prepare状态的值



  • 可以看出,CLOCK-SI中明确定义了, 当分布式事务处于Prepare 状态时, 如果其他读事务可以看见这个 Prepare, 那么需要等到分布式事务状态变为提交后,才可以继续执行。这对应到 mongodb 4.2 代码中,在wt的 当读事务 碰到了 正在 Prepare的事务,会返回一个 WT_PREPARE_CONFLICT 来表示当前并不是一个合适时机, 我们需要等到这个写事务完成。

  • 而上层的MongoDB如果碰到 WT_PREPARE_CONFLICT, 则会等待事务提交后,继续尝试读取,

    int ret = wiredTigerPrepareConflictRetry(opCtx, [&] { return c->search(c); });
    • 所有对底层 cursor的遍历和查询操作,都会被封装到 wiredTigerPrepareConflictRetry 函数中, wiredTigerPrepareConflictRetry 会根据传入的函数对象的执行结果进行retry,


    • 如果返回结果不是 WT_PREPARE_CONFLICT, 那么 wiredTigerPrepareConflictRetry 直接返回,

    • 如果返回对象是 wiredTigerPrepareConflictRetry, 那么会下一次有事务提交或者abort时,再次调用函数对象检查一次。


  • 更多关于 WT 层修改,参考这篇文章


为什么 commitTs=max{prepareTs}


首先我们需要明确的是,分布式事务哪怕在多个节点运行,最终的 commit timestamp 只能有一个

因为 commit timestamp 必须大于 prepare timestamp, 所以 commitTs=max{prepareTs} 是一个合理的规定。

如果不认同上面规则, 认为 commit timestamp 可以小于某个节点的 prepare timestamp, 那会违反事务的原子性要求的,

举例如下:

  • prepareTs(P1, T1) = commitTs(T1) < snapshotTs(T2) < prepareTs(P2, T1)

那么T2事务在节点1,可以看见T1的修改,但是在节点2, 是看不到 T1修改的, 这就违背了 事务的原子性的要求。


【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。