数据库分布式事务2PC设计方案

数据库分布式事务2PC设计方案

数据库事务的概念

数据库事务通常指对数据库进行读或写的一个操作序列。
1、为数据库操作提供了一个从失败中恢复到正常状态的方法,同时提供了数据库即使在异常状态下仍能保持一致性的方法。
2、当多个应用程序在并发访问数据库时,可以在这些应用程序之间提供一个隔离方法,以防止彼此的操作互相干扰。[1]

事务的4个特性

  • 原子性(Atomicity):事务包含的操作全部成功或者全部失败。
  • 一致性(Consistency):数据库从一个一致性状态变到另一个一致性状态。一系列操作后,所有的操作和更新全部提交成功,数据库只包含全部成功后的数据就是数据的一致性。由于系统异常或数据库系统出现故障导致只有部分数据更新成功,但是这不是我们需要的最终数据,这就是数据的不一致。
  • 隔离性(Isolation):事务互相隔离互不干扰。事务内部操作的数据对其它事务是隔离的,在一个事务执行完之前不会被其他事务影响和操作。
  • 持久性(Durability):事务提交后数据应该被永久的保存下来,出现宕机等故障后可以恢复数据。

事务依赖的基础组件

  • redo/undo。事务利用redo来恢复数据,利用undo做回滚;
  • 表锁/行锁。事务利用锁来实现事务间的隔离,不同形式的锁可以实现不同隔离级别[8]
  • MVCC。不使用行锁,还可以实现MVCC来实现事务间的隔离。使用MVCC配合行锁,可以实现不同的隔离级别。
  • WAL和Snapshot。WAL全称Write-Ahead Logging,日志和快照(Snapshot)功能是事务实现持久化的基础。

分布式事务

分布式事务是指一个事务执行的操作,涉及到了多个节点,需要多个节点共同协调,才能满足事务特性。目前有不少理论支持分布式事务,最常见的有2PC,3PC,TCC。其中2PC是最常见的分布式数据库中采用的方案。
本文介绍的不是一个最终实现或采用的方案设计,只是描述了很多在设计时遇到的问题与各种参考方法。

分布式事务实现

2PC介绍

2PC就是二阶段提交,顾名思义,事务分为两步提交,先由一方进行提议(propose),或者称为prepare,并收集其他节点的反馈(vote),再根据反馈决定提交(commit)或中止(abort/rollback)事务。发起提议的节点称为协调者(coordinator),其它节点称为(participant)。

coordinator B C prepare prepare log redo/undo log redo/undo prepare OK prepare OK commit commit log commit log commit commit OK commit OK participant coordinator B C 2PC

阶段1:请求阶段(commit-request phase,或称表决阶段,voting phase)
协调者节点向所有参与者节点询问是否可以执行提交操作,并开始等待各参与者节点的响应。
参与者节点执行询问发起为止的所有事务操作,并将Undo信息和Redo信息写入日志,还可以包含此次事务的其它相关信息,比如协调者ID。
各参与者节点响应协调者节点发起的询问。如果参与者节点的事务操作实际执行成功,则它返回一个"同意"消息;如果参与者节点的事务操作实际执行失败,则它返回一个"中止"消息。或者在"超时"时间内,没有收到所有参与者节点的"同意"消息,就需要通知参与者节点,需要"中止"事务。 有时候,第一阶段也被称作投票阶段,即各参与者投票是否要继续接下来的提交操作。

阶段2:提交阶段(commit phase)
在该阶段,协调者将基于第一个阶段的投票结果进行决策:提交或取消。当且仅当所有的参与者同意提交事务协调者才通知所有的参与者提交事务,否则协调者将通知所有的参与者取消事务。参与者在接收到协调者发来的消息后将执行响应的操作。

这里开始介绍2PC的一个设计方案(并不是通用理论)。

系统结构

在这里插入图片描述
节点之间使用RPC作为通讯组件。

客户端

客户端,可以连接任一节点,并发起请求。但是,同一个发起的同一个事务,只能由同一个节点处理。即客户端不作为代理或协调者角色。一个数据库节点可以同时处理多个客户端,为每个客户端维护会话信息和事务信息。

事务的创建

客户端可以向任意一个节点发起操作,需要访问其他节点数据时,节点通过RPC转发请求,并记录本次事务操作。客户端第一次发起操作时,就是一个事务的开始。节点通过RPC转发数据请求时,会附带当前事务标识,接收RPC请求的节点,也会为这个客户端维护一个事务数据。等后续提交或回滚时,由与客户端连接的节点,通知此节点。

节点间事务的关联

按照上面的描述,与客户端直接连接的节点可能需要转发数据请求,收到转发请求的节点同时维护与此客户端本次操作相关的事务数据,在后续的数据操作和事务相关的操作中,节点都需要知道操作是与哪个事务相关联的。因此,节点转发请求或者执行相关事务时,都需要增加一个标识。

  • 用session做标识,表示是哪个客户端,或者哪个连接(假设一个客户端或一个连接在一个时间点,最多只会有一个相关事务)。
  • 使用全局事务ID。节点在事务开始时,就创建一个全局唯一标识编号,后续此事务相关的操作,使用这个编号标识。
  • 数据请求节点创建事务ID。某个节点收到客户端请求,发现本节点不能满足需求,需要转发,转发的节点是第一次收到当前事务相关请求时,转发节点发送事务ID -1。处理请求的节点遇到-1事务编号就创建一个新的事务,并将事务ID与应答一起一并返回转发节点。后续与此节点相关的同一个事务的操作,都带上这个ID。
Client A B connect session_id = create_session insert(table, record) insert(session_id, table, record) find_trx(session_id) = null trx = create_trx(session_id) insert(table, record) log(trx, insert, table, record) OK OK commit commit(session_id) trx = find_trx(session_id) commit(trx) delete trx OK OK Client A B 使用session标志事务
Client A B connect OK insert(table, record) trx_id = alloc_trx_id() insert(trx_id, table, record) find_trx(trx_id) = null trx = create_trx(trx_id) insert(table, record) log(trx, insert, table, record) OK OK commit commit(trx_id) OK OK Client A B 使用全局事务ID
Client A B connect OK insert(table, record) insert(-1, table, record) trx = create_trx() insert(table, record) log(trx, insert, table, record) OK(trx.id) map[B] = trxid OK commit commit(trxid) trx = find_trx(trxid) commit(trx) OK OK Client A B 处理请求的节点分配事务号

分布式事务的分类

按照事务中参与的节点个数和位置,可以分成4类:

  1. 只有与客户端连接的节点参与了事务,其实就是最简单的普通本地事务;
  2. 与客户端连接的节点没有参与事务,但是只有另外一个节点参与了此事务,这样的事务没有多个节点参与,其实也是普通事务;
  3. 多个节点参与了事务,包括与客户端连接的节点;
  4. 多个节点参与了事务,但是与客户端连接的节点没有参与。

对于类型1和2,因为没有多个节点参与事务,所以不在分布式事务的考虑范围内。
对于2,客户端提交事务时,对应节点将提交命令转发给执行操作的节点即可,但是会存在提交事务的节点中途异常,与客户端连接的节点,只能返回一个"未决"的错误信息。

协调者的设计

协调者的主要工作是向所有的参与者发起prepare和commit/rollback命令。在有些系统上,会专门设置一个节点,或者一个模块,作为协调者。这样的话,不涉及到协调者选择的问题。这个缺点是,协调者是一个单点,每次分布式事务提交时都要经过协调者节点。另外,还需要对"协调者"模块做高可用,毕竟协调者出现异常,整个系统就不可用,是非常可怕的。
这里的选择放弃了独立协调者的设计,而是每个节点都可以作为协调者。上面对事务的分类,主要就是为了协调者选择做准备。
对于类型3,直接选择与客户端连接的节点作为协调者。类型4,选择第一个操作事务的节点作为协调者。

按照上面的描述,分布式事务的协调者,肯定也是参与者之一。

if (!writes_.empty()) {
    if (participants_.empty())
    	commit_local(); // local transaction
    else
        commit_2pc(); // 本地节点作为协调者
}
else if (writes_.empty() && ) {
    if (participants_.size() == 1)
    	commit_remote(participants_.front()); // 提交远程节点的事务
    else
        commit_2pc_remote(participants_.front(), participants_);// 选择一个远程节点作为协调者
}    

提交

协调者选出来之后,就开始执行2PC操作。协调者同时向所有节点(包括自己)发起prepare命令。如果在超时时间内,收到了所有节点的成功应答,那么就发起commit命令,否则就发起rollback命令。

Client A B C commit prepare prepare prepare log prepare log prepare log prepare OK OK OK SUCCESS 注意:prepare成 功后 就认为事务提交成功 commit commit commit OK OK OK clean log clean log clean log clean log 消息是参考了 OceanBase设计 Client A B C 2PC commit

事务ID

在协调者向参与者发起提交指令时,参与者要知道提交的是哪一个事务,那么就需要一个事务标识,就是事务ID。
事务ID要能够区分所有的分布式事务。事务ID的设计要考虑下面几个因素:

  1. 事务产生的节点;
  2. 事务产生的时间;
  3. 节点宕机,备机接管。

为了在任何时刻任何节点创建的事务ID都不会重复,那么可以在事务ID上增加节点ID(假设节点ID是全局唯一),在单个节点上,可以使用单调递增序列号作为事务ID的第二部分。这样的话事务ID就是这样的

节点ID序列号
32bit32bit

序列号存在两个问题:

  1. 节点宕机再起来,需要接着上次的序列号继续走。但是也不能每次落地,这样简单的解决方法就是增加一个步长,比如10W落一次地。或者直接使用数据库的sequence功能。
  2. 这里示例是32bit序列号,最大支持大约42亿数字,容量有限,要考虑可能会回绕的问题。如果使用48bit,假设每秒钟10W量增长,大约可以使用89年。只是节点ID只剩下16个字节,即最多支持65536个节点。

事务ID生成时机

事务ID可以在事务提交时生成,也可以在第一次涉及到多分片数据操作时生成。除了考虑生成的时机,还要考虑多个分片之间交互数据时,通过什么作为事务的标识。
比如节点A,需要访问节点B的数据,那么节点B上就需要创建一个与A相关的事务。下次A再次访问B的数据时,B需要找到上次关联的事务。
其中一个方法是,由A节点创建分布式事务ID cid,在A节点第一次访问B节点数据时,将分布式事务ID发送给B。B节点收到A的请求,使用cid在本地节点查找或创建对应的事务数据,并在以后每次操作此事务时都使用cid作为标识。

NodeA NodeB NodeC 创建分布式事务ID cid 数据操作(cid) 数据操作(cid) commit(cid) commit(cid) NodeA NodeB NodeC 访问节点创建分布式事务ID

另一个方法是,由B节点创建一个唯一标识tid,在B返回A节点数据时,将tid返回给A节点。提交时,再由协调节点创建分布式事务ID。

NodeA NodeB NodeC insert(tid=-1, table, record) return(tid=tidB, OK) update(tid=-1, table, record) return(tid=tidC, OK) delete(tid=tidB, table, key) 创建分布式事务ID cid commit(cid, tidB) commit(cid, tidC) NodeA NodeB NodeC 被访问节点创建事务ID

两种方法各有优缺点。

由访问者创建分布式事务ID的优缺点

优点

  1. 相对于由被访问者分配ID的方法,所有的节点使用同一个事务ID,很容易跟踪和观察某个事务的流程和访问数据。
  2. 管理简单,所有节点都使用同一个ID。

缺点

  1. 分布式事务ID上的节点ID信息可能不是协调者节点ID。
    节点A访问节点B之前,创建分布式事务ID,如果后续A节点与此事务无关,那么A不会成为协调节点。这样分布式事务ID上的节点ID信息就不是协调者节点ID。
  2. 会存在不必要的分配。
    如果后续事务访问只跟B节点相关,那么这个事务就不是分布式事务,分配的分布式事务ID也是不必要的。
  3. 存在事务被异常释放后再次访问的风险。
    假设这种场景,节点A访问节点B,向B发了一个分布式事务ID cid,B节点创建了cid标识的本地事务。在这个事务结束之前,B节点上认为此事务出现异常(比如超时未提交,或者与A节点心跳超时),将此事务销毁。后面又收到了A节点与cid关联的数据访问,B节点应该能够识别出来,不能再次创建与cid相关的事务对象,并告知A节点异常。简单的解决方法是,A节点访问B节点时,应该告诉B是否应该创建一个新的事务,还是从之前的事务中查找。

由被访问者创建事务ID的优缺点

优点

  1. 上面的方法中描述的缺点在这里都不存在。
  2. 事务信息各自维护,方便。

缺点

  1. 访问者要记录每个被访问者的事务ID。
  2. 提交时,要关联本地事务ID与分布式事务ID。

日志

参与者在提交的时候,需要记录两次日志,一次是prepare,一次是commit/rollback。
这里说的日志是WAL,在普通事务中,记录的是redo数据,用来在数据库异常恢复时使用。
日志只能追加,不能删除,在做完快照后,可以删除快照前对应的日志(类似于innodb的日志)。

prepare日志内容

prepare中除了要记录事务操作的数据(redo数据),还要记录都有哪些参与者。
记录参与者的目的是为了在异常恢复时,可以确定这条事务的状态,是roll-forward还是roll-back。

信息数据
事务头 size等字段N个数据操作内容
数据头 操作类型=PREPARE分布式事务ID | 参与者个数 | 参与者ID 0 | ID 1| ID2 |…| ID N
数据头 操作类型=INSERTTABLE ID,RECORD DATA
数据头 操作类型=UPDATETABLE ID, KEY, RECORD DATA
数据头 操作类型=xxx

commit/rollback日志内容

commit日志内容很简单,记录当前事务已经提交。rollback类似,记录当前事务已经回滚。

信息数据
事务头 size等字段
数据头 操作类型=COMMIT/ROLLBACK分布式事务ID

日志与raft中日志数据的结合

raft[5]是一个强一致性协议,基于日志复制的方式解决多节点数据一致性,其中每条日志都有一个index,这个index是单调依次递增的,并且是连续的(从1到正无穷)。

作为一个分布式数据库来说,高可用性是最基本的要求。单个分片的高可用,可以通过raft来实现。同一个分片中的不同节点,处于一个raft group中。

对于分布式事务产生的每个数据,都要通过单个分片上raft group的投票决议。成功后才是可用状态。在raft投票过程中,raft leader节点(分片上的主节点),将数据同时发给多个follower节点(备机节点),leader和follower并行的将数据落地到磁盘。如果leader收到了包括自己在内的大多数(>=N/2+1)节点的成功应答消息,认为这次投票通过,日志状态变成committed。当然,这个日志中的数据,就是数据库中的事务数据,有普通的单个节点的事务,也有涉及到多个节点的分布式事务,分布式事务又有prepare日志和commit/rollback日志。这样,只涉及单个节点的普通事务对应的raft日志只有一条,而分布式事务对应的raft日志就有两条。

事务模型

2PC的一般事务模型逻辑上是比较简单的。它的难点在于出现异常时,怎么确定事务的状态,比如节点执行过prepare之后宕机了,恢复后,应该继续提交(roll-forward)还是回滚(roll-back)。
在Percolator[6]模型中,提出了primary-row的概念,就是在事务的多个操作中,拿出一个操作(操作的是一个记录,或称为一行)作为primary-row。先执行primary-row的提交操作,如果primary-row提交成功,则认为整个事务成功,否则整个事务回滚。事务中的其它操作,记录日志时都会关联primary-row。异常恢复时,通过查询primary-row的状态,来确定事务的状态,进而提交/回滚事务。

Client A B C commit choosen as coordinator primary-row = _writes.front() prepare(primary-row) prepare在原论文 中是prewrite prepare(primary-row) prepare(primary-row) OK OK OK commit(primary-row) 这个commit可 以 带上A的其它操 SUCCESS commit commit Client A B C Percolator 2PC模型

YugaDB提供了一种Percolator模型的变种,在Yuga中使用事务表替换了primary-row

Client A B C set k1=v1, k2=v2 txn = create_trx_record(status=running) set k1=v1 with txn set k2=v2 with txn prepare prepare OK OK set_trx(status=committed) OK commit commit OK OK Client A B C YugaDB事务模型

还有另一种方法,OceanBase和NDB都在使用。节点在异常恢复时,遇到只有prepare日志的事务,就向协调者节点询问当前事务的状态,或者向所有参与者询问当前事务的状态。这种方法的优点是相对于Percolator来说,回复客户端可以更快一点,缺点是异常恢复时,可能需要向所有参与者发起请求,询问当前事务的状态。

Client A B C commit prepare prepare prepare OK OK OK SUCCESS 原文中要先向 其它节 点发起 pre-co mmit命令 等待应 答后回复 Clien t成功, 这里忽略了 commit commit commit Client A B C OceanBase/NDB 2PC模型

我们应该假设,异常,是一个比较少出现的情况。另外,要优先考虑对业务提供更高效的服务,所以OceanBase/NDB的模式,可以更快的给客户端应答。不过也可以调整percolator模型,在所有节点prepare成功后,回复成功。后续使用异步的方式提交primary-row和其它参与者。primary-row这种方式,YugaDB中做了一个变种,使用事务表来记录事务的状态替换primary-row[9]

NOTE: OceanBase原方案有点不同,协调节点在收到所有参与者prepare成功回复后,没有立即给客户端应答。而是再向所有节点发送commit消息,参与者收到commit消息后,立即回复应答,然后落地日志commit日志。这里直接在prepare成功后,回复了客户端,可能没有参透OceanBase的目的。

两种模型的auto roll-forward/roll-back

在Percolator模型中,每一行都记录了primary-row信息,那么异常恢复后,行记录上会有primary-row信息。恢复后,新的事务访问这个行时,会发现上面有primary-row,就开始针对这行执行事务恢复操作,找到primary-row对应的事务状态,然后对该条数据,执行提交或回滚操作。

在OceanBase/NDB模型中,因为没有primary-row,恢复后,只能知道当前事务是未决状态。那么在提供服务之前,可以向协调者节点询问这条事务的状态。否则,只有当所有节点都回复prepare成功的消息,这个事务才能提交,也必须等待所有节点回复关于这个事务的状态信息。

A Coordinator B C redo prepare log,事务ID cid transaction state(cid, prepared)? transaction state(cid)? transaction state(cid)? committed(cid) commit(cid) committed(cid) 这条消息 会被忽略 A Coordinator B C 恢复节点询问事务状态-有节点回复确认消息
A Coordinator B C redo prepare log,事务ID cid transaction state(cid, prepared)? transaction state(cid)? transaction state(cid)? prepared prepared commit(cid) commit(cid) commit(cid) OK OK OK A Coordinator B C 恢复节点询问事务状态-所有节点回复prepared

性能对比

2PC是分布式数据库系统中的性能杀手,会让很多人放弃事务,选择最终一致性。
在实际运行过程中,影响性能的是提交开始到客户端收到应答这个时间。所以这里不关心回复客户端之后,异步执行的动作消耗的时间。在2PC中,影响性能比较大的主要是网络延迟和磁盘落地时间。
Google的Percolator模型中采用的是Bigtable,所以没有磁盘落地,不过Bigtable的一次原子写操作应该不会比磁盘落地小,那么就姑且当做一次磁盘落地。

模型延迟
Percolatorprepare: 1次RPC + 1次磁盘
commit-primary-row: 1次磁盘
YugaDBprepare: 1次RPC + 1次磁盘
set_trx_status(committed): 1次磁盘
OceanBaseprepare: 1次RPC + 1次磁盘

可见oceanbase的回复速度最快。

oceanbase的方案在日志清理实现上非常困难,不知道他们内部团队如何设计的。

节点恢复

对于非分布式的数据库节点来说,恢复时需要先恢复快照数据,然后恢复快照后对应的redo日志。在分布式情况下,redo日志中可能会包含分布式事务prepare和commit/rollback日志。这里需要额外考虑的就是分布式事务redo的恢复。

方案1 缓存prepare数据

在遇到prepare日志时,不将prepare对应的数据redo到数据库中,而是缓存起来,直到后面遇到commit/rollback日志,再决定redo(commit)还是删除(rollback)。

如果所有的redo日志全部做完了,还有prepare缓存没有清理,那么就需要按照auto roll-forward/roll-back的方案,向其它节点发起询问,获取事务状态,再决定是redo或者直接清理掉(rollback)。

方案2 恢复prepare状态

参考Percolator模型中提供的方案,遇到pre-write(prepare)日志时,将锁状态一并恢复到数据库中,当然还会有关联的undo数据用来做rollback。这样在后面遇到对应的commit/rollback日志,做对应操作即可。

在所有日志都恢复完成后,可以直接忽略掉prepare状态的日志,因为行锁信息还在,其它事务访问时,可以再去检查数据状态,进而恢复事务,即执行auto roll-forward/roll-back逻辑。

或者,在恢复过程中,也缓存哪些事务在prepare状态,直到日志redo结束,去遍历prepare状态的事务,执行auto roll-forward/roll-back逻辑。

日志的清理

先考虑oceanbase模型。因为这种模型在日志清理的时候更复杂。
众所周知,数据库都会落redo日志。一般情况下,使用raft时,都会将redo与raft的日志合并起来。现在假设只考虑raft的日志。raft在每次做完快照后,会将之前对应的日志清理掉。这样会存在一个问题,就是日志清理后,如果有异常节点刚刚恢复,向本节点询问某个事务对应的状态,但是本地没有对应的日志,那么就无法回复。所以,日志应该在磁盘中保存一段时间。一般情况下,异常恢复的节点,访问某个prepare状态的事务日志时,可以仅向该事务对应的协调者节点询问,由协调者节点确认该事务的状态,或者访问其它参与者汇总信息。或者,有其它机制保证可以在其它节点需要询问某个事务状态时,一定可以找到对应的数据。

A B transaction state(cid)? 内存缓存中查找对应事务 未找到在磁盘日志中查找事务 事务状态(prepared? committed? rollbacked?) A B 其它节点询问事务状态

日志清理的时机

那么问题就来了,日志在什么时候可以清理?
首先确定一点,肯定要在做过快照之后才能清理。其次就是明确知道不会再有其它节点访问某个事务的状态了,这个事务对应的日志就是可以清理掉的了。
可以在每次确认提交或回滚后,协调者节点再发起一个cleanlog的消息。

Client A B C commit ... ... SUCCESS commit commit commit OK OK cleanlog cleanlog cleanlog clean ok clean ok Client A B C cleanlog消息

cleanlog消息也可以在异常恢复时发起

A Coordinator B C redo prepare log,事务ID cid transaction state(cid, prepared)? transaction state(cid)? transaction state(cid)? prepared prepared commit(cid) commit(cid) commit(cid) OK OK OK cleanlog cleanlog cleanlog OK OK OK A Coordinator B C 异常恢复时确认完状态发起cleanlog

另外,cleanlog时,存储引擎要知道对应的raft日志编号,就是raft log index是多少。log index与分布式事务ID是不同的,这就要求在每次使用raft propose之后,记录log index与分布式事务ID的关联,这样在执行cleanlog时,根据分布式事务ID找到关联的log index,更新当前能够清理的最小log index。raft清理日志的逻辑就变成了这样:

trxMgr raft timerThread 快照后清理日志 snapshot get_min_cleanlog() min_log_index clean_index = min(min_log_index, snapshot index) clean_redo_log(clean_index) 定时检测清理 timeout get_min_cleanlog() min_log_index clean_redo_log(min_log_index) trxMgr raft timerThread raft 清理日志

之所以增加一个定时器,是因为raft做快照的时间间隔可能比较长,可以增加一个间隔较短的定时器,定时检查是否有需要清理的日志。

节点启动时如何维护已经committed/rollbacked日志

节点启动时,需要恢复所有的redo日志。对于prepare日志,因为事务信息还不完整,所以不用考虑清理相关。对于已经明确状态的日志,指committed/rollbacked日志,对当前节点来说,事务是已经完成的。但是不能明确,是否还可能会有其它节点再来询问对应事务的状态。如果设计上要求异常恢复节点只会从协调者节点确认事务状态,那么这种日志可以清除,否则还需要保留。可以在节点处理完所有日志后,向事务日志对应的协调者节点发请求。协调者节点判断对应事务号的状态,如果是committed/rollbacked,或者没有对应日志,就认为可以清理。否则就需要保留。NOTE:协调者节点的日志也可能是prepare状态。

A Coordinator recover redo cache committed/rollbacked trx trx_state(trx list) preparing/not found/ committed/rollbacked A Coordinator 节点启动清理日志

事务状态内存缓存的维护

当然,这里还存在一个问题,就是上面考虑的都是在磁盘中的日志清理,还要考虑事务状态在内存中的清理。毕竟从磁盘中读取数据是相当消耗资源的,特别是时间。内存缓存保存的时间不像磁盘中日志清理的机制那么明确,内存还受限于整个系统的内存大小。除了协调者明确发起cleanlog时删除对应的事务状态,当缓存占用量比较大时,也要考虑删除一部分。因为比较旧的事务数据,对方节点异常的可能性更大,未来访问的时间也不明确(异常节点恢复的时间),所以应该优先清理。

YugaDB模型数据清理

按照auto roll-forward/roll-back章节描述,Percolator模型的变种YugaDB使用事务表记录分布式事务的状态。YugaDB的流程大概是这样的。

Client A B C set k1=v1, k2=v2 txn = create_trx_record(status=running) set k1=v1 with txn set k2=v2 with txn prepare prepare OK OK set_trx(status=committed) OK commit commit OK OK delete_trx(txn) Client A B C YugaDB事务模型

在这种模型下,就不再需要各个节点考虑事务状态而维护日志数据。当异常节点恢复需要确定某个事务状态时,从事务表中查找即可。这里需要维护的是事务表,毕竟事务表不能只增加不删除。另外,为了支持事务表的可扩展,可以把事务表当做普通的数据表来维护,某个分片创建的事务,就放在哪个分片上的事务表中。

事务表中的数据可以删除依据就是可以确定不会再有节点访问这条数据了,就是所有节点都确认这个事务的状态。可以确认的是,committed/rollbacked日志是不需要再去事务表确认事务状态,只有prepare日志才需要确认状态。那么在所有节点都正常的状态下,可以收集所有prepare状态的事务,记录下它们的事务ID,如果事务表中某条事务ID不在这些事务ID中,就可以清除掉。这可以作为一个定时任务。更进一步,假设每个分片上分配事务ID时,数字中一部分是单调递增的,那么可以针对这一部分,可以称之为序列号,找出所有分片上prepare状态日志最小的序列号,所有与该分片相关的比这个序列号小的事务日志就都可以清理掉。

A B C begin_clean_trx 遍历所有prepare事务(sharding_id=A) 遍历所有prepare事务(sharding_id=A) 遍历所有prepare事务(sharding_id=A) 找出序列号最小的事务 min_trx_no_A min_trx_no_B min_trx_no_C min_trx_no = min(min_trx_no_A/B/C) remove_trx(trx_no < min_trx_no) end_clean_trx A B C 清理事务表

节点异常

上面介绍的节点恢复,在节点第一次启动时,或者正常重启,也都是这样的逻辑。没有讨论的是,在节点异常停止时,会发生什么。

某个leader节点停止时,会由follower节点接管。但是follower节点上并没有leader上对应的事务数据等信息,所以对于其他分片的节点来说,与该异常节点相关的事务,应该回滚掉。

获取其它节点异常的方法

  1. 如果节点正常停止,可以直接向其它节点发起广播消息。
  2. 异常停止的情况,节点应该拥有心跳机制,定时检测其它节点的状态。
  3. 节点从follower状态切换到leader状态时,通知其它节点。

对于前两种情况,异常处理都比较容易做。

A B stopping stopping OK node stopped(A) A B 节点正常停止
A B heart beat OK heart beat heart beat timeout node stopped(B) A B 节点心跳超时

但是对于第3个,考虑raft group场景,如果leader节点停掉,在短时间内重启并且重新选举为leader节点,也会存在follower切为leader状态。还有,如果出现短暂网络异常,leader切换为follower,然后重新选主,又变为leader状态,要考虑这种情况是否有问题。是否会存在某个节点异常后,与它交互的节点还未处理完相关的请求,异常恢复后新的请求又发过来,两个请求是否有依赖,或者是否会造成异常。

A A1 A2 B request r1 crash restart( follower state) vote(A) vote(A) OK OK change to leader request r2 handle r2 response r2 handle r1 response r2 A A1 A2 B leader宕机后迅速恢复又成为leader

又或者,节点短时间内恢复,其他的节点会收到该节点发出的从follower切换为leader状态的消息,需要处理该分片相关的异常,比如把所有与该异常分片相关的事务回滚。但是如果在回滚消息处理前,收到了该分片对应的发起的新事务,这个新事务明显不能被清理掉。
这里有几个解决方法:

  1. 节点在向另一个节点请求数据时,增加session标识;
  2. 异常节点恢复后明确通知其它节点它可能发起的新的事务号是多少,假设是next_trxid, 其它节点只清理掉异常节点对应的事务ID小于next_trxid的事务即可。
  3. 节点恢复后使用同步消息通知其它节点状态变更,等所有节点回复。使用同步消息会带来新的问题,比如要考虑其它节点在异常状态,或者网络异常。
A B operation(trxid, table, record) crash recover to follower follower to leader leadership_change(sharding_id, node_id, next_trxid) operation(new_trxid, table, record) cleanup_trx(A, next_trxid) result A B 节点状态变更,发送next_trxid

通讯异常

通讯异常应该当做分布式系统中最常见的异常。通讯异常会导致RPC请求发送失败或者应答接收失败。在这个设计中还是有很多异步消息的发送,比如cleanlog、节点状态变更消息。对于这些"不可靠"消息,就需要额外设计,维护系统的正确性。比如cleanlog未接收到,可以去协调者节点去询问事务日志状态。节点状态变更影响到其它节点对此节点的事务数据,可以引入超时机制清理长时间不活跃的事务。

系统中的超时问题和定时任务

超时

在分布式系统中,超时是个很头疼的问题。这里涉及到的超时有下面几个:

  1. RPC调用超时。远程调用超时涉及到了所有的远程接口,比如数据访问,事务提交,心跳超时等。

  2. 表锁/行锁超时。节点内部执行超时时间,除了受配置设置上限,还受RPC调用超时限制,因为超出RPC超时时间后的多余资源消耗没有意义,对方节点不再需要此次请求处理结果;

  3. 写磁盘超时。写磁盘是一个非常耗时的动作,分布式事务中有很多数据需要落地,但是目前比较少关心写磁盘超时,也不控制写磁盘的时间;

  4. 内部的一些锁,比如索引latch超时。以数据访问为例,访问索引数据时,也需要加锁,一般称为latch。大部分场景下,latch的加锁时间与等待时间都非常短,同时为了提高并发,也很少考虑latch的消耗。在分布式系统下,latch的等待时间或许会称为问题,具体看实现方式;

  5. 事务超时。在分布式系统上,如果不能做到消息的可靠传送与处理,那么就应该假设所有的消息都可能会丢失。那么,考虑到节点异常时,会向所有节点发起广播消息,其它节点收到此消息应该清理与此节点相关的事务。但是如果这个消息发送失败,相关节点可能会永久保留相关事务数据。这时可以引入超时机制,支持最大事务时长。或者折中方案,再引入心跳,假设总会有心跳会检测到节点异常,并处理相关异常。

定时

在这个设计中涉及到一些定时任务,比如心跳检测,定时清理事务表数据。心跳检测可以在对方异常时有手段得知对方异常,以便清理相关的资源。定时清理事务表,是因为不能得知何时某条事务数据不再有节点访问,不能使用类似引用计数的方法,清理事务。

总结

事务是数据库中最基本的一个功能,分布式事务是分布式数据库最基本的一个功能。从事务角度看,需要为数据库提供ACID特性。事务在表锁/行锁和日志、MVCC等基础功能上实现这些。分布式事务的难点在于协调多个参与者"共进退",并在各种异常场景下,满足数据库正确的前提下提高性能。

参考

  1. 事务的概念和作用
  2. 聊聊分布式事务,再说说解决方案
  3. 2PC到3PC到Paxos到Raft到ISR
  4. 分布式系统理论基础 - 一致性、2PC和3PC
  5. Raft算法详解
  6. Percolator 2PC模型
  7. 两阶段提交的工程实践
  8. A Critique of ANSI SQL Isolation Levels
  9. Yes We Can! Distributed ACID Transactions with High Performance
  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值