分布式协同面试
分布式协同面试
复制
【基础】什么是复制?复制有什么作用?
要点
复制主要指通过网络在多台机器上保存相同数据的副本。
复制数据,可能出于各种各样的原因:
- 提高可用性 - 当部分组件出现位障,系统依然可以继续工作,系统依然可以继续工作。
- 降低访问延迟 - 使数据在地理位置上更接近用户。
- 提高读吞吐量 - 扩展至多台机器以同时提供数据访问服务。
【中级】复制有哪些模式?
要点
复制的模式有以下几种:
- 主从复制 - 所有的写入操作都发送到主节点,由主节点负责将数据更改事件发送到从节点。每个从节点都可以接收读请求,但内容可能是过期值。支持主从复制的系统:
- 数据库:Mysql、PostgreSQL、MongoDB 等
- 消息队列:Kafka、RabbitMQ 等
- 多主复制 - 系统存在多个主节点,每个都可以接收写请求,客户端将写请求发送到其中的一个主节点上,由该主节点负责将数据更改事件同步到其他主节点和自己的从节点。
- 无主复制 - 系统中不存在主节点,每一个节点都能接受客户端的写请求。此外,读取时从多个节点上并行读取,以此检测和纠正某些过期数据。支持无主复制的系统:
- 数据库:Cassandra
此外,复制还需要考虑以下问题:
- 同步还是异步
- 如何处理失败的副本
- 如何保证数据一致
【中级】主从复制是如何工作的?
要点
最常见的解决方案就是主从复制,其原理如下:
主从复制模式中只有一个主副本(或称为主节点) ,其余称为从副本(或称为从节点)。
- 所有的写请求只能发送给主副本,主副本首先将新数据写入本地存储。
- 然后,主副本将数据更改作为复制的日志或更新流发送给所有从副本。每个从副本获得更新数据之后将其应用到本地,且严格保持与主副本相同的写入顺序。
- 读请求既可以在主副本上,也可以在从副本上执行。
再次强调,只有主副本才可以接受写请求:从客户端的角度来看,从副本都是只读的。如果由于某种原因,例如与主节点之间的网络中断而导致主节点无法连接,主从复制方案就会影响所有的写入操作。
【中级】同步复制、半同步复制、异步复制有什么差异?
要点
一般,复制速度会非常快;但是,系统不能保证复制多久能完成。有些情况下,从节点可能落后主节点几分钟甚至更长时间,例如:从节点刚从故障中恢复;或系统已经接近最大设计上限;或节点之间的网络出现问题。
全同步复制的优缺点:
- 优点:只有所有从节点都完成复制,才视为成功,因此是强一致的。
- 缺点:即使只有一个从节点未完成复制,写入都不能视为成功。所有从节点完成复制过程之前,主节点会阻塞后续所有的写操作。
因此,把所有从节点都配置为同步复制有些不切实际。因为这样的话,任何一个同步节点的中断都会导致整个系统更新停滞不前。
全异步复制的优缺点:
- 优点:不管从节点上数据多么滞后,主节点总是可以继续响应写请求,系统的性能更好。
- 缺点:如果主节点发生故障且不可恢复,则所有尚未复制到从节点的写请求都会丢失。
还有一种折中的方案——半同步复制:只要有一个从节点或半数以上的从节点同步成功,就视为同步,直接返回结果;剩下的节点都通过异步方式同步。万一同步的从节点变得不可用或性能下降,则将另一个异步的从节点提升为同步模式。这样可以保证至少有两个节点(即主节点和一个同步从节点)拥有最新的数据副本。
【中级】新的从节点如何复制主节点数据?
要点
两种不可行的方案:
- 由于主节点会源源不断接受新的写入数据,数据始终处于变化中,因此一次性从主节点复制数据到从节点是无法保证数据一致的。
- 另一种思路是:考虑锁定数据库(使其不可写)来使磁盘上的文件保持一致,但这会违反高可用的设计目标。
可行的方案:
- 生成主节点某时刻的快照,避免长时间锁定数据库。
- 将快照复制到从节点。
- 从节点复制主节点快照过程中,所有的数据变更写入一个日志中(这个数据变更日志在不同数据库中有着不同的称呼,Mysql 称其为 binlog;Redis 称其为 AOF)。
- 从节点复制完主节点的快照后,请求数据变更日志中的数据,并基于此补全数据,这个过程称为追赶,直至主从数据一致。井重复步骤 1 ~步骤 4 。
【高级】如何通过主从复制技术来实现系统高可用呢?
要点
从节点失效:追赶式恢复
从节点的本地磁盘上都保存了副本收到的数据变更日志。如果从节点从故障中恢复,可以和主节点对比数据变更日志的偏移量,从而确认数据是否滞后。如果数据存在滞后,则向主节点请求数据变更日志,并补全数据。这个过程称为追赶。
主节点失效:节点切换
主节点失效后,需要选举出新主节点。然后,客户端需要更新路由,将所有写请求发送给新的主节点;其他从节点要接受来自新的主节点上的变更数据。这个过程称之为切换。
主节点切换可以手动或自动进行。自动切换的步骤通常如下:
- 确认主节点失效。有很多种出错可能性,很难准确检测出问题的原因。所以,大多数系统都基于超时机制来确认主节点是否失效:节点间频繁地互相发生发送心跳存活悄息,如果发现某一个节点在一段比较长时间内没有响应,即认为该节点发生失效。
- 选举新的主节点。基于多数派共识选主。候选节点最好与原主节点的数据差异最小,这样可以最小化数据丢失的风险。
- 重新配置系统使新主节点生效。客户端现在需要将写请求发送给新的主节点。原主节点若恢复,需降级处理,避免脑裂。
【高级】复制日志如何实现?
要点
复制日志的视线方式:
- 基于语句的复制 - 将数据写操作写入日志。主要缺点是必须完全按照相同顺序执行,否则可能会产生不同的结果。
- 基于预写日志(WAL)传输 - 通常每个写操作都是以追加写的方式写入到日志中。主要缺点是日志描述的数据结果非常底层,如果数据库不同版本的存储格式存在差异,就可能无法兼容。
- 对于日志结构存储引擎,日志是主要的存储方式。日志段在后台压缩井支持垃圾回收。
- 对于采用覆写磁盘的 BTree 结构,每次修改会预先写入日志,如系统发生崩溃,通过索引更新的方式迅速恢复到此前一致状态。
- 基于行的逻辑日志复制 - 如果复制和存储引擎采用不同的日志格式,这样复制与存储的逻辑就可以剥离。这种复制日志称为逻辑日志,以区分物理存储引擎的数据表示。
- 基于触发器的复制 - 这种方式很灵活,可以定制化控制复制逻辑。主要缺点是复制开销更高,也更容易出错。
【高级】多主复制是如何工作的?
要点
对主从复制模型进行自然的扩展,则可以配置多个主节点,每个主节点都可以接受写操作,后面复制的流程类似:处理写的每个主节点都必须将该数据更改转发到所有其他节点。这就是多主节点( 也称为主-主,或主动/主动)复制。此时,每个主节点还同时扮演其他主节点的从节点。
在一个数据中心内部使用多主节点基本没有太大意义,其复杂性已经超过所能带来的好处。
但是,以下场景这种配置则是合理的:
- 多数据中心
- 离线客户端操作
- 协作编辑
多数据中心
有了多主节点复制模型,则可以在每个数据中心都配置主节点。在每个数据中心内,采用常规的主从复制方案;而在数据中心之间,由各个数据中心的主节点来负责同其他数据中心的主节点进行数据的交换、更新。
部署单主节点的主从复制方案与多主复制方案之间的差异
- 性能:对于主从复制,每个写请求都必须经由广域网传送至主节点所在的数据中心。这会大大增加写入延迟,井基本偏离了采用多数据中心的初衷(即就近访问)。而在多主节点模型中,每个写操作都可以在本地数据中心快速响应,然后采用异步复制方式将变化同步到其他数据中心。因此,对上层应用有效屏蔽了数据中心之间的网络延迟,使得终端用户所体验到的性能更好。
- 容忍数据中心失效:对于主从复制,如果主节点所在的数据中心发生故障,必须切换至另一个数据中心,将其中的一个从节点被提升为主节点。在多主节点模型中,每个数据中心则可以独立于其他数据中心继续运行,发生故障的数据中心在恢复之后更新到最新状态。
- 容忍网络问题:数据中心之间的通信通常经由广域网,它往往不如数据中心内的本地网络可靠。对于主从复制模型,由于写请求是同步操作,对数据中心之间的网络性能和稳定性等更加依赖。多主节点模型则通常采用异步复制,可以更好地容忍此类问题,例如临时网络闪断不会妨碍写请求最终成功。
【高级】无主复制是如何工作的?
要点
无主复制模式,系统中不存在主节点,每一个节点都能接受客户端的写请求。此外,读取时从多个节点上并行读取,以此检测和纠正某些过期数据。
读修复和反熵
复制模型应确保所有数据最终复制到所有的副本。当一个失效的节点重新上线之后,如何赶上中间错过的那些写请求呢?
有以下两种机制:
- 读修复 - 客户端并行读取多个副本,根据版本识别过期返回值并更新最新值到相应副本。这种方法主要适合那些被频繁读取的场景。
- 反熵 - 利用后台进程不断查找副本间的数据差异,将任何缺少的数据从一个副本复制到另一个副本。与基于主节点复制的复制日志不同,反熵过程并不保证以特定的顺序复制写入,并且会引入明显的同步滞后。
QuorumNWR 算法
无主复制模式中,究竟多少个副本完成才可以认为写成功?
如果有 n 个副本,写人需要 w 个节点确认,读取必须至少查询 r 个节点, 则只要 w+r>n
,读取的节点中一定会包含最新值。
并发写冲突
无主模式中,并发向多副本写操作,以及读时修复或数据回传都会导致并发写冲突。如何解决冲突呢?有以下几种机制:
- 最后写入者获胜(丢弃并发写入) - 每个副本总是保存最新值,允许覆盖井丢弃旧值。
- Happens Before - 利用全序的逻辑时钟来确定事件发生的前后顺序。
- 向量时钟、版本向量时钟 - 本质上是将全序的逻辑时钟改造为维护所有副本版本号的合集,基于此合集可以进行偏序比较。
分区
【基础】什么是分区?为什么要分区?
要点
分区通常是这样定义的,即每一条数据(或者每条记录,每行或每个文档)只属于某个特定分区。实际上,每个分区都可以视为一个完整的小型数据库,虽然数据库可能存在一些跨分区的操作。
在不同系统中,分区有着不同的称呼,例如它对应于 MongoDB, Elasticsearch 和 SolrCloud 中的 shard, HBase 的 region, Bigtable 中的 tablet, Cassandra 和 Riak 中的 vnode ,以及 Couch base 中的 vBucket。总体而言,分区是最普遍的术语。
数据量如果太大,单台机器进行存储和处理就会成为瓶颈,因此需要引入数据分区机制。
分区的目地是通过多台机器均匀分布数据和查询负载,避免出现热点。这需要选择合适的数据分区方案,在节点添加或删除时重新动态平衡分区。
【中级】分区有哪些模式?
要点
分区通常与复制结合使用,即每个分区在多个节点都存有副本。这意味着某条记录属于特定的分区,而同样的内容会保存在不同的节点上以提高系统的容错性。
一个节点上可能存储了多个分区。每个分区都有自己的主副本,例如被分配给某节点,而从副本则分配在其他一些节点。一个节点可能既是某些分区的主副本,同时又是其他分区的从副本。
分区主要有两种模式:
- 基于关键字区间的分区 - 先对关键字进行排序,每个分区只负责一段包含最小到最大关键字范围的一段关键字。对关键字排序的优点是可以支持高效的区间查询,但是如果应用程序经常访问与排序一致的某段关键字,就会存在热点的风险。采用这种方怯,当分区太大时,通常将其分裂为两个子区间,从而动态地再平衡分区。典型代表:HBase
- 哈希分区 - 将哈希函数作用于每个关键字,每个分区负责一定范围的哈希值。这种方法打破了原关键字的顺序关系,它的区间查询效率比较低,但可以更均匀地分配负载。采用哈希分区时,通常事先创建好足够多(但固定数量)的分区, 让每个节点承担多个分区,当添加或删除节点时将某些分区从一个节点迁移到另一个节点,也可以支持动态分区。典型代表:Elasticsearch、Redis。
【高级】二级索引如何分区?
要点
二级索引是关系数据库的必备特性,在文档数据库中应用也非常普遍。但考虑到其复杂性,许多键值存储(如 HBase 和 Voldemort)并不支持二级索引。此外, 二级索引技术也是 Solr 和 Elasticsearch 等搜索引擎数据库存在之根本。
分区不仅仅是针对数据,二级索引也需要分区。通常有两种方法:
基于文档来分区二级索引(本地索引) - 二级索引存储在与关键字相同的分区中,这意味着写入时我们只需要更新一个分区,但缺点是读取二级索引时需要在所有分区上并行执行。它广泛用于实践: MongoDB 、Riak、Cassandra、Elasticsearch 、SolrCloud 和 VoltDB 都支持基于文档分区二级索引。
基于词条来分区二级索引(全局索引) - 它是基于索引的值而进行的独立分区。二级索引中的条目可能包含来自关键字的多个分区里的记录。在写入时,不得不更新二级索引的多个分区;但读取时,则可以从单个分区直接快速提取数据。
【基础】什么是分区再均衡?
要点
集群节点数变化,数据规模增长等情况,都会导致分区的分布不均。要保持分区的均衡,势必要将数据和请求进行迁移,这样一个迁移负载的过程称为分区再均衡。
【高级】分区再均衡有哪些策略?
要点
固定数量的分区
创建远超实际节点数的分区数,然后为每个节点分配多个分区。接下来, 如果集群中添加了一个新节点,该新节点可以从每个现有的节点上匀走几个分区,直到分区再次达到全局平衡。
选中的整个分区会在节点之间迁移,但分区的总数量仍维持不变,也不会改变关键字到分区的映射关系。这里唯一要调整的是分区与节点的对应关系。考虑到节点间通过网络传输数据总是需要些时间,这样调整可以逐步完成,在此期间,旧分区仍然可以接收读写请求。
原则上,也可以将集群中的不同的硬件配置因素考虑进来,即性能更强大的节点将分配更多的分区,从而分担更多的负载。
目前,Riak、Elasticsearch、Couchbase 和 Voldemort 都支持这种动态平衡方法。
使用该策略时,分区的数量往往在数据库创建时就确定好,之后不会改变。原则上也可以拆分和合并分区(稍后介绍),但固定数量的分区使得相关操作非常简单,因此许多采用固定分区策略的数据库决定不支持分区拆分功能。所以,在初始化时,已经充分考虑将来扩容增长的需求(未来可能拥有的最大节点数),设置一个足够大的分区数。而每个分区也有些额外的管理开销,选择过高的数字可能会有副作用。
动态分区
对于采用关键宇区间分区的数据库,如果边界设置有问题,最终可能会出现所有数据都挤在一个分区而其他分区基本为空,那么设定固定边界、固定数量的分区将非常不便:而手动去重新配置分区边界又非常繁琐。
因此, 一些数据库如 HBase 和 RethinkDB 等采用了动态创建分区。当分区的数据增长超过一个可配的参数阔值(HBase 上默认值是 10GB),它就拆分为两个分区,每个承担一半的数据量。相反,如果大量数据被删除,并且分区缩小到某个阈值以下,则将其与相邻分区进行合井。该过程类似于 B 树的分裂操作。
每个分区总是分配给一个节点,而每个节点可以承载多个分区,这点与固定数量的分区一样。当一个大的分区发生分裂之后,可以将其中的一半转移到其他某节点以平衡负载。对于 HBase,分区文件的传输需要借助 HDFS。
动态分区的一个优点是分区数量可以自动适配数据总量。如果只有少量的数据,少量的分区就足够了,这样系统开销很小;如果有大量的数据,每个分区的大小则被限制在一个可配的最大值。
但是,需要注意的是,对于一个空的数据库, 因为没有任何先验知识可以帮助确定分区的边界,所以会从一个分区开始。可能数据集很小,但直到达到第一个分裂点之前,所有的写入操作都必须由单个节点来处理, 而其他节点则处于空闲状态。为了缓解这个问题,HBase 和 MongoDB 允许在一个空的数据库上配置一组初始分区(这被称为预分裂)。对于关键字区间分区,预分裂要求已经知道一些关键字的分布情况。
动态分区不仅适用于关键字区间分区,也适用于基于哈希的分区策略。MongoDB 从版本 2.4 开始,同时支持二者,井且都可以动态分裂分区。
按节点比例分区
采用动态分区策略,拆分和合并操作使每个分区的大小维持在设定的最小值和最大值之间,因此分区的数量与数据集的大小成正比关系。另一方面,对于固定数量的分区方式,其每个分区的大小也与数据集的大小成正比。两种情况,分区的数量都与节点数无关。
Cassandra 和 Ketama 则采用了第三种方式,使分区数与集群节点数成正比关系。换句话说,每个节点具有固定数量的分区。此时, 当节点数不变时,每个分区的大小与数据集大小保持正比的增长关系; 当节点数增加时,分区则会调整变得更小。较大的数据量通常需要大量的节点来存储,因此这种方法也使每个分区大小保持稳定。
当一个新节点加入集群时,它随机选择固定数量的现有分区进行分裂,然后拿走这些分区的一半数据量,将另一半数据留在原节点。随机选择可能会带来不太公平的分区分裂,但是当平均分区数量较大时(Cassandra 默认情况下,每个节点有 256 个分区),新节点最终会从现有节点中拿走相当数量的负载。Cassandra 在 3.0 时推出了改进算洁,可以避免上述不公平的分裂。
随机选择分区边界的前提要求采用基于哈希分区(可以从哈希函数产生的数字范围里设置边界)。这种方法也最符合本章开头所定义一致性哈希。一些新设计的哈希函数也可以以较低的元数据开销达到类似的效果。
【高级】如何确定读写请求发往哪个节点?
要点
当数据集分布到多个节点上,需要解决一个问题:当客户端发起请求时,如何知道应该连接哪个节点?如果发生了分区再平衡,分区与节点的对应关系随之还会变化。
这其实属于一类典型的服务发现问题,任何通过网络访问的系统都有这样的需求,尤其是当服务目标支持高可用时(在多台机器上有冗余配置)。
服务发现有以下处理策略:
- 允许客户端链接任意的节点(例如,采用循环式的负载均衡器)。如果某节点恰好拥有所请求的分区,则直接处理该请求:否则,将请求转发到下一个合适的节点,接收答复,并将答复返回给客户端。
- 将所有客户端的请求都发送到一个路由层,由后者负责将请求转发到对应的分区节点上。路由层本身不处理任何请求,它仅充一个分区感知的负载均衡器。
- 客户端感知分区和节点分配关系。此时,客户端可以直接连接到目标节点,而不需要任何中介。
许多分布式数据系统依靠独立的协调服务(如 ZooKeeper )跟踪集群范围内的元数据。每个节点都向 ZooKeeper 中注册自己, ZooKeeper 维护了分区到节点的最终映射关系。其他参与者(如路由层或分区感知的客户端)可以向 ZooKeeper 订阅此信息。一旦分区发生了改变,或者添加、删除节点, ZooKeeper 就会主动通知路由层,这样使路由信息保持最新状态。
例如,HBase、SolrCloud 和 Kafka 也使用 ZooKeeper 来跟踪分区分配情况。MongoDB 有类似的设计,但它依赖于自己的配置服务器和 mongos 守护进程来充当路由层。
Cassandra 和 Redis 则采用了不同的方法,它们在节点之间使用 gossip 协议来同步群集状态的变化。请求可以发送到任何节点,由该节点负责将其转发到目标分区节点。这种方式增加了数据库节点的复杂性,但是避免了对 ZooKeeper 之类的外部协调服务的依赖。
共识
分布式事务
扩展:
【基础】什么是事务?什么是分布式事务?
要点
事务将多个读、写操作捆绑在一起成为一个逻辑操作单元。事务中的所有读写是一个执行的整体,整个事务要么成功(提交)、要么失败(中止或回滚)。
在单一数据节点中,事务仅限于对单一数据库资源的访问控制,称之为本地事务。几乎所有的成熟的关系型数据库都提供了对本地事务的原生支持。
分布式事务指的是事务操作跨越多个节点,并且要求满足事务的 ACID 特性。
【基础】什么是 ACID?什么是 BASE?二者有何区别?
要点
ACID
ACID 是数据库事务正确执行的四个基本要素的单词缩写:
- 原子性(Atomicity)
- 原子是指不可分解为更小粒度的东西。事务的原子性意味着:事务中的所有操作要么全部成功,要么全部失败。
- 回滚可以用日志来实现,日志记录着事务所执行的修改操作,在回滚时反向执行这些修改操作即可。
- ACID 中的原子性并不关乎多个操作的并发性,它并没有描述多个线程试图访问相同的数据会发生什么情况,后者其实是由 ACID 的隔离性所定义。
- 一致性(Consistency)
- 数据库在事务执行前后都保持一致性状态。
- 在一致性状态下,所有事务对一个数据的读取结果都是相同的。
- 一致性本质上要求应用层来维护状态一致(或者恒等),应用程序有责任正确地定义事务来保持一致性。这不是数据库可以保证的事情。
- 隔离性(Isolation)
- 同时运行的事务互不干扰。换句话说,一个事务所做的修改在最终提交以前,对其它事务是不可见的。
- 持久性(Durability)
- 一旦事务提交,则其所做的修改将会永远保存到数据库中。即使系统发生崩溃,事务执行的结果也不能丢失。
- 可以通过数据库备份和恢复来实现,在系统发生奔溃时,使用备份的数据库进行数据恢复。
BASE
BASE 是 基本可用(Basically Available)
、软状态(Soft State)
和 最终一致性(Eventually Consistent)
三个短语的缩写。
BASE 理论的核心思想是:即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。
- **基本可用(Basically Available)**分布式系统在出现故障的时候,保证核心可用,允许损失部分可用性。例如,电商在做促销时,为了保证购物系统的稳定性,部分消费者可能会被引导到一个降级的页面。
- 软状态(Soft State)指允许系统中的数据存在中间状态,并认为该中间状态不会影响系统整体可用性,即允许系统不同节点的数据副本之间进行同步的过程存在延时。
- 最终一致性(Eventually Consistent)强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能达到一致的状态。
BASE vs. ACID
ACID 要求强一致性,通常运用在传统的数据库系统上。而 BASE 要求最终一致性,通过牺牲强一致性来达到可用性,通常运用在大型分布式系统中。BASE 唯一可以确定的是“它不是 ACID”,此外它几乎没有承诺任何东西。
【基础】什么是一致性?什么是最终一致性?
要点
一致性(Consistency)指的是多个数据副本是否能保持一致的特性。
数据一致性又可以分为以下几点:
- 强一致性 - 数据更新操作结果和操作响应总是一致的,即操作响应通知更新失败,那么数据一定没有被更新,而不是处于不确定状态。
- 最终一致性 - 即物理存储的数据可能是不一致的,终端用户访问到的数据可能也是不一致的,但系统经过一段时间的自我修复和修正,数据最终会达到一致。
在分布式领域,要实现强一致性,代价非常高昂。因此,有人基于 CAP 理论以及 BASE 理论,有人就提出了柔性事务的概念。柔性事务是指:在不影响系统整体可用性的情况下 (Basically Available 基本可用),允许系统存在数据不一致的中间状态 (Soft State 软状态),在经过数据同步的延时之后,达到最终一致性。并不是完全放弃了 ACID,而是通过放宽一致性要求,借助本地事务来实现最终分布式事务一致性的同时也保证系统的吞吐。
【中级】有哪些分布式事务解决方案?各有什么利弊?
要点
分布式事务的常见方案如下:
- 两阶段提交(2PC) - 将事务的提交过程分为两个阶段来进行处理:准备阶段和提交阶段。参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。
- 三阶段提交(3PC) - 与二阶段提交不同的是,引入超时机制。同时在协调者和参与者中都引入超时机制。将二阶段的准备阶段拆分为 2 个阶段,插入了一个 preCommit 阶段,使得原先在二阶段提交中,参与者在准备之后,由于协调者发生崩溃或错误,而导致参与者处于无法知晓是否提交或者中止的“不确定状态”所产生的可能相当长的延时的问题得以解决。
- 补偿事务(TCC)
- Try - 操作作为一阶段,负责资源的检查和预留。
- Confirm - 操作作为二阶段提交操作,执行真正的业务。
- Cancel - 是预留资源的取消。
- 本地消息表 - 在事务主动发起方额外新建事务消息表,事务发起方处理业务和记录事务消息在本地事务中完成,轮询事务消息表的数据发送事务消息,事务被动方基于消息中间件消费事务消息表中的事务。
- 消息事务 - 基于 MQ 的分布式事务方案其实是对本地消息表的封装。
- SAGA - Saga 事务核心思想是将长事务拆分为多个本地短事务,由 Saga 事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。
分布式事务方案对比:
- 2PC/3PC 依赖于数据库,能够很好的提供强一致性和强事务性,但相对来说延迟比较高,比较适合传统的单体应用,在同一个方法中存在跨库操作的情况,不适合高并发和高性能要求的场景。
- TCC 适用于执行时间确定且较短,实时性要求高,对数据一致性要求高,比如互联网金融企业最核心的三个服务:交易、支付、账务。
- 本地消息表/消息事务都适用于事务中参与方支持操作幂等,对一致性要求不高,业务上能容忍数据不一致到一个人工检查周期,事务涉及的参与方、参与环节较少,业务上有对账/校验系统兜底。
- Saga 事务不能保证隔离性,需要在业务层控制并发,适合于业务场景事务并发操作同一资源较少的情况。Saga 相比缺少预提交动作,导致补偿动作的实现比较麻烦,例如业务是发送短信,补偿动作则得再发送一次短信说明撤销,用户体验比较差。Saga 事务较适用于补偿动作容易处理的场景。
2PC | 3PC | TCC | 本地消息表 | MQ 事务 | SAGA | |
---|---|---|---|---|---|---|
数据一致性 | 强 | 强 | 若 | 弱 | 弱 | 弱 |
容错性 | 低 | 低 | 高 | 高 | 高 | 高 |
复杂性 | 中 | 高 | 高 | 低 | 低 | 高 |
性能 | 低 | 低 | 中 | 中 | 高 | 中 |
维护成本 | 低 | 中 | 高 | 中 | 中 | 高 |
【中级】2PC 是如何工作的?
要点
二阶段提交协议(Two-phase Commit,即 2PC)将事务的提交过程分为两个阶段来进行处理:准备阶段和提交阶段。事务的发起者称协调者,事务的执行者称参与者。二阶段提交的思路可以概括为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈,决定提交或回滚。
阶段 1:准备阶段
- 协调者向所有参与者发送事务内容,询问是否可以提交事务,并等待所有参与者答复。
- 各参与者执行事务操作,将 undo 和 redo 信息记入事务日志中(但不提交事务)。
- 如参与者执行成功,给协调者反馈 yes,即可以提交;如执行失败,给协调者反馈 no,即不可提交。
阶段 2:提交阶段
如果协调者收到了参与者的失败消息或者超时,直接给每个参与者发送回滚 (rollback) 消息;否则,发送提交 (commit) 消息;参与者根据协调者的指令执行提交或者回滚操作,释放所有事务处理过程中使用的锁资源。(注意:必须在最后阶段释放锁资源) 接下来分两种情况分别讨论提交阶段的过程。
情况 1,当所有参与者均反馈 yes,提交事务。
- 协调者向所有参与者发出正式提交事务的请求(即 commit 请求)。
- 参与者执行 commit 请求,并释放整个事务期间占用的资源。
- 各参与者向协调者反馈 ack(应答)完成的消息。
- 协调者收到所有参与者反馈的 ack 消息后,即完成事务提交。
情况 2,当任何阶段 1 一个参与者反馈 no,中断事务。
- 协调者向所有参与者发出回滚请求(即 rollback 请求)。
- 参与者使用阶段 1 中的 undo 信息执行回滚操作,并释放整个事务期间占用的资源。
- 各参与者向协调者反馈 ack 完成的消息。
- 协调者收到所有参与者反馈的 ack 消息后,即完成事务中断。
方案总结:
2PC 方案实现起来简单,实际项目中使用比较少,主要因为以下问题:
- 性能问题 - 所有参与者在事务提交阶段处于同步阻塞状态,占用系统资源,容易导致性能瓶颈。
- 可靠性问题 - 如果协调者存在单点故障问题,如果协调者出现故障,参与者将一直处于锁定状态。
- 数据一致性问题 - 在阶段 2 中,如果发生局部网络问题,一部分事务参与者收到了提交消息,另一部分事务参与者没收到提交消息,那么就导致了节点之间数据的不一致。
【中级】3PC 是如何工作的?
要点
三阶段提交协议(Three-phase Commit,3PC),是二阶段提交协议的改进版本,与二阶段提交不同的是,引入超时机制。同时在协调者和参与者中都引入超时机制。
阶段 1:canCommit
协调者向参与者发送 commit 请求,参与者如果可以提交就返回 yes 响应(参与者不执行事务操作),否则返回 no 响应:
- 协调者向所有参与者发出包含事务内容的 canCommit 请求,询问是否可以提交事务,并等待所有参与者答复。
- 参与者收到 canCommit 请求后,如果认为可以执行事务操作,则反馈 yes 并进入预备状态,否则反馈 no。
阶段 2:preCommit
协调者根据阶段 1 canCommit 参与者的反应情况来决定是否可以基于事务的 preCommit 操作。根据响应情况,有以下两种可能。
情况 1:阶段 1 所有参与者均反馈 yes,参与者预执行事务。
- 协调者向所有参与者发出 preCommit 请求,进入准备阶段。
- 参与者收到 preCommit 请求后,执行事务操作,将 undo 和 redo 信息记入事务日志中(但不提交事务)。
- 各参与者向协调者反馈 ack 响应或 no 响应,并等待最终指令。
情况 2:阶段 1 任何一个参与者反馈 no,或者等待超时后协调者尚无法收到所有参与者的反馈,即中断事务。
- 协调者向所有参与者发出 abort 请求。
- 无论收到协调者发出的 abort 请求,或者在等待协调者请求过程中出现超时,参与者均会中断事务。
阶段 3:doCommit
该阶段进行真正的事务提交,也可以分为以下两种情况:
情况 1:阶段 2 所有参与者均反馈 ack 响应,执行真正的事务提交。
- 如果协调者处于工作状态,则向所有参与者发出 doCommit 请求。
- 参与者收到 doCommit 请求后,会正式执行事务提交,并释放整个事务期间占用的资源。
- 各参与者向协调者反馈 ack 完成的消息。
- 协调者收到所有参与者反馈的 ack 消息后,即完成事务提交。
情况 2:任何一个参与者反馈 no,或者等待超时后协调者尚无法收到所有参与者的反馈,即中断事务。
- 如果协调者处于工作状态,向所有参与者发出 abort 请求。
- 参与者使用阶段 1 中的 undo 信息执行回滚操作,并释放整个事务期间占用的资源。
- 各参与者向协调者反馈 ack 完成的消息。
- 协调者收到所有参与者反馈的 ack 消息后,即完成事务中断。
注意:进入阶段 3 后,无论协调者出现问题,或者协调者与参与者网络出现问题,都会导致参与者无法接收到协调者发出的 doCommit 请求或 abort 请求。此时,参与者都会在等待超时之后,继续执行事务提交。
方案总结:
优点:相比二阶段提交,三阶段降低了阻塞范围,在等待超时后协调者或参与者会中断事务。避免了协调者单点问题,阶段 3 中协调者出现问题时,参与者会继续提交事务。
缺点:数据不一致问题依然存在,当在参与者收到 preCommit 请求后等待 doCommit 指令时,此时如果协调者请求中断事务,而协调者无法与参与者正常通信,会导致参与者继续提交事务,造成数据不一致。
【中级】TCC 是如何工作的?
要点
TCC 是服务化的二阶段编程模型,其 Try、Confirm、Cancel 3 个方法均由业务编码实现;
- Try - 操作作为一阶段,负责资源的检查和预留。
- Confirm - 操作作为二阶段提交操作,执行真正的业务。
- Cancel - 是预留资源的取消。
TCC 事务的 Try、Confirm、Cancel 可以理解为 SQL 事务中的 Lock、Commit、Rollback。
Try 阶段
从执行阶段来看,与传统事务机制中业务逻辑相同。但从业务角度来看,却不一样。TCC 机制中的 Try 仅是一个初步操作,它和后续的确认一起才能真正构成一个完整的业务逻辑,这个阶段主要完成:
- 完成所有业务检查(一致性)
- 预留必须业务资源(准隔离性)
- Try 尝试执行业务 TCC 事务机制以初步操作(Try)为中心的,确认操作(Confirm)和取消操作(Cancel)都是围绕初步操作(Try)而展开。因此,Try 阶段中的操作,其保障性是最好的,即使失败,仍然有取消操作(Cancel)可以将其执行结果撤销。
假设商品库存为 100,购买数量为 2,这里检查和更新库存的同时,冻结用户购买数量的库存,同时创建订单,订单状态为待确认。
Confirm / Cancel 阶段
根据 Try 阶段服务是否全部正常执行,继续执行确认操作(Confirm)或取消操作(Cancel)。 Confirm 和 Cancel 操作满足幂等性,如果 Confirm 或 Cancel 操作执行失败,将会不断重试直到执行完成。
Confirm:当 Try 阶段服务全部正常执行, 执行确认业务逻辑操作
这里使用的资源一定是 Try 阶段预留的业务资源。在 TCC 事务机制中认为,如果在 Try 阶段能正常的预留资源,那 Confirm 一定能完整正确的提交。Confirm 阶段也可以看成是对 Try 阶段的一个补充,Try+Confirm 一起组成了一个完整的业务逻辑。
Cancel:当 Try 阶段存在服务执行失败, 进入 Cancel 阶段
Cancel 取消执行,释放 Try 阶段预留的业务资源,上面的例子中,Cancel 操作会把冻结的库存释放,并更新订单状态为取消。
方案总结
TCC 事务机制相比于上面介绍的 XA 事务机制,有以下优点:
- 性能提升 - 具体业务来实现控制资源锁的粒度变小,不会锁定整个资源。
- 数据最终一致性 - 基于 Confirm 和 Cancel 的幂等性,保证事务最终完成确认或者取消,保证数据的一致性。
- 可靠性 - 解决了 XA 协议的协调者单点故障问题,由主业务方发起并控制整个业务活动,业务活动管理器也变成多点,引入集群。
缺点: TCC 的 Try、Confirm 和 Cancel 操作功能要按具体业务来实现,业务耦合度较高,提高了开发成本。
【高级】本地消息表是如何工作的?
要点
本地消息表的核心思路是将分布式事务拆分成本地事务进行处理。
方案通过在事务主动发起方额外新建事务消息表,事务发起方处理业务和记录事务消息在本地事务中完成,轮询事务消息表的数据发送事务消息,事务被动方基于消息中间件消费事务消息表中的事务。
这样设计可以避免”业务处理成功 + 事务消息发送失败",或"业务处理失败 + 事务消息发送成功"的棘手情况出现,保证 2 个系统事务的数据一致性。
事务的主动方需要额外新建事务消息表,用于记录分布式事务的消息的发生、处理状态。
整个业务处理流程如下:
- 步骤 1、事务主动方处理本地事务。 事务主动发在本地事务中处理业务更新操作和写消息表操作。 上面例子中库存服务阶段再本地事务中完成扣减库存和写消息表(图中 1、2)。
- 步骤 2、事务主动方通过 MQ 通知事务被动方处理事务。 消息中间件可以基于 Kafka、RocketMQ 消息队列,事务主动方法主动写消息到消息队列,事务消费方消费并处理消息队列中的消息。 上面例子中,库存服务把事务待处理消息写到消息中间件,订单服务消费消息中间件的消息,完成新增订单(图中 3 - 5)。
- 步骤 3、事务被动方通过 MQ 返回处理结果。 上面例子中,订单服务把事务已处理消息写到消息中间件,库存服务消费中间件的消息,并将事务消息的状态更新为已完成(图中 6 - 8)
为了数据的一致性,当处理错误需要重试,事务发送方和事务接收方相关业务处理需要支持幂等。具体保存一致性的容错处理如下:
- 当步骤 1 处理出错,事务回滚,相当于什么都没发生。
- 当步骤 2、步骤 3 处理出错,由于未处理的事务消息还是保存在事务发送方,事务发送方可以定时轮询超时 d 的消息数据,再次发送消息到 MQ 进行处理。事务被动方消费事务消息重试处理。
- 如果是业务上的失败,事务被动方可以发消息给事务主动方进行回滚。
- 如果多个事务被动方已经消费消息,事务主动方需要回滚事务时需要通知事务被动方回滚。
方案总结
方案的优点如下:
- 从应用设计开发的角度实现了消息数据的可靠性,消息数据的可靠性不依赖于消息中间件,弱化了对 MQ 中间件特性的依赖。
- 方案简单,容易实现。
缺点如下:
- 与具体的业务场景绑定,耦合性高,不可复用。
- 需要额外维护消息数据的传输,占用业务系统资源。
- 业务系统在使用关系型数据库的情况下,消息服务性能会受到关系型数据库并发性能的局限。
【高级】消息事务是如何工作的?
要点
MQ 事务方案本质是利用 MQ 功能实现的本地消息表。事务消息需要消息队列提供相应的功能才能实现,Kafka 和 RocketMQ 都提供了事务相关功能。
- Kafka 的解决方案是:直接抛出异常,让用户自行处理。用户可以在业务代码中反复重试提交,直到提交成功,或者删除之前修改的数据记录进行事务补偿。
- RocketMQ 的解决方案是:通过事务反查机制来解决事务消息提交失败的问题。如果 Producer 在提交或者回滚事务消息时发生网络异常,RocketMQ 的 Broker 没有收到提交或者回滚的请求,Broker 会定期去 Producer 上反查这个事务对应的本地事务的状态,然后根据反查结果决定提交或者回滚这个事务。为了支撑这个事务反查机制,业务代码需要实现一个反查本地事务状态的接口,告知 RocketMQ 本地事务是成功还是失败。
RocketMQ 事务消息实现
事务消息是 Apache RocketMQ 提供的一种高级消息类型,支持在分布式场景下保障消息生产和本地事务的最终一致性。
事务消息处理流程
事务消息交互流程如下图所示。
- 生产者将消息发送至 Apache RocketMQ 服务端。
- Apache RocketMQ 服务端将消息持久化成功之后,向生产者返回 Ack 确认消息已经发送成功,此时消息被标记为"暂不能投递",这种状态下的消息即为半事务消息。
- 生产者开始执行本地事务逻辑。
- 生产者根据本地事务执行结果向服务端提交二次确认结果(Commit 或是 Rollback),服务端收到确认结果后处理逻辑如下:
- 二次确认结果为 Commit:服务端将半事务消息标记为可投递,并投递给消费者。
- 二次确认结果为 Rollback:服务端将回滚事务,不会将半事务消息投递给消费者。
- 在断网或者是生产者应用重启的特殊情况下,若服务端未收到发送者提交的二次确认结果,或服务端收到的二次确认结果为 Unknown 未知状态,经过固定时间后,服务端将对消息生产者即生产者集群中任一生产者实例发起消息回查。 说明 服务端回查的间隔时间和最大回查次数,请参见 参数限制。
- 生产者收到消息回查后,需要检查对应消息的本地事务执行的最终结果。
- 生产者根据检查到的本地事务的最终状态再次提交二次确认,服务端仍按照步骤 4 对半事务消息进行处理。
MQ 事务方案总结
相比本地消息表方案,MQ 事务方案优点是:
- 业务解耦 - 消息数据独立存储 ,降低业务系统与消息系统之间的耦合。
- 吞吐量优于本地消息表方案。
缺点是:
- 一次消息发送需要两次网络请求 (half 消息 + commit/rollback 消息)
- 业务处理服务需要实现消息状态回查接口
【高级】SAGA 事务是如何工作的?
要点
Saga 事务的核心思想是:将长事务拆分为多个本地短事务,由 Saga 事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序依次调用补偿操作。
Saga 事务基本协议如下:
- 将长事务拆分为多个有序子事务 - 每个 Saga 事务由一系列幂等的有序子事务 (sub-transaction) Ti 组成。
- 每个子事务 Ti 都有对应的幂等补偿动作 Ci,补偿动作用于撤销 Ti 造成的结果。
可以看到,和 TCC 相比,Saga 没有“预留”动作,它的 Ti 就是直接提交到库。
下面以下单流程为例,整个操作包括:创建订单、扣减库存、支付、增加积分 Saga 的执行顺序有两种:
- 事务正常执行完成 T1, T2, T3, ..., Tn,例如:扣减库存 (T1),创建订单 (T2),支付 (T3),依次有序完成整个事务。
- 事务回滚 T1, T2, ..., Tj, Cj,..., C2, C1,其中 0 < j < n,例如:扣减库存 (T1),创建订单 (T2),支付 (T3,支付失败),支付回滚 (C3),订单回滚 (C2),恢复库存 (C1)。
恢复策略
Saga 定义了两种恢复策略:
- 向前恢复 (forward recovery)
对应于上面第一种执行顺序,适用于必须要成功的场景,失败需要进行重试,执行顺序是类似于这样的:T1, T2, ..., Tj(失败), Tj(重试),..., Tn,其中 j 是发生错误的子事务 (sub-transaction)。该情况下不需要 Ci。
- 向后恢复 (backward recovery)
对应于上面提到的第二种执行顺序,其中 j 是发生错误的子事务 (sub-transaction),这种做法的效果是撤销掉之前所有成功的子事务,使得整个 Saga 的执行结果撤销。
Saga 事务常见的有两种不同的实现方式:命令协调和事件编排。
命令协调
- 命令协调 (Order Orchestrator):中央协调器负责集中处理事件的决策和业务逻辑排序。
中央协调器(Orchestrator,简称 OSO)以命令/回复的方式与每项服务进行通信,全权负责告诉每个参与者该做什么以及什么时候该做什么。
以电商订单的例子为例:
- 事务发起方的主业务逻辑请求 OSO 服务开启订单事务。
- OSO 向库存服务请求扣减库存,库存服务回复处理结果。
- OSO 向订单服务请求创建订单,订单服务回复创建结果。
- OSO 向支付服务请求支付,支付服务回复处理结果。
- 主业务逻辑接收并处理 OSO 事务处理结果回复。
中央协调器必须事先知道执行整个订单事务所需的流程(例如通过读取配置)。如果有任何失败,它还负责通过向每个参与者发送命令来撤销之前的操作来协调分布式的回滚。基于中央协调器协调一切时,回滚要容易得多,因为协调器默认是执行正向流程,回滚时只要执行反向流程即可。
事件编排
- 事件编排 (Event Choreography0:没有中央协调器(没有单点风险)时,每个服务产生并观察其他服务的事件,并决定是否应采取行动。
在事件编排方法中,第一个服务执行一个事务,然后发布一个事件。该事件被一个或多个服务进行监听,这些服务再执行本地事务并发布(或不发布)新的事件。
当最后一个服务执行本地事务并且不发布任何事件时,意味着分布式事务结束,或者它发布的事件没有被任何 Saga 参与者听到都意味着事务结束。
以电商订单的例子为例:
- 事务发起方的主业务逻辑发布开始订单事件
- 库存服务监听开始订单事件,扣减库存,并发布库存已扣减事件
- 订单服务监听库存已扣减事件,创建订单,并发布订单已创建事件
- 支付服务监听订单已创建事件,进行支付,并发布订单已支付事件
- 主业务逻辑监听订单已支付事件并处理。
事件编排是实现 Saga 模式的自然方式,它很简单,容易理解,不需要太多的代码来构建。如果事务涉及 2 至 4 个步骤,则可能是非常合适的。
方案总结
命令协调设计的优点和缺点:
优点如下:
- 服务之间关系简单,避免服务之间的循环依赖关系,因为 Saga 协调器会调用 Saga 参与者,但参与者不会调用协调器
- 程序开发简单,只需要执行命令/回复(其实回复消息也是一种事件消息),降低参与者的复杂性。
- 易维护扩展,在添加新步骤时,事务复杂性保持线性,回滚更容易管理,更容易实施和测试
缺点如下:
- 中央协调器容易处理逻辑容易过于复杂,导致难以维护。
- 存在协调器单点故障风险。
事件/编排设计的优点和缺点
优点如下:
- 避免中央协调器单点故障风险。
- 当涉及的步骤较少服务开发简单,容易实现。
缺点如下:
- 服务之间存在循环依赖的风险。
- 当涉及的步骤较多,服务间关系混乱,难以追踪调测。
值得补充的是,由于 Saga 模型中没有 Prepare 阶段,因此事务间不能保证隔离性,当多个 Saga 事务操作同一资源时,就会产生更新丢失、脏数据读取等问题,这时需要在业务层控制并发,例如:在应用层面加锁,或者应用层面预先冻结资源。
分布式锁
扩展:
【初级】什么是分布式锁?为什么需要分布式锁?
要点
在计算机科学中,锁是在并发场景下用于强行限制资源访问的一种同步机制,即用于在并发控制中通过互斥手段来保证数据同步安全。
在 Java 进程中,可以使用 Lock、synchronized 等来支持并发锁。如果是同一台机器的不同进程,想要同时操作一个共享资源(例如修改同一个文件),可以使用操作系统提供的「文件锁」或「信号量」来做互斥。这些发生在同一台机器上的互斥操作,可以称为本地锁。
本地锁无法协同不同机器间的互斥操作。为了解决这个问题,需要引入分布式锁。
分布式锁,顾名思义,应用于分布式场景下,它和单进程中的锁并没有本质上的不同,只是控制对象由一个进程中的多个线程变成了多个进程中的多个线程。此外,临界区的资源也由进程内共享资源变成了分布式系统内部共享资源。
【高级】实现分布式锁有哪些要点?
要点
分布式锁的实现要点如下:
- 互斥 - 分布式锁必须是独一无二的,表现形式为:向数据存储插入一个唯一的 key,一旦有一个线程插入这个 key,其他线程就不能再插入了。
- 保证 key 唯一性的最简单的方式是使用 UUID。
- 此外,可以参考 Snowflake ID(雪花算法),将机器地址(IP 地址、机器 ID、MAC 地址)、Jvm 进程 ID(应用 ID、服务 ID)、时间戳等关键信息拼接起来作为唯一标识。
- 避免死锁 - 在分布式锁的场景中,部分失败和异步网络这两个问题是同时存在的。如果一个进程获得了锁,但是这个进程与锁服务之间的网络出现了问题,导致无法通信,那么这个情况下,如果锁服务让它一直持有锁,就会导致死锁的发生。常见的解决思路都是引入超时机制,即成功申请锁后,超过一定时间,锁失效(删除 key),原因在于它们无法感知申请锁的客户端节点状态。而 ZooKeeper 由于其 znode 以目录、文件形式组织,天然就存在物理空间隔离,只要 znode 存在,即表示客户端节点还在工作,所以不存在这种问题。
- 可重入 - 可重入指的是:同一个线程在没有释放锁之前,能否再次获得该锁。其实现方案是:只需在加锁的时候,记录好当前获取锁的节点 + 线程组合的唯一标识,然后在后续的加锁请求时,如果当前请求的节点 + 线程的唯一标识和当前持有锁的相同,那么就直接返回加锁成功;如果不相同,则按正常加锁流程处理。
- 公平性 - 当多个线程请求同一锁时,它们必须按照请求的顺序来获取锁,即先来先得的原则。锁的公平性的实现也非常简单,对于被阻塞的加锁请求,我们只要先记录好它们的顺序,在锁被释放后,按顺序颁发就可以了。
- 重试 - 有时候,加锁失败可能只是由于网络波动、请求超时等原因,稍候就可以成功获取锁。为了应对这种情况,加锁操作需要支持重试机制。常见的做法是,设置一个加锁超时时间,在该时间范围内,不断自旋重试加锁操作,超时后再判定加锁失败。
- 容错 - 分布式锁若存储在单一节点,一旦该节点宕机或失联,就会导致锁失效。将分布式锁存储在多数据库实例中,加锁时并发写入
N
个节点,只要N / 2 + 1
个节点写入成功即视为加锁成功。
【中级】数据库分布式锁如何实现?
要点
数据库分布式锁原理
(1)创建锁表
CREATE TABLE `distributed_lock` (
`id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`resource` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '资源',
`count` INT(10) UNSIGNED NOT NULL DEFAULT '0' COMMENT '锁次数,统计可重入锁',
`desc` TEXT DEFAULT NULL COMMENT '备注',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_resource`(`resource`)
)
ENGINE = InnoDB DEFAULT CHARSET = `utf8mb4`;
(2)获取锁
想要锁住某个方法时,执行以下 SQL:
insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)
因为我们对 method_name
做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。
成功插入则获取锁。
(3)释放锁
当方法执行完毕之后,想要释放锁的话,需要执行以下 Sql:
delete from methodLock where method_name ='method_name'
数据库分布式锁小结
数据库分布式锁的问题:
- 死锁:一旦释放锁操作失败,或持有锁的机器宕机、断连,就会导致锁记录一直存在,其他线程无法再获得锁。解决办法:为锁增加失效时间字段,启动一个定时任务,隔一段时间清除一次过期的数据。
- 非阻塞:因为
insert
操作一旦失败就会报错,因此未获得锁的线程并不会进入排队队列,要想获得锁就要再次触发加锁操作。解决办法:循环重试,直到插入成功,这么做会产生一定额外开销。 - 非重入:同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。解决办法:在数据库表中加个字段,记录当前获得锁的节点信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
- 单点问题:如果数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。解决办法:单点问题可以用多数据库实例,同时写入
N
个节点,N / 2 + 1
个成功就加锁成功。
数据库分布式锁的利弊:
- 优点:直接借助数据库,简单易懂。
- 缺点:会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。此外,数据库性能易成为瓶颈。
【高级】ZooKeeper 分布式锁如何实现?
要点
ZooKeeper 分布式锁实现原理
ZooKeeper 分布式锁的实现基于 ZooKeeper 的两个重要特性:
- 顺序临时节点:ZooKeeper 的存储类似于 DNS 那样的具有层级的命名空间。ZooKeeper 节点类型可以分为持久节点(
PERSISTENT
)、临时节点(EPHEMERAL
),每个节点还能被标记为有序性(SEQUENTIAL
),一旦节点被标记为有序性,那么整个节点就具有顺序自增的特点。 - Watch 机制:ZooKeeper 允许用户在指定节点上注册一些
Watcher
,并且在特定事件触发的时候,ZooKeeper 服务端会将事件通知给用户。
下面是 ZooKeeper 分布式锁的工作流程:
- 创建一个目录节点,比如叫做
/locks
; - 线程 A 想获取锁,就在
/locks
目录下创建临时顺序 zk 节点; - 获取
/locks
目录下所有的子节点,检查是否存在比自己顺序更小的节点:若不存在,则说明当前线程创建的节点顺序最小,获取锁成功; - 此时,线程 B 试图获取锁,发现自己的节点顺序不是最小,设置监听锁号在自己前一位的节点;
- 线程 A 处理完,删除自己的节点。线程 B 监听到变更事件,判断自己是不是最小的节点,如果是则获得锁。
ZooKeeper 分布式锁小结
ZooKeeper 分布式锁的优点是较为可靠:
- 避免死锁:ZooKeeper 通过临时节点 + 监听机制,可以保证:如果持有临时节点的线程主动解锁或断连,Zk 会自动删除临时节点,这意味着锁的释放。所以,不存在锁永久不释放从而导致死锁的问题。
- 单点问题:ZooKeeper 采用主从架构,并确保主从同步是强一致的,因此不会出现单点问题。
ZooKeeper 分布式锁的缺点是:加锁、解锁操作,本质上是对 ZooKeeper 的写操作,全部由 ZooKeeper 主节点负责。如果加锁、解锁的吞吐量很大,容易出现单点写入瓶颈。
【高级】Redis 分布式锁如何实现?
要点
Redis 分布式锁实现原理
极简版本
我们先来看一下,如何实现一个极简版本的 Redis 分布式锁。
(1)加锁
Redis 中的 setnx
命令,表示当且仅当 key 不存在时,才会写入 key。由于其互斥性,所以可以基于此来实现分布式锁。
执行 setnx key val
,若返回 1,表示写入成功,即加锁成功;若返回 0,表示该 key 已存在,写入失败,即加锁失败。
(2)解锁
Redis 分布式锁如何解锁呢?
很简单,删除 key 就意味着释放锁,即执行 del key
命令。
避免死锁
极简版本的解决方案有一个很大的问题:存在死锁的可能。持有锁的节点如果执行业务过程中出现异常或机器宕机,都可能导致无法释放锁。这种情况下,其他节点永远也无法再获取锁。
对于异常,在 Java 中,可以通过 try...catch...finally
来保证:最终一定会释放锁,其他编程语言也有相似的语法特性。
对于机器宕机这种情况,如何处理呢?通常的对策是:为锁加上超时机制,过期自动删除。
在 Redis 中,expire
命令可以为 key 设置一个超时时间,一旦过期,Redis 会自动删除 key。如此看来,setnx
+ expire
组合使用,就能解决死锁问题了。可惜,没那么简单。Redis 只能保证单一命令的原子性,不保证组合命令的原子性。
那么,Redis 中有没有一条命令可以实现 setnx + expire 的组合语义呢?还真有,可以通过下面的命令来实现:
# 下面两条命令是等价的
SET key val NX PX 30000
SET key val NX EX 30
参数说明:
NX
:该参数表示当且仅当 key 不存在,才能写入成功PX
:超时时间,单位毫秒EX
:超时时间,单位秒
超时续期
为了避免死锁,我们为锁添加了超时时间。但这里有一个问题,如果应用加锁时,对于操作共享资源的时长估计不足,可能会出现:操作尚未执行完,但是锁没了的尴尬情况。为了解决这个问题,很自然会想到,时间不够,就续期呗。
具体来说,如何续期呢?一种方案是:加锁后,启动一个定时任务,周期性检测锁是否快要过期,如果快要过期并且操作尚未结束,就对锁进行自动续期。自行实现这个方案似乎有点繁琐,好在开源 Redis 客户端 Redisson 中已经为锁的超时续期提供了一个成熟的机制——WatchDog(看门狗)。我们可以直接拿来主义即可。
安全解锁
前文提到了,解锁的操作,实际上就是 del key
。这里存在一个问题:因为没有任何判断,任何节点都可以随意删除 key,换句话说,锁可能会被其他节点释放。如何避免这个问题呢?解决方法就是:为锁添加唯一性标识来进行互斥。唯一性标识可以是 UUID,可以是雪花算法 ID 等。
在 Redis 分布式锁中,唯一性标识的具体实现就是在 set key val
时,将唯一性标识 id 作为 val
写入。解锁前,先判断 key 的 value,必须和 set 时写入的 id 值保持一致,以此确认锁归属于自己。解锁的伪代码如下:
if (redis.get("key") == id)
redis.del("key");
这里依然存在一个问题,由于需要在 Redis 中,先 get
,后 del
操作,所以无法保证操作的原子性。为了保证原子性,可以将这段伪代码用 lua 脚本来实现,这么做的理由是 Redis 中支持原子性的执行 lua 脚本。下面是安全解锁的 lua 脚本代码:
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
自旋重试
有时候,加锁失败可能只是由于网络波动、请求超时等原因,稍候就可以成功获取锁。为了应对这种情况,加锁操作需要支持重试机制。常见的做法是,设置一个加锁超时时间,在该时间范围内,不断自旋重试加锁操作,超时后再判定加锁失败。
下面是一个自旋重试获取锁的伪代码示例:
try {
long begin = System.currentTimeMillis();
while (true) {
String result = jedis.set(lockKey, uniqId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
// 加锁成功,执行业务操作
return true;
}
long time = System.currentTimeMillis() - begin;
if (time >= timeout) {
return false;
}
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} catch (Exception e) {
// 异常处理
} finally {
// 释放锁
}
Redis 分布式锁小结
在前文中,为了实现一个靠谱的 Redis 分布式锁,我们讨论了避免死锁、超时续期、安全解锁几个问题以及应对策略。但是,依然存在一些其他问题:
- 不可重入 - 同一个线程无法多次获取同一把锁。
- 单点问题 - Redis 主从同步存在延迟,有可能导致锁冲突。举例来说:线程一在主节点加锁,如果主节点尚未同步给从节点就发生宕机;此时,Redis 集群会选举一个从节点作为新的主节点。此时,新的主节点没有锁的数据,若有其他线程试图加锁,就可以成功获取锁,即出现同时有多个线程持有锁的情况。解决这个问题,可以使用 RedLock 算法。
Redisson 是一个流行的 Redis Java 客户端,它基于 Netty 开发,并提供了丰富的扩展功能,如:分布式计数器、分布式集合、分布式锁 等。
Redisson 支持的分布式锁有多种:Lock, FairLock, MultiLock, RedLock, ReadWriteLock, Semaphore, PermitExpirableSemaphore, CountDownLatch,可以根据场景需要去选择,非常方面。一般而言,使用 Redis 分布式锁,推荐直接使用 Redisson 提供的 API,功能全面且较为可靠。
【中级】RedLock 分布式锁如何实现?
要点
RedLock 分布式锁,是 Redis 的作者 Antirez 提出的一种解决方案。
扩展:RedLock 官方文档
RedLock 分布式锁原理
RedLock 分布式锁在普通 Redis 分布式锁的基础上,进行了扩展,其要点在于:
- (1)加锁操作不是写入单一节点,而是同时写入多个主节点,官方推荐集群中至少有 5 个主节点。
- (2)只要半数以上的主节点写入成功,即视为加锁成功。
- (3)大多数节点加锁的总耗时,要小于锁设置的过期时间。
- (4)解锁时,要向所有节点发起请求。
下面来逐一解释以上各要点的用意:
(1)RedLock 加锁时,为什么要同时写入多个主节点?
这是为了避免单点问题,即使有部分实例出现异常,依然可以正常提供加锁、解锁能力。
(2)为什么要半数以上的主节点写入成功,才视为加锁成功?
在分布式系统中,为了达成共识,常常采用“多数派”策略来进行决策:大多数节点认可的行为,就视为整体通过。
(3)为什么加锁成功后,还要计算加锁的累计耗时?
因为操作的是多个节点,所以耗时肯定会比操作单个实例耗时更久。而且,网络情况是复杂的,可能存在延迟、丢包、超时等情况。网络请求越多,异常发生的概率就越大。所以,即使大多数节点加锁成功,但如果加锁的累计耗时已经超过了锁的过期时间,那此时有些实例上的锁可能已经失效了,这个锁就没有意义了。
(4)解锁时,为什么要向所有节点发起请求?
因为网络环境的复杂性,可能会存在这种情况:向某主节点写入锁信息,实际写入成功,但是响应超时或丢包。
所以,释放锁时,不管之前有没有加锁成功,需要释放所有节点的锁,以保证清理节点上残留的锁。
RedLock 分布式锁小结
(1)RedLock 不能完全保证安全性
分布式系统会遇到三座大山:NPC
- N:Network Delay,网络延迟;
- P:Process Pause,进程暂停(GC);
- C:Clock Drift,时钟漂移。
RedLock 在遇到以上情况时,不能保证安全性。
(2)RedLock 加锁、解锁需要处理多个节点,代价太高
总结来说,已知的分布式锁,无论采用什么解决方案,在极端情况下,都无法保证百分百的安全。
【高级】分布式锁如何进行技术选型?
要点
下面是主流分布式锁技术方案的对比,可以在技术选型时作为参考:
数据库分布式锁 | Redis 分布式锁 | ZooKeeper 分布式锁 | |
---|---|---|---|
方案要点 | 1. 维护一张锁表,为锁的唯一标识字段添加唯一性约束。 2. 只要 insert 成功,即视为加锁成功。 | set lockKey randomValue NX PX/EX time 当且仅当 key 不存在时才可以写入,并且设定超时时间,以避免死锁。 | 加锁本质上是在 zk 中指定目录创建顺序临时接节点,序号最小即加锁成功。节点删除时,有监听通知机制告知申请锁的线程。 |
方案难度 | 实现简单、易于理解 | 较为简单,但要使其更可靠,需要有一些完善策略 | 应用简单,但 zk 内部机制并不简单 |
性能 | 性能最差,易成为瓶颈 | 性能最高 | 性能弱于 Redis |
可靠性 | 有锁表的风险 | 较为可靠(需要一些完善策略) | 可靠性最高 |
适用场景 | 一般不采用 | 适用于高并发的场景 | 适用于要求可靠,但并发量不高的场景 |
开源实现 | 无 | Redisson | Apache Curator |
分布式 ID
扩展:
【初级】什么是分布式 ID?为什么需要分布式 ID?
要点
ID是Identity的缩写,用于唯一的标识一条数据。分布式 ID,顾名思义,是用于在分布式系统中唯一标识数据的ID。
传统数据库基本都支持针对单表生成唯一性的自增主键。随着数据的膨胀,单机成为了性能和容量的瓶颈。为了解决这个问题,有了分库分表技术。分库分表所要面临的第一个问题是:数据分布在不同机器上,数据库无法保证多个节点上产生的主键唯一。 这就需要用到分布式 ID 了,它起到了分布式系统中全局 ID 的作用。
【中级】有哪些生成分布式 ID 的方式?
要点
生成分布式 ID 主要有以下方式:
- UUID - UUID 是通用唯一识别码(Universally Unique Identifier)的缩写,是一种 128 位的标识符,用 16 进制表示,需要 32 个字符。UUID 会根据运行应用的计算机网卡 MAC 地址、时间戳、命令空间等元素,通过一定的随机算法产生。
- UUID 存在 5 个版本。
- UUID 不保证全局唯一性,我们需要小心 ID 冲突(尽管这种可能性很小)。
- 优点:实现简单、生成速度较快(本地生成,不依赖其他服务)。
- 缺点:无序、长度过长、不安全(基于 MAC 地址生成 UUID 的算法,可能会造成 MAC 地址泄露)。
- 数据库自增主键 - 大多数数据库都支持自增主键。基于此特性,可以利用事务管理控制生成唯一 ID。
- 优点:实现简单、有序、长度较小
- 缺点:性能差、存在单点问题、不安全(可以通过 ID 递增规律推算出数据量)
- 数据库号段 - 一次批量生成一个 segment(号段),号段的大小由 step(步长)控制。用完之后再去数据库获取新的号段。
- 原子计数器 - 一些 NoSQL 数据库提供了原子性的计数器原子计数器 - 利用一些 NoSQL 数据库提供的原子性计数器,来实现分布式 ID。
- Redis
incr
/incrby
- Redis 的 String 类型提供INCR
和INCRBY
命令将 key 中储存的数字原子递增。- 优点:高性能、有序
- 缺点:和数据库自增序列方案的缺点类似
- ZooKeeper 顺序节点 - 利用 ZooKeeper 数据模型中的顺序节点作为分布式 ID。
- 优点:简单、可靠性高
- 缺点:性能不高
- Redis
- Snowflak(雪花算法) - Snowflake ID 生成过程包含多个组件:时间戳、机器 ID 和序列号。第一位未使用,以确保 ID 正确。此生成器不需要通过网络与 ID 生成器通信,因此速度快且可扩展。Snowflake 的实现各不相同。例如,可以将数据中心 ID 添加到“MachineID”组件中,以保证全局唯一性。
分布式会话
【初级】Cookie 和 Session 有什么区别?
要点
由于 Http 是一种无状态的协议,服务器单从网络连接上无从知道客户身份。
所以服务器与浏览器为了进行会话跟踪(知道是谁在访问我),就必须主动的去维护一个状态,这个状态用于告知服务端前后两个请求是否来自同一浏览器。而这个状态需要通过 cookie 或者 session 去实现。
Cookie 实际上是存储在用户浏览器上的文本信息,并保留了各种跟踪的信息。生成 Cookie 后,用户后续每次请求都会携带 Cookie。
Cookie 通常有大小限制(4KB)。用户可以选择在浏览器中禁用 Cookie。
一个简单的 cookie 设置如下:
Set-Cookie: <cookie-name>=<cookie-value>
HTTP/2.0 200 OK
Content-Type: text/html
Set-Cookie: yummy_cookie=choco
Set-Cookie: tasty_cookie=strawberry
[page content]
Session 是在服务器端创建和存储的。服务器上通常会生成一个唯一的会话 ID(sessionId),sessionId 附加到特定的用户会话。sessionId 以 Cookie 的形式返回到客户端。Session 可以容纳大量数据。由于 Session 数据不直接由客户端访问,因此 Session 提供了更高的安全性。
Cookie 和 Session 的主要区别可以参考以下表格:
Cookie | Session | |
---|---|---|
作用范围 | 保存在客户端(浏览器) | 保存在服务器端 |
隐私策略 | 存储在客户端,比较容易遭到非法获取 | 存储在服务端,安全性相对 Cookie 要好一些 |
存储方式 | 只能保存 ASCII | 可以保存任意数据类型。 一般情况下我们可以在 Session 中保持一些常用变量信息,比如说 UserId 等。 |
存储大小 | 不能超过 4K | 存储大小远高于 Cookie |
生命周期 | 可设置为永久保存 比如我们经常使用的默认登录(记住我)功能 | 一般失效时间较短 客户端关闭或者 Session 超时都会失效。 |
【中级】如果禁用了 Cookie 怎么办?
要点
既然服务端是根据 Cookie 中的信息判断用户是否登录,那么如果浏览器中禁止了 Cookie,如何保障整个机制的正常运转。
第一种方案,每次请求中都携带一个 SessionID 的参数,也可以 Post 的方式提交,也可以在请求的地址后面拼接
xxx?SessionID=123456...
。第二种方案,Token 机制。Token 机制多用于 App 客户端和服务器交互的模式,也可以用于 Web 端做用户状态管理。
Token 的意思是“令牌”,是服务端生成的一串字符串,作为客户端进行请求的一个标识。Token 机制和 Cookie 和 Session 的使用机制比较类似。
当用户第一次登录后,服务器根据提交的用户信息生成一个 Token,响应时将 Token 返回给客户端,以后客户端只需带上这个 Token 前来请求数据即可,无需再次登录验证。
【中级】分布式 Session 有几种实现方案?
要点
在分布式场景下,一个用户的 Session 如果只存储在一个服务器上,那么当负载均衡器把用户的下一个请求转发到另一个服务器上,该服务器没有用户的 Session,就可能导致用户需要重新进行登录等操作。
分布式 Session 的几种实现策略:
- 粘性 session
- 应用服务器间的 session 复制共享
- 基于缓存的 session 共享 ✅
推荐:基于缓存的 session 共享
粘性 Session
粘性 Session(Sticky Sessions)需要配置负载均衡器,使得一个用户的所有请求都路由到一个服务器节点上,这样就可以把用户的 Session 存放在该服务器节点中。
缺点:当服务器节点宕机时,将丢失该服务器节点上的所有 Session。
Session 复制共享
Session 复制共享(Session Replication)在服务器节点之间进行 Session 同步操作,这样的话用户可以访问任何一个服务器节点。
缺点:占用过多内存;同步过程占用网络带宽以及服务器处理器时间。
基于缓存的 session 共享
使用一个单独的存储服务器存储 Session 数据,可以存在 MySQL 数据库上,也可以存在 Redis 或者 Memcached 这种内存型数据库。
缺点:需要去实现存取 Session 的代码。
参考资料
- 数据密集型应用系统设计 - 这可能是目前最好的分布式存储书籍,强力推荐【进阶】