《数据密集型应用系统设计》分布式系统的挑战和一致性
柏舟 新冠4年 06-15
《数据密集型应用系统设计》讨论了:
- 数据库的数据模型和查询语言,数据编码。
- 分布式系统的挑战:复制、分区、事务以及一致性和共识。
- 派生的数据系统,如Spark、Flink等等。
我个人觉得数据库的数据结构和复制、分区、事务属于技术细节,看起来很无聊。而一致性和共识是一个分布式比较突出的特点,与原子操作等具有惊人的相似性,能够从一个更高的角度看待分布式系统状态问题。
在之前的文章:状态资源分类和《领域驱动设计》我从领域对象的状态,分析了如何对系统进行设计。而《数据密集型应用系统设计》这本书更侧重于从基础设施的角度思考现有的计算设备存储的问题和如何进行状态管理。
核心思路和问题
在单节点开发程序时,程序要么工作,要么出错。而分布式系统中,各种各样的事情都可能出错,可能会出现系统一部分工作正常,但某些部分出现难以预料的故障,主要有物理硬件和底层软件的限制和问题:
- 网络延迟和丢失;
- 不同节点的时钟不同步;
- 长度未知的暂停,如:进程切换、内存swap、GC等等。
分布式系统需要使用不可靠的组件搭建可靠的系统。它主要存在两方面的可靠性问题:
- 安全性:没有发生意外。我的理解是:系统的所有异常状态都有正确的处理方式,不会破坏系统的正常功能(在设计范围内崩溃也是安全性的体现)。比如所有节点崩溃或整个网络中断,算法不会返回错误的结果。
- 活性:可用性。比如,只有多数节点没有崩溃,以及网络最终可以恢复的前提下,才能保证最终可以收到响应。
一般来讲,我们更希望当出现故障的时候,系统宁愿崩溃也不要返回错误的结果。
线性化模型
提供只有单副本的假象,且所有操作都是原子的。可线性化与可串行化是不同的,可线性化是读写寄存器的最新值保证,它并不要求将操作组合到事务中,而可串行化可以保证事务与串行执行是相同的(即每次执行一个事务)。
我个人的理解是线性化只提供了一组原子操作,如:set, add, cas(比较和设置)。在此基础上的组合操作并不能满足原子操作的要求。但是,可以在此基础上实现信号量等锁机制,使竞争资源操作串行化。所以不管锁的具体如何实现,它必须满足可线性化。
常见用途
在数据库中,线性化常用于唯一性约束,比如创建用户时,用户标识具有唯一性;文件系统的文件路径具有唯一性。
一般的主从数据库只满足最终一致性的要求,比如用户已经完成了更新,但刷新时从副本中读取,由于异步更新和网络延迟的原因读取了旧值,在时间上的因果不一致就会给用户带来困惑。
实现方式
- 同步的主从复制。潜在的问题是采用了快照隔离,读取了就的副本;数据库本身存在并发的bug;主节点脑裂——副节点以为自己是主节点并提供服务。
- 共识算法。
代价
只要有不可靠的网络,都会发生违背线性化的风险。一旦发生网络故障,必须要么选择一致性(失效/只读),要么选择可用性(数据不一致)。
如果满足线性化,读写请求的响应时间与网络延迟成正比。
因果关系
一个请求可能存在两种情况:
- 并发请求,顺序无关;
- 请求间存在因果关系,必须严格按顺序处理。
大部分系统要求的是因果一致性而不是可线性化,可线性化一定意味着因果关系,放弃可线性化能够获得更好的性能。为保持因果关系,需要知道那个操作发生在前,在处理一个请求时,必须确保所有因果在前的请求都完成处理。
一个请求可能读取修改多个表的字段,为了追踪整个数据库的因果关系,可以使用一种类似于版本向量的技术。为了维护版本序列号的因果关系,有以下的解决方法:
- 唯一的节点分配序列号。使用一个递增的序列号作为逻辑时钟,每次提交更新的时候同时提交版本号,比较版本号是否更新,如果更新说明有冲突发生,需要自己指定覆盖哪些值保留哪些值(因为追加和覆写的解决冲突方式是不同的);
- 时间戳,最后写入者胜。但是时间同步是一个问题;
- Lamport时间戳(全序但非因果):(计数器,节点ID)。给定两个时间戳,计数器较大的时间戳大;若计数器相同,则节点ID越大,时间戳越大。每个节点以及客户端都会跟踪迄今为止见到的最大计数器值。每个请求都会附加最大计数器值,并且会把最新值写入落后者中。注意Lamport时间戳只确保了版本大小可以比较,但无法区分两个操作是并发关系还是因果依赖关系。
C++ Atomic memory order的例子
名字 | 规则 |
---|---|
memory_order_relaxed | 不对执行顺序做任何保障 |
memory_order_acquire | 所有后续的读操作均在本条原子操作完成后执行 |
memory_order_release | 所有之前的写操作完成后才能执行本条原子操作 |
memory_order_acq_rel | 同时包含memory_order_acquire和memory_order_release标记 |
memory_order_consume | 本线程中,所有后续的有关本原子类型的操作,必须在本条原子操作完成后执行 |
memory_order_seq_cst | 全部存取都按顺序执行 |
- 如果所有读写操作使用seq_cst,则顺序是完全线性化的。
- 如果所有读写操作使用release或读relaxed写acq_rel,那么写操作之间因果一致,而读操作直接并发。
从Atomic的设计也能看出因果一致性和可线性化的区别。
共识
几个节点就某件事达成一致。场景:
- 主节点选取
- 原子事务提交:所有节点要么提交成功,要么失败回滚。
实现方式:
- 两阶段提交(2PC),它潜在的问题是协调者故障那么参与者将出现不确定的状态;
- 容错式的共识算法:Raft, Zab等。即一个共识算法不能原地空转。
容错算法的性质:
- 协商一致性:所有节点都接受相同决议。
- 诚实性:所有节点不能反悔。
- 合法性:决议的值一定是某个节点提出的。
- 可终止性:节点如果不崩溃则最终一定可以达成决议。
总结
《数据密集型应用系统设计》详细的介绍了基础设施层的状态问题和管理方法。在实际设计中,需要综合考虑领域对象的复杂性,尽量选择简单的方案,将领域的状态管理复杂性托管到基础设施中。比如,面临的最终问题可以归结于共识,并且有容错需求,那么最好采用ETCD等验证过的系统。
领域层的开发更多的是将多种数据系统组合起来,满足领域对象不同的事务性、分析性需求。基础设施的一致性、容错性等复杂度在领域层发生了转移,领域层的复杂性更多的体现在异构数据系统的组合使用上。在CQRS架构中介绍了领域层数据系统的设计方法。