快照隔离,与Percolatory分布式解决方案

Percolator 和 TiDB 事务算法

一般来讲,一致性协议保证的是某一数据实体的多副本之间的一致性;事务保证的是不同数据实体之间关系的一致性比如转账
本文先概括的讲一下 Google Percolator 的大致流程。Percolator 是 Google 的上一代分布式事务解决方案,构建在 BigTable 之上,在 Google 内部 用于网页索引更新的业务,原始的论文 在此 。原理比较简单,总体来说就是一个 经过优化的二阶段提交的实现,进行了一个二级锁的优化

隔离级别:

传统的数据库定义了四种隔离级别:
1. Read Uncommitted
2.Read Committed
3.Repeatable Read
4.Serializable
sql定义中Repeatable Read可以保证一个事务中多次读取同一条记录时,看到的结果都是一样的,但是它存在幻读(Phantoms)的问题.
其中Serializable是目前sql定义中最严格的隔离级别,即:串行事务级里面的任何并发操作保证他们的执行效果就像以相同顺序一次执行完的一样.也就是说在Serializable级别中,才做到了事务完全互不影响.但是因为实现Serializable隔离级别对数据库性能影响较大,所以往往会采取折衷的方式,使用RR或者RC隔离级别,并配上业务自身的一些控制逻辑.
5.snapshot isolation
但是随着技术的不断发展,上面4种已经不能完全包含所有的隔离情况,比如后来出现的snapshot isolation(快照隔离).
快照隔离多采用mvcc实现, 我们会记录事务开始的时间点,将这个时间点之前的数据看作是一个快照,所有读操作都只读取该时间点之前的数据.当然本次事务中update/insert/delete的操作对本次事务也是可见的.但是该事务之后新的事务所产生的数据对本次事务是不可见的(并发场景下).
快照隔离中读操作不涉及锁操作,所以一定程度了提高了性能.但是快照隔离引出了一个新的问题:write skew
write skew:
什么是write skew? 写偏序(Write Skew)也是一致性约束下的异常现象,即两个并行事务都基于自己读到的数据集去覆盖另一部分数据集,仅在串行化情况下两个事务无论何种先后顺序,最终将达到一致状态。
假设某个业务需要满足约束: x+y<100
最开始x=30, y=10,符合约束规则,现在有两个事务T1和T2,它们观察到x=30, y=10,并且开始执行各自的流程
首先它们都先读取各自需要的数据,这个过程中并没有任何的修改操作,并且采用快照读,不涉及加锁操作.它们都读取到x和y分别为30和10
之后T1首先将y设为60,从T1的角度来看,x y目前满足约束.虽然T1写数据是需要获取锁的,但是在此之前T2已经获取了y等于10.所以此时T2依然认为y为10
然后T2再将x设置为50,但是从T2的角度来看,x和y分别为50和10,它认为依然符合约束,所以更新了x值为50.但是此时的x和y的真实值分别为50和60,不满足条件约束.
这就是所谓的write skew问题, 造成这种现象的 原因就是事务以快照的方式访问数据,并且读不加锁,导致数据的不一致.
note:
snapshot isolation和RR隔离并没有可比性,mysql的RR隔离其实是采用snapshot isolation来实现的,所以mysql中的RR隔离级别不存在幻读(Phantoms)的问题.每个数据库对sql定义的隔离级别实现的程度并不相同,所以还要以具体数据库为准.参考:https://blog.acolyer.org/2016/02/24/a-critique-of-ansi-sql-isolation-levels/
serializable snapshot isolation
为了解决这种问题,后面有人提出了:serializable snapshot isolation串行化快照隔离,该隔离级别有效的解决了snapshot isolation的write skew的问题.
可串行化快照隔离本质上就是在事务开始执行的同时,依据可串行化规则,将其与快照隔离机制相结合,通过所设计的算法机制检测当前事务是否存在不可串行化的操作,是的快照隔离应用在数据他的同时最终到达可串行化的目的,解决write skew的问题.具体可参考:
http://workroom.arocmag.com/arocmag/ch/reader/create_pdf.aspx?file_no=201208053&year_id=2012&quarter_id=8&falg=1
https://drive.google.com/file/d/0B9GCVTp_FHJIcEVyZVdDWEpYYXVVbFVDWElrYUV0NHFhU2Fv/edit
http://www.zenlife.tk/serializable-snapshot-isolation.md
目前大多传统数据库都是提供了snapshot isolation,并且一部分数据库也实现了serializable snapshot isolation.
而且目前新兴的NewSql-CockroachDB也同时提供了两种snapshot隔离级别.现在分布式数据库往往也采用snapshot isolation作为它们的默认隔离级别
Percolator依赖Bigtable的timestamp来提供基于时间戳的多版本支持mvcc,快照隔离(snapshot-isolation)的实现需要mvcc来提供支持。快照隔离需要能够有效的处理写冲突,当有一个事务A和一个事务B同时写入同一个cell时,最多只有一个能提交成功。但是快照隔离并不满足serializability隔离级别,而且存在write skew的问题,但是相对比serializability隔离级别,快照隔离的好处是:提供更高效的读性能。可以认为:任何一个时间戳都对应一个快照,读取一个cell时,只需要根据指定的时间戳从Bigtable中找到对应的版本即可,并不需要获取读锁
Percolator的解决方案
Percolator提供跨表、跨行的分布式事务,隔离级别为快照隔离(snapshot-isolation)。 快照隔离级别中,如果两个事务同时修改同一个行,那么事务冲突,其中一个事务必须回滚然后重试 其核心就是2PC的增强版,通过利用client作为协调者,解决了协调者挂了对整体服务能力的影响,而在事务相关信息的一致性和持久性上充分利用了BigTable的简单事务支持以及GFS的多副本可靠性能力,另外Percolator在数据模型上是mvcc。
怎么保证的原子性
想保证多条指令作为整体执行的原子性,有单机并发编程基础的都应该知道,那就是通过加锁(原子操作不适合),要么你基于内存的mutex,要么基于文件系统的file lock,甚至于可以使用分布式锁服务。。。 Percolator的做法是对数据扩展出一个lock列,对于多行数据,它随机选择一行作为主,通过BigTable的事务对这个primary主row的lock列写入信息表明持有锁,而其他的rows则随后在其lock列写入primary锁的位置信息 。这样就完成了两件事: 一个是把事务中的所有rows关联起来了;一个是互斥点唯一了,都在primary,如同加了分布式锁一样 。另外还需要考虑一个问题,Percolator为什么不怕lock信息丢失呢?因为BigTable底层是GFS,是多副本,假定其能保证对外承诺的SLA下的可靠性,真发生了丢数据的问题那就人工处理呗。

锁的实现:

因为 Percolator是通过Bigtable来访问数据,并不是直接访问底层数据存储,所以Percolator跟PDBMS(分布式数据库?)有些不同,别的并行数据库中锁就是系统组件的一部分,控制节点和数据节点就在一起,每个节点node都可以直接访问自己磁盘上的数据,锁的分配由该node自己来决定,可以直接对数据进行锁操作。
而Percolator则是通过访问Bigtable,每个节点都需要向Bigtable发送数据请求,因此没有很有效的获取锁的方式(个人理解,作者想要表达的意思应该是:上面所说的数据库每个节点既是控制节点,又是数据节点,该节点上的数据就由该节点来控制,而Percolator访问数据时,则需要通过Bigtable,而且Bigtable中数据也不是直接由Bigtable的节点来控制的,数据是存储在GFS上面的,B igtable中的节点和GFS的节点可以是完全不同的两套集群,通过rpc通讯),所以Percolator必须自己显式的来维护锁。那么就有了如下要求:
1.锁必须能够在机器宕机时依然可见。
2.锁服务必须能够提供高吞吐。
3.锁服务也必须满足低延迟。
考虑到上述需求,锁必须满足:replicated、distributed、balanced属性,而Bigtable本身就提供这些属性,所以Percolator直接将锁保存在Bigtable的额外的多个新的内存列中(称之为meta数据,主要就两列:一个lock、一个write,另外还有3列:data,notify,ack_O)
数据模型
宏观上每一行有4列,分别是key、data、lock和write,另外每一个cell的数据都有个版本号timestamp,这是 mvcc 必须的。每一列的作用如下:
可以看到每条记录都有3列。
第一列: 数据列,存放用户的账户金额
第二列: lock列,当里面有数据时,说明该列被某事务上锁,并且该事务处于两阶段提交的第一阶段
第三列: write列,当write列有数据时,代表事务已提交,write列前面的数字代表该事务commit的时间,write列@符号后面的数值表示该事务修改后的数据的时间戳(也是事务开始的时间)

事务的流程:


下面我们详细了解一下Percolator的事务流程:事务的发起者首先向时间服务器请求一个timestamp(所有的节点都通过该服务器获取时间戳,这样能够保证所有节点所看到的是时间是全局有序的),表示事务开始的时间,get读操作通过这个timestamp来决定读取的数据的版本,set写操作的数据则一直缓存到commit的时候再提交。
事务提交采用 两阶段提交(分布式事务多是采用这种方式,note:两阶段和提交和两阶段锁并不一样)。两阶段提交通过client来协调。具体client是什么节点论文并没有解释,但是并不影响我们理解论文,可以简单理解client就是发起并执行事务流程的节点。
在提交的第一个阶段(prewrite):我们首先获取所有需要写入的cell的锁(考虑到client故障情况,我们会任意指定一个lock为primary,后面详细讨论),每个事务都会读取meta数据来检测事务是否存在冲突。
有两种冲突的情况:
1.如果一个事务A看到cell的write列中已经存在一条记录,记录的时间戳晚于该事务开始的时间戳,那么说明存在写冲突,即说明有一个事务在A发起之后更新了cell的值,事务A需要aborted。
2.如果事务看到cell的lock列中存在任意一条锁记录,不管时间戳为多少,直接aborted。 (这里需要说明一点:因为事务执行过程中有可能会失败,事务A看到的lock有可能是失败的事务B留下来的,这种lock是需要清除的,事务A此时是不应该aborted的,这里说的直接aborted,个人理解应该是不考虑失败事务的情况,即假设看到是锁都是正在进行事务操作的锁)
如果两种冲突都不存在,那么我们更新cell,并向lock列中写入上锁信息。理解这两个判断条件需要了解快照隔离级别下哪些会判断事务发生冲突。
如果没有cell冲突,那么说明事务可以提交,进行下一个阶段commit:首先client会向时间服务器申请一个timestamp,表示commit的时间。然后client会释放事务中涉及的所有cell的锁(清空lock列),释放顺序从primary lock开始。
释放锁之后便更新write列来使新的read能够读到本次事务的数据。write列的数据表示:本次事务的数据已经成功更新至cell中,write列中的数据包含了一个timestamp,该timestamp表示本次事务数据的timestamp,用户可以通过该timestamp来找到数据。一旦primary锁对应记录的write列数据可见了,代表该事务一定已经commit了,reader此时能看到该事务的数据。
下面我们谈一下上面判断冲突的两个条件,首先我们将并行事务分为3种情况:
1.读-读
由于快照隔离read并不加锁,所以读-读操作并不会冲突,事务并不需要aborted。
2.读-写
而当有读-写情况发生时,执行get操作时,需要检查[0,start_timestamp]之间的lock信息,如果此时lock列中有数据时,说明目前该cell正在被一个事务操作,那么get操作等待该事务完成,lock释放后再读取数据。
如果没有发现lock数据,那么get操作获取write列中在[0,start_timestamp]之间最新数据的timestamp,该timestamp指向了该get操作可以访问的最新的数据。这种情况下读事务等待写事务完成,也不会需要aborted。
3.写-写
只有当发生写-写冲突时,才需要将其中的一个事务aborted,而上面的两个判断条件就是判断事务是否存在写-写冲突。
(1)假如事务A需要修改一个cell,但是看到了cell已经存在lock锁,那么说明可能存在事务B正在对cell进行修改,此时事务B处于两阶段提交的第一阶段 。两个事务发生冲突,需要aborted。
(2)假如事务A需要修改cell,并且lock列中没有发现有数据,但是write列中有数据,并且该write列的时间戳晚于事务A的开始的时间戳,说明在事务A开始后,有事务B已经修改了cell的值,此时的B处于两阶段提交的第二个阶段或者已经提交成功,存在冲突,事务A需要aborted。
(3)另外如果write列的时间戳早于事务A开始的时间戳的话,说明事务B结束后事务A才开始,这时候就没必要aborted了。
所以除了看write列是否有数据外,还要看write列的时间戳是否跟A有冲突。
-------------------------------示例流程------------------------------------
首先,假设有两个用户:
一个Bob,有10块钱。
一个Joe,Joe有2块钱。
然后Bob开始给Joe转账,转7块钱。
(1)首先该事务会先获取一个开始时间戳,表示事务开始的时间,这里start_time为7,修改data列为3。
(2)并且在lock列中写入lock信息:I am primary我是主锁 ,并且lock的时间戳也为7。
这里需要注意一下,在分布式环境下,Percolator为了保证容灾,确保事务正常执行完毕或者发生错误时回滚,提出了一个primary lock的概念:一个事务可能涉及多行的修改:
(1)两阶段提交的第一阶段,需要对所有行都进行加锁,Percolator会对第一个操作的数据加primary锁,并对后续的数据加slave锁,而且slave锁都会指向primary锁,并且修改需要修改的数据,当所有数据都修改完毕之后,进入第二阶段。
(2)第二阶段,commit提交,事务提交时会首先处理primary锁对应的行,并且清空primary锁,如果处理成功,则认为该事务提交成功。注意:只要primary对应的行提交成功,就认为事务提交成功。而提交的操作就是:清空lock列中的信息,并在write列写入新的信息。
(3)假设事务因为某种原因挂了,不论当时事务是处于第一阶段还是第二阶段,部分记录的lock锁信息都是有可能还存在的,那么当后续的事务再访问数据的时候,发现该记录有锁,则会去找对应的primary锁是否还存在(因为slave锁包含有指向primary锁的信息,也就是说一定能找到primary锁的位置)
假如发现primary锁依然存在,说明之前事务没有提交成功,这个锁是残留下来的,直接清空。
假如primary锁已经不存在了,说明事务已经提交成功,之后后续的操作还没处理完,那么后续的事务必须首先处理之前的未执行完的事务。
primary锁对应行中的write列有事务开始和结束的时间戳等信息,而且slave的数据其实已经被修改,只是还没有提交而已,可以根据这些信息来补全整个事务
以下为两阶段提交中的第一阶段:
Joe的账户中新增7元:
(1)data列为:9,lock列添加:primary @ Bob.bal,该信息指向bob中的primary锁,也就是我们上面所说的:
(2)假如因为事务失败,Joe中的锁有残留,可以通过lock中的信息来找到primary锁的位置,来判断该事务是否应该继续还是应该丢弃。
之后进入两阶段提交的第二阶段:
首先Bob清空primary锁信息,并且在自己的write列中添加新字段: data@7,该数据的时间戳为:8,表示该事务提交的时间戳为8,data后面跟的数字7代表事务开始的时间为7,并且也是本次事务对应数据的时间戳。
事务开始的时间戳:其他事务的读操作可以根据lock信息中该事务开始的时间戳来判断是否应该等待该事务完成。
事务结束的时间戳:可以用来判断其他事务是否需要aborted自己事务来避免冲突。
之后Joe也清空自己的lock信息,并且更新write列,当事务涉及数据的操作都执行完毕后,事务结束。
4.故障时事务回滚or回放
下面考虑client故障的情况(Bigtable的 tablet server故障并不会影响系统,因为Bigtable本身就有容灾功能):如果一个事务正在committing的时候,client挂了,lock信息此时会被留下来,Percolator必须对这些lock信息进行清理,否则它们会把后面的事务永久的hang住, Percolator采用了一种lazy的方式进行lock清理:一个事务A碰到了事务B留下来的锁信息之后,A来决定B是否是失败的事务,并且清除掉lock。
为了确保确实是B crash了,并不是因为和A产生了竞争而清除了B的锁。为了确保正确性,我们为每一个事务选取一个cell,并将它上面的锁成为primary lock。A和B都知道事务主锁的位置(因为同一个事务其他cell的lock会记录主锁的位置),所有cleanup或者commit操作都必须从primary lock开始,而且bigtable默认的行锁粒度能够保证不会有多个client同时操作同一行,只会有一个cleanup或者commit操作能提交成功。
尤其是:当B进行commit时,必须检查目前它是否还拥有primary lock。同样在A清理B的lock的时候,必须检查B是否已经commit,也就是primary lock是否已经清除,如果primary lock还在,说明事务还未提交,此时A可以清理B的lock。
当一个事务已经提交,还未执行完,client挂了,就会有一部分lock依然存在,这时就需要进行补救,首先判断primary lock是否存在,如果存在,说明事务没有提交成功,那直接清空lock即可,若primary lock不存在,说明事务已经提交,那么将lock清除,并在write列中补上对应的信息,更新最新可读取的数据的timestamp。
补充:假如事务执行时发现数据已经上锁了,那么如何判断加锁的事务是否依然在健康的执行,还是事务已经失败,锁仅仅是残留下来的?
答案是:通过primary lock可以判断出来,因为bigtable是支持行级事务的,也就是说,假如事务B发现它需要访问的记录已经被事务A上锁,但是不确定事务A是否还在正常运行,那么B可以通过slave锁信息来找到事务A primary锁的行,如果primary锁所在的行能够访问,说明该行没有被上锁,事务A已经挂掉了,此时清空锁信息。如果primary锁对应的记录无法访问,那说明此时事务A正在执行事务,还没有释放primary锁所在记录的行级锁。
--------------------------------------------------------------------------------------------------------------------------------------
TiDB 的事务模型沿用了 Percolator 的事务模型。 总体的流程如下:

读写事务

  1. 事务提交前,在客户端 buffer 所有的 update/delete 操作。
  2. Prewrite 阶段:
首先在所有行的写操作中选出一个作为 primary,其他的为 secondaries。
PrewritePrimary: 对 primaryRow 写入 L 列(上锁),L 列中记录本次事务的开始时间戳。写入 L 列前会检查:
  1. 是否已经有别的客户端已经上锁 (Locking)。
  2. 是否在本次事务开始时间之后,检查 W 列,是否有更新 [startTs, +Inf) 的写操作已经提交 (Conflict)。
在这两种种情况下会返回事务冲突。否则,就成功上锁。将行的内容写入 row 中,时间戳设置为 startTs。
将 primaryRow 的锁上好了以后,进行 secondaries 的 prewrite 流程:
  1. 类似 primaryRow 的上锁流程,只不过锁的内容为事务开始时间及 primaryRow 的 Lock 的信息。
  2. 检查的事项同 primaryRow 的一致。
当锁成功写入后,写入 row,时间戳设置为 startTs。
  1. 以上 Prewrite 流程任何一步发生错误,都会进行回滚:删除 Lock,删除版本为 startTs 的数据。
  2. 当 Prewrite 完成以后,进入 Commit 阶段,当前时间戳为 commitTs,且 commitTs> startTs :
  1. commit primary:写入 W 列新数据,时间戳为 commitTs,内容为 startTs,表明数据的最新版本是 startTs 对应的数据。
  2. 删除L列。
如果 primary row 提交失败的话,全事务回滚,回滚逻辑同 prewrite。如果 commit primary 成功,则可以异步的 commit secondaries, 流程和 commit primary 一致, 失败了也无所谓。

事务中的读操作

  1. 检查该行是否有 L 列,时间戳为 [0, startTs],如果有,表示目前有其他事务正占用此行,如果这个锁已经超时则尝试清除,否则等待超时或者其他事务主动解锁。注意此时不能直接返回老版本的数据,否则会发生幻读的问题。
  2. 读取至 startTs 时该行最新的数据,方法是:读取 W 列,时间戳为 [0, startTs], 获取这一列的值,转化成时间戳 t, 然后读取此列于 t 版本的数据内容。
由于锁是分两级的,primary 和 seconary,只要 primary 的行锁去掉,就表示该事务已经成功 提交,这样的好处是 secondary 的 commit 是可以异步进行的,只是在异步提交进行的过程中 ,如果此时有读请求,可能会需要做一下锁的清理工作

TiDB 的事务模型 - Percolator

在 TiDB 中分布式事务实现一直使用的是 Percolator 的模型。在聊我们的悲观事务实现之前,我们先简单介绍下 Percolator。
Percolator 是 Google 在 OSDI 2010 的一篇 论文 中提出的在一个分布式 KV 系统上构建分布式事务的模型,其本质上还是一个标准的 2PC(2 Phase Commit),2PC 是一个经典的分布式事务的算法。网上介绍两阶段提交的文章很多,这里就不展开了。但是 2PC 一般来说最大的问题是事务管理器(Transaction Manager)。在分布式的场景下,有可能会出现第一阶段后某个参与者与协调者的连接中断,此时这个参与者并不清楚这个事务到底最终是提交了还是被回滚了,因为理论上来说,协调者在第一阶段结束后,如果确认收到所有参与者都已经将数据落盘,那么即可标注这个事务提交成功。然后进入第二阶段,但是第二阶段如果某参与者没有收到 COMMIT 消息,那么在这个参与者复活以后,它需要到一个地方去确认本地这个事务后来到底有没有成功被提交,此时就需要事务管理器的介入。
聪明的朋友在这里可能就看到问题,这个事务管理器在整个系统中是个单点,即使参与者,协调者都可以扩展,但是事务管理器需要原子的维护事务的提交和回滚状态,利用lazy的方式延迟检查锁是否清除。
Percolator 的模型本质上改进的就是这个问题。下面简单介绍一下 Percolator 模型的写事务流程:
其实要说没有单点也是不准确的,Percolator 的模型内有一个单点 TSO(Timestamp Oracle)用于分配单调递增的时间戳。但是在 TiDB 的实现中,TSO 作为 PD leader 的一部分,因为 PD 原生支持高可用,所以自然有高可用的能力。
每当事务开始,协调者(在 TiDB 内部的 tikv-client 充当这个角色)会从 PD leader 上获取一个 timestamp,然后使用这个 ts 作为标记这个事务的唯一 id。标准的 Percolator 模型采用的是乐观事务模型,在提交之前,会收集所有参与修改的行(key-value pairs),从里面随机选一行,作为这个事务的 Primary row,剩下的行自动作为 secondary rows,这里注意,primary 是随机的,具体是哪行完全不重要,primary 的唯一意义就是负责标记这个事务的完成状态。
在选出 Primary row 后, 开始走正常的两阶段提交,第一阶段是上锁+写入新的版本,所谓的上锁,其实就是写一个 lock key, 举个例子,比如一个事务操作 A、B、C,3 行。在数据库中的原始 Layout 如下:
假设我们这个事务要 Update (A, B, C, Version 4),第一阶段,我们选出的 Primary row 是 A,那么第一阶段后,数据库的 Layout 会变成:
上面这个只是一个释义图,实际在 TiKV 我们做了一些优化,但是原理上是相通的。上图中标红色的是在第一阶段中在数据库中新写入的数据,可以注意到,A_LockB_LockC_Lock 这几个就是所谓的锁,大家看到 B 和 C 的锁的内容其实就是存储了这个事务的 Primary lock 是谁。在 2PC 的第二阶段,标志事务是否提交成功的关键就是对 Primary lock 的处理,如果提交 Primary row 完成(写入新版本的提交记录+清除 Primary lock),那么表示这个事务完成,反之就是失败,对于 Secondary rows 的清理不需要关心,可以异步做(为什么不需要关心这个问题,留给读者思考)。
理解了 Percolator 的模型后,大家就知道实际上,Percolator 是采用了一种化整为零的思路,将集中化的事务状态信息分散在每一行的数据中(每个事务的 Primary row 里),对于未决的情况,只需要通过 lock 的信息,顺藤摸瓜找到 Primary row 上就能确定这个事务的状态。
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

0x13

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值