一致性与隔离性
Linearizability versus Serializability
Linearizability: single-operation, single-object, real-time order
…
Serializability: multi-operation, multi-object, arbitrary total order
——— [Linearizability versus Serializability]
一致性针对的是对单对象的单个操作之间的可见性。序列化(隔离性)指的是的事务(多个对象多个操作)之间的可见性。
如果把一致性与数据库事务结合,则一致性表示的是物理时间上不相交的两个事务的可见性,而物理时间上相交的两个事务的可见性由隔离性决定。
如果同时满足线性一致性和序列化性则称为Strict Serializability。
外部一致性
外部一致性即是在数据库事务之上实现线性一致性,即物理时间上不相交的两个事务之间的可见性。 至于外部一致性是否等于Strict Serializability,我是保持怀疑的,因为数据库可以实现SI的隔离性+线性一致性的,这样有时也称为外部一致性,所以我认为外部一致性更注重一致性部分,而非隔离性部分。
外部一致性的理解可以参考:
https://tyler-zx.blog.youkuaiyun.com/article/details/108915068
对于多种一致性的理解有很多解释,在这里阐述一下我的理解:
- 传统ACID的一致性:数据库从一个一致性的状态转移到另一个一致性的状态。这里的一致性表达的是数据库处于一种符合数据库约束,也符合用户预期的一种状态,其一方面依赖数据库的保证,例如原子性,也依赖于业务特性和业务层代码实现。(这种一致性既适用于单机数据库也适用于分布式数据库)
- 分布式副本一致性:CAP中的一致性和共识算法(raft、poxas)的一致性都可以算作是分布式副本的一致性,其描述的是在数据副本的复制过程中呈现的一致性。
- 分布式事务外部一致性:非 lock base 的事务引擎下,在物理时间上先后发生的事务间(执行区间不重叠,它们的在时间线上的顺序由用户确定,数据库无法感知到他们之间的关系),数据如何相互可见的性质。外部一致性的目标是:假设事务 T1 执行完成后,T2 才启动,保证让 T2 看到 T1 的结果。
- 分布式事务内部一致性:多个并发事务读写的数据(或数据范围),它们能够被数据库感知到冲突(读写之间也算冲突),因此它们在时间线上的顺序由数据库确定,这样的事务间一致性问题可以被认为是内部一致性问题,Ansi Sql Isolation 定义的隔离级别就是定义内部一致性的。
分布式事务的内外一致性描述的是多个事务间的数据的可见性。 “内部” 和 “外部” 最重要的区别是可见顺序由数据库(内部)确定,还是由用户(外部)确定。
我们很容易把分布式副本一致性和分布式事务外部一致性搞混,因为他们的表现非常相像:首先客户端写入一条数据write(x, 4),然后客户端读取数据read(x),但是却返回空。两者虽然表现是一样的,但是产生的原因却完全不同。
- 对于分布式副本一致性,产生这种现象的原因是:客户端写入数据后,数据副本还未复制到全部存储节点就返回客户端成功(弱一致性)。当客户端读取时,正好访问到未成功复制副本的存储节点,返回空。
- 对于分布式事务外部一致性,产生的原因是:客户端写入数据发生在节点A,节点A利用本地时间戳localA_ts作为事务提交的时间戳commit_ts。客户端读取数据时发生在节点B,节点B利用本地时间戳localB_ts,作为事务读取的时间戳snapshot_read_ts。由于多节点的时钟偏移,很可能出现snapshot_read_ts < commit_ts,这样就会误以为读取是在写入之前发生的,从而返回空。
在分布式数据库中,为了避免数据副本不一致的情况,通常只读取leader副本(通过分区避免负载过大),保证读取到最新的数据,数据副本只是用来作为故障备份,所以基本没有分布式副本一致性的问题。但是对于使用MVCC并发控制的分布式数据库,由于多节点的时钟偏移,很可能出现分布式事务外部一致性的问题(对于单机数据库,则不存在事务外部一致性的问题,因为单机的时间戳是严格递增的)。
由于分布式副本一致性、分布事务外部一致性的表现是相像的,所以一些对一致性的描述经常使用混乱,既用于副本一致性也用于分布式事务外部一致性,比如可线性化、顺序一致性、因果一致性、强一致性、弱一致性,最终一致性,有些甚至本用于描述数据库事务ACID的一致性。但是我们只需要明白各种一致性的表现,以及在不同语境下产生原因即可。
分布式事务的时间戳
分布式事务中的时间戳是影响分布式外部一致性的重要因素,同时它也会影响隔离性的表现。数据库通过可以时间戳来判断当前记录的可见性,即MVCC(快照隔离级别)。如果当前记录是已经提交的事务,则其体现的是一致性;如果当前记录是并发执行的事务,则其体现的是隔离性。
分布式节点不可能保证每个机器上的时钟完全同步,所以这为实现外部一致性带来了困难。已知的时间戳方案中,仅有 TSO 和 TrueTime 能够保证线性一致性;
可参考:
Distributed System Clocks分布式系统时钟解决方案
分布式系统中各类时间戳
逻辑时钟和复合逻辑时钟
逻辑时钟/复合逻辑时钟可以保证有关系的事务之间的外部一致性,但是对于独立的事务之间无法保证外部一致性,所以称为因果一致性。
HLC/LC 用在分布式事务中,我们将时间戳附加到所有事务相关的 RPC 中,也就是 Begin、Prepare 和 Commit 这几个消息中:
- Begin:取本地时间戳 local_ts 作为事务读时间戳 snapshot_ts
- Snapshot Read: 用 snapshot_ts 读取其他节点数据(MVCC)
- Prepare:收集所有事务参与者的当前时间戳,记作 prepare_ts
- Commit:计算推高后的本地时间戳,即 commit_ts = max{ prepare_ts } + 1
比如:分布式数据库包括Node1、Node2…多个节点,假设同一个客户端可以连接到不同的Node上进行数据库操作。
- 独立的事务:在Node1上开启事务TA,在Node1写入数据并提交。随后Node2上启动事务TB,TB需要读取Node1上TA写入的数据。因为TB使用Node2上的snapshot_ts去读取Node1上的数据,所以TB不一定能读取到TA写入的数据。
- 有关系的事务:在Node1上开启事务TA,事务TA在Node1、Node2上写入数据并提交,所以Node1需要发消息给Node2。随后Node2开始事务TB读取Node1上TA写入的数据。因为消息传递时,逻辑时钟/复合逻辑时钟进行了更新,所以TB的snapshot_ts肯定小于TA的commit_ts。
综上可知,如果两个事务所涉及的节点有重叠,则事务之间产生了关系(因果),则在消息传递时产生了逻辑时钟/复合逻辑时钟的更新,从而保证了事务之间的因果一致性,即有关系的事务之间的外部一致性。
然而一些在外部世界看起来有因果关系的事情,在数据库中是不会产生因果关系的。比如客户端调用微服务节点 A 负责用户注册,A连接数据库节点1写入数据,之后客户端向微服务节点 B 发送消息,通知节点 B 进行下订单,B连接数据库节点2读取数据,此时 B 却查不到这条用户的记录。
复合逻辑时钟
纯逻辑时钟是一个递增的计数器,与物理时钟毫不相关。而复合逻辑时钟将物理时钟和逻辑时钟拼接,从而使符合逻辑时钟和物理时钟保持在一定误差范围内,且保持了逻辑时钟的特性。其具有以下性质:
- 满足LC的因果一致性happens-before,即若事件e先行发生于f,则有hlc.e < hlc.f。
- 单个时钟O(1)的存储空间(VC是O(n),n为分布式系统下的节点个数)。
- 单个时钟的大小有确定的边界(不会无穷大)。
- 尽可能接近物理时钟PT,也就是说,HLC和PT的差值有个确定的边界。这条规则的好处是,只要两次操作间隔大于这个确定的边界,就可以保证外部一致性,无论是否是当前分布式系统内的。
复合逻辑时钟和物理时钟保持在一定误差范围内,同时多个节点的物理时钟利用NTP校准也保持在一定的误差范围内,从而就可以模仿spanner的TrueTime,使两个事务操作保持一定的时间差,实现多个独立事务之间的外部一致性。
CockroachDB
CockroachDB 实现 Snapshot Read:假定存在假设:当前物理时间pt.e+MaxOffset一定是当前系统的最大时间,发生在pt.e+MaxOffset之后的事务的物理时间一定大于当前事务(根据 NTP 时间同步特性ε ≥ |ptnode1 – ptnode2|得出该假设成立,ε代表集群中最大时钟漂移,也就是MaxOffset),CockroachDB 启动事务e,根据 HLC 的特性ε ≥ |pt.e – l.e| ,可得推论:任意时刻当前集群的整体物理时间不可能超过hlc.e+MaxOffset。那么当 CockroachDB 执行 Snapshot Read 的时候有
- 若事务g满足hlc.e + MaxOffset < hlc.g。根据 HLC 算法的特性 2(对任一事件e,一定有l.e ≥ pt.e),可得出:pt.e < pt.g,e hb g,事务e发生在g之前。
- 若事务g满足hlc.e < hlc.g <= hlc.e + MaxOffset,那么此时事务e陷入一个叫 Uncertain Read 的状态,意思是不确定事务g的物理时间pt.g一定大于 pt.e。例如:(1)hlc.e=(10,10,2),hlc.g=(11,11,0),假设MaxOffset=5,此时hlc.g > hlc.e,但是pt.g > pt.e。(2)hlc.e=(10,10,2),hlc.g=(9,11,0),假设MaxOffset=5,此时hlc.g > hlc.e,但是pt.g < pt.e。在这种两种情况下 CockroachDB 无法获得一致的 Snapshot,因此当前事务e必须 Restart 并等待时间足够长之后,获取一个新的时间戳 hlc.g +1 重新执行。
- 若事务g满足hlc.g < hlc.e, 那么根据 HLC 算法的 Snapshot Read,事务e可以直接执行 Snapshot Read。注意hlc.g < hlc.e并不代表事务g提交在事务e启动之前,这有可能造成不可重复读或者幻读吗?不会的,因为不可重复读或者幻读至少需要两次读取操作。事务e第一次读取时,由于存在消息传递,所以事务g所在节点的时钟被更新,所以事务g的commit_ts一定会大于事务e的snapshot_ts,所以事务e第二次读取时不会看到事务g提交写入的数据。事务g不提交,则其写入的数据没有commit_ts,也是无法读取的。
所以复合逻辑时钟既保持了有关系的事务间的外部一致性,也可以保证独立事务之间的外部一致性,只要两个事务操作保持一定的时间差。
但是CockroachDB并没有达到可线性化
TrueTime
关于Spanner中的TrueTime和Linearizability
个人理解:
Spanner的TrueTime是利用GPS时钟和原子钟实现的, 可以提供非常精确的时钟, 使得每台机器上的本地时间戳和absolute time误差在ε之间。
TrueTime API TT.now()返回一个区间[earlist, latest], 保证absolute time是在区间以内,即earliest ≤ absolute time ≤ latest。所以latest - earlist = 2ε,(earlist, + latest) / 2为本地时间戳。
当A事务进行提交时,选择TT.now().latest作为事务提交时间戳commitA_ts,并且等待2ε时间后再进行提交。这样做的原因是让提交时的absolute time大于commitA_ts。那么当另一个节点的B事务在absolute time之后再开启,那么其提交时选定的事务提交时间戳commitB_ts必定是大于absolute time的。
为什么要求B事务一定要在A事务提交之后再开启呢?因为B事务如果在A事务提交之前开启,那么B事务开启时的absolute time可能小于commitA_ts,那么B事务选定的commitB_ts可能大于absolute time 且小于commitA_ts。
TrueTime的面向的对象是时间上没有重叠的两个事务,先提交的事务的时间戳,必定小于后提交事务的时间戳。这保证了连续写事务的线性一致性。
当写事务A提交之后,absolute time > commitA_ts,则此时开启一个读事务,将TT.now().latest作为其snapshot_read_ts,则可以保证snapshot_read_ts > absolute time > commitA_ts,即读取到写事务的数据。但是spanner里面还做了一些其他的优化,细节可看论文。