推特用户发送推文,关注者接受推送消息,采用了混合方案的思想(p19)
人多的(超级大V)那种采用关系型数据模型来支持时间线(类似的,推文单独提取,读取的时候才和用户的时间线主表合并),人少的用户关注者采用数据流水线方式推送。
延迟和响应时间:
响应时间=网络传输时间+应用延迟时间
前者是指数据(包括请求数据和响应数据)在客户端和服务器端进行传输的时间,而后者是指网站软件实际处理请求所需的时间。类似的,软件性能测试也更关心“应用延迟时间”。
一般至少关注p90或者p95即以上指标性能,响应时间快速,确保绝大部分用户体验是好的
如亚马逊关注的是p99.9的指标,当然这样的话优化的成本就很高了
nosql和sql一起使用,混合持久化
对象-关系不匹配:
面向对象编程语言,因为兼容性问题,对sql模型存在抱怨:如果数据存储在关系表中,应用代码中对象与表,行和列的数据库模型之间需要一个笨拙的转换成,模型之间的脱离有时被称为阻抗失谐。orm框架减少了此转换层所需样板代码量,但是不能完全隐藏两个模型之间差异。
使用id:对人类木有任何直接意义,使用永远不需要任何直接改变,即使id表示的信息发生了变化,它也可以保持不变。任何对人类有意义的东西都可能在将来某个时刻发生变更。如果这些信息被赋值,那么所有的冗余副本也都需要更新。这会导致更多写入开销,并且存在数据不一致风险(信息的一些副本被更新,而其他的副本未更新)。
关系模型的核心:只要构建一次查询优化器,然后使用该数据库所有应用程序都可以从中受益
文档数据模型:灵活性,由于局部性带来好性能,更接近于应用程序所使用的数据结构,关系模型强在联结操作,多对一和多对多关系更简洁的表达上(倾向于某种数据分解,文档结构分解为多个表)与文档模型抗衡。
图:简单的多对多用关系模型处理,数据之间关联越来越复杂,图最合适。社交网络,web图,公路或铁路网(交叉路口市定点,边是他们之间公路或铁路线)
更强大的用途在于:提供了单个数据存储区中保存完全不同类型对象的一致性方式,比如,Facebook维护一个包含许多不同类型的顶点和边的大图,顶点包括人,地点,事件,签到和用户评论,边表示哪些人是彼此的朋友,签到发生在哪些位置,谁评论了哪个帖子,谁参与了哪个事件等。
数据库的日志,appendonly 模式
索引的基本想法都是保留一些额外的元数据,这些元数据作为路标,帮助定位想要的数据。
哈希索引(bitcask文件为例):
只追加到一个文件,如何避免最终用尽磁盘空间:将日志分解成一定大小的段,当文件达到一定大小就关闭它,并将后续写入到新的段文件中。
后台线程压缩合并段文件。
部分写入:文件包括检验值,这样发现损坏的可以丢弃,比如mysql 的innodb底层结构的校验页
删除记录:在文件中追加一个特殊的删除记录(有时候称为墓碑),当合并日志段,一旦发现墓碑标记,会丢弃这已删除键的所有值。
并发控制:由于写入以严格的先后顺序追加到日志中,通常实现是选择只有一个写线程,因为只追加不可变(更新),所以读取可以并发读取。
appendonly这个模式非常不错:
1.追加和分段合并是顺序写。
2.段文件是追加或者不可变,并发和崩溃恢复要简单很多,不用担心重写的时候值发生崩溃,留下一个包含新旧值混一起的
3.合并旧段可以避免随着时间推移数据文件出现碎片化问题
hash限制:
1.区间查询效率不高,只能逐个查找
2.hah表必须全部放入内存,大量键的话就很麻烦,放入磁盘,会有大量随机io,hash满了,继续增长代价昂贵,并且冲突需要复杂逻辑处理。
SSTable(排序字符串表): 要求每个键在每个合并的段文件中只能出现一次(压缩过程已经确保了)
合并的更加简单高效,
即文件大于可用内存。并发读取多个输入,比较每个文件的第一个键,最小的键(根据排序)拷贝到输入文件,并重复这个过程,这会产生一个新的按键排序的合并段文件。
因为有序了,所以查找特定键,不需要内存中保存所有键索引,假设正在查找键handiwork,并不知道该键在段文件中的确切偏移,但是如果知道handbag和handsome的偏移量,因为有序,那么
handiwork一定位于两者之间,意味着可以跳到handbag的偏移,从那里扫描,直到找到handiwork。
内存记录某些键的偏移,但它可以是稀疏的,由于可以很快扫描几千字节,对于段文件中每几千字节,只需要一个键就足够了。
lsm-tree(日志结构的合并树):保存在后台合并的一系列的SSTable
sstable:缺点
最大的隐患是在压缩合并分段的时候不能进行数据的读写,否则数据一致性会存在问题,这对于吞吐量要求高的系统很难接受。
压缩另一个问题是对于带宽的占用非常高,压缩数据量越大,带宽消耗越高,容易阻塞大量的读写请求。
如果压缩速度跟不上写入数据,那么就会出现大量未压缩数据堆积情况,长期累计容易造成磁盘空间不足,但是sstable的数据结构决定了追加写入是不受控制的,需要外部力量监控。
b-tree:
底层基本写操作是使用新数据覆盖磁盘上的旧页,假设覆盖不会改变页的磁盘存储位置,也就是说,页被覆盖,对这个页所有引用保持不变。这与lsm-tree这类的日志结构索引是鲜明的对比(lsm-tree是只追加的)
b-tree和lsm-tree对比
b-tree通常来说读取更快,lsm-tree写入更快:
b-tree索引必须写两次,一次预写日志(崩溃恢复用),一次写入树本身(这里可能还有叶子节点分裂的问题),即时页面中几个字节改变,也要承受整个页面的写开销。
lsm-tree通常能承受比b-tree更高的写入吞吐量,因为他们有是有较低的写放大(写放大 指比如反复压缩和ssTable的合并,日志索引结构可能重写多次数据,这种影响在一次数据库内,由于一次数据库写入请求导致的多次磁盘写),而且他们以顺序方式写入紧凑的sstable文件,不需要重写树中的多个页。因为磁盘顺序写比随机写快得多。
lsm-tree支持压缩也更好,所以在磁盘上比b-tree小很多,因为它不是面向页,所以定期重写ssTables以消除碎片化。
lsm-tree缺点:
高写入吞吐量时,磁盘的有限写入带宽需要在初始写入和后台运行的压缩线程之间所共享。写入空数据库,全部磁盘带宽可用于写入,但是数据库数据量越大,压缩所需的磁盘带宽就越多。
b-tree树每个键都恰好唯一对应索引的某个位置,lsm-tree的不同段可能有相同键的多个副本,这种情况下事务语义方面就b-tree树更有优势。
索引中存储值:
存储行的具体位置称为堆文件,存在多个二级索引时,避免赋值数据,每个索引只引用堆文件中的位置信息,实际数据仍保存在一个位置。
更新值不改键,堆文件方法高效,如果新值较大,那么情况就复杂了,可能需要移动数据来得到一个足够大空间的新位置,这样的话,所有索引都需要更新以指向记录的新的堆位置,或者在旧堆位置保留一个间接指针。
因为有些情况,索引到堆文件额外跳转有太多性能损失,所以直接使用聚簇索引(希望可能将索引行直接存储在索引中。)
介于聚簇索引和非聚簇索引之间的这种折中设计是覆盖索引或者包含索引的列。
当然这么设计聚簇和覆盖索引读取快了,但是需要额外的存储,就有写入开销。
内存数据库提供了基于磁盘索引难以实现的某些数据模型,比如redis的优先队列和集合。
快的原因:避免使用写磁盘的格式对内存数据结构编码的开销。
内存数据库架构可以扩展到支持远大于可用内存的数据集,不会导致以磁盘为中心架构的开销。所谓的反缓存的方法:没有足够内存,将最近最少使用数据从内存写入磁盘,以后再次访问的时候再加载到内存,不过这样就需要将所有索引放入内存了。
olap为什么和oltp不同:当查询需要在大量行中顺序扫描时,索引关联性能就会显著降低。相反,最重要的是非常紧凑的编码数据,以尽量减少磁盘读取的数据量。
数据编码:
proto buf的向前兼容,可添加新字段到模式,只要给每个字段一个新的标记号码,旧代码试图读取新代码写入的数据,包括一个它不能识别的标记号码中断的字段,他可以简单地忽略该字段。
删除字段就像添加字段一样,不过向后和向前兼容性问题相反,只能删除可选字段(必填字段永远不可能被删除),而且不能再次使用相同的标签号码(因为可能仍然有写入的数据包含旧的标签号码,而该字段必须被新代码忽略)。
大多数数据库允许进行简单的模式更改,例如添加具有默认值为空的新列,而不重写现有数据。
读取旧行时,数据库会为磁盘上编码数据缺失的所有列填充为空值。
这个就是兼容。
面向服务/微服务体系结构关键的设计目标:通过使服务可独立部署和演化,让应用程序更易于更改和维护。
因为滚动升级这样的版本迭代或其他原因,所有假设不同的节点正在运行应用代码的不同版本,所以系统内流动的所有数据都以提供向后兼容性(新代码可以读取旧数据)和向前兼容性(旧代码可以读取新数据)的方式进行编码显得很重要。
thrift,protocol buffers和avro这样的有一个缺点,只有在数据解码后才是人类可读的。支持使用清晰定义的向前和向后兼容性语义进行紧凑,高效的编码。这些模式对于静态类型语言中的文档和代码生成非常有用。
数据库的:写入进程的编码,读取的进程的解码
rpc 和rest api 客户端和服务端 编码和解码
分布式数据系统:
扩展性,高可用性和容错,延迟考虑
系统的扩展能力:
垂直扩展是指购买更强大的机器,这种共享内存的架构,但是成本增长过快甚至超过了线性。
无共享内存:水平扩展。每个节点独立的部署和使用本地cpu,内存和磁盘
数据分布多节点方式:复制和分区
复制:如果复制的数据一成不变,就很容易,但是所有技术挑战都是处理那些持续更改的数据
三种流行的复制数据变化的方法:
主从复制:
所有客户端写入操作都发送到某一个节点(主节点),由该节点负责将数据更改事件发送到其他副本(从节点)。每个副本都可以接受读请求,但是内容可能是过期值。
多主节点:
每个主节点都可以接受写操作,并且每个主节点也扮演其他主节点的从节点,后面的复制流程类似:处理写的每个主节点都必须将该数据更改转发到所有其他节点。
适用于多数据中心。(为了容忍整个数据中心级别故障或者更接近用户)
多主节点缺点就是写冲突的处理,两个用户同时编辑wiki页面,用户1将标题a改成b,用户2将标题a改成c,单主节点写可以让第二个写请求被阻塞知道第一个写请求完成,但是多主就不行了,这种就要采用单主节点的主从避免写冲突。一般来说大家采用避免冲突的方法。
比如将用户更新数据的时候,按照用户的id使用hash取模总是路由到特定的数据中心,并且总在该数据中心的主节点上进行读/写。
收敛于一致状态:
主从模型,因为数据更新符合顺序性原则,使用同一个字段多个更新,最后一个写操作决定该字段最终值。
多主节点的话:
给每个写入分配一个唯一id,版本号之类的,最大的获胜。
或者以某种方式将值合并比如字母顺序排序,那么上面例子就变成标题b/c
利用预定义格式来记录和保留冲突相关所有信息,然后依靠应用层逻辑代码,事后解决冲突(依靠用户)。(这个也用的不少) 所有冲突保留,交给应用层代码执行 ,这种分为写入时候处理或者读取时候处理。
无主节点复制:(quroum实现)
客户端将写请求发送到多个节点上,读取时从多个节点上,读取时从多个节点上并行读取,一次检测和纠正某些过期数据。
同步复制和异步复制:例如数据库至少保证两个节点(主节点和一个同步从节点拥有最新的数据副本)。全是同步的话,只要有一个同步从节点无法完成确认,写入就不能视为成功。这样肯定不行
基于触发器复制:支持注册自己的应用层代码,是当数据库系统发生数据更改时自动执行上述自定义代码。灵活性更高,可以只复制数据一部分,但是开销比其他复制方式更高,更容易出错。
读自己的写:
比如用户提交数据,然后查看他们自己所提交的内容,数据库的记录或者主题的评论等。
这种时候异步复制的话,用户写入数据不久,新的数据可能尚未到达从节点,看起来就是刚提交的数据失败了。这里需要写后一致性或者称为读写一致性。
解决方案:
主节点读取用户自己可以被修改的内容,其余人的配置从从节点读取。
如果大部分内容可能被所有用户修改,那就使用跟踪最近更新时间,更新后一分钟内从主节点读取,并且监控从节点复制滞后程度,避免从那些滞后超过一分钟的从节点读取。
或者也可以记住最近更新时的时间戳,附带在读请求中,然后系统根据这个信息,确保对该用户提供读服务都包含了这个时间戳更新。当前节点不够新就交给另一个副本处理,或者等知道副本接收到了最近的更新。
如果同一用户可能从多个设备访问数据:多台设备比如一个桌面web浏览器和一个移动端
那么使用记录用户上次更新时间戳的方法就比较困难,因为一台设备上运行的代码不知道其他设备上发生了什么,这时候需要全局共享元数据。
副本在多数据中心,这种情况如果方案要求必须从主节点读取,需要想办法首先将来自不同设备的请求路由到同一个数据中心。
单调读:这是一种比强一致性弱,但是比最终一致性强的保证。(例如用户从不同副本进行读取,用户刷新网页,读请求可能被随机路由到某个从节点。然后用户先后在两个从节点上执行了两次完全相同的查询(先是少量滞后的节点,然后是滞后很大的从节点),就很可能出现,第一次查询返回了最近用户1234添加的评论,但是第二个查询因为滞后,没收到更新就没返回结果。)
单调读保证某个用户一次进行多次读取,绝对不会看到上述回滚现象,即读取较新值之后又发生读旧值的情况。一种实现方式确保每个用户总从固定的同一副本执行读取,可以基于用户id哈希而不是随机副本。但该副本失效,用户查询必须重新路由到另一个副本。
前缀一致读:(保证数据之间因果关系,总以正确方式先读取数据)后面牛奶的例子会看到解决方案
对于一系列按照某个顺序发生的写请求,那么读取这些内容时也会按照当时写入的顺序。
例如:
a说:你能看到多远的未来
b说:通常约十秒
这个对话倒过来就不对了。
对于上述方案的一种解决是:
确保任何有因果顺序关系的写入都交给一个分区来完成。
但上述方案实现效率还是有问题,后面会讲述解决方案。
Quorum一致性的局限性和改进(宽松的Quorum)Quorum机制_xjk201的博客-CSDN博客
我们来看个 例子 解决前缀一致读问题
以上算法流程:
服务器判断操作是否并发主要依据是对比版本号:
服务器为每个主键维护一个版本号,每当主键新值写入时递增版本号,并将新版本号与写入的值一起保存。
客户端写主键,写请求必须包含之前读到的版本号,读到的值和新值合并后的集合。写请求的响应可以像读操作一样,会返回所有当前值,这样就可以像上面购物车例子那样一步步连接起多个写入的值。
当服务器收到带有特定版本号的写入时,覆盖该版本号或更低版本的所有值(因为知道这些值已经被合并到新传入的值集合中),但必须保存更高版本号的所有值(因为这些值与当前写操作属于并发)。
版本矢量:
riak将版本矢量(所有副本版本号集合)编码为一个称之为因果上下文的字符串。然后使得数据库可以区分究竟应该覆盖还是合并。
以上述购物车为例子,合并并发的合理方式是包含新值和旧值的操作(union操作)
两个客户端最后值分别是[牛奶,面粉,鸡蛋,熏肉]和[鸡蛋,牛奶,火腿]。合并最后的应该是[牛奶,面粉,鸡蛋,培根,火腿],其去掉了重复值。
合并同时写入的值
根据版本号合并
分区不均匀,出现某些分区节点比其他分区承担更多的数据量或查询负载,就是倾斜,负载严重不成比例的分区成为系统热点(比如10个节点九个空闲)。
避免热点:均匀分布所有节点,因为有的数据读取需要顺序,或者排序,那么这个均匀分布节点的方法可以设计下比如按照字母排序。
基于关键字区间分区:(12个分区按照abcd这样的首字母排序)
每个分区按照关键字排序保存(sstables和lsm-trees这样的)
缺点是某些访问模式会导致热点,如果关键字是时间戳,那么分区对应的是一个时间范围,比如每天一个分区,但这样的话,数据从传感器写入数据库的时候,数据都集中在同一个分区(即当天的分区,这样会导致这个分区写入负载过高,其余分区空闲。)
这样我们就可以使用时间戳以外其他内容作为关键字第一项。比如加上传感器名称作为前缀,这样就由传感器名称,然后按照时间进行分区。如果同时有许多传感器是活动状态,那么写入负载会比较均匀分布在多个节点,然后需要获取一个时间范围内,多个传感器的数据,可以根据传感器名称,各自执行区间查询。
基于关键字哈希值分区
对于数据倾斜和系统热点,采用hash函数散列来处理,但是这样就没法使用良好的区间查询特性(范围查询)。
像Cassandra这样的在两种分区策略做了一个折中,使用复合主键。复合主键只有第一部分hash分区,其他列用作组合索引排序,这样的话比如社交网络上用户发送很多消息更新,然这样就可以搜索一个用户一段时间所做的操作按照时间戳排序。(这又是一种混合的思想,架构设计中很多地方常见混合的思想)
二级索引的分区:
基于文档的二级索引:本地二级索引 查询的时候读放大
每个分区完全独立,各自维护自己的二级索引,不关心别的分区数据只关心自己分区数据。
但是这样的话除非对文档id特殊处理,否则不可能把所有特定颜色或特定品牌汽车都放一个分区。
所以这种也是分散/聚集,查询延迟显著放大。因为是需要所有区间数据归并。
基于词条的二级索引:全局二级索引 写放大
所有数据分区中颜色为红色的都放到一个分区中(分区0)。
这种方案读取更为高效,因为不需要所有分区都查一遍再合并,但是写入速度就很复杂,因为单个文档的更新,可能会涉及多个二级索引,而二级索引的分区又可能完全不同甚至在不同的节点上,有此势必引入显著的写放大。同步更新这样会存在相关分区的分布式事物上,所以全局二级索引都是异步更新。
分区再平衡
动态再平衡策略:
为什么不取模:
因为如果按照节点数取模,节点数变化的话,那么很多关键字要从现有节点迁移到另一个节点去。
这样就要迁移很多数据。
固定数量分区:
创建远超实际节点数的分区数。然后添加的新节点就从现有节点上匀走几个分区,直到分区再次达到全局平衡。删除节点那么处理就相反。这样只需要维护分区和节点的映射关系。调整可以逐步完成,在此期间,旧分区仍然可以接受读写请求。
同样分区的数据量应该恰到好处,太大的话每次再平衡和节点故障的恢复代价太大,分区太小,会产生太多开销,分区大小应该恰到好处。
动态分区
类似b-tree,大量数据被删除,就合并,数据增长超过一个可配的参数阈值,就拆成两个区。
一个优点是分区数量可以自动适配数据总量。只有少量数据,少量分区就够了,这样系统开销很小,大量数据的话,每个分区大小被限制在一个可配的最大值。
关键字分区和基于hash的分区都适合。
按节点比例分区
每个节点有固定数量的分区,节点数不变,每个分区和数据集大小是正比的增长关系,节点数增加,分区会调整变得更小,较大数据量需要大量节点存储,所以这种方法让每个分区大小保持稳定。
请求路由(服务发现问题):
1.允许客户端连接任意节点,如果该节点恰好有请求的分区,直接处理,否则,请求转到下一个适合的节点,收到答复再回复客户端。
2.所有客户端请求都发到一个路由层,后者负责将请求发到对应分区节点,路由层不处理请求,仅仅充当一个分区安置的负载均衡器。
3.客户端感知分区和节点分配关系,客户端可以直接连接到目标节点,不需要任何中介。
上面三个方法核心问题都是如何知道分区与节点对应关系和变化的
许多分布式系统依靠zk来达成共识,有的依靠gossip协议(redis)达成(这样的话就不需要依赖zk这样的中间件)了。
事务:
是被人为创造出来,目的是简化应用层的编程模型,这样应用程序可以不用考虑某些内部潜在的错误以及复杂的并发性问题,这些都可以交给数据库来负责处理(安全性保证)。
ACID:
原子性:不关乎多个操作的并发性,没有描述多个线程试图访问相同数据会发生什么,而是在出错时终止事务,并将部分完成的写入全部丢弃。这里也许可终止性比原子性更为准确。
没有原子性保证,处理错误会异常复杂,缺乏隔离性容易出现并发性方面的各种奇怪问题。
一致性:
原子性,隔离性和持久性是数据库自身属性,一致性更多是应用层的属性。(应用层有责任正确地定义事务来保持一致性,因为提供的数据修改违背了恒等条件,数据库很难检测进而阻止该操作)
隔离性:
意味着并发执行的多个事务相互隔离。
持久性
多对象事务必要性:
很多分布式数据存储系统都不支持多对象事务
弱隔离级别
从理论上讲,隔离是假装没有发生并发,让程序员生活更轻松。为了性能考虑,许多数据库采取较弱的隔离级别,可以防止某些但并非全部的并发问题。
读-提交
防止脏读
防止脏写:
两个事务同时尝试更新相同的对象,先前的写入是尚未提交事务的一部分,还是被覆盖了,那就是脏写。
事务更新多个对象,脏写会带来非预期的错误结果。两人a和b试图购买同一辆车,然后购买汽车要
两张表写入数据库,a抢先成功更新了车辆表,但是发票给了b,因为b成功执行了发票的表,读提交隔离要防止这种事故。
实现读-提交
通常数据库采取行级锁防止脏写:一个事物拿到锁,后面的事务只能等待锁释放才能拿到。
脏读的防止:为了性能考虑不使用读锁,对每个待更新对象,数据库维护其旧值和当前持有锁的事务将要设置的新值两个版本,事务提交前,都读取旧值,仅当事务提交之后,才会切换到读取新值。
读已提交的问题是不可重复读:
需要实现快照级别的隔离(mysql和postgresql叫可重复读,oracle叫可串行化)
写锁来防止脏写,但是读锁不需要加(因为是不可重复读的)
数据库使用的是mvcc机制(保留了对象多个不同的提交版本),多个正在进行的事务可能会在不同的时间点查看数据库状态。
只是为了读-提交级别的隔离,那就不需要完整的快照级别隔离,和上面说的一样,只保留两个版本(一个已提交的旧版本和尚未提交的新版本)。典型做法,读-提交级别,对每个不同的查询单独创建一个快照,快照级别隔离使用一个快照来运行整个事务。(这里可以对应mysql的mvcc的实现来想想)。
一致性快照的可见性规则:这个可以对照mysql的mvcc理解
1.每笔事务开始,数据库列出所有当时正在进行的其他事务(尚未提交或终止的),然后忽略这些事务完成的部分写入,即不可见
2.所有终止事务所做的修改全部不可见。
3.较晚事务id(即晚于当前事务)所做任何修改不可见,不管这些事务是否完成了提交。
4.除此以外,其他所有写入都对应用查询可见
或者这么说下面两个条件城里,该数据对象对事务可见
1.事物开始时,创建该对象的事务已经完成了提交
2.对象没有被标记为删除,或者即使标记了,但删除事务在当前事务开始时还没有完成提交
并发写入事务冲突:不处理的话可能并行更新的值被覆盖了
原子操作和锁都是通过强制“读-修改-写回”操作(update xxx set value where value = xxx 这样的,然后判断返回值0还是1这样的)序列串行执行来防止丢失更新。另一种思路则是先让他们并发执行,但如果事务管理器检测到了更新丢失风险,则会终止当前事务,并强制回退到安全的“读-修改-写回”方式。这种方式可以借助快照级别隔离来高效执行检查。postgresql和oracle及sql sever都支持这种,但是mysql的innodb可重复读就不支持检测更新丢失。
原子比较和设置
可以这样写然后应用层判断更新成功与否
可能保留多个版本冲突让应用层用户自己解决或者最后写入获胜(lww)这样的。
写倾斜:
两个值班的医生提交请假,几乎同一时间点了调班按钮,然后每笔事务总是先检查是否至少有两名医生目前在值班,是的话,那么一名医生可以安全离开。因为数据库在只用快照级别隔离,两个检查都返回有两名医生。所以两个事务都进入到下一阶段,然后alice医生更新自己的值班记录是离开,bob医生也这样更新自己的记录,两个事务都成功提交,那么就是没有任何医生值班。这就是写倾斜。
这是一种更广义的更新丢失问题,即如果两个事务读取相同的一组对象,然后更新其中一部分,不同的事务可能更新不同的对象,则可能发生写倾斜;而不同事物如果更新同一个对象,则可能发生脏写或者更新丢失。
这种要么使用串行化级别的隔离,要么对事物的依赖行显示加锁:
声明一个用户名
网站要求每个用户有一个唯一的用户名,两个用户可能同时尝试创建相同的用户名。可以采用事务方式首先来检查名称是否被使用,没有就用这个名称创建账户。和上面例子类似,这个是不安全的,对于这个例子,简单方案采用唯一性约束。
存储过程:
单线程串行执行系统不支持交互式的多语句事务。
而存储过程和内存式数据存储使得单线程上执行所有事务变得可行,他们不需要等待I/O(网络交互io,一条命令结束然后通过网络来了下一条命令),避免加锁等复杂并发控制机制,可以得到相当不错的吞吐量。
或者对于写倾斜使用ssi 可串行化的快照隔离来处理
和mvcc类似,但是在事务提交时(写入操作),会判断事务期间操作的数据时候已被其他操作修改,如被修改,则事务提交失败
// 1.事务A查询
select name from student where id=2;// name='jack'
// 2.事务B修改
update student set name='rose' where id=2;
// 3.事务A再次查询
select name from student where id=2;// name='jack'
// 4.事务A修改
update student set name='张三' where id=2;
// 5.事务B提交
// 6.事务A提交,报错。因为事务B修改了数据。
ssi优点是事务不需要等待其他事物所持有的的锁,这样读写一般不会互相阻塞。但是事务中止比例会显著影响ssi的性能表现。
秉持乐观预期原则,允许多个事务并发执行而不互相阻塞,仅当事务尝试提交,才检查看冲突,如果发现违背串行化,那么某些事务会被终止。
两阶段加锁(2pl)
不仅并发写操作之间互斥,读取也会和修改产生互斥。快照级别隔离的口号是(读写互不干扰),这就是和2pl的显著区别。
需要读取的事务获取共享锁,如果所有事物获取了对象独占锁,其他事务必须等待。
事务要修改对象,必须获得独占锁,(包括共享或者独占模式)同时只有一个事务有该锁。
因为性能问题而放弃使用
谓词锁:
类似于前面的共享/独占锁,区别是他不属于特定对象,而是作用于满足某些搜索条件的所有查询对象。
由于谓词锁性能不佳,如果活动事务有多个锁,那检测这些锁就会很耗时。
所以需要简化,将保护对象扩大化,所以使用索引区间锁,这样开销比谓词锁开销低得多,这就是一种很好的折中方案。(mysql 的临界锁)
---------
分布式系统挑战
单台机器执行要么成功要么失败,因为计算机设计有个非常审慎的选择:如果发生某种内部错误,宁愿全部崩溃,也不返回一个错误结果,因为错误结果往往更难处理。
我们一般主要用的是分布式无共享系统,网络是跨节点通信的唯一途径。这种是主流的构建互联网服务方式,虽然并不是构建集群系统的唯一方式。
tcp和udp的选择:
一些对延迟敏感的应用(视频会议和IP语音VoIP)使用了UDP而不是TCP。这是一种可靠性与可变性之间的权衡考虑。因为UDP不支持流量控制也不会重传丢失的数据包,所以可以避免一些网络延迟不确定的因素。
丢失或延迟的数据价值不大,udp是不错的选择(IP电话可能没有足够时间重传丢失的数据包或者重传并没有太大的意义,应用程序会采取静音填充丢失的位置,出现短暂的声音中断,然后数据流必须尽快向前继续,由人为即通话双方来沟通重试:请你再说一遍好吗,刚刚声音没有听到)。
延迟和资源利用率:
从更为广泛的意义来说,将延迟波动归为动态资源分区的结果。
互联网是动态分享网络带宽,多个发送方互相竞争,以尽快地通过网络发送他们的数据,最大限度地利用了带宽,但是需要排队,线路本身对应一个固定的成本,更充分使用的话,发送每个字节平均摊薄成本就会下降。
电话交换机之间有一跟线路可以同时支撑1万个呼叫,线路上的每条电路都占用一个槽位。这种资源是以静态方式分配:即使现在你是线路上唯一的通话中,别的9999个槽位都是空闲的,你的电路也只能使用固定带宽。 这种可以保证延迟确定性,但是以降低资源使用率为代价的。
同样的cpu也是这样,多个线程动态共享cpu。
不可靠的时钟:
时间戳和事件顺序
如上所示x=1的时间戳是42.004秒,但是x=2的时间戳虽然后续发生,但是时间戳是42.003s,这样按照lww最后写如获胜就是x=1的版本获胜了。
这里就应该使用基于递增计数器来处理。
调整垃圾回收影响:
gc视为节点的一个计划内的临时离线,节点启动gc通知其他节点接管客户端请求,然后gc完再把请求接回来,这种技巧以某种方式对客户端隐藏垃圾回收,降低负面影响(如金融交易系统)这样的,这种方案是对延迟敏感的系统使用的。
或者只对短期对象执行垃圾回收,在其变为长期存活对象之前,定期重启避免长期存活对象的全面回收。
下图就是某个节点自认为他是唯一的那个,但是多数节点声明他已经失效,这时候这个节点还在执行自认为唯一的逻辑。 持有租约的客户端被暂停太久直到租约到期。然后另一个客户端已经获得了文件的锁租约,并开始写文件。接下来,当暂停的客户端重新回来时,它仍然错误地认为合法持有锁并尝试写文件。结果导致客户2的文件写入被破坏。
Fencing令牌 下图这样有了个令牌版本,每次服务器更新根据版本匹配就没问题了(34版本改完,33的就进不来了)zk就使用zxid或节点版本之类的充当fencing令牌。
一致性和共识
分布式最重要的抽象之一就是共识。
线性化可以避免竞争。
线性化本质上意味着“表现得好像只有一个数据副本,且其上的所有操作都是原子的”
可线性化与可串行化
可串行化:用来确保事务执行的结果与串行执行结果完全相同,即时串行执行的顺序可能与事务实际执行顺序不同。
可线性化:
是读写寄存器(单个对象)的最新值保证。
无主复制(可能不可线性化)
基于Quorum实现
之所以放弃线性化是因为性能,而不是为了容错。因为想满足线性化,读写请求的响应时间至少与网络延迟成正比。
因为多数计算机网络高度不确定的网络延迟,线性化读写性能肯定很差。
线性化:目标使多副本对外看起来好像是单一副本,然后所有操作以原子方式运行,就像一个单线程程序操作变量一样,概念简单,容易理解,看起来很有吸引力,但它问题在性能,特别是网络延迟较大的环境中。
偏序:集合内只有部分元素之间在这个关系下是可以比较的
比如:比如复数集中并不是所有的数都可以比较大小,那么“大小”就是复数集的一个偏序关系~
序列号排序:
序列号或者时间戳(逻辑时钟,比如算法产生的一个数字序列,因为时钟可能不可靠)做全局排序。
这样每一个操作都有一个唯一的序列号,保证了全序关系。
Lamport(兰伯特)时间戳:保证全序关系,相比于版本向量,更加紧凑和高效(版本向量主要区分两个操作是并发还是因果依赖)
Lamport 时间戳是一个值对(计数器,节点 ID)。两个节点可能会有相同的计数器值,但时间戳中还包含节点 ID 信息,因此可以确保每个时间戳都是唯一的。
Lamport 时间戳与物理墙上时钟并不存在直接对应关系,但它可以保证全序:给定两个 Lamport 时间戳,计数器较大那个时间戳大;如计数器值正好相同,则节点 ID 越大,时间戳越大。
每个节点以及每个客户端都跟踪迄今为止所见到的最大计数器值,并在每个请求中附带该最大计数器值。当节点收到某个请求(或者回复)时,如果发现请求内嵌的最大计数器值大于节点自身的计数器值,则它立即把自己的计数器修改为该最大值。
但是兰伯特时间戳有个缺点:需要收集系统中所有的用户创建请求,然后才可以比较他们的时间戳。但是,当节点刚刚收到用户的创建请求,无法当时就做出的决定该请求成功还是失败,此时节点根本不知道是否还有另一个节点在同时创建相同用户名。
全序关系广播:(提供fencin令牌服务那样的,zk的zxid,序列号作为令牌,符合单调递增要求 p328)需要满足下面两个定义
可靠发送:
没有消息丢失,如果消息发送到某一个节点,则他一定要发送到所有节点。
严格有序:
消息总是以相同的顺序发送给每个节点。
2pc:
这种只能等待协调者恢复,这也就是为什么协调者必须在向参与者发送提交(或中止)请求之前要将决定写入磁盘的事务日志,等协调者恢复之后,通过读取事务日志来确定所有未决的事务状态。
从协调者故障中恢复
理论上,协调者崩溃之后重新启动,它应该可以从日志中恢复那些停顿事务。但是现实中,鼓励的不确定事务是会发生的。最终协调者出现了恢复失败。那些悬而未决的事务无法自动解决,永远留在那里,而且持有锁并阻止其他事物。
这种就有许多xa支持某种紧急避险措施:
启发式决策:这样参与者节点在紧急情况下单方面作出决定,放弃或者继续那些停顿的事务,不等到协调者发出指令。(其实是破坏了原子性)
共识必须满足的:
协商一致性:所有节点都接受相同的决议。
诚实性:所有节点不能反悔,即对一项提议不能有两次决定
合法性:如果决定了值V,那么V一定是由某个节点所提议的。
可终止性:节点如果不崩溃则最终一定可以达成决议。
共识算法和全序广播:
vsr,paxos,raft,zab
决定了一序列的值,然后采用全序关系广播算法。
全序关系广播相当于持续多轮共识过程:
由于协商一致性,所有节点决定以相同的顺序发送相同的消息。
由于诚实性,消息不能重复。
由于合法性,消息不会被破坏,也不是凭空捏造的
由于可终止性,消息不会丢失。
vsr,raft,zab都采用了全序关系广播,这比重复性的一轮共识只解读一个提议更加高效,而paxos则有对应优化版本multi-paxos。
全序关系广播:消息按照相同的顺序发送到所有节点,有且只有一次。这其实相当于进行了多轮的共识过程:在每一轮,节点提出他们接下来想要发送的消息,然后决定下一个消息的全局顺序。
Epoch和Quorum
主节点都不固定,都定义了一个版本号,paxos中ballot number,vsp中view number ,raft中term number,然后每次选举的主节点都有一个epoch号码,然后每次主节点做决定前,先看自己的epoch号码是不是最高的,是的话就做决定,不是那么就有新的主节点被选举了。
必须从quorum节点收集投票,主节点如果想要做某个决定,必须将提议发送给其他所有节点,等待Quorum节点的响应。quorum通常多数节点组成,并且,只有没发现更高epoch主节点存在,节点才会对当前提议(带有epoch号码)进行投票。
所以这里是两轮投票,首先,投票决定谁是主节点,然后对主节点提议投票,重要的一点是,参与两轮的quorum必须有重叠,如果某个提议获得通过,其中参与投票的节点必须至少有一个也参加了最近一次的主节点选举。也就是说,如果针对提议的投票中没有出现更高的epoch号码,那么可以得出这样的结论:因为没有发生更高的epoch主节点选举,当前主节点地位没有改变,所以可以安全地就提议进行投票。
共识局限性:对网络问题特别敏感
zk通常是固定数量节点(通常三五个)上运行投票,可以非常高效迟滞大量客户端,数千节点的庞大集群上多数投票会非常低效。zk所管理的数据变化非常缓慢,变化频率往往在分钟级别甚至小时级别。 应用实时运行的状态数据,每秒产生书签甚至百万次更改,这种考虑别的工具(bk)。
全序广播:
消息系统决定以何种顺序发送消息。
无主和多主复制可能不支持共识算法,那这个就得找别的方案解决冲突。比如没有线性化保证,就要努力处理好数据多个冲突分支以及版本合并等。