Google Percolator事务

352 阅读10分钟
原文链接: zhuanlan.zhihu.com

背景

倒排索引是Google搜索引擎中最为关键的技术之一。应对海量数据时,高效的索引创建和索引的实时更新都是必须解决的难题。Google设计了MapReduece系统解决了海量数据索引创建的问题,但MR并没有解决增量数据的实时更新问题。

因此,Google设计Percolator的初衷是:支持海量数据存储、并行随机读写、跨行事务的分布式数据库

由于Percolator构建在不支持跨行事务的BigTable之上,基于BigTable达到Percolator的设计目标便是其要解决的核心问题,本文主要描述Percolator系统中的事务相关设计。

特点

Percolator 提供了跨行、跨表的、基于快照隔离的ACID事务。

Snapshop isolation

Percolator 使用Bigtable的时间戳记维度实现数据的多版本化从而达到了snapshot isolation,优点是:

对于读:读操作都能够从一个带时间戳的稳定快照获取

对于写:较好地处理写-写冲突:若事务并发更新同一个记录,最多只有一个会提交成功

快照隔离的事务均携带两个时间戳:​(图中小空格)与​(图中小黑球)。上图中:

  • start\_ts_{t2} < commit\_ts_{t1},所以事务1的更新对2不可见
  • 事务 3 可以看到事务 2 和 事务 1的提交信息
  • 事务 1 和 事务 2并发执行:如果两者更新同一个记录,至少有一个会失败

Lock

Percolator后端存储基于BigTable。由于Bigtable没有提供便捷的冲突解决和锁管理方案,Percolator需要独立实现一套锁管理机制。锁的管理必须满足以下条件:

能直面机器故障:若一个锁在两阶段提交时消失,系统可能将两个有冲突的事务都提交。

高吞吐量:上千台机器会同时请求获取锁。

低延时:每个读操作都需要读取一次锁

事务

存储-COLUMN

Percolator在BigTable上抽象了五个COLUMN,其中三个跟事务相关

LOCK COLUMN

事务产生的锁,未提交的事务会写本项,会包含primary lock的位置。事务成功提交后,该记录会被清理。记录内容格式:

\{key,start\_ts\}=>\{primary\_key,lock\_type,...\}

key :数据的key

start\_ts :事务开始时间戳

primary :事务primary引用。在执行Percolate事务时,会从待修改的keys中选择一个作为primary,其余的则作为secondaries

DATA COLUMN

存储实际用户数据,数据格式为

\{key,start\_ts\} => \{value\}

key ​ :真实的key

start\_ts :对应事务的开始时间

value : 真实的用户数据值

WRITE COLUMN

已提交的数据信息,存储数据所对应的时间戳。数据格式

\{key,commit\_ts\}=>\{start\_ts\}

key :数据的key

commit\_ts :事务的提交时间

start\_ts :该事务的开始时间,指向该数据在DATA COLUMN中信息。

关键在于WRITE COLUMN,只有该列正确写入后,该事务涉及的修改才会真正被其他事务可见。读请求会首先在该COLUMN中寻找最新一次提交的start timestamp,这决定了接下来从DATA COLUMN的哪个key读取最新数据。

关键流程

Prewrite

Prewrite是事务两阶段提交的第一步:

  1. 客户端首先从Oracle获取全局唯一时间戳作为当前事务的​;
  2. 客户端会从所有key中选出一个作为​,其余的作为​。并将所有的key/value数据写入请求并行地发往对应的存储节点。存储节点对每个key的处理过程如下:
1. write-write 冲突检查:从WRITE COLUMN列中获取当前key的最新数据,若其​ commit\_ts 大于等于 start\_ts ​,说明在该事务的更新过程中存在着其他事务的提交,返回WriteConflict错误

2. 检查key是否已被锁,如果是,返回KeyIsLock的错误

3. 向LOCK COLUMN列写入 \{start\_ts,key,primary\_ref\} ​为当前key加锁。若当前key被选为primary,​ primary\_ref 标记为 primary ​。若为secondary,则指向 primary ​的信息

4. 向DATA COLUMN列写入数据​ \{key,start\_ts,value\}

Commit

Prewrite成功后,进入事务的第二阶段Commit。

  1. 从Oracle获取时间戳​作为事务的提交时间
  2. 首先向primary key所在的存储节点发送commit请求
  3. 步骤2正确完成后该事务即可标记位成功,接下来异步并行地向secondary keys所在的节点发送commit请求
  4. 存储节点对于客户端请求的处理流程:
1. 获取key的lock,检查其合法性,若非法,则返回失败

2. 将​ \{key,commit\_ts,start\_ts\} 写入WRITE COLUMN

3. 从LOCK COLUMN中删除key的锁记录以释放锁

值得说明的是,一旦primary节点上提交成功后,整个事务就算提交成功了。

在某些实现中(如TiDB),Commit阶段并非并行执行,而是先向primary节点发起commit请求,成功后即可响应客户端成功且后台异步地再向secondaries发起commit。

读取

WRITE COLUMN记录了key的提交记录,当客户端读取一个key时,会从WRITE COLUMN中读取key所对应数据在DATA COLUMN中的存储位置,再从DATA COLUMN中读取真正的数据。

存储节点对读请求处理流程如下:

  1. 检查​区间 [0,start\_ts] 内Lock是否存在,若存在,则返回错误。在该区间内有lock意味着有未提交的事务,客户端需要等到持有该锁的事务提交了才能读取到最新的数据
  2. 如果不存在有冲突的Lock,获取WRITE COLUMN中合法的最新提交记录​ \{key, commit\_ts => start\_ts\}
  3. 根据步骤2获取的信息,从DATA COLUMN中获取到相应的数据​ \{key,start\_ts => value\}

异常处理-清理锁

在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元。 Txn.start\_ts = 7,Txn.commit\_ts = 8

首先​以 \{key=Bob\} 查询WRITE COLUMN获取最新时间戳(小于7的最新时间戳),得到 start\_ts=5 ​。再从DATA COLUMN里面读取该时间戳的数据值​ key=Bob,ts = 5, value=10 ,同样获取到Joe 的帐户下该时间戳下的值为 key=Joe,ts=5,value=2 ​。

转账开始:使用​ start\_ts=7 作为事务开始时间戳,将Bob选为本事务的 primary ​,写入LOCK COLUMN来锁定Bob的帐户,同时将数据 \{key=Bob, start\_ts=7,value=3\} ​写入DATA COLUMN。

与此同时,使用​ start\_ts=7 锁定Joe的帐户,当前锁作为 secondary ​并存储一个指向 primary ​的引用(当失败时,能够快速定位到​锁,并根据其状态异步清理),并将Joe改变后的余额 \{key=Joe,start\_ts=7,value=9\} ​写入到DATA COLUMN

事务携带当前时间戳​ commit\_ts=8 进入commit阶段:WRITE COLUMN列中写入记录 \{key=Bob,commit\_ts=8,start\_ts=7\} ​,删除​ primary 所在的LOCK COLUMN数据至此,读请求过来时将看到Bob的余额为3。

依次在​ secondary 中写入WRITE COLUMN数据项并清理锁,整个事务提交结束。在本例中,只有Joe,写入的内容为​ \{key=Joe,commit\_ts=8,start\_ts=7\}

点评

Percolator的事务方案对写友好,对读不友好

事务写primary record就相当于先把协调者的决议持久化,然后再异步持久化到参与者,减少了多参与者出现异常的等待,但协议的交互轮次并未减少

对于读而言,因为持久化决议分成先写primary再写其他参与者,导致参与者的加锁时间变长了。SI隔离级别下,单机读分布式事务参与者因此会等待更长的时间。单机写的锁冲突也会加剧。

Percolator的事务方案写性能本身也不算非常理想,体现在

协议基于BigTable设计,持久化次数多

如果2pc中commit时primary出问题,其他参与者也不可用且持锁的参与者再没有可能推进,依赖其他事务的锁清理机制。

参考

Shirly's Blogandremouche.github.io图标LoopJump's Blogloopjump.com图标Percolator简单翻译与个人理解www.jianshu.com图标Codis作者首度揭秘TiKV事务模型,Google Spanner开源实现!dbaplus.cn图标快速理解Omid:Yahoo在HBase上的分布式事务方案 - 数据库服务器 - 最新IT资讯_电脑知识大全_网络安全教程 - 次元立方网www.it165.net图标