本文章为你收集了分布式的基础理论,罗列了常见的分布式应用场景的实现方案:分布式锁,分布式事务,分布式主键ID(美团的Leaf),服务限流算法,一致性hash算法。同时每一部分内容笔者都详细的进行了收集整理并分析了每一种方案的优缺点。笔者希望该文章能够成为网速比较全面的汇总文章,能为读者带来比较系统的讲解。如果读者发现文章内还有收集不全以及错误的地方还请在评论区留言,笔者会尽快完善文章内容。谢谢!
CAP理论:
- C(一致性) 如果在某个节点更新了数据,那么在其他节点如果都能读取到这个最新的数据,那么就称为强一致,如果有某个节点没有读取到,那就是分布式不一致。即所有的节点同时看到相同的数据
- A (可用性):非故障的节点在合理的时间内返回合理的响应。任何时候,读写都是成功的。
- P (分区容错性):当部分节点出现消息丢失或故障的时候,分布式系统仍能正常工作。
CAP理论认为:一个分布式系统最多只能同时满足,一致性,可用性,分区容错性的三项中的两项。由于分区容错性是必然存在的,所以大部分分布式软件系统都在CP和AP中做取舍
- Zookeeper 采用CP一致性,强调一致性,弱化可用性。
- Eureka 采用AP可用性,强调可用性,弱化一致性。
Base理论
Base理论:即基本可用(Basically Available),软状态(Soft State),最终一致性(Eventually Consistent)。既然无法做到强一致性,那么不同的应用可用根据自己的业务特点,采用适当的方式来达到最终一致性。Base理论是对CAP理论的实际应用
- 基本可用性:不追求强可用性,而且强调系统基本能够一直运行对外提供服务,当分布式系统遇到不可预估的故障时,允许一定程度上的不可用,比如:对请求进行限流排队,使得部分用户响应时间变长,或对非核心服务进行降级。
- 软状态:对于数据库中事务的原子性:要么全部成功,要不全部不成功。软状态允许系统中的数据存在中间状态。
- 最终一致性:数据不可能一直都是软状态,必须在一个时间期限之后达到各个节点的一致性。在此之后,所有的节点的数据都是一致的。系统达到最终一致性。
分布式一致性算法
WARO:Write All Read One
一种简单的副本控制协议,当客户端向一个分布式应用发送写请求的时候,只有当所有的副本节点都更新成功之后,这次写操作才算成功。否则视为失败。这降低了写操作的可用性,提高了读操作的可用性。
Quorm:最终一致性
假设有N个副本,客户端向一个分布式应用发送写请求的时候,如果有W个副本更新成功之后,这次写操作才算成功。则读操作最多需要读N-W个副本就能读取到最新的结果。
Quorm无法保证强一致性,它是分布式系统中常用的一种机制,用来保证数据冗余的最终一致性的投票算法。Kafka的ISR机制有点类似该机制。
Paxos算法:分布式一致性算法
在Paxos协议中,一共有三类角色节点
- Proposer 提案者
提案者可以有多个,在流程开始时,提案者提出操作(被称为value)(比如修改某个变量的值),多个提案者可以提出不同的操作。但经过一轮的Paxos算法后,只有一个提案者的操作被运行执行。 - Acceptor 批准者
在集群中,批准者有多个(数量设为N)。批准者之间完全独立对等。提案者提出的操作,必须获得半数以上(N/2+1)的批准者批准后才能通过执行 - Learner 学习者
学习者不参与选举,而是执行被批准者批准的操作。
分布式事务:
分布式事务解决方案有 两阶段提交协议,三阶段提交协议,TCC分段提交,基于消息队列的最终一致性
两阶段提交协议(2PC)
- 两阶段提交系统中,存在一个节点作为协调者,其他节点为参与者。
- 所有的节点都采用预写式日志。日志记录不会丢失。
- 所有的节点不会永久性的宕机,即使宕机后仍可以恢复。
- 第一阶段:事务管理器要求每个涉及到事务的数据库预提交此操作,并反映是否可以提交.
- 第二阶段:根据第一阶段的反馈,事务协调器要求每个数据库提交数据,或者回滚数据。
缺点:
- 事务管理器为单点,故障以后整个数据库集群无法使用
- 在执行过程中,所有参与事务的节点都是事务独占状态,当有参与者占用公共资源时,那么其他第三方节点对公共资源的访问会被阻塞。
- 第二阶段中,假设协调者发出了事务commit的通知仅被一部分参与者所收到并执行,其余的参与者则因为没有收到通知一直处于阻塞状态,这时候就产生了数据的不一致性。
三阶段提交协议(3PC)
为解决两阶段提交协议中,公共资源占用堵塞的问题,三阶段提交协议中协调者和参与者都引入了超时机制,然后把两阶段提交协议里的第一个阶段拆分为两步:先询问(CanCommit),再锁资源(PreCommit),再最后提交(DoCommit)。
- CanCommit:协调者向参与者发送Commit请求,参与节点反映是否可以调节。
-
PreCommit:根据CanCommit响应情况有以下两种执行情况。
- 如果所有的参与节点返回Yes,则进行事务的预执行:协调者向参与者发送PreCommit请求,使参与者进入Prepare阶段。并向协调者反馈ACk。
- 如果任意一个节点返回了NO,或者等待超时进进行中断操作。则协调者向所有的参与者发送abort请求,参与者执行abort请求放弃事务的执行。
-
DoCommit:阶段根据PreCommit的响应也有两种执行情况。
- 如果协调者收到所有参与者的ACk响应,则发送doCommit请求,所有的参与者提交事务释放资源,并向协调者反馈ACK响应。
- 如果协调者没有到所有参与者的ACK响应,则会执行中断事务
缺点:在DoCommit阶段中,假设协调者发出了事务commit的通知仅被一部分参与者所收到并执行,其余的参与者则因为没有收到通知一直处于阻塞状态,这时候就产生了数据的不一致性。
TCC
TCC:TCC是支付宝提出的分布式事务解决方案,每个分布式事务的参与者都需要实现3个接口:try、confirm、cancel。
- Try阶段:调用方调用各个服务的 try 接口,各个服务执行资源检查和锁定,看自己是否有能力完成,如果允许,则锁定资源.
- Confirm阶段:各个服务的 try 接口都返回了 yes,则进入提交阶段,调用方调用各个服务的 confirm 接口,各个服务对 try 阶段锁定的资源进行处理。如果 try 阶段有一方返回了 no,或者超时,调用方调用各个服务的 cancel 接口,释放锁定的资源
- Cancel阶段:取消执行,释放Try阶段预留的业务资源
- Confirm阶段和Cancel阶段是互斥的,只能进行一个,而且都是幂等性的,允许失败重试。
优点
1. TCC解决了跨服务的业务操作原子性问题,可以让应用自己定义数据库操作的粒度,降低锁冲突,提高系统的业务吞吐量。
2. TCC的每一阶段都由业务自己控制,避免了长事务,提高了性能。
缺点
1. 业务侵入性强:业务逻辑必须都要实现Try,Confirm,Cancel三个操作
异常情况
- 空回滚
现象是 try 没被执行,就调用了 cancel:调用 try 时出现异常,try 接口实际没有被调用,自然没有返回 yes,那么会按照正常流程进入第2阶段,调用 cancel 接口,这就形成了空回滚。
解决方法:让 cancel 能够识别出这是一个空回滚,可以记录事务执行状态,cancel 中判断 try 是否执行了。

- 重复调用
提交阶段异常时可能会重复调用 confirm 和 cancel,所以要实现幂等,保证多次执行效果一致。
解决方法:记录事务执行状态,如果执行过了,就不再执行。
接口幂等性:指的是在调用方多次调用的情况下,接口最终得到的数据是一致的。查询接口具有天然的幂等性。
- 悬挂
现象是先执行了 cancel,后执行的 try,造成资源没人释放:调用 try 时网络拥堵超时,被认为失败,然后调用 cancel,这时事务相当于结束了,但后来网络好点之后 try 开始执行了,锁定了相关资源,因为事务已经结束,confirm、cancel 都不会再调用了,就造成资源悬挂,无法释放。
解决方法:还是记录事务执行状态,try 执行时判断 cancel 是否执行了。
MySql内部的XA事务规范
XA事务是基于两阶段提交协议的,XA规范主要定义了事务协调者和资源管理器之间的接口。
- 事务协调者:用来保证所有的事务参与者都完成了准备工作。如果事务协调者收到所有参与者都准备好的消息,就会通知所有的事务可以提交。
- 资源管理器:负责控制和管理实际资源。
XA事务执行流程与两阶段提交协议差不多。
- Prepare阶段: 事务管理者向所有的资源管理器发送prepare指令,管理器收到指定后执行数据操作和日志记录。然后反馈结果。
- Commit阶段:事务协调者接收到所有的资源管理器的结果,选择执行RollBack命令或者Commit命令。完成一次事务操作。
MySQL中的XA事务有两种情况,内部XA和外部XA。如果事务发生在MySQL服务器单机上使用内部XA,如果事务发生在多个外部节点上,使用外部XA。
内部XA: Mysql会同时维护binlog日志与InnoDB的redolog,为了保证两个日志一致性,MySql使用了XA事务。当有事务提交时:
1. 第一步:InnoDB进入Prepare阶段,将事务的XID写入redo日志。binlog不做任何操作。
2. 第二步:进行写binlog日志,也将XID写入binlog。
3. 第三部:调用InnoDB引擎的Commit完成事务的提交。然后将Commit信息写入redo日志。
分布式锁实现方案汇总
基于数据库的主键实现方案
获得锁:当要锁住某一个资源时,就在表中插入对应的一条记录。
释放锁:删除对应的记录。
基于数据库实现分布式锁的方案实现简单,但有很多的问题存在。
- 存在单点故障:一旦数据库挂掉,整个业务系统不可用。可以配置主从,防止单点故障。
- 超时问题:如果解锁操作失败,会导致锁一直在数据库中,其他线程无法获得锁。可以添加独立的定时任务,通过时间戳等方式删除超时数据。
- 不可重入:同一个线程在没有释放锁之前不能再次获得该锁。实现可重入需要改造加锁方法,增加存储和判断线程信息。
- 阻塞等待问题:其他线程在请求对应的资源时,插入数据失败,会直接返回,不会阻塞线程。故线程内要做循环插入判断,对数据库操作较大的资源浪费。
- 主从问题:在高并发的场景下,数据库主从延时增大,线程读取的数据非最新版,导致锁重复。
基于ZooKeeper的实现方案
利用Zookeeper支持的临时顺序节点的特性,可以实现分布式锁。
独占锁-使用临时节点实现
获得锁: 当要对某个资源加锁时,Zookeeper在该资源对应的指定的节点目录下,生成一个唯一的临时节点。其他客户端对该节点设置一个Watcher通知。
释放锁:Zookeeper删除对应的临时节点,其他客户端可以监听到节点被删除的通知,并重新竞争锁。
读写锁-使用临时有序节点实现
获得读锁:
- 获得临时有序节点,并标记为读锁
- 获取资源路径下所有的子节点,从小到大排序。
- 获取当前临近节点前的临近写锁节点。
- 如果不存在临近写锁节点,则成功获得读锁
- 如果存在临近写锁节点,则设置Water监听该节点的删除事件。
- 一旦监听到删除事件,重复2,3,4,5的步骤。
获得写锁
- 创建临时有序节点,并标记为写锁。
- 获取路径下的所有子节点,并进行从小到大排序。
- 获取当前节点的临近的写锁节点和读锁节点。
- 如果不存在临近节点,则成功获取锁。
- 如果存在临近节点,对其进行监听删除事件。
- 一旦监听到删除事件,重复2,3,4,5的步骤(递归)。
释放锁
删除对应的临时节点。
基于Redis的实现方案
原理:在获取锁之前,先查询一下以该锁为key对应的value是否存在,若存在,说明该锁被其他客户端获取了。
改进1:为了防止获取锁的客户端突然宕机,需要在设置key的时候,指定一个过期时间,以确保即使宕机了,锁也能最后释放。通过SETNX命令设置key的值,通过EXPIRE命令设置过期时间。
改进2:由于SETNX和EXPIRE命令的执行不是原子性的,多个客户端在检验锁是否存在时会导致多个客户端都认为自己能获取到锁。Redis提供了
Set原子性命令,在设置值的同时指定过期时间。
改进3:客户端获取锁以后任务未执行完,但锁已经过期,被别的客户端获取了,这时客户端扔会释放锁,导致锁失效。可以在设置key的时候,设置value为一个随机值r,删除的时候先比较一下redis里的value是否为r再决定删除。
改进4:客户端先比较一下redis的value是否为r再决定删除,但由于比较后再删除锁不是原子的。在比较过程中,key有可能因过期而被清除了导致一个客户端就可以获取到key。Redis并没有提供相关的比较后删除的原子操作。此时释放锁的过程可以使用lua脚本,redis将lua脚本的命令视为一个原子操作。
分布式唯一ID生成系列
UUID:
UID使用以太网卡地址、纳秒级时间、芯片ID码和许多可能的数字来生成一串唯一随机32位长度数据。
优点:性能好,本地生成,全局唯一。
缺点:
- UUID长度固定为32位,对于Mysql索引来说,所有的非主键索引都会包含一个主键,UUID长度过长会不利于MySql的存储和性能。
- UUID是乱序的,每一次UUID数据的插入都会对主键地城的b+树进行很大的修改。
- 信息不安全,UUID里包含了MAC地址,芯片ID码能信息。会造成信息泄露。
数据库自增ID
对于多台数据库,通过每台数据库的起始值增值和自增值的跨度,可以实现全局的自增ID。以4台数据库为例,如下表
数据库编号 | 起始值增值 | 自增值的跨度 | 生成的主键序列 |
---|---|---|---|
1 | 1 | 4 | [1,5,9,13,17,21,25,29.....] |
2 | 2 | 4 | [2,6,10,14,18,22,26,30....] |
3 | 3 | 4 | [3,7,11,15,19,23,27,31....] |
4 | 4 | 4 | [4,8,12,16,20,24,28,32....] |
优点:容易存储,可以直接用数据库存储。
缺点:
- 统水平扩展比较困难,定义好步长和机器台数之后,再增加数据库需要重调整所有的数据库起始值增值和自增值的跨度。
- 数据库压力大,每次获取ID都会写一次数据库。
- 信息不安全,递增性太强。很容根据两个ID的差值判断竞争对手的中间的出单量。
Snowflake
snowflake生成id的结果是一个64bit大小的整数。由一位标识位,41个比特位的时间戳,10位的机器位,可以标识1024台机器,还有就是10比特位的自增序列化组成。结构如下:
优点:趋势递增,不依赖第三方组件(数据库等),性能更高,可以根据自身业务特点动态分配bit位。
缺点:强依赖机器时钟,如果出现时钟回拨,那么整个系统生成的ID将会不可用。
美团Leaf
leaf提供了的两种模式。
Segment模式
Segment模式在之前数据库方案基础之上进行了优化。该模式不是每次都获取ID都操作一次数据库,而是异步的一次性的从数据库中取N个ID组成一个号段,然后放入本地缓存。同时采用双buffer 方法,在第一个号段下发了一定的百分比时,就会有另一个线程启动来获取并更新下一个号段的缓存数据。
优点:
- Id单调递增,通过内部有号段缓存,数据库挂了依旧能够支持一段时间。
缺点:
- 号段过短会导致DB宕机容忍时间变短,号段过长会导致ID号跨度过大。可以根据号段使用情况动态调整号段跨度。
- 最终还是强依赖DB
Snowflake模式
美团Leaf的Snowflake像较于普通的Snowflake,有两点改进。
- 10位的workID是应用在zookeeper注册的顺序节点的序号。
- 时钟回拨问题:应用会将定时将自己的时间写入zookeeper中,同时会将本地时间和zookeeper的存储的时间做比较。如果差值超过设置阈值则认为是本地服务的时间发生回拨。
服务限流算法:
计数器
从第一个请求进来开始计时,在接下去的1s内,每来一个请求,就把计数加1,如果累加的数字达到了100,那么后续的请求就会被全部拒绝。等到1s结束后,把计数恢复成0,重新开始计数.可使用redis的incr原子自增性和线程安全即可轻松实现。
如果我在单位时间1ms内的前10ms,已经通过了100个请求,那后面的990ms,请求全部会被拒绝,即:突刺现象。
滑动窗口算法
滑动窗口算法是将时间周期分为N个小周期,分别记录每个小周期内访问次数,并且根据时间滑动删除过期的小周期,滑动窗口的格子划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确
漏桶算法
算法内部有一个容器,不管上面流量多大,下面流出的速度始终保持不变。可以准备一个队列,用来保存请求,另外通过一个线程池定期从队列中获取请求并执行,可以一次性获取多个并发执行。
令牌桶算法:
算法中存在一种机制,以一定的速率往桶中放令牌。每次请求调用需要先获取令牌,只有拿到令牌,才有机会继续执行,否则选择选择等待可用的令牌、或者直接拒绝。可以准备一个队列,用来保存令牌,另外通过一个线程池定期生成令牌放到队列中,每来一个请求,就从队列中获取一个令牌,并继续执行。guava的RateLimiter可以简单生成一个令牌限流器。
集群限流:每次有相关操作的时候,就向redis服务器发送一个incr命令,比如需要限制某个用户访问/index接口的次数,只需要拼接用户id和接口名生成redis的key,每次该用户访问此接口时,只需要对这个key执行incr命令,在这个key带上过期时间,就可以实现指定时间的访问频率。
一致性Hash算法:
使用Hash算法让固定的一部分请求落到同一台服务器上,这样每台服务器固定处理一部分请求起到负载均衡的作用。但是普通的hash算法伸缩性很差,当新增或者下线服务器机器时候,用户id与服务器的映射关系会大量失效。一致性hash则利用hash环对其进行了改进。
一致性hash:将所有的服务器散列值看成一个从0开始的顺时针环,然后看请求的hash值落到了hash环的那个地方,在hash环上的位置顺时针找距离最近的ip作为处理服务器
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。