背景
倒排索引是Google搜索引擎中最为关键的技术之一。应对海量数据时,高效的索引创建和索引的实时更新都是必须解决的难题。Google设计了MapReduece系统解决了海量数据索引创建的问题,但MR并没有解决增量数据的实时更新问题。
因此,Google设计Percolator的初衷是:支持海量数据存储、并行随机读写、跨行事务的分布式数据库。
由于Percolator构建在不支持跨行事务的BigTable之上,基于BigTable达到Percolator的设计目标便是其要解决的核心问题,本文主要描述Percolator系统中的事务相关设计。
特点
Percolator 提供了跨行、跨表的、基于快照隔离的ACID事务。
Snapshop isolation
Percolator 使用Bigtable的时间戳记维度实现数据的多版本化从而达到了snapshot isolation,优点是:
对于读:读操作都能够从一个带时间戳的稳定快照获取
对于写:较好地处理写-写冲突:若事务并发更新同一个记录,最多只有一个会提交成功
快照隔离的事务均携带两个时间戳:
-
,所以事务1的更新对2不可见
- 事务 3 可以看到事务 2 和 事务 1的提交信息
- 事务 1 和 事务 2并发执行:如果两者更新同一个记录,至少有一个会失败
Lock
Percolator后端存储基于BigTable。由于Bigtable没有提供便捷的冲突解决和锁管理方案,Percolator需要实现一套独立的锁管理机制。必须满足以下条件:
能直面机器故障:若一个锁在两阶段提交时消失,系统可能将两个有冲突的事务都提交。
高吞吐量:上千台机器会同时请求获取锁。
低延时:每个读操作都需要读取一次锁
事务
存储-COLUMN
Percolator在BigTable上抽象了五个COLUMN,其中三个跟事务相关
LOCK COLUMN
事务产生的锁,未提交的事务会写本项,记录primary lock的位置。事务成功提交后,该记录会被清理。记录内容格式:
DATA COLUMN
存储实际用户数据,数据格式为
WRITE COLUMN
已提交的数据信息,存储数据所对应的时间戳。数据格式
关键在于WRITE COLUMN,只有该列正确写入后,事务的修改才会真正被其他事务可见。读请求会首先在该COLUMN中寻找最新一次提交的start timestamp,这决定了接下来从DATA COLUMN的哪个key读取最新数据。
事务提交关键流程
Prewrite
Prewrite是事务两阶段提交的第一步:
- 客户端首先从Oracle获取全局唯一时间戳作为当前事务的
;
- 客户端会从所有key中选出一个作为
,其余的作为。并将所有的key/value数据写入请求并行地发往对应的存储节点。存储节点对key的处理如下:
1.冲突检查:从WRITE COLUMN列中获取当前key的最新数据,若其大于等于,说明在该事务的更新过程中其他事务提交过对该key的修改,返回WriteConflict错误
2. 检查key是否已被锁,如果是,返回 KeyIsLock的错误
3. 向LOCK COLUMN列写入为当前key加锁。若当前key被选为primary,标记为。若为secondary,则指向的信息
4. 向DATA COLUMN列写入数据
Commit
Prewrite成功后,进入事务的第二阶段Commit。
- 从Oracle获取时间戳作为事务的提交时间
- 向primary key所在的存储节点发送commit请求
- 步骤2正确完成后该事务即可标记为成功,接下来异步并行地向secondary keys所在的节点发送commit请求
- 存储节点对于客户端请求的处理:
1. 获取key的lock,检查其合法性,若非法,则返回失败
2. 将写入WRITE COLUMN
3. 从LOCK COLUMN中删除key的锁记录以释放锁
值得说明的是,一旦
在某些实现中(如TiDB),Commit阶段并非并行执行,而是先向
读取
WRITE COLUMN记录了key的提交记录,当客户端读取一个key时,会从WRITE COLUMN中读根据key查找记录的
存储节点对读请求处理流程如下:
- 检查区间
内Lock是否存在,若存在,则返回错误。在该区间内有lock意味着有未提交的事务,客户端需要等到持有该锁的事务提交了才能读取到最新的数据
- 如果不存在有冲突的Lock,获取WRITE COLUMN中合法的最新提交记录
- 根据步骤2获取的信息,从DATA COLUMN中获取到相应的数据
异常处理-清理锁
在Prewrite阶段检测到锁冲突时会直接报错(读时遇到锁就等直到锁超时或者被锁的持有者清除,写时遇到锁,直接回滚然后给客户端返回失败由客户端进行重试),锁清理是在读阶段执行。有以下几种情况时会产生垃圾锁:
1. Prewrite阶段:部分节点执行失败,在成功节点上会遗留锁
2. Commit阶段:Primary节点执行失败,事务提交失败,所有节点的锁都会成为垃圾锁
3. Commit阶段:Primary节点执行成功,事务提交成功,但是在secondary节点上异步commit失败导致遗留的锁
4. 客户端奔溃或者客户端与存储节点之间出现了网络分区造成无法通信
对于前三种情况,客户端出错后会主动发起Rollback请求,要求存储节点执行事务Rollback流程。这里不做描述。
对于最后一种情况,事务的发起者已经无法主动清理,只能依赖其他事务在发生锁冲突时来清理。
Percolator采用延迟处理来释放锁
事务A运行时发现与事务B发生了锁冲突,A必须有能力决定B是一个正在执行中的事务还是一个失败事务。因此,问题的关键在于如何正确地判断出LOCK COLUMN中的锁记录是属于当前正在处于活跃状态的事务还是其他失败事务遗留在系统中的垃圾记录 ?
梳理事务的Commit流程一个关键的顺序是:事务Commit时,1). 检查其锁是否还存在;2). 先向WRITE COLUMN写入记录再删除LOCK COLUMN中的记录。
假如事务A在事务B的primary节点上执行,它在清理事务B的锁之前需要先进行锁判断:如果事务B的锁已经不存在(事实上,如果事务B的锁不存在,事务A也不会产生锁冲突了),那说明事务B已经成功提交。如果事务B的primary lock还存在,说明事务没有成功提交,此时清理B的primary lock。
假如事务A在事务B的secondary 节点上执行,如果发现与事务B存在锁冲突,那么它需要判断到底是执行Roll Forward还是Roll Back动作。
判断的方法是去Primary上查找primary lock是否存在:
如果存在,说明事务B没有成功提交,需要执行Roll Back:清理LOCK COLUMN中的锁记录;
如果不存在,说明事务已经被成功提交,此时执行Roll Forward:在该secondary节点上的WRITE COLUMN写入内容并清理LOCK COLUMN中的锁记录。
几种情形分析:
节点作为Primary在事务B的commit阶段写WRITE COLUMN成功,但是删除LOCK COLUMN中的锁记录失败。如果是由于在写入过程中出现了进程退出,那么节点在重启后可以恢复出该事务并删除LOCK COLUMN
节点作为Primary在事务B的commit阶段写WRITE COLUMN失败:意味事务B提交失败,那么事务A可以直接删除事务B在LOCK COLUMN中的锁记录
节点作为Secondary在事务B的commit阶段写WRITE COLUMN成功,但是清理LOCK COLUMN锁失败,因为在事务commit的时候先向primary节点发起commit,因此,进入这里必然意味着primary节点上commit成功,即primary lock肯定已经不存在,因此,直接执行Roll Forward即可。
有一个场景值得探讨
假如事务A(清理锁)和事务B(提交)并发执行,可能出现的执行顺序是:A1->B1->A2->B2->B3,也即在事务B向WRITE COLUMN中插入记录之前其锁就被其他事务清理了,会不会出现什么问题?
可能产生的问题:如果此时有start_ts更大的读请求到来,由于事务B的锁记录已经不存在,因此它会认为事务B的WRITE COLUMN已经得到是最新内容,但是实际情况是B的WRITE COLUMN记录还未得到更新,造成了无法读取到最新的数据。
暂时还没想清楚这个问题是如何解决的?
示例
银行转账,Bob 向 Joe 转账7元。
首先以
转账开始:使用
与此同时,使用
事务携带当前时间戳
依次在
点评
Percolator的事务方案对写友好,对读不友好
事务写primary record就相当于先把协调者的决议持久化,然后再异步持久化到参与者,减少了多参与者出现异常的等待,但协议的交互轮次并未减少
对于读而言,因为持久化决议分成先写primary再写其他参与者,导致参与者的加锁时间变长了。SI隔离级别下,单机读分布式事务参与者因此会等待更长的时间。单机写的锁冲突也会加剧。
Percolator的事务方案写性能本身也不算非常理想,体现在
协议基于BigTable设计,持久化次数多
如果2pc中commit时primary出问题,其他参与者也不可用且持锁的参与者再没有可能推进,依赖其他事务的锁清理机制。