Category: System Design

0

分布式系统之怎么都不可靠的网络

当我们聊到分布式系统和单机程序不同之处时第一反应就是多台机器之间的网络问题。多台机器之间的网络连接给我们带来了很多便利,比如我们可以把多个不同地方的机器互联,再也不用担心单台机器带来的性能瓶颈等等。但同时也给我们带来了很多意想不到的问题,本文就来详细介绍为什么我们说网络是不可靠的。 简单Request可能遇到的问题 我们首先来看一下当一个节点发送一个request到另外一个节点,可能会遇到的问题(如下图所示): 你的请求可能直接丢失了。(比如发送时网络突然断了) 你的请求可能会被堵塞在一个queue中,一段时间之后才会被发送。(比如网络负载很重或者接收端的直接过载等) 接收端的节点出问题了(crash或者掉电等等) 接收端的可能出现暂时性的问题,过一段时间才能响应(比如正在做GC等) 接收端处理了你的请求,但是response丢了。 接收端很快处理了你的请求,但是response回复得很慢。 所以从上面的情况来看,一个request出问题,发送端可能压根就不知道发生了什么,是没有发送出去,还是发送出去了接收端没有处理,还是接收端处理了但是response没有能够及时返回。发送端能知道的唯一信息就是一段时间内没有收到response。 正是基于上面的原因,一般来说,这种情况的处理方法就是加一个timeout:一段时间内没有收到response就认为请求出问题了,但是事实上也许接收端还是处理了相关的请求(比如只是response没有能够成功发送回来。) 错误的探测 既然网络是如此地不可靠,那么有很多系统就需要有一个机制来探测网络是否出了问题,比如下面这些场景: 一个负载均衡系统需要停止把请求发送给有问题的节点。 一个单leader的数据库,假如leader出问题了需要重新做leader的选举。 我们上文已经说了,判断一个节点是否真的工作其实比较难,但是在一些特殊场景下判断一个节点的某一个方面是否工作则是有可能做到的: 你可以连接到远端节点,但是没有人在listening,比如说操作系统可以通过返回RST或者FIN包来说明TCP连接已经断了或者拒绝了。 一个节点的process crash了,但是整个操作系统还是正常工作的,我们可以主动通过一个script来通知别的节点process crash了,而不需要使用timeout来判断。 假如你可以访问数据库网络交换机的管理接口,你就可以通过它来探测硬件层面的连接错误。当然这些的前提是你有访问的权限。 假如路由器发现目的IP不能连接,可能会回复你Destination Unreachable的packet。 当然通常来说,我们还是通过心跳机制来进行错误的探测。比如说一段时间没有收到心跳就认为对应的节点有问题了。只是说如何来确定超时是一个值得研究的话题。 超时以及不受控制的延时 假如我们把超时设置得过长,那么在真正出问题到探测到的这段时间内,请求是还会继续发送到这个节点,只是我们会看到很多错误的回复。假如我们把超时设置得很短,那么一个小小的网络波动或者一个负载的波峰都可能导致我们错误地把节点认为是有问题,而这样带来的后果就是把原本属于这个节点的负载转移到了别的节点,这其实也是有问题的(想象极端情况下,很多节点都被认为有问题,从而只有某几个节点在处理请求) 假如我们的网络传输能有一个固定时间的承诺,比如说或每一个包都会在时间d以内完成传输,否则就丢失。然后每个节点都能够在时间r以内完成请求的处理。这样就可以认为我们必然会在时间2d+r内收到response,就可以把超时设置成这个值。 可惜现实中没有这种承诺,下面我们来介绍一下具体的原因: 网络的拥堵和排队 其实就和开车上下班一样,网络包的传输很多时候也会拥堵需要排队: 假如有多个源同时给一个目的发送网络包,那么网络switch就需要把它们排好队,然后一个一个发送到目的地,如下图所示。 在网络包到达之后,假如所有的CPU都很忙,这个时候操作系统就会把收到网络包排队,知道有空闲的CPU可以处理它们。 在一个虚拟机上,有可能CPU会被别的虚拟机在使用,这个时候就会把当前的虚拟机暂停个几十毫秒,而这段时间是不能处理任何网络包的,就只能等待了。 TCP的流控,这里一个节点会控制网络包的发送速度,也就是说包还没有开始发送就被控制了。...

0

一文带你深入理解Serializable隔离最新技术SSI

我们已经在前文了解了数据库的弱隔离以及Serializable隔离的两种技术(串行执行和两阶段锁),每个人都想用Serializable隔离,毕竟它很好的处理了各种冲突。但很多时候又被逼无奈选择弱隔离,原因也很简单,Serializable隔离好虽然好,但是性能消耗太大了,选择它就意味着选择了低的性能。所以人们一直在感叹假如有一种方法能做到Serializable隔离,性能损耗又不大的话就好了。皇天终归不负有心人,一个新的算法横空出世,它就是Serializable Snapshot isolation,简称SSI,它的优点就是能够牺牲很小的性能达到Serializable隔离的效果,这个算法是2008才出现的,不过已经被运用在PostgreSQL的版本9.1和FoundationDB中了。本文就来详细介绍一下这一最新的技术。 悲观和乐观的同步控制 我们在《Transaction Serializable隔离之两阶段锁》中提到的两阶段锁其实就是一个悲观的同步控制策略:任何有可能出现冲突的数据,我们都加锁(不管是不是真的会有冲突),就有点类似多线程的编程。而《Transaction Serializable隔离之串行执行》中提到的串行执行,更是把悲观做到了极致,直接变成串行的单线程编程了。 相比较上面两者来说,SSI则是一种乐观的同步控制:假设所有的同步都是不产生冲突的,每个Transaction都能够互不干扰地执行,只在提交之前进行检查,假如发现了冲突,阻止提交进行重试。只有符合serializable的transaction才能够提交。 其实使用悲观还是乐观的同步控制大家争论了很久,他们在不同的场景下各有其优缺点,比如说乐观的同步控制在一个经常发生冲突的系统中就会带来很糟糕的体验,毕竟你的假设不符合系统的实际情况,这样一来就会有很多重试的处理,从而加大系统的资源消耗,在一些系统资源本就解决极限的情况下反而不如悲观的同步控制,反之亦然。 既然乐观同步控制不是什么新鲜的概念,SSI又有什么优势呢,正如它名字所说的,它是基于Snapshot隔离的,也就是说所有的读是从一个一直的snapshot来获取的。这就和早期别的乐观控制不同,SSI正是基于此来开发了一个算法探测Serialization的冲突从而决定哪个Transaction去abort的。 基于过时假设的决定 我们在之前的《Transaction弱隔离之Write Skew和Phantoms》中讨论过Write Skew,它的流程是一个transaction从数据库中读一些数据,检测相关的结果,然后基于结果决定做一些操作。这里的问题就是在snapshot隔离中,我们读的结果可能已经过时了,也就是说被别的transaction更新了。所以我们之前的基于结果决定做的操作可能不是我们想要的。 当我们读数据的时候,其实数据库并不知道应用程序怎么使用这些数据(你可能只是显示,也可能基于读的结果做某些操作),所以为了安全,数据库一般会说假如有任何transaction改变了你查询的结果就是不valid的。所以为了提供Serializable的隔离,数据库需要能够探测到这种情况,一般怎么做呢?有两种方法: 探测所有基于Stale MVCC object 版本的读(没有提交的写发生在这个读之前) 探测所有影响之前读的写操作(写发生在读之后) 探测Stale MVCC读 还记得我们在《Snapshot的隔离和Repeatable的读》中提到的多版本控制吗?也就是说当一个transaction读的时候,它会忽略也没有提交的写。依然来看我们之前提到的医生值班的例子,Transaction 43开始读的时候,Transaction 42还没有提交,所以它读的时候Alice的on_call还是为true。但是在43提交的时候,42已经提交了,这就意味着提交时候其实42的修改已经生效了,也就是说43看到的内容其实已经是不对的了,这个时候就需要abort 43的操作。 实现也就很简单了,就是在提交之前会检查一下是否有任何写操作提交了,假如有了就需要abort。为什么我们等到提交的时候才检查呢?原因也很简单,因为我们其实并不知道42会对查询到的数据做什么操作,假如只是简单地读,我们完全没有必要去abort 43。这样一来我们就不会出现没有必要的abort操作了。 探测所有影响之前读的写 另外一种情况就是一个transaction修改了之前读的数据。如下图所示: 我们在之前《Transaction Serializable隔离之两阶段锁》中有提到索引区间锁的概念,这里其实使用了类似的技术,如上图所示,Transaction 42和transaction 43同时查询了Shift1234的值班医生,假如我们对Shift_id加一个索引,数据库就可以使用entry1234来记录transaction...

1

Transaction Serializable隔离之两阶段锁

我们在前面的《Transaction Serializable隔离之串行执行》中介绍了Serializable隔离的第一个实现方法:串行执行。本文来介绍第二个实现方法:两阶段锁(Two-Phase Locking)。这个方法也是一个由古到今一直流行的方法,需要注意的是他和我们通常说的两阶段提交(Two-Phase Commit)是完全不同的两个概念,我们会在后面的文章中再详细介绍两阶段提交的概念。 我们都知道锁的作用:就是有transaction想同时写一个object,这个锁可以保证两个写中的一个必须等待另外一个完成。两阶段锁也是类似的,只是说它更加强一点。假如一个object没有人在写,那么多个读可以同时进行。但是只要有人想要写这个object,那么就有下面这样的限制: 假如Transaction A已经读了这个object,Transaction B想要写这个object,那么它就需要等待A提交或者abort。 假如Transaction A已经写了这个object,Transaction B想要读这个Object,那么B必须等待A提交或者Abort。 从上面我们可以看出来,在两阶段锁中,写不仅仅会block别的写,它还会同时block别的读。反过来也是这样。 两阶段锁的实现 目前MySQL和SQL Server都在使用两阶段锁来实现Serializable隔离,DB2使用它来做Repeatable的读隔离。 两阶段锁的具体实现是在数据库中的每一个object上使用一个锁,这个锁有两种模式Shared Mode或者exclusive mode,具体的规则如下所示: 假如一个transaction想要读一个object,它必须首先获得这个锁的shared mode。可以有多个transaction同时以shared mode获得锁,但是假如别的transaction以exclusive 模式获得这个锁,那么所有别的transaction必须等待。 假如一个transaction想要写一个object,它必须获得锁的exclusive mode。这种模式下别的任何transaction都不能以任何形式或者这个锁,假如这个锁以任何形式被获取,都需要等待。 假如一个transaction先读了一个object,再写一个object,就可能需要升级锁,从shared lock升级到exclusive lock。这个升级的过程和单独获取是一样的。 Transaction获得锁之后,需要一直持有,知道transaction提价或者abort了。这也是我们为什么称之为两阶段锁,第一个阶段就是获取锁,第二阶段是释放所有的锁。 从这个实现我们看到,这里有很多锁的操作,也就很容易发生死锁的状况,就是Transaction A在等待Transaction B释放某一个锁,而Transaction B在等待Transaction A释放另外一个锁。对于死锁的状况,数据库会自动检测到,并做相应的处理。 两阶段锁的性能...

2

Transaction Serializable隔离之串行执行

我们在前面几篇Transaction弱隔离相关的文章中介绍了读提交,snapshot隔离,write skew和Phantoms。但这些总给我们一种感觉就是他们只能解决部分问题,而且使用起来很不方便,需要注意很多方面。那么有没有什么好的办法来一次性解决所有问题呢?答案是有的,那就是我们今天要介绍的史上最强,超级无敌的Serializable隔离。 Serializable隔离一般会认为是最强的隔离级别。它保证了哪怕有多个transaction并行执行,它的结果和串行执行的结果是一样的。而这个是由数据库本身来保证的,也就是说数据库处理了所有的冲突情况。 既然Serializable隔离这么强,那么大家都用它就好了啊,我们为什么还需要考虑别的呢?显然世界上不会有免费的午餐,想要得到强大的力量,必然就要付出相应的代价。要想了解这个代价是什么,我们首先要来看看一般来说都是怎么实现Serializable隔离的,大概的方法有下面这些: 真正的串行执行所有的transaction。 两段锁,很长一段时间这个都是唯一的可行的方法。 优化的同步控制技术比如Serializable的snapshot隔离。 本文主要讨论如何在单节点上使用这些技术,多节点的实现我们以后再讨论。 真正的串行执行 最简单的想法就是把所有的transaction操作串行化,那么就没有我们遇到的这些并行的问题了。单毕竟串行执行可以有更好的性能,所以直到最近因为硬件提升了很多,对读写比较短的OLTP,大家才认为单thread执行是一个不错的选择。 这种方法现在被用于VoltDB/H-Store, Redis以及Datomic上。有时候单thread的执行效率比多thread还要好,因为它不要消耗精力来处理多thread的冲突。当然它也有其局限,就是性能不能超过单核的极限。对于单thread来说,transaction的组成和传统的方式不太一样。 使用Store Procedure来封装Transaction 我们之前有一种想法,就是假如用户能一次性把一件事情都做完,那么就会方便很多,比如说我们在之前的文章中提到的医生值班系统,当时的设计是医生先去查询是否有两个以上的人在值班,如果有就允许请假。如下图所示,假如我们把请假这件事变成一个store procedure,查询更新都在这个store procedure中。这样做的好处就是在查询到结果后,我们不需要返回(网络消耗)也不需要等待用户来确认(认为的操作延迟,比如去泡杯茶啥的),所有的查询结果都被保存在内存中,性能显然会提高很多。 Store Procedure已经被使用了很长一段时间了,必然也暴露出了很多缺点: 每一个数据库都有它自己的Store Procedure的语言,和我们通常使用的通用语言不太相同,所以使用起来总是怪怪的。 它是在数据库上运行的代码,所以调试,分析,版本控制都比较复杂。 数据库对性能比较敏感,所以写得不好的store procedure其实对总体的性能影响会比较大。 当然有问题就有解决的方案,现在的数据库语言已经很友好了,比如VoltDB使用Java或者Groovy,Datomic使用Java或者Clojure,Redis使用Lua等等。 VoltDB还使用Store Procedure来进行replication:不是从一个节点拷贝数据到另外一个节点,他是在每一个节点都执行相同的Store Procedure。这其实就有一个额外的要求,就是Store Procedure的结果必须是固定的。比如说假如你要使用当前的时间等不确定的值的时候,需要调用特定的API。 使用Partitioning 我们上面提到的顺序执行的方案最大的问题就是只能使用一个CPU来执行,对于一些系统来说(比如写很重的系统),这就有可能成为一个瓶颈。那么有没有可能把多核使用起来呢?答案是肯定的,一个解决方案就是partition你的数据。假如你可以把数据做好partition,然后每一个transaction只访问一个partition,这样每一个partition就有它自己的transaction thread,从而可以让每一个CPU核产处理对应的partition,这样就可以把throughput提高了。 这样做了之后的问题就在于假如你有一个transaction需要访问多个partition,你就需要在所有的partition之间进行同步了。这样一来,其实跨partition的transaction其实消耗就会变大很多,所以速度也会变慢。 另外transaction是否可以做到单partition取决于数据的结果,比如说简单的key-value数据就很容易partition,而有很多二级index的数据则更多地会需要很多跨partition的同步。...

0

Transaction弱隔离之Write Skew和Phantoms

我们在前面的《Transaction弱隔离之读提交的介绍和实现》和《Transaction弱隔离之更新的丢失》中分别介绍了脏写和更新丢失。他们都是有两个写同时发生,从而产生了冲突。那么对于这种情况,我们必然需要进行保护和处理,可以是数据库来自动处理也可以是手动的加一些保护比如锁或者原子写操作等。然而上面提到的两种写冲突就是全部了吗?现实世界显然更加残酷,本文就来具体看看别的冲突的例子。 如下图所示,假设你正在维护一个医院的值班系统,这个系统一般来说会安排几个医生一起来值班,当然它的最低要求是必须有一个医生值班。所以说,当轮到你值班的时候,假如还有别的医生在值班,你就可以从这个系统中请假。请假的操作也很简单,就是假如系统中显示有两个及以上的医生在值班,那么就允许你更新自己的状态成不值班。 现在我们假设Alice和Bob突然同时有事,一个身体不舒服,一个想去和女朋友约会,他们在下班之前的那一刻同时准备到系统中来请假,并且同时执行了请假的操作。然后就有了两个transaction同时执行,他们都是读的snapshot的内容,所以在更新的之前on call count都是大于等于2的,然后他们同时更新了他们自己的状态到请假(on_call = false)。这样一来,当天就没有医生值班了。 Write Skew的特征 我们把上面这个例子中出现的现象称之为write skew。它既不是脏写也不是更新丢失,因为他们最终更新的其实是各自的属性,并没有更新同一个结构体。这种冲突其实很难发现,因为他们看起来就像没有关系的两个独立体。只是和之前提到的脏写以及更新丢失不同,他们在更新了不同的object之后,导致的结果就是某一个查询的结果发生了变化(比如值班的人数)。那一般来说Write skew有什么特征呢? 单个的object加锁没有用,因为这里引入了多个object。 一些数据中的自动的更新丢失的探测也没有效果。 有些数据库允许你配置一些约束条件,然后来强制数据库进行检查。但是像这个例子中,你要保证至少有一个医生值班,就需要把这个约束条件扩大到多个object。而很多数据库内置是不支持这样的约束条件的。 一个比较好的方法可能就是把所涉及的行加上锁。比如说使用下面这种语句: 这里的FOR UPDATE就是告诉数据库你需要对我查询的所有行加一个锁。 常见的Write Skew的例子 开始看到Write Skew的例子的时候,你可能觉得这种情况应该比较少见,但你了解了之后就会发现,其实还是蛮常见的: 会议室预定系统 比如说你希望一个会议室同一时间不会被两个人预定。所以在预定之前,你需要先确认这个会议室在这段时间内没有被预定,然后才允许这个预定操作的发生,比如使用下面这种语句(不安全) 当使用snapshot隔离的时候,这个语句其实不能处理同时的写,所以可能需要serialization隔离。 创建一个用户名 当我们注册一个网页的时候,需要保证用户名是唯一的,假如有两个用户同时尝试创建同一个用户名该如何处理。假如你和上面例子类似使用操作说先检查有没有用户名,如果没有就创建,那么这种在snapshot隔离的场景中就会有问题。当然这个例子的解决方案比较简单,只要使用一个简单的unique限制就好了,这样第二个用户名的插入会报错返回。 消费保护 比如说你有一个账号,里面有钱和积分,当用户用这些钱或者积分购买东西的时候,一个简单的要求就是花费不能超过账户的钱或者积分的总和。假如你的实现是每一个消费的内容都插入一条数据,然后看总的消费是不是超过了余额,那么假如有两个插入同时发生的话就有可能会有问题,因为他们并不知道对方的存在。 Phantoms导致的Write Skew 我们上面提到的例子都有一个共同的pattern: 一个SELECT语句检查是否满足某一个条件。(比如至少有两个人值班,没有人已经预定了会议室等) 基于查询的结果,决定如何做。...

1

Transaction弱隔离之更新的丢失

我们在前文《Transaction弱隔离之读提交的介绍和实现》和《Snapshot的隔离和Repeatable的读》中介绍了read commit和snapshot隔离。这两篇文章其实更多地讨论的是当有写发生的时候如何进行读,除了脏写之外没有更多地讨论如何处理两个写同时发生的情况。其实当有两个写同时发生的时候还会有很多问题需要处理,其中最常见的就是两个写有冲突,这个时候就会发生更新丢失的问题。 我们举个例子,比如一个transaction先从数据库读一个数据,然后修改这个值,再把它写回数据库(读-修改-写的模式)。当有两个transaction同时发生这件事的时候,其中一个transaction的写就会丢失。这种pattern其实在现实中很常见的: 比如说你需要增加一个计数器或者更新账户的余额,需要先读出原来的值,计算出新的值,然后更新。 更新一个复杂值的一个部分,比如说你在JSON文档里面加一个元素,需要首先解析这个文档,做修改,然后写回去。 两个用户 一起写wiki页面,然后每个人都把整个页面发送给了服务器,就会有覆盖的情况发生。 既然它很常见,那么显然就已经有了很多的解决方案。我们来一起看看常见的解决方法: 原子写 很多数据库支持原子写,这样你就不用把数据读出来再进行修改了。它也能保证同时写的安全,比如说下面的语句在很多数据库中其实就是安全的: 常见的数据库比如MongoDB对更新JSON文档中部分数据就提供了原子写。但是并不是所有的操作都可以用原子写来解决,比如上面提到的更新wiki就不太容易做到,但是对于那些可以支持原子写的显然这种方法是一个很好的解决方案。 原子写常见的实现方法有两种,一种就是用一个锁来保证只要有人再读了之后,准备更新的过程中,别的读没法进行,直到更新结束才可以再读新的内容。另外一种方法就是把所有的原子操作放到一个thread中,这样也可以规避同时更新的问题。 显式锁 假如我们上文提到的原子写不能使用,另外一种方法来防止更新丢失就是对要更新的数据使用显式锁。其实和原子写的实现比较类似,这个时候别的同时发生的写就只能等待这个锁了。 下面就是一个显式锁的例子: 这里的FOR UPDATE就是告诉数据库你需要对我查询的所有行加一个锁。这个其实还是蛮有效的,只是说你写代码的时候要仔细点,不要忘了加这个FOR UPDATE。 自动探测更新丢失 我们上面提到的方法主体思想都是想办法让一个transaction先执行,然后再执行另外一个transaction。其实还有一个方法就是让两个transaction同时执行,当我们发现这两个transaction有冲突或者会造成更新丢失的时候再进行处理,比如把其中一个终止了,让他待会重试等等。 这种方法其实相对前面两种来说有其好的方面,比如说开发者不需要时刻记住需要使用原子操作或者加入FOR UPDATE在代码中,所有的这一切数据库都帮你处理了,所以说容错率比较高。 比较再更新 在一些不支持transaction的数据库中,有一种操作称之为“比较再更新”。这个操作的目的就是为了防止更新丢失的。它会限制更新只发生在你之前读的数据没有变化的时候。假如你之前读的数据发生了变化,那么这个更新就会失败,你必须重试。 比如说我们上文提到的wiki更新的例子,要想更新成功,只有在你之前读的数据和更新的时候没有变化才运行执行更新的操作。 当然这个也取决于数据库的实现。比如说你的数据运行在比较的时候content读的是snapshot的值(还记得我们之前的snapshot隔离吗),这个时候这个操作也是不安全的,不能够保证不会更新丢失。 Replication的处理 我们现实中的数据库其实会更复杂一点,比如说我们有replicated的数据库,这样一来,我们就会有多个备份在不同的节点上,尤其是对于多leader或者无leader的数据库,任何一个备份都有可能执行写操作,这个时候如何来防止更新丢失呢? 显然我们之前提到的锁和比较再更新在这个例子下是有问题的,但是我们提到的“自动探测更新丢失”则是一个不错的方案,就是运行每一个replication都做他们自己的写,到时再用一个程序来处理冲突,从而解决更新丢失的问题。 总结 本文介绍了transaction弱隔离之中的更新丢失问题,分析了多种解决这一个问题的方法,各个方法都有其针对的场景也都不完美的解决方案,希望大家读了之后能够有所了解。

0

Snapshot的隔离和Repeatable的读

我们在上文的《Transaction弱隔离之读提交的介绍和实现》中详细介绍了读提交,总得来说它是一个很好的保证,但是有了它是否就足够了?答案是否定的,即使有了读保证,我们仍然会有问题,而这就是本文准备详细介绍的内容 。 我们首先来看下面这个图: 我们假设Alice有1000块存在银行里,分别在两个account里面,开始的时候一个里面放了500块。然后Alice开始查询两个账户一共多少钱,这个查询是一个transaction,它开始查到账户1里面有500,然后这个时候发生了一个交易,就是把账户2的钱转了100到了账户1. 在整个第二个transaction(转账的transaction,包含两个操作,一个是把账户1加100,一个是把账户2减少100)完成后,之前查询的transaction的第二个查账户2的操作才到了,所以它看到的账户2的数值是400,这就导致了Alice看到的账户总额变成了900而不是1000。这个过程中,并没有脏读,因为第二个400的读是在transaction完成之后才读的,所以完全符合之前读提交,但仍然还是有了问题。 这个问题我们称之为read skew,同时也是一个不repeatable读的例子:就是在整个transaction结束的之前再去读transaction开始读的数据会发现有不同的值。就比如例子中账户1的值,我们在transaction开始读的时候它的值是500,假如我们在transaction即将结束的时候去读的话它的值就变成了600,这就是不repeatable的读。 当然在上面的例子中其实问题不是很严重,因为假如Alice再去读一下,就会发现它的余额又变成了1000,并没有发生真的丢失的问题。但是在某些情况下这种错误会导致很严重的问题。 备份 我们通常需要备份整个数据库,而这个备份的过程中原来的数据也是在同时执行的。假如什么的情况发生的时候,正好原来的数据库出问题了,我们需要切换到备份数据库,那么这个错误就是永久的了,Alice的100元就再也找不回来了。 一些分析查询和集成检测 通常我们需要运行一些分析的查询,用于分析,比如说银行会用一个查询来看看少掉的余额和取出的钱是否相等,这个时候这些查询就会得到一些错误的信息,从而影响分析。 解决这个问题的常见方法是使用过Snapshot隔离,所谓snapshot隔离就是一个transaction看到的数据是一个这个transaction开始的时候的snapshot,如果在这个transaction执行过程中数据发生了改变,对整个transaction来说是不可感知的。这样就可以解决我们上面提到的问题。 目前很多数据库都支持snapshot的隔离,比如PostgreSQL,MySQL,Oracle,SQL Server等等。 Snapshot隔离的实现 和上面的脏读的思想类似,要实现snapshot隔离就需要数据库能同时保持不同的版本数据,然后一个transaction的过程中它只会读到它开始的时候的版本的数据,对后面修改的版本数据不去感知。这种技术我们也称之为Multi-Version Concurrency Control(MVCC)。 下图就展示了这一实现的方法: 在这里,每一个账户其实有多个版本的数值,这里对每行数据有两个域,一个是create by,用来表示谁插入了这行数据。另外一个是delete by,用来表示谁delete了数据。这也意味着即使一个数据被delete了,它也不是真的delete了,而是更新了delete by这个域。只是一段时间之后,一个类似垃圾回收的机制来处理这些真的被delete的数据。这样一来,在transaction最后去读账户2的钱的时候,其实它是可以看到不同版本的,它需要选择的就是在整个transaction开始对的时候的版本(就是这里已经设置了deleted by的版本)。这样就可以做到snapshot的隔离了。 一致的snapshot的规则 当一个transaction到来时,怎么决定哪个snapshot可以被看到呢?一般会使用Transaction ID按照下面的方法来决定: 当一个transaction开始的时候,数据库其实维护了一个所有正在进行的transaction的列表。那么就可以知道哪些写入是由这些没有完成的transaction实现的,所有的这些没有完成的transaction的写都会被忽略。 所有终止的transaction的写也会被忽略。 所有由这个transaction后面的transaction写入的值都被忽略,也就是说哪怕在这个transaction后面有一个已经提交的transaction的写,对当前的transaction来说也是忽略的。 其他的写就都是可以看到的了。 这个规则就基本保证了snapshot的隔离,就像上面例子中,我们在读账户2的时候看到的还是500,因为这个delete是后一个transaction做的,所以不需要管这个修改。 Snapshot隔离的index处理...

2

Transaction弱隔离之读提交的介绍和实现

我们在上文《Transactions的基本概念和介绍》中提到,transaction的一个作用就是可以做到隔离。比如说当一个translation在写一个数据的同时,如果有另外一个transaction在同时读或写,就有可能会有问题。而且这种问题很难发现和debug,所以一般来说这种transaction级别的隔离都是由数据库本身来保证的,理论上来说这样应用的开发者就不需要来操心具体的细节了。可惜理想很丰满,现实却很骨感。很多时候数据库本身因为各种原因(性能等因素),它提供的不是严格的隔离,而是一种弱隔离,简单来说就是能保证一些同时读写的操作,但是有一些却不能保证。这样一来开发者就有点懵了,我究竟该何去何从呢?本文就来详细给大家介绍一下常见的transaction弱隔离,相信了解了这些之后,你再去开发应用就会游刃有余了。 读提交(Read Committed) 所谓的读提交其实是最基本的一种transaction隔离,它做到了以下两点保证: 当读数据库的时候,只会读到已经提交的数据。(没有脏读) 当写数据库的时候,只会覆盖已经写了的数据。(没有脏写) 没有脏读 所谓没有脏读就是当有一个transaction写一个数据到数据库的时候,只要它还没有提交,那么别人就不能读到它。 如下图所示,user1的transaction做了两件事,一个是把x写成3,一个是把y写成3。整个transaction只会在这两件事都完成的时候才会提交。现在我们来看,当user1把x写成3的时候,user2正好来读,因为User1的整个transaction还没有提交,所以这个时候User2读到的x的值还是2而不是3,这就是没有脏读。 很显然,没有脏读可以有两个好处,一个是在整个transaction的过程中,别的用户不会看到不一致的两个状态。另外一个好处就是假如这个transaction在后续的过程中出问题了,那么前面已经完成写操作会被roll back,如果缺少了“没有脏读”的保证,那么就可能出现有用户看到这个被roll back的数据了。 没有脏写 这个保证主要是用来出来两个同时发生的写的。一般来说,我们可以说有两个写同时发生,我们希望后面发生的写会覆盖前面发生的写。但是当有transaction存在的情况就有点不同,假如一个transaction有多个操作,在这个transaction还没有提交的过程中,又同时发生了一个新的写,是否还要覆盖一个没有提交的写呢?假如覆盖了,那么就是一个脏写。一般来说都是通过让第二个写延迟到第一个transaction提交之后再执行。 我们来看一个例子,我们假设这是一个购车系统,要买id为1234这辆车需要做两件事情,一件是更新它的buyer名字,一个是产生一个发票。我们需要这两件事在一个transaction里面发生。假如缺少了“没有脏写”的保证就会发生下图所示的问题,listings中的数据在中间被改成了bob,而发票则被写成了Alice,从而产生问题。 读提交的实现 我们理解了读提交的概念之后,再来看看一般我们怎么才能做到上面这些保证呢? 对没有脏写来说,通常的实现方法是使用一个锁:当一个transaction要写一个object的时候,首先会获取这个锁,直到它整个transaction完成或者终止才会释放这个锁。这样一来别的想要写的操作就需要等待这个锁,从而可以避免脏写。 那么如何避免脏读呢?第一想法可能也比较简单,就是读的时候也需要去获取上面写抓住的锁,从而就可以保证不会读到脏的数据。但是这种方法一个最大的问题就是假如有一个transaction在写一个object,所有的读都需要在那边等待,直至写完成,这显然是一个消耗很大性能很低的实现。 那一般如何来避免脏读了,其实很简单就是我们同时保存一份旧的数据和新的数据,在写transaction没有完成的时候,任何读不会被block,但是它读到的时候transaction更新之前的旧的数据。只有当整个transaction完成之后,才会读到新的数据。 总结 本文介绍了Transaction弱隔离中的读提交的基本概念和实现方法。

1

Transaction的基本概念和介绍

我们已经知道了分布式系统中有很多问题,比如说我们正在读的数据也许正在被别人修改,系统有可能在我们执行一系列操作的中途crash,多个同时发生的写操作可能相互覆盖等等。我们总归需要一个好的方法来解决这个问题。而Transaction的产生就是为了解决这些问题,所谓的transaction就是应用程序把一系列操作组合成一个逻辑单元, 这个单元可以认为是一个原子操作,要么就全部成功,要么就全部失败。有了Transaction的概念之后,错误的处理就变得方便了很多。 单Object和多Object操作 在我们详细介绍之前,请大家一起先来回顾一下ACID的概念,你可以在我之前的这篇文章《SQL和NoSQL的分析》中找到详细的介绍,这里不再复述。 多Object的操作 回顾了一下ACID之后,让我们来看一个简单的例子,假如你想找到你的未读邮件的数目,有可能会使用下面这样的query 这样的query粗看起来还不错,但是有一天你突然发现假如一个人的邮件数目很多,这个query可能耗时比较长,于是你决定把未读邮件的数目存储到一个单独的域。当有邮件来了,就去把未读的数目加一,当有一个邮件被读了之后,就把未读邮件减少一。 这里有两个操作,一个就是插入新来的邮件,一个是增加未读邮件的数目,我们原本的想法这两个操作必须是一体,否则就可能出现下图所示的问题: 这里插入邮件和更新未读数量是分开的,假如在这个中间有另外一个用户正好来读未读邮件的数目,就会发现并没有未读邮件,而事实上收件箱里是有一封未读邮件的。而我们上文ACID中的Isolation就可以很好的规避这个问题,也就是说用户2要不两么都看不到(插入邮件和更新未读数量),要么可以看到两个。 而ACID中的atomic则可以解决另外一个问题,如下图所示,这个情况下邮件的插入是成功的,但是未读邮件数目的更新失败了,假如没有atomic的保证,这两者就有了冲突。Atomic会保证在这种情况下,邮件的插入也会被roll back。 上面这个例子其实就是多object的操作,这种情况下的transaction可以成为多object的transaction。一般来说,它需要知道哪些操作是属于一个transaction的,在关系性数据库中,这通常是由客户端的TCP 连接来决定的:在一个连接中,任何处于BEGIN TRANSACTION和COMMIT之间的内容被认为是同一个transaction。 单object的写 也许你会认为单object的写就没有我们上面提到的问题了,其实不然。比如说你现在要写一个20KB的JSON文档,假如你写了一般比如10KB之后突然出问题了,数据库该怎么处理,是保存你已经写了的这一半数据还是整个就都丢掉。或者说当你更新数据的时候更新了一半,这个时候又有人来读这个数据,它读到的是什么数据,一半新的一半旧的吗? 所以一般来说storage level的引擎会保证单object的atomicity和isolation。通常会使用log来进行 crash的recovery保证原子性,使用object锁来保证isolation。 这种单object的保证是很有用的,但是一般来说这个不是我们通常所说的transaction的概念,毕竟很多时候这个都是数据库内部的保证。所以通常来说我们提到的transaction的概念是指多object的组合操作。 错误和终止的处理 Transaction的一个核心功能就是当有错误发生的时候,我们能够安全的进行重试,这里一个核心思想就是一个组合中的操作假如在某一步发生了问题,那么整个组合的操作都会全部roll back。因此一般来说,对于transaction的错误处理就是进行重试。 虽然说有错误进行重试是一个很好也很有效的方法,但它仍然有可能会造成我们想不到的问题: Transaction事实上是成功了,只是因为网络等原因没有能够成功告诉客户端,这个时候假如还去重试,就会有可能造成重复操作的错误。 假如错误是因为负载过高造成的,你重复transaction可能会使得问题更加严重。当然我们可以限制重试的次数,特殊处理overload的错误等等来进行规避。 重试对于一些transient错误是有效的,比如死锁,临时的网络故障等等。对于一些永久的错误,重试也还是会有问题。 假如一个transaction会造成额外的影响,那么哪怕transaction终止了这些影响也可能会存在。这个时候two phase的commit可能会有所帮助。 总结 我们简单介绍了transaction的概念,以及当transaction发生错误时候的常见处理方法。

0

多index的Partition处理介绍

我们在上文《Partition的基本概念和实现介绍》中主要分析的是key-value格式的数据,也就是说所有的partition都是基于primary key来决定如何route读写请求的。而实际的系统可能不是只有primary key这么简单,它可能会有第二个index,那么如何处理这种有第二个index数据的partition呢?一般来说有两种常见的方法:一种是基于文档的 document-based partition,另外一种是term-base的partition。 Document-based partition 现在我们来假设你在卖二手车,每一辆车都有一个id,然后你的数据开始就是根据这个id来进行partition的。当你开始卖车了,你希望你的用户可以通过品牌和颜色来搜索车辆,所以你把品牌和颜色做成了第二个index。那么数据在各个partition中的存储可能就如下图所示了: 在插入数据的时候,比如说你加入了一个红色的汽车,首先会根据id分到对应的partition中,同时需要更新这个partition中第二个index的文档,比如你加入的是一辆红色honda,它的id是235,那么就需要把这里的color:red插入235的值,同时还要根据你的生产商对应更新make:Honda的值。 我们可以发现,每次其实根据id找到partition之后,所有的更新就都发生在这个partition了,和别的partition没有关系,所以也称这种方法为local index。 细心的你不难发现,这种方法的更新操作很简单。但是读取操作就有点复杂了,比如你想找到所有的红色汽车,你会发现需要找到所有的partition,然后才能得到所有的红色汽车的结果。正是基于这个原因,我们也称这种实现为Scatter/gather。总得来说虽然我们可以平行访问多个partition,但是它的效率其实并不是很高。不过不管怎样,还是有很多数据库使用这个方式,比如MongoDB,Riak,Cassandra等等。 不过换一个角度来想,假如你的primary key选的比较好,比如说根据make来做partition,然后大多数用户可能会有自己心中的车的品牌,一般基于这个品牌再选择颜色,那么这样的存储其实实现的效率还是不错的。 Term-based partition 和上面方法不同的实现,就是我们把第二个index做成一个global的index,然后把这个global index也partition到不同的节点上,而且partition的方法和primary key可能不同,还是上面的例子,我们可以按照下图的方法来组织第二个index的partition: 说白了,就是把第二个index重新partition,比如上面的color按照字母顺序来进行partition,a-r的字母放在了partition0,其他放在partition1。当然我们也可以使用其它方法来进行partition,比如哈希函数等等,不过这个在这里不重要。 我们来看看这种情况下写该怎么做,比如说我们插入了一个银色的Audi,id是893,那么根据id的partition来看,这个数据是保存在partition1中的,所以,在partition1的primary key index中保存了一行数据,然后根据color的字母来看color该更新哪个partition,这里silver是s开头的,所以也保存在Partition1中,所以更新对应的Partition1中的secondary index中的color:silver。然后再看make,这里是Audi,所以开头的字母是A,那么就和之前不一样了,需要更新的就是Partition0中的secondary index了,所以他会更新Partition0中的make:Audi的数值。 我们可以看到这种方法的写操作其实比之前负责了很多,因为它很有可能就会涉及不同的Partition。那这么做有什么好处呢?答案应该很显然,就是它的读简单了很多了,比如现在我们要搜索所有的红色车辆,就只要找到对应的secondary index所在的Partition(这里就是Partition0)就能找到所有对应的车辆id了,而不需要再去所有partition查询了。 这种实现理论上其实蛮好的,但是现实中一个大的问题就在于一个写操作需要更新多个Partition,既然涉及到多个节点,那么写操作就有可能会有失败和延时,通常来说secondary index的更新是一个异步操作,这也就意味着这里面会有延时,也就是说你刚写入的数据,再立即读就有可能看不到这个数据。比如Amazon的DynamoDB就是这样的实现,在正常情况下,也会有零点秒的延时,至于极端环境这个延时是没有保证的。 总结 本文总结了多index的partition常见的两种处理方式,各有其优缺点(好吧,要是一方没有缺点,那么另外一种肯定也就不会存在了),希望大家看完能对这两个种方法有个大概的了解。