Tagged: transaction

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的同步。...

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处理...

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发生错误时候的常见处理方法。