【读书笔记】设计数据密集型应用-第二部分
分布式数据,replication and partition
- Replication: keeping a copy of the same data on sevral differrent nodes, or in different locations
- Partition: splittion a big databse into smaller subsets called partitions so that different partitions can be assigned to different nodes(called sharding)
第五章-复制
复制的作用:
- 数据物理上靠近用户
- 允许部分节点故障而系统正常,提高可靠性
- 水平扩展机器数可提高吞吐量
Leaders and Followers(单Leader)
如何确保所有节点都获得数据?使用leader-based replication方案
- 选择其中一个副本作为主节点,客户端的写请求必须通过leader
- 其他副本作为从节点,leader收到请求并发送relication log给followers。followers获取到后再写数据
- 读请求可通过任何节点读取
MySQL、PostgreSQL,Kafka等均通过该方案实现
同步还是异步复制
同步复制:主节点等待从节点写入数据完成
异步复制:主节点不等待从节点写入数据完成,而直接返回
优缺点:
- 同步优点:能确保从节点的数据与主节点保持一致性
- 同步缺点:主从节点之间由于各种原因,导致写操作阻塞。从而阻塞后续的写请求
通常,主节点与一个从节点保持同步,与其他保持异步。而实际中,完全异步的架构也是很普遍的,这可能会导致数据丢失,可通过共识解决
设置新的Followers
简单地拷贝数据是不可行的,因为数据是在不断变化的。也许能锁磁盘来解决,但是显然会影响性能。
- 主节点创建自身快照
- 复制快照到从节点
- 新的从节点请求从快照创建时间点之后的数据并写入
- follower处理完back log之后,我们为caught up
处理节点宕机
Follower failure:Catch-up recovery
根据log,节点知道失败前的最后一个事务的具体时间点,follower恢复后只需向主节点请求改时间点之后的数据即可。
Leader failure: Failure(故障切换)
自动流程:
-
确认leader失败。很简单,超时-心跳机制
-
选择个一个新leader。通过其他节点或者控制器选举leader,通常来说是拥有数据最新的(减少数据丢失)
-
使用leader重新配置整个系统。系统需要保证原leader只能变成follower并认同新leader
潜在问题:
- 原leader拥有未同步的数据,重新加入集群后,跟新leader的数据会有冲突。通过丢去未同步的数据处理,持久性未满足
- 抛弃数据造成灾难,尤其是与其他系统协作时
- 某些情况下,两个节点认为自身为leader,我们成为split brain(脑裂)
- 判定leader失败的超时时间多少合适?网络、负载等等原因都会影响心跳时间
Implementation of Replication Logs
介绍Leader-based replication 是如何实现的
statement-based replication
leader记录每条写请求(statement,语句),并发送给followers。听起来很简单,但是缺点也多
- 任何非确定性的语句都会有问题,例如NOW()函数
- 语句如果存在顺序关系,例如
UPDATE
操作,也会存在问题(除非保证顺序) - 语句有副作用,例如触发器,存储过程等
Write-ahead log(WAL) 传输
诸如基于log-structure以及B-Tree的数据库,都会有有一个append-only log。
leader将数据的log发送到各个followers,follower再通过它建立一个与leader一样的copy。广泛应用PostgreSQL等
缺点:
- log记录的数据过于底层,例如包含哪些磁盘中的哪些字节改变。
- 数据库版本升级时,版本之间并不兼容,需要停机升级
Logical(row-based)log replication
替代底层log,而使用逻辑上的log来表示,其通常是一系列的记录来代表写请求。例如插入,log则包含所有列的新值。
由于其与底层存储解耦了,因此易于兼容不同版本,且能被外部系统解析使用。应用于MySQL中的binlog等
Trigger-based replication
上述均为数据库底层实现,若需要更高的灵活性,则可使用trigger-based的方式。它让你注册一些用户代码,在数据变化时自动触发执行
Porblems with Replication Lag
relication lag:the delay between a write happening on the leader and being reflected on a follower(leader与follower之间的数据延迟)
通常来说,followers的数据经常会落后于leader,这种状态通常很短。但是一旦lag过长,则会出大问题。如何解决?
Reading Your Owen Writes
用户修改了,再次查询时可能还是老的。
使用read-after-write consistency 、或称为read-your-writes consistency
来保证一致性,即保证用户再次查询时能查到其更新的,这对其他用户不保证。
方案:
- 当获取某些刚修改的数据时,只通过leader查询
- 常见情况下,可对数据标记,例如更新时间,若更新时间小于1分钟,则从leader中读取
- 客户端记住最近写的时间戳,然后判读读取
单调读
由于同步时间的不确定性,用户可能发现修改了的数据又复原了,出现时光倒流现象。
monotonic reads,使用单调读技术来确保这种情况不会发生。
方案:确保每个用户总是从单个副本中读取
Consistent Prefix Reads
本来带有顺序含义的语句,由于复制延迟,第三方观测下顺序异常导致的混乱。例如,聊天的对话顺序
使用consistent prefix reads(一致性前缀读)来避免。
方案:
- 保证任何因果关系的写入都写入到同一个partition。
- 显式跟踪因果依赖关系的算法。例如happens-before技术
Solution for Replication Lag
使用最终一致性以及分布式事务等
Multi-Leader Replication(多主复制)
单Leader缺点:所有写入必须经过它。
多Leader缺点:同一数据可能会在不同数据中心被同时处理,需要解决因此带来的冲突。
多leader下,每个leader都担任其他leader的follower
应用场景
-
性能:多数据中心下,写请求被本地数据中心处理,随后与其他数据中心异步复制
-
可用性:能有效容忍某个数据中心挂掉
-
网络性能:数据中心间的网络往往很拥堵且不稳定,单leader无法应对,而多leader至少本地能处理用户请求
-
客户端离线操作:客户端(例如手机设备等)本机类比作为一个datacenter
-
协同编辑:多人协同编辑应用,每个设备都类比作为一个datacenter
处理写冲突
避免写冲突
同一地区或者同一一批设备的写请求都通过同一leader写入。例如通过hash路由的方式等
收敛到一致的状态
通过一些方法将不同状态的数据收敛到同一状态。
方案:
- 使用LWW(last write wins)技术,即每次写入都绑定一个唯一ID(UUID,时间戳等),选择最大的ID并丢弃其他
- 给副本绑定ID,选择ID大的副本里的数据
- 通过某种方式合并,例如字母顺序最大者
- 或者用数据结构保留冲突,提示用户自己解决
自定义冲突解决
允许用户编写冲突解决逻辑,并在读、写操作是执行。
- 写时:只要检测到冲突即会执行
- 读时:检测到冲突,会保存所有冲突。用户读取时,提示用户解决,并回写结果。
拓扑结构
- 环形:节点收到来自前一个节点的写请求并传递给下一个节点
- 星形:一个指定的节点传递给掐所有节点
- All-to-all:
- 优点:能避免环形以及星形的单点故障影响其他节点的问题
- 缺点:节点间网络的速率不同,一些复制消息将超过其他消息
Leaderless Replication(无主复制)
无主复制中,写请求通常是直接发送到所有副本中,或者通过coordinator代表客户端写入。
单节点故障时写入
无主复制中,故障切换是不存在的。节点故障时,只要剩余节点能承认写入即可。
当原节点恢复,将会存在数据不一致的现象,如何解决?
客户端接收所有副本的返回数据,并选择最新的。通常使用Version numbers来确定
Read pair and anti-entropy
Read repair
客户端读取并行所有节点的数据,能检测到stale responses,并回写新数据到老节点
Anti-entropy process
后台进程不断地检测不同节点检测数据差异,并补齐。
Quorums(法定人数) for reading and writing
定义:只要w + r > n
,则客户端总能获得最新的数据。遵守该规则的r与w称为quorums read and write
n: 副本数;r: 读请求时,至少得有r个节点能返回数据;w: 写请求至少要被w个节点确认
Quorums的局限性
- 如果两个写入同时发生,不清楚哪一个先发生。
- 如果写操作在某些副本上成功,而在其他节点上失败(例如,因为某些节点上的磁盘已满),在小于w个副本上写入成功
- 如果携带新值的节点失败,需要读取其他带有旧值的副本。
Dynamo风格可以忍受最终一致性,但是无法绝对保证。更强有力的保证通常需要事务或共识
监控staleness(陈旧度)
监视你的数据库是否返回最新的结果是很重要的,如果显著落后,应该提醒您,以便您可以调查原因(例如,网络中的问题或超载节点)。
Sloppy Quorums and Hinted Handoff
写和读仍然需要w和r成功的响应, 只不过把把一些请求暂时寄宿在可达节点上,等不可达节点恢复后再传递到目标节点上
多数据中心
自然支持,quorums只需在一个数据中心满足即可。
检测并发写
Dynamo-stype允许客户端并发写同一key。存在事件以不同顺序到达不同的节点。
如果每个节点只是简单地覆盖对应key的值,则会出现不一致现象。因此需要解决冲突
Last wirte wins(discharding current wirtes)
保存"recent"值,并允许“older”值被覆盖或丢弃。使用一些标识来区分"rencent"以及"older"
The "happens-before" relationship and concurrency
只要有两个操作A和B,就有三种可能性:A在B之前发生,或者B在A之前发生(因果关系),或者A和B并发。
如果一个操作发生在另一个操作之前,则后面的操作应该覆盖较早的操作,但是如果这些操作是并发的,则存在需要解决的冲突。
捕获"happens-before"关系
合并同时写入的值
如果多个操作并发发生,则客户端必须通过合并并发写入的值来擦屁股。
版本向量
使用单个版本号(上图)来捕获操作之间的依赖关系,但是当多个副本并发接受写入时,这是不够的。
所有副本的版本号集合称为版本向量(version vector)
总结
单主复制是非常流行的,因为它很容易理解,不需要担心冲突解决。在出现故障节点,网络中断和延迟峰值的情况下,多领导者和无领导者复制可以更加稳健,但以更难以推理并仅提供非常弱的一致性保证为代价。
多领导者和无领导者复制方法所固有的并发问题:因为他们允许多个写入并发发生冲突。我们研究了一个数据库可能使用的算法来确定一个操作是否发生在另一个操作之前,或者它们是否同时发生。
第六章-分区
复制提高了数据容错性。
但是当数据量大,且查询吞吐量大时,我就必须把数据拆开并分布到不同分区上(partitions or sharding)。这体现了可扩展性
分区与复制
分区与复制往往是同时存在的,同一个分区有多个副本,多个副本归属不同分区
Key-Value 类数据的分区
偏斜(skewed): 分区不公平,导致部分分区拥有较多的数据和查询压力
热点(hopt spot): 由于分区不公平,部分节点承受了过高的负载
如何避免不公平的现象呢?
通过Key Range分区
将连续的keys划分成多个段,并分配个每个分区。如下图的单词分区
为了避免skewed,需要针对数据实际分布来分区。例如单词始子母,书籍1只有A,B,而书籍12却有TUVWXYZ
优点:可以让keys保持排序,这样范围查询将变得很容易
缺点:特定的访问模式容易导致热点现象。
比如时间戳作为key,按天分区,则当天的时序数据都会往同一分区写,造成热点。此时你可以选择数据源作为key
通过Hash of Key 分区
通过给定key的哈希值来决定分区。已知哈希值的范围后,再按分数区划分范围来指定分区。
有点:分区公平
缺点:范围查询困难
折中方案:使用compound primary key(组合主键),即将多个列组合成一个key。其中key中的第一部分取用hash后分区,剩余部分做为排序所用的连接索引
负载倾斜与消除热点
尽管hash的方法能帮助减少热点,但并不能完全避免。比如微博明星出轨现象,对同一key的大量请求,同样会造成热点现象。
这只能应用程序自己处理,例如在key前面或者后面append上随机数字。当然有利必有弊,这将导致其他低吞吐量的key产生不必要的性能损耗
分区以及二级索引
基于文档分区二级索引
有利于写,不利于读
每个分区维护自身的二级索引,并覆盖自身分区内的所有文档数据。因此又称,本地索引(local index)
缺点:
- 索引往往会分布在多个分区里。在读取时,会造成分散/聚集(scatter/gather)现象,读取二级索引较为低效。
基于关键词(Term)分区二级索引
有利于读,不利于写
关键词分区(term-partitioned),因为我们寻找的关键词决定了索引的分区方式。是一种全局的分区方式,又称全局索引
优点:使读取更有效率:不需要分散/收集所有分区,客户端只需要向包含关键词的分区发出请求
缺点:写入速度较慢且较为复杂,因为写入单个文档现在可能会影响索引的多个分区。而且,通常更新全局二级索引都是异步的,你写入后查询并不能立即生效
分区再平衡
定义:更改都需要数据和请求从一个节点移动到另一个节点。 将负载从集群中的一个节点向另一个节点移动的过程,称为称为再平衡(reblancing)
最低要求:
- 再平衡之后,负载(数据存储,读取和写入请求)应该在集群中的节点之间公平地共享。
- 再平衡发生时,数据库应该继续接受读取和写入。
- 节点之间只移动必须的数据,以便快速再平衡,并减少网络和磁盘I/O负载。
平衡策略
反面教材:hash mod N
为什么我们不使用mod(许多编程语言中的%运算符)?
因为,如果节点数量N发生变化,大多数密钥将需要从一个节点移动到另一个节点,这过于昂贵了
固定数量的分区
创建比节点更多的分区,并为每个节点分配多个分区
如果一个节点被添加到集群中,新节点可以从当前每个节点中窃取一些分区,直到分区再次公平分配。
优点:简单
缺点: 如果数据集的总大小难以预估,选择正确的分区数是困难的。
如果分区非常大,再平衡和从节点故障恢复变得昂贵。但是,如果分区太小,则会产生太多的开销。
动态分区
当分区增长到超过配置的大小时,会被分成两个分区,每个分区约占一半的数据。与之相反,如果大量数据被删除并且分区缩小到某个阈值以下,则可以将其与相邻分区合并。此过程与B树底层发生的过程类似
注意,数据集开始时很小,直到达到第一个分区的分割点,所有写入操作都必须由单个节点处理,而其他节点则处于空闲状态。使用预分割(pre-splitting)避免
优点:分区数量适应总数据量
按节点比例分区
使分区数与节点数成正比,即每个节点具有固定数量的分区。
当一个新节点加入集群时,它随机选择固定数量的现有分区进行拆分,然后获取每个分区的一半数据,另外一半保留。
随机化可能会产生不公平的分区,可以通过增大分区数来平均化(Cassandra每个节点默认就256个分区)
运维:手动还是自动平衡
全自动重新平衡可以很方便,但是自动化与自动故障检测相结合可能十分危险。例如误判过载的节点已经死亡
请求路由
当客户想要发出请求时,如何知道要连接哪个节点?使用服务发现(service discovery) 技术
挑战:无论如何,所有参与者都同意路由规则,否则请求将被发送到错误的节点。
方案一:
许多分布式数据系统都依赖于一个独立的协调服务,比如ZooKeeper来跟踪集群元数据。
- 每个节点在ZooKeeper中注册自己,ZooKeeper维护分区到节点的可靠映射
- 只要分区分配发生的改变,或者集群中添加或删除了一个节点,ZooKeeper就会通知路由层使路由信息保持最新状态。
方案二:
在节点之间使用流言协议(gossip protocol) 来传播群集状态的变化。
- 请求可以发送到任意节点,该节点会转发到包含所请求的分区的适当节点。如上图中的方法1
执行并行查询
大规模并行处理(MPP, Massively parallel processing),MPP查询优化器将这个复杂的查询分解成许多执行阶段和分区,其中许多可以在数据库集群的不同节点上并行执行
第七章-事务
事务是应用程序将多个读写操作组合成一个逻辑单元的一种方式,整个事务要么成功(提交(commit))要么失败(中止(abort),回滚(rollback))。如果失败,应用程序可以安全地重试。
提供一层抽象,给应用程序一种并发、软硬件问题均不会存在的假象。
ACID的含义
原子性
一般来说,原子是指不能分解成小部分的东西。
在多线程编程中,如果一个线程执行一个原子操作,这意味着另一个线程无法看到该操作的一半结果。
ACID的原子性中,描述了当客户想进行多次写入,在一些写操作处理完之后出现故障时,能够在错误时中止事务,丢弃该事务进行的所有写入变更的能力
一致性
ACID的上下文中,一致性是指数据库在应用程序的特定概念中处于“良好状态”
you have certain statements about your data that must always be true, 比如在会计系统中,所有账户整体上必须借贷相抵
原子性,隔离性和持久性是数据库的属性,而一致性(在ACID意义上)是应用程序的属性
隔离性
同时执行的事务是相互隔离的:它们不能相互冒犯
持久性
持久性 是一个承诺,即一旦事务成功完成,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失。
单对象和多对象操作
多对象事务需要某种方式来确定哪些读写操作属于同一个事务,在关系型数据库中,通常基于客户端与数据库服务器的TCP连接:在任何特定连接上,BEGIN TRANSACTION
和 COMMIT
语句之间的所有内容,被认为是同一事务的一部分。
单对象写入
对单节点上的单个对象(例如键值对)上提供原子性和隔离性
多对象事务的需求
场景:
- 在关系数据模型中,一个表中的行通常具有对另一个表中的行的外键引用
- 当需要更新非规范化的信息时,需要一次更新多个文档
- 每次更改值时都需要更新索引
针对多对象,若没有原子性,错误处理就要复杂得多,若缺乏隔离性,就会导致并发问题
处理错误和中止
事务的一个关键特性是,如果发生错误,它可以中止并安全地重试。如若未被ACID,则会终止整个事务,而不是保留半完成的状态
然而,有些数据库则选择,做尽可能多的事,运行遇到错误时,它不会撤消它已经完成的事情,此时,从错误中恢复就是应用程序的责任了。
中止重试的潜在问题:
- 如果事务实际上成功了,但是在服务器试图向客户端确认提交成功时网络发生故障,那么重试事务会导致事务被执行两次
- 重试事务将使负载过大变得更糟
- 仅在临时性错误(例如,由于死锁,异常情况,临时性网络中断和故障切换)后才值得重试。在发生永久性错误(例如,违反约束)之后重试是毫无意义的。
- 如果事务在数据库之外也有副作用,即使事务被中止,也可能发生这些副作用
- 如果客户端进程在重试中失效,任何试图写入数据库的数据都将丢失。
弱隔离级别
当一个事务读取由另一个事务同时修改的数据时,或者当两个事务试图同时修改相同的数据时,并发问题(竞争条件)才会出现。而且并发BUG很难通过测试找到。
因此,数据库提供了事务隔离(transaction isolation) 来隐藏应用程序开发者的并发问题。
可序列化(serializable) 的隔离等级意味着数据库保证事务的效果如同连续运行。
然而,为了性能,系统通常使用较弱的隔离级别来防止一部分,而不是全部的并发问题
读已提交(Read Commited)
保证:
- 从数据库读时,只能看到已提交的数据(没有脏读( no dirty reads))
- 写入数据库时,只会覆盖已经写入的数据(没有脏写(no dirty writes))。
No dirty reads
脏读(dirty reads): 假定一个事务已经将一些数据写入数据库,但事务还没有提交或中止。另一个事务可以看到未提交的数据吗?如果是的话,那就叫做脏读
避免脏读的原因:
- 如果事务需要更新多个对象,脏读取意味着另一个事务可能会只看到一部分更新
- 如果事务中止,则所有写入操作都需要回滚,若数据库允许脏读,就意味着一个事务可能会看到稍后需要回滚的数据,即从未实际提交给数据库的数据。
No dirty writes
如果两个事务同时尝试更新数据库中的相同对象,会发生什么情况?通常认为后面的写入会覆盖前面的写入。如果先前的写入是尚未提交事务的一部分,又会发生什么情况,后面的写入会覆盖一个尚未提交的值?这被称作脏写。
在读已提交的隔离级别上运行的事务必须防止脏写,通常是延迟第二次写入,直到第一次写入事务提交或中止为止。
避免脏写的原因:
- 如果事务更新多个对象,脏写会导致不好的结果,比如,来自不同事务的冲突写入可能会混淆在一起。
- Read commited并不能防止两个计数器增量之间的竞争状态。
实现
防止脏写:
使用行锁(row-level lock),当事务想要修改特定对象(row or ducument)时,它必须首先获得该对象的锁。然后必须持有该锁直到事务被提交或中止。一次只有一个事务可持有任何给定对象的锁;如果另一个事务要写入同一个对象,则必须等到第一个事务提交或中止后,才能获取该锁并继续。
防止脏读:
也可以用锁,不过有性能问题,不采用。因此,通常对于写入的每个对象,数据库都会记住旧的已提交值,和由当前持有写入锁的事务设置的新值。当事务正在进行时,任何其他读取对象的事务都会拿到旧值。 只有当新值提交后,事务才会切换到读取新值。
快照隔离和可重复读
这种异常被称为不可重复读(nonrepeatable read)*或*读取偏差(read skew)。
对于read commited来说,这种情况是可容忍的,然而以下情况则不可
- 备份,备份进程运行时,数据库仍然会接受写入操作。因此备份可能会包含一些旧的部分和一些新的部分。
- 分析查询和完整性检查,一个查询,扫描大部分的数据库,查询在不同时间点观察数据库的不同部分,则可能会返回毫无意义的结果
使用快照隔离(snapshot isolation),每个事务都从数据库的一致快照(consistent snapshot) 中读取。也就是说,事务可以看到事务开始时在数据库中提交的所有数据。即使这些数据随后被另一个事务更改,每个事务也只能看到该特定时间点的旧数据。
实现
也通常使用写锁来防止脏写,这意味着进行写入的事务会阻止另一个事务修改同一个对象。但是读取不需要任何锁定。性能角度,读不阻塞写,写不阻塞读
数据库必须可能保留一个对象的几个不同的提交版本,因为各种正在进行的事务可能需要看到数据库在不同的时间点的状态。因为它并排维护着多个版本的对象,所以这种技术被称为多版本并发控制(MVCC, multi-version concurrentcy control)。
表中的每一行都有一个 created_by
字段,其中包含将该行插入到表中的的事务ID。
每行都有一个 deleted_by
字段,删除时,将 deleted_by
字段设置为请求删除的事务的ID来标记为删除。其中,update转换为一个delete和create
一致性快照的可见性规则
当一个事务从数据库中读取时,事务ID用于决定它可以看见哪些对象,看不见哪些对象。工作流程:
- 在每次事务开始时,数据库列出当时所有其他在运行(尚未提交或尚未中止)的事务清单,即使在它之后提交了,这些事务已执行的任何写入也都会被忽略。
- 被已中止的事务所执行的任何写入都将被忽略。
- 由具有较晚事务ID(即,在当前事务开始之后开始的)的事务所做的任何写入都被忽略,而不管这些事务是否已经提交。
- 所有其他写入,对应用都是可见的。
一个对象可见的条件:
- 读事务开始时,创建该对象的事务已经提交。
- 对象未被标记为删除,或如果被标记为删除,请求删除的事务在读事务开始时尚未提交。
索引和快照隔离
索引如何在多版本数据库中工作?一种选择是使索引简单地指向对象的所有版本,并且需要索引查询来过滤掉当前事务不可见的任何对象版本
可重复读与命名混淆
快照隔离是一个有用的隔离级别,特别对于只读事务而言。但是,许多数据库实现了它,却用不同的名字来称呼。在Oracle中称为可序列化(Serializable)*的,在PostgreSQL和MySQL中称为*可重复读(repeatable read)
防止丢失更新
如果应用从数据库中读取一些值,修改它并写回修改的值(读取-修改-写入序列,read-modify-write-cycle),则可能会发生丢失更新的问题。例如,如果两个事务同时执行,则其中一个的修改可能会丢失,因为第二个写入的内容并没有包括第一个事务的修改。
场景:
- 增加计数器或更新账户余额(需要读取当前值,计算新值并写回更新后的值)
- 在复杂值中进行本地修改:例如,将元素添加到JSON文档中的一个列表(需要解析文档,进行更改并写回修改的文档)
- 两个用户同时编辑wiki页面,每个用户通过将整个页面内容发送到服务器来保存其更改,覆写数据库中当前的任何内容。
原子写
使用原子操作,通常是最好的解决方案。
方法一,原子操作通常通过在读取对象时,获取其上的排它锁来实现,以便更新完成之前没有其他事务可以读取它。
方法二,强制所有的原子操作在单一线程上执行
显式锁定
让应用程序显式地锁定将要更新的对象。应用程序可以执行读取-修改-写入序列,如果任何其他事务尝试同时读取同一个对象,则强制等待,直到第一个读取-修改-写入序列完成
自动检测丢失的更新
允许它们并行执行,如果事务管理器检测到丢失更新,则中止事务并强制它们重试其读取-修改-写入序列
优点:数据库可以结合快照隔离高效地执行此检查;它不需要应用代码使用任何特殊的数据库功能
比较并设置(CAS)
比较并设置(CAS, Compare And Set),只有当前值从上次读取时一直未改变,才允许更新发生
冲突解决和复制
最后写入胜利(LWW)的冲突解决方法很容易丢失更新,不幸的是,LWW是许多复制数据库中的默认方案。
写入偏差与幻读
写偏差(write skew)
如果两个事务读取相同的对象,然后更新其中一些对象(不同的事务可能更新不同的对象),则可能发生写入偏差。
例子:
-
会议室预订系统,当有人想要预订时,首先检查是否存在相互冲突的预订(即预订时间范围重叠的同一房间),如果没有找到,则创建会议。快照隔离并不能防止另一个用户同时插入冲突的会议。
-
多人游戏,玩家将两个不同的棋子移动到棋盘上的相同位置,或者采取其他违反游戏规则的行为。
-
抢注用户名,在每个用户拥有唯一用户名的网站上,两个用户可能会尝试同时创建具有相同用户名的帐户。
-
防止双重开支,允许用户花钱或积分的服务,需要检查用户的支付数额不超过其余额。有了写入偏差,可能会发生两个支出项目同时插入,一起导致余额变为负值,但这两个事务都不会注意到另一个。
导致写入偏差的幻读
上述例子,基本遵循规律
- 一个
SELECT
查询找出符合条件的行,并检查是否符合一些要求。 - 按照第一个查询的结果,应用代码决定是否继续。(可能会继续操作,也可能中止并报错)
- 如果应用决定继续操作,就执行写入(插入、更新或删除),并提交事务。
一个事务中的写入改变另一个事务的搜索查询的结果,被称为幻读(快照隔离避免了只读查询中幻读)
物化冲突
将幻读变为数据库中一组具体行上的锁冲突
Serializability(可序列化)
能有效解决写入偏差,幻读等问题。
可序列化(Serializability )隔离通常被认为是最强的隔离级别。它保证即使事务可以并行执行,最终的结果也是一样的,就好像它们没有任何并发性,连续挨个执行一样。
因此数据库保证,如果事务在单独运行时正常运行,则它们在并发运行时继续保持正确 —— 换句话说,数据库可以防止所有可能的竞争条件。
如何实现?
真*串行执行
最简之法,完全不要并发:在单个线程上按顺序一次只执行一个事务。
这样做就完全绕开了检测/防止事务间冲突的问题,由此产生的隔离,正是可序列化的定义。
能实际运用的原因:
- RAM足够便宜了,许多场景现在都可以将完整的活跃数据集保存在内存中
- 数据库设计人员意识到OLTP事务通常很短,而且只进行少量的读写操作。相比之下,长时间的OLAP,通常是只读的
在存储过程中封装事务
即使用存储过程,将各个阶段的操作合并为单个事务
串行执行小结
- 每个事务都必须小而快,只要有一个缓慢的事务,就会拖慢所有事务处理。
- 仅限于活跃数据集可以放入内存的情况
- 写入吞吐量必须低到能在单个CPU核上处理,如若不然,事务需要能划分至单个分区,且不需要跨分区协调。
- 跨分区事务是可能的,但是它们的使用程度有很大的限制。
两阶段锁定(2PL)
只要没有写入,就允许多个事务同时读取同一个对象。但对象只要有写入(修改或删除),就需要独占访问(exclusive access) 权限:
- 如果事务A读取了一个对象,并且事务B想要写入该对象,那么B必须等到A提交或中止才能继续。(这确保B不能在A底下意外地改变对象。)
- 如果事务A写入了一个对象,并且事务B想要读取该对象,则B必须等到A提交或中止才能继续。(读取旧版本的对象在2PL下是不可接受的。)
实现
为数据库中每个对象添加锁来实现的。锁可以处于共享模式(shared mode)*或*独占模式(exclusive mode
- 若事务要读取对象,则须先以共享模式获取锁。允许多个事务同时持有共享锁。但如果另一个事务已经在对象上持有排它锁,则这些事务必须等待。
- 若事务要写入一个对象,它必须首先以独占模式获取该锁。没有其他事务可以同时持有锁(无论是共享模式还是独占模式),所以如果对象上存在任何锁,该事务必须等待。
- 如果事务先读取再写入对象,则它可能会将其共享锁升级为独占锁。升级锁的工作与直接获得排他锁相同。
- 事务获得锁之后,必须继续持有锁直到事务结束(提交或中止)。这就是“两阶段”这个名字的来源:第一阶段(当事务正在执行时)获取锁,第二阶段(在事务结束时)释放所有的锁。
死锁发生:事务A等待事务B释放它的锁,反之亦然。数据库会自动检测,并中止其中一个。
性能
一部分是由于获取和释放所有这些锁的开销,但更重要的是由于并发性的降低。
只需要一个缓慢的事务,或者一个访问大量数据并获取许多锁的事务,就能把系统的其他部分拖慢。
当事务由于死锁而被中止并被重试时,它需要从头重做它的工作。如果死锁很频繁,这可能意味着巨大的浪费。
谓词锁
它类似于前面描述的共享/排它锁,但不属于特定的对象(例如,表中的一行),它属于所有符合某些搜索条件的对象
索引范围锁
如果活跃事务持有很多锁,检查匹配的锁会非常耗时。因此,大多数使用2PL的数据库实际上实现了索引范围锁(也称为**间隙锁(next-key locking **),这是一个简化的近似版谓词锁
在房间预订数据库中,您可能会在room_id
列上有一个索引,并且/或者在start_time
和 end_time
上有索引(否则前面的查询在大型数据库上的速度会非常慢)
- 设您的索引位于
room_id
上,并且数据库使用此索引查找123号房间的现有预订。现在数据库可以简单地将共享锁附加到这个索引项上,指示事务已搜索123号房间用于预订。 - 或者,如果数据库使用基于时间的索引来查找现有预订,那么它可以将共享锁附加到该索引中的一系列值,指示事务已经将12:00~13:00时间段标记为用于预定。
现在,如果另一个事务想要插入,更新或删除同一个房间和/或重叠时间段的预订,则它将不得不更新索引的相同部分,此时,它会遇到共享锁,它将被迫等到锁被释放。
序列化快照隔离(SSI)
很有前途
悲观与乐观的并发控制
两阶段锁(2PL)是一种所谓的悲观并发控制机制。意味着,如果有事情可能出错(如另一个事务所持有的锁所表示的),最好等到情况安全后再做任何事情。这就像互斥,用于保护多线程编程中的数据结构。
序列化快照隔离是一种乐观(optimistic) 的并发控制技术。意味着,如果存在潜在的危险也不阻止事务,而是继续执行事务,希望一切都会好起来。当一个事务想要提交时,数据库检查是否有什么不好的事情发生(比如,隔离是否被违反);如果是的话,事务将被中止,并且必须重试。
SSI基于快照隔离,事务中的所有读取都是来自数据库的一致性快照。SSI添加了一种算法来检测写入之间的序列化冲突,并确定要中止哪些事务。
基于过时前提的决策
事务从数据库读取一些数据,检查查询的结果,并根据它看到的结果决定采取一些操作(写入数据库)。但是,在快照隔离的情况下,原始查询的结果在事务提交时可能不再是最新的,因为数据可能在同一时间被修改。
事务基于一个前提(premise) 采取行动(事务开始时候的事实,例如:“目前有两名医生正在值班”)
检测旧MVCC读取
数据库需要跟踪一个事务由于MVCC可见性规则而忽略另一个事务的写入。当事务想要提交时,数据库检查是否有任何被忽略的写入现在已经被提交。如果是这样,事务必须中止。
检测影响之前读取的写入
第二种情况要考虑的是另一个事务在读取数据之后修改数据。
如果在shift_id
上有索引,则数据库可以使用索引项1234 来记录事务42 和43 读取这个数据的事实。
当事务写入数据库时,它必须在索引中查找最近曾读取受影响数据的其他事务。并通知其他事务:你们读过的数据可能不是最新的啦。
可序列化的快照隔离的性能
有很多细节影响。例如,跟踪事务的读取和写入的粒度,粒度越细,准确度越高,但性能损耗也越高。事务可以读取被另一个事务覆盖的信息。
与两阶段锁定相比,可序列化快照隔离的最大优点是一个事务不需要阻塞等待另一个事务所持有的锁。
中止率显著影响SSI的整体表现,对于慢事务,SSI可能比两阶段锁定或串行执行更不敏感。
总结
- 脏读: 一个客户端读取到另一个客户端尚未提交的写入。读已提交或更强的隔离级别可以防止脏读。
- 脏写: 一个客户端覆盖写入了另; 一个客户端尚未提交的写入。几乎所有的事务实现都可以防止脏写。
- 读取偏差(不可重复读): 在同一个事务中,客户端在不同的时间点会看见数据库的不同状态。快照隔离,通常使用多版本并发控制(MVCC) 来实现
- 更新丢失: 两个客户端同时执行读取-修改-写入序列。其中一个写操作,在没有合并另一个写入变更情况下,直接覆盖了另一个写操作的结果。所以导致数据丢失。
- 写偏差: 一个事务读取一些东西,根据它所看到的值作出决定,并将该决定写入数据库。但是,写入时,该决定的前提不再是真实的。只有可序列化的隔离才能防止这种异常。
- 幻读: 事务读取符合某些搜索条件的对象。另一个客户端进行写入,影响搜索结果。快照隔离可以防止直接的幻像读取,但是写入偏差上下文中的幻读需要特殊处理,例如索引范围锁定。
第八章-分布式系统的麻烦
故障与部分失效
当你在一台计算机上编写一个程序时,它通常会以一种相当可预测的方式运行:无论是工作还是不工作。因为计算机设计目的就是总是正确地计算
部分失效(partial failure): 部分正常工作,系统其它部分则被不可预料地破坏了。
不确定性的(nonderterministic):如果你试图做任何涉及多个节点和网络的事情,它有时可能会工作,有时会出现不可预知的失败。
云计算与超级计算机
超级计算机:高性能计算(HPC)领域,计算密集型科学计算任务,如天气预报或分子动力学。(垂直扩展的极致),类似于单个计算机
云计算:通常与多租户数据中心,连接IP网络的商品计算机(通常是以太网),弹性/按需资源分配以及计量计费等相关联。
分布式系统更接近于云计算
不可靠的网络
异步分组网络(asynchronous packet networks),一个节点可以向另一个节点发送一个消息(一个数据包),但是网络不能保证它什么时候到达,或者是否到达。
处理这个问题的通常方法是超时(Timeout),在一段时间之后放弃等待,并且认为响应不会到达。
实际的网络错误
一句话,并非永远可靠。需要你做额外处理,假设。
检测故障
系统需要自动检测故障节点。例如,负载平衡器需要停止向已死亡的节点转发请求。
网络不定,加重判断难度:
- 如果节点在处理请求时发生崩溃,则无法知道远程节点实际处理了多少数据
- 节点操作系统上,添加脚本,可以通知其他节点有关该崩溃的信息
总的来说,必须假设你根本就没有得到任何回应。可以重试几次,等待超时过期,并且如果在超时时间内没有收到响应,则最终声明节点已经死亡。
超时与无穷的延迟
超时应该是多少?
- 过多?意味着长时间等待,期间,用户可能不得不等待,或者看到错误信息。
- 过短?错误地判断节点失效(例如节点只是暂时地高负载)的风险更高,而转移节点,这会给其他节点和网络带来额外的负担,从而更加恶化情况
如何估算?2d+r?实际情况下,都无法保证,异步网络具有无限的延迟,服务器并不能保证它们可以在一定的最大时间内处理请求。
网络拥塞和排队
网络上数据包延迟的可变性通常是由于排队,例如
- CPU内核当前都处于繁忙状态,网络传入请求将被操作系统排队
- TCP执行流量控制,意味着在数据甚至进入网络之前,在发送者处需要进行额外的排队
解决办法:
- 通过实验方式选择超时:测量延长的网络往返时间和多台机器的分布,以确定延迟的预期可变性
- 连续测量响应时间及其变化:根据观察到的响应时间分布自动调整超时时间
同步网络 vs 异步网络
同步网络:即使数据经过多个路由器,也不会受到排队的影响,因为呼叫的16位空间已经在网络的下一跳中保留了下来(电话通信)
存在的问题,利用率低,吞吐量低。
异步网络:如TCP协议,是try best策略
不可靠的时钟
通信不即时:消息通过网络从一台机器传送到另一台机器需要时间。
时钟硬件设备也不一定稳定正确
单调钟与时钟
时钟:
根据某个日历,返回当前日期和时间。如timestamp, date之类
单调钟:
单调钟适用于测量持续时间(时间间隔)。
NTP协议检测到计算机的本地石英钟比NTP服务器要更快或更慢,可以调整单调钟向前走的频率
在分布式系统中,使用单调钟测量经过时间(elapsed time)
时钟同步与准确性
单调钟不需要同步,但是时钟需要根据NTP服务器或其他外部时间源来设置才能有用。而外部来源不可靠
石英钟不够精确:它会漂移(drifts)(运行速度快于或慢于预期)。且NTP同步也会由于各种因素而不稳定
当然,如果你足够在乎这件事并投入大量资源,可以达到非常好的时钟精度
依赖同步时钟
时钟的缺陷:一天可能不会有精确的86,400秒,时钟可能会前后跳跃,而一个节点上的时间可能与另一个节点上的时间完全不同。
如果使用需要同步时钟的软件,必须仔细监控所有机器之间的时钟偏移,时钟偏离太远则宣布该节点死亡
有序事件的时间戳
依赖时钟,在多个节点上对事件进行排序,例如LWW策略?不可取,因为时钟不可靠。
how?逻辑时钟(logic clock),基于递增计数器而不是振荡石英晶体,对于排序事件来说是更安全的选择。例如tx_id
时钟读数存在置信区间
将时钟读数视为一个时间点是没有意义的——它更像是一段时间范围:
例如,一个系统可能以95%的置信度认为当前时间处于本分钟内的第10.3秒和10.5秒之间,它可能没法比这更精确了
全局快照的同步时钟
分布式系统,由于需要协调,(跨所有分区)全局单调递增的事务ID会很难生成。
可行: 为了确保事务时间戳反映因果关系,在提交读写事务之前,Spanner在提交读写事务时,会故意等待置信区间长度的时间
进程暂停
租约(lease):类似一个带超时的锁,当一个节点获得一个租约时,它知道它在某段时间内自己是领导者,直到租约到期。
如果节点发生故障,就会停止续期,所以当租约过期时,另一个节点可以接管。类似如下代码
while(true){
request=getIncomingRequest();
// 确保租约还剩下至少10秒
if (lease.expiryTimeMillis-System.currentTimeMillis()< 10000){
lease = lease.renew();
}
if(lease.isValid()){ // 期间可能会发生进程暂停
process(request);
}}
}
一个线程可能会暂停很长时间?当然了,例如GC,虚拟机挂起,磁盘IO阻塞等等
分布式系统没有共享内存,只有通过不可靠网络发送的消息,因此:
分布式系统中的节点,必须假定其执行可能在任意时刻暂停相当长的时间,即使是在一个函数的中间。在暂停期间,其它部分在继续运转,甚至可能因为该节点没有响应,而宣告暂停节点的死亡。最终暂停的节点可能会继续运行,在再次检查自己的时钟之前,甚至可能不会意识到自己进入了睡眠。
响应时间保证
硬实时(hard real-time)可行,例如飞机系统中。但是实现昂贵,例如在系统中提供实时保证需要各级软件栈的支持,且会降低延迟、吞吐等
限制垃圾收集的影响
只用垃圾收集器来处理短命对象(这些对象要快速收集),并定期在积累大量长寿对象(因此需要完整GC)之前重新启动进程
知识、真相与谎言
真理由多数所定义
节点自身并没问题,但是由于网络、GC等会被误判死亡。
因此,分布式系统不能完全依赖单个节点。决策需要来自多个节点的最小投票数,以减少对于某个特定节点的依赖。
leader and lock
图8-4 分布式锁的实现不正确:客户端1认为它仍然具有有效的租约,即使它已经过期,从而破坏了存储中的文件
即使一个节点认为它是“天选者(the choosen one)”(分区的负责人,锁的持有者,成功获取用户名的用户的请求处理程序),但这并不一定意味着有法定人数的节点同意
防护令牌
资源方需要确保一个被误认为自己是“天选者”的节点不能扰乱系统的其它部分,使用防护(fencing)
假设每次锁定服务器授予锁或租约时,它还会返回一个防护令牌(fencing token),这个数字在每次授予锁定时都会增加
拜占庭故障
如果节点可能声称其实际上没有收到特定的消息(欺骗行为)。这种行为被称为拜占庭故障(Byzantine fault),在不信任的环境中达成共识的问题被称为拜占庭将军问题
当一个系统在部分节点发生故障、不遵守协议、甚至恶意攻击、扰乱网络时仍然能继续正确工作,称之为拜占庭容错(Byzantine fault-tolerant)的
例如web 服务的输入验证,数据清洗和输出转义等,即是容错行为。
系统模型与现实
时机模型。
- 同步模型:假设网络延迟,进程暂停和和时钟误差都是有界限的
- 部分同步模型:一个系统在大多数情况下像一个同步系统一样运行,但有时候会超出网络延迟,进程暂停和时钟漂移的界限。实际
- 异步模型:一个算法不允许对时机做任何假设。场景很有限
节点故障模型
- 崩溃-停止故障:假设一个节点只能以一种方式失效,即通过崩溃。意味着节点可能在任意时刻突然停止响应,此后该节点永远消失
- 崩溃-恢复故障:假设节点可能会在任何时候崩溃,但也许会在未知的时间之后再次开始响应。实际
- 拜占庭(任意)故障: 节点可以做(绝对意义上的)任何事情,包括试图戏弄和欺骗其他节点
算法的正确性
分布式算法的属性。
- 唯一性: 没有两个防护令牌请求返回相同的值。
- 单调序列:if request x returned token tx, and request y returned token ty, and x competed before y began, then tx < ty
- 可用性: 请求防护令牌并且不会崩溃的节点,最终会收到响应。
安全性和活性
安全性通常被非正式地定义为,没有坏事发生,而活性通常就类似:最终好事发生。
- 如果安全属性被违反,我们可以指向一个特定的时间点
- 活性属性反过来:it may not hold the point in time. But always hope that it may be satisfied in the fueture
对于分布式算法,在系统模型的所有可能情况下,要求始终保持安全属性是常见的。即使所有节点崩溃,或者整个网络出现故障,算法必须确保它不会返回错误的结果。
但是,对于活性属性,我们可以提出一些注意事项。例如部分同步模型的定义要求系统最终返回到同步状态——即任何网络中断的时间段只会持续一段有限的时间,然后进行修复。