在计算和系统设计中,“分布式” 通常指的是计算任务或系统服务被拆分并分散到多个网络连接的计算节点上执行,他们之间通过网络进行通信或者协调工作。这种方式相对于集中式系统,可以提供更高的可伸缩性、可靠性和容错性。
而分布式系统的设计和管理是复杂的,涉及到网络通信、数据一致性、容错处理等挑战,但同时也为现在的高需求、高可用性、高性能应用提供了基础
一些理论 #
CAP #
提到分布式,就离不开 CAP 的讨论,CAP即 Consistency 一致性、Availability 可用性、Partition Tolerance 分区容错性这三个单词的缩写。
-
Consistency 一致性:所有节点在同一时间看到的数据是一致的。即,如果一个数据在系统中的一部分被更新,那么所有的访问都应该看到这个新的值。这要求系统在更新数据后必须保证数据在所有副本之间同步。
-
可用性 (Availability): 每个请求都能够接收到一个响应,不论响应是成功的还是失败的。对于分布式系统而言,即使部分节点发生故障,系统仍然需要能够处理客户端的请求。
-
分区容忍性 (Partition tolerance): 系统在网络分区发生时仍能继续运行。网络分区是指系统中的一部分节点由于网络故障而无法与系统中的其他节点通信。在现实中,由于网络故障等问题出现节点分区是不可避免的,这需要系统保证出现分区后人能持续运行。
CAP 理论中,任何分布式系统都不可能同时满足以上三个属性,最多只能同时满足两个。因此系统设计需要在这三个属性之间做出权衡和选择。通常,设计需要在一致性和可用性之间做出选择,因为现代的分布式系统几乎都需要处理网络分区的问题,从而确保分区容忍性。
-
CP 一致性和分区容忍性:系统在遇到网络分区时保证一致性,可能会牺牲部分可用性。例如,某些数据库系统在网络分区发生时可能会拒绝处理查询或更新,以保持数据的一致性。
-
AP 可用性和分区容忍性:系统即使在网络分区的情况下也能保持可用性,但可能无法保证所有节点上的数据立即一致。这类系统通常在网络恢复正常后,通过一些后台进程来恢复数据的一致性。
BASE #
BASE 是 Basically Available 基本可用、Soft-state 软状态 和 Eventually Consistent 最终一致性 三个单词的缩写。BASE 理论一般是分布式系统中数据存储的一个概念性理论。
-
Basically Available 基本可用:分布式系统在出现部分故障后,仍然能保证核心可用。不保证系统所有功能 100% 可用,但能保证核心业务可以正常运行。
-
Soft state软状态:系统状态的一致性没有严格的时间界限,系统中的状态不需要实时一致。这种状态的变化可能是由于系统自身的更新延迟造成的,不是所有的操作都立即可见。
-
Eventual Consistency 最终一致性:是指系统中的所有数据副本,在没有新的更新操作的情况下,最终将达到一致的状态。在这种模型中,系统允许在一段时间内数据是不一致的,但是最终数据会同步。这允许系统进行更高效的数据复制处理,尤其是在大规模分布式环境中。
一些共识算法 #
共识算法用于在多个系统组件(如计算节点、进程、服务器等)之间就某个数据值或系统状态达成一致意见,常见的有 Paxos 算法、Raft 算法等。
Paxos 算法由 Leslie Lamport 提出,是解决共识问题的最初算法之一。Paxos 算法非常有效,但其复杂性使得理解和实现变得困难。
Raft 相较于 Paxos 简化了管理复杂性,具体可以参考
分布式事务 #
分布式事务是一种跨多个独立的数据库、系统或网络节点的事务处理方式。在分布式系统中,由于数据可能被分散存放在不同的服务器或位置上,因此需要一种机制来确保事务在所有相关的系统中一致地执行。事务需要保持 ACID 的特性,即原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。
实现机制 #
实现分布式事务常见的机制包括 2PC、3PC 等
2PC #
2PC 即 两阶段提交,它引入了一个协调者(Coordinator)和多个参与者(Participant)的概念,保证了事务的原子性。所有参与者提交事务则是成功,否则全部回滚。
Prepare Phase 准备阶段
-
协调者节点向所有参与者节点发送准备请求(Prepare Request),确定是否可以执行事务操作并提交。
-
参与者节点收到请求后,将执行事务,但不进行提交,将操作结果记录到本地日志中。参与者根据执行结果向协调者反馈是否可以提交事务。
Commit Phase 提交阶段
提交阶段区分两种情况:
- 成功:协调者收到所有参与者的 “同意” 反馈,向所有参与者发送提交请求。参与者收到提交请求后,将之前执行的事务操作正式提交,并释放相关资源,然后向协调者反馈提交结果。
- 失败:协调者收到任何一个参与者的 “拒绝” 反馈,或者存在参与者的反馈超时,它会向所有参与者发送回滚请求。参与者收到回滚请求后,回滚之前执行的事务操作,然后向协调者反馈结果。
存在的问题:
- 阻塞:如果协调者失败阻塞,参与节点可能会无限期地等待指令,导致系统阻塞。
- 单点故障:协调者出现单点故障,其故障可能导致整个事务处理系统的瘫痪。
- 容错性低:2PC 不能处理网络分区等问题。同样如果协调者在提交阶段故障,可能出现数据不一致的情况。
3PC #
3PC 是 2PC 的改进版本,引入了超时机制和一个额外的阶段,解决了 2PC 中可能出现的阻塞问题。
3PC 增加了询问阶段,即三个阶段:准备(CanCommit)、预提交(PreCommit)、提交(DoCommit)。在准备阶段,协调者会询问参与者是否能接受请求,而预提交和提交阶段则和 2PC 的准备和提交阶段相同。
准备阶段的 CanCommit 其实就是在执行事务之前来确认参与者是否正常,防止在有参与者不正常的情况下,其他参与者都执行了事务,锁定资源。
除此之外参与者也引入了超时机制,这样在协调者挂了的情况下,如果已经到了提交阶段,参与者超时没有收到协调者的回复,就会自动提交事务。
存在的问题:
- 系统更复杂:存在更多的消息交换、状态管理
- 性能开销更大:同上
- 仍存在极端情况下的协调器故障问题,如 PreCommit 阶段协调器故障,仍存在不一致问题。
业界方案 #
TCC #
TCC(Try Confirm Cancel) 基于 2PC 实现。顾名思义 TCC 区分为三个阶段 Try、Confirm、Cancel。其针对每一个操作都需要提供确认和回滚操作。
Try
对事务所需资源进行预留。事务开始时对所有参与者调用 Try
方法,参与者服务检查资源是否可用(是否满足业务条件),并对资源预留。这个阶段并不会执行实际的业务处理。
Confirm
提交事务,确认事务变更。调用所有服务的 Confirm
接口,正式提交资源变更(如扣减库存、扣减余额等)。一旦所有服务完成Confirm,事务最终提交成功。
Cancel
处理异常情况,回滚事务,如果 Try 阶段或 Confirm 阶段出现异常(如服务失败或超时),调用所有服务的 Cancel
接口,释放预占的资源,回滚至事务开始前的状态。
TCC 不保证强一致,而是通过补偿(cance)确保事务的最终一致性。只需要预留资源,不会长时间锁定资源,一定程度上减少了 2PC 的性能问题。
但是需要业务实现 Try、Confirm、Cancel 三个接口,同时保证三个接口的幂等性,对业务侵入性较高。
Try 阶段的资源预留,当业务复杂度比较高的时候,就会产生很多的中间状态,预留的资源的成本会比较高,也增加了业务的复杂度,一定程度上影响了性能
Saga #
Saga 将一个复杂的事务拆分为多个独立的本地事务,每个本地事务都有一个对应的补偿操作。如果某个本地事务失败,Saga 会触发补偿操作,依次回滚之前已经完成的事务步骤,从而保持数据的一致性。Saga 也需要保证提交和补偿接口的幂等性。
Saga 因为将复杂事物拆分成多个本地事务,可以由每个拆分的业务来确定是否需要补偿或提供补偿,多数场景相对 TCC 的接入成本会低一些。
XA #
XA(eXtended Architecture)是一个分布式事务协议,通过两阶段提交来保证强一致性
概念:
- AP(Application Program,应用程序)
- RM(Resource Manager,资源管理器):RM 管理着某些共享资源的自治域,比如说一个 MySQL 数据库实例。
- TM(Transaction Manager,事务管理器):TM 能与 AP 和 RM 直接通信,协调 AP 和 RM 来实现分布式事务的完整性。
两阶段:
- 第一阶段 TM 请求 RM 准备,并告知其所需要的局部事务(Transaction Branch)。RM 收到请求后,如果判断可以完成自己的局部事务,那就持久化局部事务的工作内容,再给 TM 肯定答复;如果不能或者出现异常,就会给 TM 否定答复。如果是否定大幅的话,需要在发送了否定答复并回滚了局部事务之后,RM 才能丢弃持久化了的局部事务信息。
- 第二阶段 TM 根据情况(如所有 RM Prepare 成功,或者 AP 请求需要 Rollback 等),先持久化它对这个全局事务的处理决定和所涉及的 RM 清单,然后通知所有涉及的 RM 去提交或者回滚它们的局部事务。RM 们处理完自己的局部事务后,将返回值告诉 TM,最终 TM 清除掉包括刚才持久化的处理决定和 RM 清单在内的这个全局事务的信息。
XA 是强一致分布式协议。由于实现在资源层,主要逻辑在 TM 上,相对业务侵入较低。但是由于是阻塞性协议,可能存在锁时间长、死锁、相应慢等问题,在高并发场景存在性能问题,且实现运维复杂。
AT #
AT(Automatic Transaction)模式是基于 XA 事务演进而来,改进了两阶段提交,对业务没有侵入性,需要数据库支持。
三个概念:
- Transaction Coordinator(TC):事务协调器,维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚。
- Transaction Manager(TM):控制全局事务的边界,负责开启一个全局事务,并最终发起全局提交或全局回滚的决议。
- Resource Manager(RM):控制分支事务,负责分支注册、状态汇报,并接收事务协调器的指令,驱动分支(本地)事务的提交和回滚。
详情参考 博客
基于本地消息表的分布式事务 #
- 生产者额外创建一个消息表,写入消息表和业务逻辑要在一个本地事务中,然后将消息发送到 MQ。
- 消费者监听消息,完成相关事务逻辑。
- 生产者需要启动兜底任务轮询本地消息表,把未成功的消息再发送一遍,消费者需要保证幂等。
事务消息 #
最大努力通知 #
最大努力通知型(Best-effort Delivery)是最简单的一种柔性事务。最终一致性时间敏感度低,且需要消费者处理结果不影响生产者的处理结果。一般可以借助消息中间件实现。生产者将消息发送到消息中间件,消费者读取消息处理,并返回 ACK 给生产者。如果未收到 ACK,生产者会重复通知确保最终一致性。
使用场景:支付通知、短信通知等。
分布式锁 #
分布式锁会具备以下特性:
-
互斥性:在任意时刻,只有一个实例可以持有锁。
-
超时机制:防止出现网络不可靠或者宕机的情况,导致锁被长时间占有不释放
-
性能高:高并发场景下,锁的获取和释放需要高效。分布式锁会直接影响到 QPS,如果获取锁的操作就需要 100ms,那么接口性能只能达到 10QPS。
部分分布式锁还会实现一下功能:
-
通知机制:当锁被释放掉时,会通知实例,可以防止自旋抢占带来的机器性能压力或者轮询锁带来的网络压力。另外可以保证每次释放锁之后只唤醒一个实例,防止多个实例被唤醒带来的惊群效应,造成网络和性能的激增损耗。
-
公平性:释放锁之后优先将锁分配给等待时间最长的实例
-
可重入性:已获取锁的实例可以再次获取锁,且加锁和释放锁的次数保持一致。
基于 MySQL 的实现 #
一个基于 MySQL 唯一索引的简单实现,通过向表中插入或删除一行记录还实现加锁和释放锁:
CREATE TABLE `lock` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`lock_key` varchar(256) NOT NULL, # 锁的唯一标识
`holder` varchar(256) NOT NULL, # 持有锁的实例唯一标识
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_lock_key` (`lock_key`)
);
# 加锁
INSERT INTO `lock`(`lock_key`, `holder`) VALUES ('my_lock_key_1', 'instance_uid_1');
# 释放锁
DELETE FROM `lock` WHERE `lock_key` = 'my_lock_key_1';
如果需要实现超时机制,可以在表中加一个字段表示最后一次加锁的时间:
`occupy_timestamp` int(11) NOT NULL DEFAULT '0'
# 加锁
UPDATE `lock` SET `holder` = 'instance_uid_1', `occupy_timestamp` = ${now} where `lock_key` = 'my_lock_key_1' and `occupy_timestamp` < ${now} - ${timeout};
## 释放锁
UPDATE `lock` SET `holder` = '', `occupy_timestamp` = 0 where `lock_key` = 'my_lock_key_1' and `holder` = 'instance_uid_1';
加锁时首先获取当前时间戳,如果判断表中的加锁时间超时,就可以加锁。释放锁的时候需要判断当前 holder 是否是自己,避免误删其他实例加的锁。
但是如果超时时间很短的,可能出现两个实例同时占有锁的情况。而且以上实现是非阻塞、非公平、不可重入的。
除此之外还可以通过 SELECT... FOR UPDATE
实现阻塞锁的加锁,这样数据库会给数据加上排它锁,其他请求会阻塞到 commit,当执行完成后,可以通过 connect.Commit()
来释放锁。如果实例宕机后,一般数据库也会将锁释放掉。不过如果连接很多,会对数据库造成很大压力;锁的实现依赖数据库的内部实现,如果数据库认为全表扫描效率更高(比如一些小表的情况),给数据库加上全表锁,所有请求都会阻塞住,可能会造成服务阻塞。
基于 Redis 的实现 #
Redis 提供了 setnx
指令,即 set if not exist,如果 key 设置成功,则返回 1,由于 Redis 的指令是原子操作,保证了互斥性,可以通过 setnx
来实现加锁或释放。
TTL 参数可以支持 Redis 的过期机制,,可以通过 LUA 脚本来实现"比较、判断相等、删除",可以防止误删其他实例的锁:
// 如果 get 的值等于传进来的值,就 del
if redis.call("get",KEYS[1])==ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
redislock 以及 gopkg/redis-lock 都是基于此来实现的。
优雅续期
Redission 实现了对锁续期的 WatchDog
机制,当加锁成功之后,会开启一个监听线程,每隔 1/3 的过期时间就检查一下,如果当前线程持有锁,就会不断延长锁的过期时间,防止锁过期释放。
RedLock
Redis 集群一般是多个节点分片组成,每个节点一般是主从架构。如果主节点发生宕机,从节点会升级为主节点保证服务可用性。如果主节点加锁成功,还未同步到从节点时,主节点发生宕机,那么从节点升主,会发生锁的丢失。为了解决这个问题,Redis 作者 antirez 提出了 RedLock 算法。
RedisLock 的核心思想是客户端向 Redis 集群中的多个实例(都为主节点)请求加锁,当请求半数以上成功加锁后,就认为加锁成功。
go-redsync/redsync 是 RedLock 算法的一种实现。
在作者发布算法之后,Martin Kleppmann 与其发生了争论,相关争论文章如下
Martin Kleppmann: I think the Redlock algorithm is a poor choice because it is “neither fish nor fowl”:
- Redlock 依赖集群系统各节点时钟的准确性,当获取锁后,如果时钟发生漂移,会导致锁的过期时间被错误计算,锁提前过期,导致后续出现多个节点同时认为自己持有锁。
- Redis 发生故障重启会丢失锁信息,加入一个客户端实例获取锁,Redis 节点故障重启,其他客户端实例在故障重启节点上可以重新获取锁,破坏了锁的互斥性
- GC 延迟过高导致互斥失效
基于 ZooKeeper 实现 #
核心基于临时顺序点实现,并且可以实现公平性、通知机制,可靠性更高
- 公平性:在一个目录下可以创建顺序的节点,顺序性由 ZooKeeper 保证
- 可靠性:不再依赖过期时间。客户端与 ZooKeeper 之间心跳保持连接,如果心跳丢失,服务器认为客户端宕机,会自动去除临时型节点。
- 通知机制:客户端可以监听节点的事件,实现了通知机制。
加锁
- 在指定 lock 路径下创建临时顺序点,客户端可以获取路径下所有的子节点,如果当前客户端创建的顺序号最小,则获得锁。
- 如果获取失败,可以监听比自己次小节点的删除时间,避免了轮询
释放锁
客户端删除自己的节点,ZooKeeper 通知监听客户端获取锁。
nladuo/go-zk-lock 是 Go 基于 ZooKeeper 的实现。