😨啊我数据呢? 一个分布式数据库中最严肃的问题

date
Mar 19, 2024
slug
distributed_system_consistency
status
Published
tags
Database
Distributed System
summary
Morris 在MIT 6.824分布式系统的第一节课就讲到,如果不是绝对的需求,那就千万不要考虑把系统变成分布式,因为它会让很多东西变得很复杂。但是现在随着对数据量,对可用性,和对可扩展性的需求增加,分布式似乎成了唯一的解决办法,而这样也带来了许多麻烦:很多事情放在一个机器上很简单,但放在多个机器上就出问题了。数据库系统,特别是OLTP,则是这些麻烦的栖居地,因为它对“错误”没有任何的忍耐度。
type
Tech
lang
zh
Morris 在MIT 6.824分布式系统的第一节课就讲到,如果不是绝对的需求,那就千万不要考虑把系统变成分布式,因为它会让很多东西变得很复杂。但是现在随着对数据量,对可用性,和对可扩展性的需求增加,分布式似乎成了唯一的解决办法,而这样也带来了许多麻烦:很多事情放在一个机器上很简单,但放在多个机器上就出问题了。数据库系统,特别是OLTP,则是这些麻烦的栖居地,因为它对“错误”没有任何的忍耐度。我的上一篇文章就曾提过New SQL为了同时满足传统数据库的“正确性”和No SQL的高可用和高扩展性,做出了很多努力,诞生过很多精彩巧妙的论文。本篇文章将从Spanner,Cockroach DB,TiDB,Calvin来分析这些系统是如何解决这些麻烦,向客户保证正确性的。
 
说到“正确性”,那就不得不提“一致性”了,那么什么是一致性呢?
 

到底啥是一致性

网上对一致性定义千奇百怪,并且非常拗口。我觉得最直观的解释就是:别管这个系统的背后到底有多少机器在合作运行,它看起来摸起来用起来就像只有一个机器一样。这就意味着:
💡
所有操作都是有顺序的,并且这个顺序和这些操作的时间顺序契合。比如一个用户先更新A之后再更新B,那么这个系统必须先处理A,再处理B。
这看起来是一个系统最基本的要求,但这在分布式的环境中却没有看起来那么简单。想象一个场景:你在机器A上更新了x,然后又在机器B上读x,那么你一定能读到x的最新值吗?好像看起来需要做一些额外的工作吧!比如A在更新x的时候需要复制到尽可能多的服务器上,或者读x的时候需要多读一些服务器确保能读到最新值。你也许会觉得那也很简单啊:我每次写的时候我就写进所有的机器,读的时候就从所有机器上读,那不就解决所有问题吗?但问题是如果有个机器突然崩溃了,整个服务就死掉了。现在的分布式系统基本上都是上千台服务器,说不准哪个服务器就下线了,而这样做就会导致系统的可用性极低。
所以对于数据库而言保证一致性不是一个轻松的活,而很多数据库(NoSQL)干脆就直接放弃了一致性,并且编造出了如 “最终一致性“ 这种弱一致性的概念。为什么不做一致性呢?很多人觉得都怪CAP,毕竟CAP提到了在网络分区的情况下,要么保证100%一致性(consistency)要么保证100%的可用性(availability)嘛。但这是不准确的
Any distributed system or data store can simultaneously provide only two of three guarantees: consistency, availability, and partition tolerance.
CAP是一个伟大的理论,但是很多人都把它用在了错误的方向。网上很多博客文章都把CAP理解成C和A是两个互斥的概念,要么保证C要么保证A(因为从常理上讲我们希望分布式系统在网络分区的情况下也能正常工作)。所以他们看到一个分布式系统就想把C或者A往上面套。但这种粗暴的归类法显然低估了分布式系统的复杂程度。CAP只是提出了一个理想的世界,在这个世界中网络分区是唯一发生的问题,解决这个问题的办法就是你要么只保证100%可用性,或者只保证100%的一致性。而现实生活中,分布式系统中会出现的问题多了去了,比如最常发生的就是服务器崩溃。第二,分布式系统中从来没有过100%的可用性,没有任何云服务厂商敢说他们的服务可用性是100%。可用性最通用的描述方式是用9来描述:服务可用性有多少个9。是99.99%还是99.999%?所以NoSQL即使放弃了一致性的保证,也不代表他们的可用性是100%。同理,New SQL“放弃了”可用性,也不代表他们可用性就是0。比如spanner,很多人划分成CP系统,可用性是99.999%,而DynamoDB,很多人划分成AP系统,可用性也是99.999% 。 CAP理论没办法归类现代分布式系统的最根本原因我认为是云服务里最常发生的问题不是网络分区,而是服务器崩溃或者是软件层面的bug。
那为什么NoSQL数据库放弃保证一致性了?我觉得更多的是性能和成本的考量。比如如果有f台服务器的容错,那么DynamoDB只需要至少f+1台服务器就行(具体取决于用户的需求),而spanner则需要至少2f+1(因为用了paxos的缘故)。
我想提出一个更具实际意义的分布式理论,名字叫C(Consistency)P(Performance)M(Money):在设计分布式系统的时候这三个方向最多只能考虑两个,比如选择了一致性和性能就要放弃一部分成本(spanner)或者选择了性能和成本就要放弃一致性(DynamoDB)或者选择了一致性和成本就要放弃性能(Chain Replication)
理解了这些就更好理解NoSQL为什么放弃一致性保证了,因为能以更便宜的价格带来可用性。而NewSQL则愿意用更高的成本去带来更高的可用性的同时保证一致性。这就是一个高难度的挑战,而不同的数据库厂商有着不同有意思的解决方法值得我们讨论。

Spanner - 优雅!

简单来讲,Google就是要做一款一致性的分布在全球的SQL数据库,并且有两个要求:
  • 尽可能的提高它的扩展性和可用性
  • 尽可能的保证读的性能
Google的方法永远都是最优雅的。它利用了自己独有的云硬件优势,以极小的性能代价去保证了系统的高可用性。

系统架构

Spanner的部分架构Spanner的部分架构
Spanner的部分架构
如图所示,Spanner的架构非常清晰。tablet负责以Key - Value的形势储存数据,并将数据持久化在Colossus(Google 内部的文件系统)中。为了让读的性能最大化,Spanner采用了MVCC+2PL多版本并发控制,这样的话只读的事务并不会和其他事务产生冲突。具体做法就是所以每一个插入进来的Key都会带上一个系统产生的时间戳以表示该数据commit的时间。
所有新写入的数据会根据paxos通过leader复制到所有的机器上,这样做有几个好处:第一保证了一致性的同时也兼顾了系统的可用性,因为只要有超过半数的服务器是正常运行的,那么Spanner就是正常运行的。第二,这些服务器也分布在不同的数据中心里,这样的话即使是一个数据中心出问题了也不会造成整个数据库的崩溃。
而在paxos的leader节点上则实现了事务管理的相关逻辑。
总体来讲一个事务的执行逻辑如下:当事务请求到达paxos leader之后,leader给它一个代表当前时间的时间戳,所有该事务写下的数据都会带有这么一个时间戳,并且复制到所有的机器上。同时它在读数据时也只能读到时间戳小于等于它的“最新”数据。举一个简单的例子,假设我们有如下的数据库:
a@09:00-02-15-2024
1
a@10:00-02-15-2024
2
a@12:00-02-15-2024
3
b@11:00-02-15-2024
100
  • 事务Tx1@15:00-02-15-2024
    • 读取 a
    • 写入 b = a + 1
  • 事务Tx2@15:01-02-15-2024
    • 写入 a = 4
假设Spanner同时在处理Tx1 和 Tx2:
因为Tx1的时间戳是15:00,所以它只能读到a@12:00-02-15-2024 = 3,最后写入b@15:00-02-15-2024 = 4
同时Tx2 的时间戳是15:01,所以它会写入a@15:01-02-15-2024 = 4
a@09:00-02-15-2024
1
a@10:00-02-15-2024
2
a@12:00-02-15-2024
3
a@15:01-02-15-2024
4
b@11:00-02-15-2024
100
b@15:00-02-15-2024
4
从这个例子我们可以看出读写不会发生任何冲突,并且所有事务都是有顺序的并且遵从事务发生的先后顺序 (Tx1 然后 Tx2)。
一个简易的高可用,强一致的数据库就这么搭建好了,这么看似乎很简单,但这样的架构对Google而言是不够的,因为没办法支撑海量级的请求。Google采用了分区(sharding)的方法进行横向扩容,简单来说,就是把上面的架构复制几十份,每一份只负责所有数据中的一小块,如果一个事务涉及到更新多个分区,最后会通过2PC来进行commit。
notion imagenotion image
这样的分区扩容大大提高了系统处理请求的速度和吞吐量,但带来了一个严重的一致性问题。假设我有一个事务对三个分区(A, B, C)一起进行更新,那么该事务的时间戳则是由三个分区协商后进行选择,一般来讲(也是spanner的做法)是三个分区分别产生一个时间戳,然后选择最大的。每个分区产生的时间戳和现实世界的真实时间会有微小差别,因为不同的机器系统时间是有偏移的(Clock Skew)。具体来讲假设现实世界的真实时间是15:00:01.50-02-15-2024,可能分区A的系统时间是15:00:01.40-02-15-2024,但分区系统B的系统时间是15:00:01.55-02-15-2024,我们没办法让一个分布式系统中所有的机器产生出完全一样的时间戳,而这几十ms的偏差则会完全破坏我们之前所架构出来的一致性的保障。
 
假设我有这两个事务:
  • 事务Tx1@09:00:10.55-02-20-2024 (现实世界的时间戳 09:00:10.45-02-20-2024) :对分区ABC分别进行操作
    • 读取 a (分区A)
    • 读取 b (分区B)
    • 读取 c (分区 C)
    • 。。。 (一系列其他操作)
    • 写入 c = c + a + b
  • 事务Tx2@09:00:10.50-02-20-2024 (现实世界的时间戳09:00:10.58-02-20-2024):对分区CDE分别进行操作
    • 读取 d (分区D)
    • 读取 e (分区E)
    • 读取 c (分区C)
按照现实世界的时间戳来看,先执行Tx1后执行了Tx2,但是从数据库给的时间戳来看Tx1的时间戳大于Tx2的时间戳,这会导致Tx2没办法读到Tx1写进去的数据,因为Tx2只能读到时间戳小于等于它的数据。这是一个严重的数据不一致,因为用户可能没办法读到之前写进去的数据,会给用户造成“数据丢失”的假象。
我们来仔细思考一下问题到底出在哪里:
  1. 事务发生的现实时间和事务实际上获得的时间戳不一样。事务获取的时间戳是多个分区协商而成,而最后选择的时间戳和真实时间有偏移。如果一个事务向前偏移,一个事务向后偏移,那么就有可能造成不一致。
  1. 两个不同的事务只更新了部分相同的分区。如果两个事务更新的是完全一样的分区,那么一致性的问题就像没有分区一样好解决。而如果只更新了部分相同的分区(ABC, CDE),Tx1产生出来的时间戳可能是A决定的,Tx2的时间戳可能是由D决定的,那么就会导致时间戳的混乱。
所以问题可以简单的归纳为:如何在分布式系统中判断事务发生的先后顺序。你可能觉得这个简单,用Vector Clock就行了,但是Spanner并没有采用这么粗暴的方法。论文并没有给具体的理由,但我认为是因为Vector Clock并没有很高的扩展性,因为vector的长度和整个系统的节点数量成正比。Google的解决方案是利用它们独特的硬件优势。

True Time API

在介绍Google的解决方案之前,我们先介绍一下Google的硬件优势。在Google数据中心的服务器上有一个特殊的系统API叫TrueTime API
struct TTinterval { Timestamp earliest; Timestamp latest; }; TTinterval curr = TT.now(); { // if curr timestamp is definitely passed bool res = TT.after(curr); } { // if curr timestamp is definitely not passed bool res = TT.before(curr); }
每当我们调用TT.now()的时候,TrueTime API会返回两个值,earliest和latest,代表着现实世界真实的时间一定在两个值之间。而TT.after(t) 则代表现实世界的时间一定比t大,before则刚好相反。我不是很清楚True Time具体的实现方法,论文说用了GPS和原子钟,我不是很能看懂。不过没关系,Nobody cares,更值得关心的是如何利用这个API解决问题。

Spanner的方法

Spanner还是利用一样的系统架构,一样的增删查改的方法,只是时间戳完全就是由True Time API提供。而这个时间戳是虽然也是偏移的,但它的偏移是有限的,True Time API保证了偏移完全不会超过latest。Spanner就在这个上面玩了一点小花招。
  • 事务Tx1@09:00:10.55-02-20-2024
    • 读取 a (分区A)
    • 读取 b (分区B)
    • 读取 c (分区 C)
    • 。。。 (一系列其他操作)
    • 写入 c = c + a + b
  • 事务Tx2@09:00:10.50-02-20-2024
    • 读取 d (分区D)
    • 读取 e (分区E)
    • 读取 c (分区C)
这跟上面是同一个例子。首先,一个事务在commit的时候,所有涉及到的分区会分别调用TT.now(),然后事务会选择最大的TT.now().latest来作为时间戳。不过这完全没解决时间偏移的问题,因为不同的分区产生的TT.now().latest也是有偏移的。请看下图:
notion imagenotion image
假设Tx1用的是分区B产生出来的时间戳,Tx2用的是分区D产生出来的时间戳,那么图中的情况则是Tx2发生在Tx1 commit之后,但Tx2的时间戳却小于Tx1的时间戳。那么导致的结果还是Tx2读不到Tx1写进去的数据。
彻底解决这样的不一致性则需要每次在commit多做额外的一步。spanner把它叫做commit-wait。每次一个事务要进行commit的时候,需要一直等待到TT.after(timestamp) == true才能通知外界commit。
notion imagenotion image
从图中我们可以看出,Tx1结束后不会马上commit,而是会等待TT.after(TT.now().latest) == true的时候才会commit。诶很多人会觉得只看图的话好像没啥差别啊?其实对于用户而言差别很大。第一张图对用户而言是先发生Tx1,Tx1 commit后再发生Tx2但是Tx2读不到Tx1的数据,所以用户有了丢失数据的幻觉。但第二张图则是Tx2先commit,然后Tx1再commit,所以Tx2读不到Tx1的数据对用户而言很正常。
另一种更直观的理解方式就是:一个事务A在commit的时候,TT.after(TT.now().latest) == true,也就意味着当前分区的时间肯定是过了,所以发生在事务A后面的事务的时间戳一定比A大,一定能读到A写进去的内容,一致性就得到了保障。
那这么做性能开销很大吗?其实不会。因为Google的TrueTime API保证了latest的偏差很小,一般只有10ms,所以事务最多等10ms,而这段时间正好可以做Paxos的复制。
 
TrueTime API的偏移量TrueTime API的偏移量
TrueTime API的偏移量
这就是Spanner的核心思想了,利用TrueTime API的独特性质去优雅的解决分布式系统中不一致的问题。我只是非常粗略的讲了讲大致的设计思想和架构,原文中有更多的细节,同时也讨论了很多优化,比如对只读事务的流程简化等等,感兴趣的朋友可以多去研究研究。

Cockroach DB - Good Enough Is Good Enough

Time is an illusion. – Albert Einstein
Spanner有很大的局限性,因为它过于依赖Google自己内部的硬件,也就只能部署到Google的数据中心里。但很多金融用户不愿意把数据交给Google,希望把数据库部署到自己内部的服务器上。所以其他的商业数据库,如Cockroach DB,Yugabyte DB看到了机会。但他们首先需要解决的问题是如果不依赖于TrueTime API,那怎样去做一致性保障呢?

Hybrid Logical Clock

从上文我们的分析就能看出来,解决一致性问题的关键就是处理好分布式系统中的事件时间先后的问题。在TrueTime之前已经有很多解决方式,比如Lamport Clock或者Vector Clock。但是这些解决办法在当今的分布式系统中有着严重的缺陷:1. 它们都是逻辑时钟,没办法解决关于现实时钟的请求。2. 对于 Vector Clock而言,它的空间复杂度是O(n),可扩展性极差。
14年的时候有一伙人就提出了Hybrid Logical Clock ,其实就是兼顾了逻辑时钟和现实时钟,解决了逻辑时钟的痛点:
  1. 输出格式就是时间戳,所以可以让系统很好的解决关于现实时钟的请求
  1. 空间复杂度是O(1),所以可扩展性高
从不严谨的角度来讲,它其实就是TrueTime API纯软件方面的实现,和现实时钟的偏移也是有范围限制的,只是该范围比TrueTime 宽多了。True Time一般也就10ms的偏移,但是Hybrid Logical Clock高达500ms。这就导致Cockroach DB没办法像spanner一样硬等500ms,因为这会让事务延迟高的可怕。

Cockroach DB 的方法

Cockroach DB的系统架构和Spanner几乎一模一样,也是利用了多分区,每个分区用Raft进行复制。只是事务处理上和Spanner有区别。因为Hybrid Logical Clock的偏移可能会用500ms之高的关系,Cockroach DB干脆直接放弃了像spanner那样最后的commit-wait,甚至直接就放弃了最强的一致性保障,对你没听错,Cockroach DB作为New SQL并不能保障一致性,他们稍微放松了对一致性的定义,找到了一个客户能接受的“正确性“,Good enough is good enough!
用一个例子简略一下Cockroach DB的大致思想:
  • 事务Tx1@09:00:10.55-02-20-2024:对分区AB分别进行操作(最大时间戳:09:00:11.05-02-20-2024
    • 读取 a (分区A)
    • 读取 b (分区B)
在记录下该事务的时间戳的同时也会记录下该事务的最大时间戳。最大时间戳记录了最大有可能的时间偏移。
💡
MaxTimestamp = CurrTimestamp + MaxTimeOffset;
读的方式还是和Spanner一样用的MVCC:a和b会有多个版本的值,而Tx1会去读它时间戳之前最大的那个版本。但是如果Tx1发现a的最新版本大于Tx1的时间戳而小于Tx1的最大时间戳,Cockroach DB就没招了,因为Cockroach DB并没有办法知道Tx1到底应不应该读a的最新版本。如果大家读着有点拗口的话我举个例子:
  • 事务Tx2@09:00:10.65-02-20-2024
    • 写入 a = 5
假设Tx2在09:00:10.65-02-20-2024的时间写入了a = 5,但是因为时间偏移的关系,我们并不能知道Tx1到底应不应该读到a = 5,因为Tx1的最大时间戳(最大有可能的时间偏移)09:00:11.05-02-20-2024。所以也许Tx1发生在Tx2之后 (Tx1应该读到a = 5)也有可能Tx1发生在Tx2之前(Tx1不应该读到a = 5)没有办法进行判断。
所以Cockroach DB的解决办法就非常粗暴了,重试!如果Cockroach DB感觉到了有任何的一致性的冲突,都会进行重试,这样就会让每个事务都能读取到之前事务写进去的值,不会读到过期的值。但如果你细心一点就会发现,假设有两个事务,他们操作的数据并没有交集,是不是Cockroach DB就没有办法检测到一致性冲突,就无法判断先后顺序了呢?是的!
  • 事务Tx3@09:00:10.55-02-20-2024
    • 读取 a (分区A)
  • 事务Tx4@09:00:10.65-02-20-2024
    • 读取 b (分区B)
在这种情况下,Cockroach DB没有办法判断到底Tx3 和 Tx4 谁先发生谁后发生,就没有办法提供最强的一致性(线性一致性)。
💡
线性一致性:所有操作都是有顺序的,并且这个顺序和这些操作的时间顺序契合。比如一个用户先更新A之后再更新B,那么这个系统必须先处理A,再处理B。
事实证明,没有人在乎。Cockroach DB的客户包括了各种银行和交易平台,但它们都不在乎最强一致性。如果两个事务读写的完全是不一样的数据,那么谁先发生谁后发生有什么关系呢?Cockroach DB给出的一致性保障足够好了,足够好已经足够了。
 
总体来讲,Cockroach DB事务的核心思想就在于重试。我只是大致讲了讲背后的逻辑,忽略了很多其他冲突处理的细节,有感兴趣的朋友可以去读源码和看其他知乎大佬写的更加细节的分析

TiDB

TiDB的架构和Spanner也极其类似,用raft就行底层数据容错,数据也会进行分区,使用多版本并发控制。但是具体的时间戳处理就不太一样了,TiDB的做法比较简单粗暴,直接采用了中心化的时间戳服务,这样就从根本上解决了一致性的问题:所有事务时间戳都是由中心化服务决定的,所以不会出现任何时间偏移的问题,事务的顺序性也能保证。
但这么做的问题就是会很容易成为bottleneck,极大的限制了数据库的可扩展性,降低了数据库的可用性,因为每个事务都会请求这个时间戳服务。解决这些问题就是TiDB主要面临的挑战。

可用性

中心化服务面临的第一个问题就是single point failure。如果时间戳服务崩溃了,那么基本上没有事务能在TiDB上运行。所以该服务背后必须有多个机器进行高容错。TiDB的中心服务PD(placement driver)背后使用的etcd作为key value store,而etcd背后则是raft提供的高可用保障。所以PD本质上就是由PD leader提供提供时间戳服务,而其他的机器作为replica来进行容错。
如果PD leader崩溃了怎么办?那么PD servers就会通过raft选举出一个新的leader继续提供服务。但是这里会有个问题,PD必须保证所有产生出来的时间戳是单调递增的(不然就会出现一致性问题),新的leader应该怎么做呢?首先新的leader肯定不能直接使用本机的时间来当做时间戳。就像我们上文重复多次的,时间是有偏移的,如果本机的时间比上一个leader的时间早,那么就会出现严重的一致性问题。所以我们需要知道上一个leader最后产生的时间戳是什么,然后新leader产生一个比它大的时间戳。解决办法就是每当一个PD leader产生一个时间戳时,就会写进etcd然后通过raft复制到所有机器上。这样新leader会从etcd读到最新的时间戳是多少再进行分配。

性能优化

正确性和可用性解决解决了,性能则是下一个需要面对的问题。如果你稍加分析就能看出来上述架构有这些性能瓶颈。
  1. leader每分配一个时间戳给一个事务就会写入etcd就行复制,太费时间了。
  1. 每个事务都需要去PD获取时间戳,会严重影响吞吐量。
TiDB的解决方式非常聪明。首先,PD leader每次会产生大量的时间戳慢慢的分配给事务,这样只需要把最高的时间戳写进etcd就行复制。一次etcd的时间戳写入能服务多个事务。
其次,为了提高系统的整体吞吐量,TiDB会把多个事务batch在一起去PD获取时间戳。以延迟为代价提升系统吞吐量。
 
总结:使用中心化时间戳服务能显著降低系统的复杂度和工程复杂度。虽然会有一系列可用性和性能问题,但是也会有很多足够聪明的优化技巧。阿里巴巴的OceanBase也采用的这样的架构。

Calvin - 出奇制胜

Calvin是传奇数据库大牛在耶鲁搞出来的最早的New SQL的雏形,在那之前的分布式OLTP数据库,比如Volt DB,基本上就没有并行处理分布式事务的能力,所以没办法有效的横向扩展。Spanner更广为人知是因为它是第一个解决这个痛点的商业数据库,但其实Calvin论文的发表时间其实比Spanner还早了几个月,只不过它是学术数据库,影响力远远不如Spanner。它的性能与Spanner是一个量级的,但是它的系统架构和Spanner,Cockroach DB,TiDB完全不一样。

系统架构

了解Calvin的架构之前我需要先介绍一下Calvin是如何解决一致性的。与上面三个论文不同,Calvin完全没有用时间戳去解决一致性的。它的解决办法非常暴力,就是专门利用一个系统层去给一段时间内(一般是10ms)所有事务进行排序,排序好以后才由执行层执行,这样就不会有任何不一致性发生。原理和TiDB的PD很类似,但与之不同的是排序可以带来额外的保障,终极目的则是不用浪费时间进行多个分区的2PC了。
不用浪费时间进行多个分区的2PC了。
不用浪费时间进行多个分区的2PC了。
重要的事说三遍,这是很多Calvin的整个系统设计的重要考量。
notion imagenotion image
首先,和Spanner不同的是Calvin没有先用paxos进行复制,然后再通过分区进行扩展,而是先分区进行横向扩展,然后再用paxos复制。同时因为没有时间戳的缘故,Calvin采用的2PL做的并发控制
Calvin的系统分成三层
  1. sequence:将一段时间内的所有事务请求进行排序。sequence在每个服务器上都有,用paxos进行复制。
  1. scheduler:根据事务的排序去执行这些事务。
  1. storage:储存数据到磁盘。任何有着CRUD能力的kv store都行,比如RocksDB。
看着好像和Spanner也差不了多少嘛,为什么Spanner需要做2PC而Calvin不需要呢?

No More 2PC

为什么分布式数据库大部分都需要2PC?因为一个事务很可能是由不同的分区一起执行,可能大部分分区都成功了但有一个分区失败了,这样整个事务都应该被abort掉,其他成功的分区则需要rollback。2PC保证了事务能commit时所有分区一起commit,事务失败时所有分区一起abort。
但可恶的是2PC性能太烂了。就如它的名字一样,需要发请求给所有机器,还是两次,这是一笔不小的开销。并且如果有一个分区执行慢了的话2PC则会默认该分区执行失败了,导致整个事务都被abort。很多分布式数据库厂商就想尽办法去做优化,比如Cockroach DB就用了parallel commit去优化2PC的开销。但如果能完全避免使用2PC的话则对整个系统的性能是另一个层次的提高。
Calvin的系统设计则完全规避了用“交流”的方式来确定是不是每个分区是否都能commit。从直观上来讲,Calvin里的每个分区以某种确定性的方式执行事务,不需要交流就会一起成功一起失败,所以就完全没必要用2PC去判断到底需不需要abort。想要做成这些Calvin需要让abort有确定性。一般来讲,一个事务有可能会abort的理由有如下:
  1. 用户在事务中明确指出该事务需要abort
  1. 多个事务发生冲突需要abort
  1. 如果事务涉及到多个分区,但某些服务节点挂掉或者卡住了
 
第一个理由因为是写死在用户的事务逻辑里的,所以已经有确定性了。Calvin只需要让每个节点都执行一样的事务逻辑,如果有abort的情况那么所有节点都会一起abort,commit的话所有节点一起commit。
 
困难的是第二个和第三个情况,因为事务冲突和服务节点挂掉这两个事件基本上是不能预测的。所以Calvin出了一些奇招:
 
对于事务冲突而言,Calvin的做法是从根本上去避免事务冲突。它的事务管理有如下几个特点:
  • 所有分区必须以一样的顺序去处理事务
Calvin专门有一个sequence层去对一段时间内的所有事务请求进行排序,所以分区只要按照这个顺序去处理事务就行
  • 并发控制必须是确定性的
第一:一个事务在执行之前必须把所有需要上的锁全部拿到。在事务执行之前Calvin会分析该事务会读写哪些数据,然后提前拿到所有的锁。在执行结束之后才把锁给释放掉。
第二:上锁的顺序和事务的排序完全一致。假设Tx1和Tx2 想要拿到同一个锁,而Tx1在Tx2前面,那么Tx1一定会先拿到这个锁,然后Tx2才能拿到。
Calvin的事务管理是一个单线程,从前向后扫描一个一个把锁给需要的事务。如果锁出现冲突,便把事务放在等待名单里(等待名单里的顺序也是遵从事务原本的排序),等锁释放掉时再把给锁。我们可以很清晰的发现这么做一是不会有死锁,二是事务的执行顺序是有确定性的,不会有任何随机的因素,每个节点一定会以相同的顺序执行这些事务。这完全避免了由于事务冲突而导致的abort。
 
对于服务节点挂掉,Calvin的做法就是简单的让其挂掉,其他节点的事务该commit便commit,需要abort则abort。当挂掉的节点恢复时,再重新执行一遍所有事务就行。关键点在于,因为所有事务执行都是确定性的,该节点最后的状态能与其他健康的节点保持一致:如果有事务在其他节点上abort了,那么该节点在重新执行时也会abort。反之亦然。
 
通过这些巧妙的招数,Calvin在不需要2PC的情况下也能确保分布式事务的ACID。Calvin不仅在学术上是成功的,工业界也非常成功。Fauna DB是在Calvin基础上写的New SQL,这几年运营的非常成功。

💩 我的一些个人看法

前面讨论到的系统都是非常成功的,这说明他们在不同的取舍中已经做到了最优解,所以单纯从设计的“好”或“坏”去分析已经没有了任何意义,我觉得更有意义的是去分析这些系统会采用不同一致性方法背后的理由。
New SQL在一致性的领域可以粗略的分成两大流派:Spanner和Calvin。首先说说Spanner,我觉得Spanner的出现是一定是最符合直觉的,因为在那之前并发控制上MVCC已经成为主流。理所当然的,在面对一致性的问题时人们就会想着会不会也可以在时间戳上做文章。Spanner应运而生。Spanner是非常成功的商业数据库,影响也十分深远:Spanner的论文被引用了超过2000次,是所有New SQL领域的必修课之一。但Spanner有个致命的问题就是过于依赖于Google数据中心内部的硬件,导致部署十分不灵活,损失了很多在意数据安全的潜在客户(以金融业为主)。这给New SQL的后起之秀留下了机会。
Cockroach DB是目前New SQL的头部厂商之一。它巧妙的用“重试”的办法解决了一致性的问题,这样使其完全不依赖于任何特殊的硬件,部署也变得灵活且方便。但Cockroach DB有个致命的问题:它并不是最强一致性。Calvin的作者之一,Daniel Abadi,在他的博客里大骂Cockroach DB和Yugabyte DB说他们不是真正的一致性数据库,在欺骗用户。我去看了Cockroach DB和Yugabyte DB的一些技术博客,的确发现他们在面对最强一致性问题时遮遮掩掩(我只读了一点点他们的博客,所以这个结论当然不客观)。Yugabyte DB CTO在和Daniel Abadi 争论时基本不正面回答任何关于一致性的质疑,而是顾左右而言其他的大谈特谈数据库的Serializability,完全是两码事。我相信Cockroach DB和Yugatbyte DB的一致性模型对大部分客户来讲已经足够好了,但是没有最强的一致性保证我觉得或多或少的会对销售带来影响,需要花很多精力去和消费者解释到底是咋回事,到底他们的“不严格”的一致性会不会对客户的业务带来影响。
TiDB和Ocean Base则聪明的从根源上避开了问题,直接使用中心化的时间戳服务去提供最强一致性保障,这样就不用像Cockroach DB一样会花心思给客户上“分布式”的课了。并且工程上也不复杂,不会产生各种奇奇怪怪的bug。我想这是TiDB这样做出取舍的主要原因之一?知乎上 TiDB的大佬还挺多的,希望能听听他们的观点。那天听OceanBase的技术负责人Charlie Yang的讲座,他就专门指出了Cockroach DB没有做到一致性。也许TiDB和OceanBase在销售的时候同样会有意无意的要提到他们的友商Cockroach DB和Yugabyte DB缺少一致性,而自己的数据库则能做出最强的正确性保障。
From Charlie Yang的讲座From Charlie Yang的讲座
From Charlie Yang的讲座
Calvin是很标准的学术上极其成功数据库,十分的严谨。但是除了Fauna DB采用了它的架构之外,Calvin在当今的商业数据库上算不上很流行。为什么大部分数据库厂商的系统设计更愿意去模仿Spanner而不是Calvin?我觉得有两点原因:
第一:Spanner作为成功的商业数据库已经证明了它架构的可行性,新的数据库厂商造数据库时不需要再去过多的试错。
第二:在跟投资人和客户讲故事的时候只需要告诉他们”我们和Spanner非常类似,只是我们开源了,部署更加灵活同时还有xxx的优势“,投资人和客户一下就懂了。Spanner是Google搞的,模仿着Google做准没错。
采用Calvin的架构则有可能遇到各种各样的工程问题,并且需要花更多时间告诉客户和投资者他们做的到底是个啥,架构设计有何精彩之处,和Spanner的差异化到底在哪里。我觉得这也是学术数据库落地的困难之处:像造商业数据库这样费时费力费财的活,负责人在技术选型时都会偏向保守,很少会去为了仅仅是学术上成功的技术而冒险。他们最终还是落脚于解决客户需求,肯定优先考虑最有把握的技术和架构。
 
这就是本篇文章的全部内容了。我参考了很多论文和博客,直接引用的地方我都做了外链,间接引用的地方我把链接附在了文章后面。如果我的内容有任何不准确和错误的地方,或者你有什么想法,欢迎来讨论。最后我想说一下,去年我面试了我的梦司Cockroach DB,但流程走到一半HR离职了,我被ghost了三个月,最后被拒了。so
🤬
🖕🖕🖕Cockroach DB🖕🖕🖕

[1] J. Corbett et al., “Spanner: Google’s Globally Distributed Database,” ACM Trans. Comput. Syst, vol. 31, no. 8, 2013, doi: https://doi.org/10.1145/2491245.
[2] Murat Demirbas, M. Leone, Bharadwaj Avva, Deepak Madeppa, and S. Kulkarni, “Logical Physical Clocks and Consistent Snapshots in Globally Distributed Databases,” Jan. 2014. [3] https://zhuanlan.zhihu.com/p/462398795
[4] S. Kimball and I. Sharif, “Living without atomic clocks: Where CockroachDB and Spanner diverge,” Cockroach Labs, Jan. 27, 2022. https://www.cockroachlabs.com/blog/living-without-atomic-clocks/ [5] D. Abadi, “DBMS Musings: NewSQL database systems are failing to guarantee consistency, and I blame Spanner,” DBMS Musings, Sep. 21, 2018. https://dbmsmusings.blogspot.com/2018/09/newsql-database-systems-are-failing-to.html (accessed Mar. 19, 2024). [6] D. Eeden, “TimeStamp Oracle (TSO) in TiDB,” docs.pingcap.com. https://docs.pingcap.com/tidb/stable/tso (accessed Mar. 19, 2024).
[7] Haitao Gao, “TiDB’s Timestamp Oracle - DZone,” dzone.com. https://dzone.com/articles/tidbs-timestamp-oracle (accessed Mar. 19, 2024).
[8] N. VanBenschoten, “Parallel Commits: An atomic commit protocol for globally distributed transactions,” Cockroach Labs, Nov. 07, 2019. https://www.cockroachlabs.com/blog/parallel-commits/ (accessed Mar. 19, 2024).
[9] D. Huang et al., “TiDB: A Raft-based HTAP Database,” Proceedings of the VLDB Endowment, vol. 13, no. 12, pp. 3072–3084, Aug. 2020, doi: https://doi.org/10.14778/3415478.3415535.
[10] A. Thomson and D. J. Abadi, “The case for determinism in database systems,” Proceedings of the VLDB Endowment, vol. 3, no. 1–2, pp. 70–80, Sep. 2010, doi: https://doi.org/10.14778/1920841.1920855.