分布式系统(Distributed System)是由多个独立的计算机组成的,它们通过网络连接并协同工作,以实现一个共同的目标或提供服务。由于单机存在各种性能瓶颈且可靠性较差,所以需要高性能,高可用的系统大多设计为分布式系统。
CAP理论
CAP理论是分布式系统的基本理论。
- C指一致性,所有节点的数据都是一致的,都保存最新数据,客户端在任意节点上都能读到最新数据
- A指可用性,部分节点故障后集群仍然可用
- P指分区容错性,出现网络故障,节点间无法正常通信时系统仍然正常工作
一个分布式系统最多只能满足CAP中的两者,而由于网络故障是一定存在的,所以一个分布式系统要么是CP的,要么是AP的。例如Redis追求AP,而Zookeeper追求CP。
BASE理论
BASE理论基于CAP理论演化而来的,追求最终一致性和基本可用的系统。
- BA指基本可用,基本可用指可损失部分可用性,例如响应时间变长,功能降级
- S指软状态,软状态指允许多个节点间存在数据不同步,例如主从延迟
- E指最终一致性,即不要求每个时刻所有节点的数据都一致,只要最终每个节点的数据一致即可。
分布式一致性算法
CAP中的一致性需要靠相关算法保证。如Raft算法或Paxos算法。这里介绍Raft算法。
- 各个节点间保持心跳,如果Follower在一定时间内没收到Leader的心跳,就认为Leader挂了,此时要做Leader选举。
- Leader选举
- 当前Follower节点成为候选者,随机等待一小段时间发起选举,任期加一,先投自己一票,然后向其他节点拉票。
- 若其他节点认为该拉票请求是合法的(候选人拥有已提交的所有日志,候选人任期大于当前任期,在候选人任期中未给其他节点投过票)则投票给当前节点。如果某个节点与Leader断开连接,这个节点开启选举,但是他的日志不是最新的,故其他节点不会投票给他,这可以避免某个从机与主机断开连接但与其他主机正常通信造成的错误。
- 如果候选者得到超过半数的票即可晋升为Leader
- 旧Leader重连上来发现其他节点的任期比自己当前任期大,就自动转化为从机
- 如果选举时两个候选者选票相同,则本轮选举失败,开启新一轮选举
- 日志复制
- 选出Leader后,客户端命令Leader保存数据,Leader操作完后复制日志给Follower
- Leader必须等大多数从节点都保存完日志后才向客户端回复成功
- 分区错误时
- 新的分区选出新的领导用于接收数据(能够选举成功说明获得大多数节点同意,则保存数据也能成功)
- 旧的领导接收数据时可能因为无法获得大多数节点的确认导致保存数据失败
- 分区恢复后以新的领导的数据为准,旧领导的数据失效
分布式事务
分布式事务是分布式系统中的一个关键概念,它确保跨多个节点或多个数据库的一系列操作能够以原子性的方式执行。有多种保证分布式事务的方式:
两阶段提交(2PC)
- 第一阶段:事务协调者向事务参与者发送prepare请求,参与者执行操作(此时会写入undolog和redolog但是不提交)并返回执行结果
- 第二阶段:若第一步所有参与者都执行成功,则协调者让所有参与者commit,若有参与者在第一步失败,则协调者命令所有参与者rollback
但是2PC事务性能较差,原因有:
- prepare阶段参与者会被阻塞直到其他参与者都完成操作,而且此时事务未提交,会锁定相关资源
- 事务协调者单点故障会导致所有参与者一直阻塞
- 网络抖动导致一部分参与者收不到commit命令导致数据不一致,或部分参与者commit失败
三阶段提交(3PC)
- 询问阶段:协调者向参与者发出CanCommit命令,询问是否能提交事务,这一阶段不锁表。多设置了这一阶段保证提交事务前各参与者的状态一致。
- 准备阶段:协调者向参与者发出PreCommit命令,参与者执行操作但不提交,返回ack。若有参与者执行失败或超时未返回,协调者就回滚所有参与者。
- 提交阶段:所有参与者回复ack后,协调者让所有参与者提交事务,参与者完成事务后回复ack,协调者收到所有ack后事务完成。
补偿事务(TCC)
- try阶段:检查是否有执行事务需要的资源,并锁定资源
- confirm阶段:确认提交业务
- cancel阶段:业务执行错误需要回滚时执行的逻辑
TCC属于应用层的补偿方式,需要自己写补偿方案。2PC是数据库层面的回滚补偿,而TCC实质上就是应用层的补偿机制。
最大努力通知型方案
业务不保证一定成功,但是会将结果通知到调用者。调用者如果收到业务失败的消息,去回滚或补偿自己之前做的事务(例如订单支付失败,则超时自动解锁订单)。具体可以使用本地事务+通知的方式:
本地消息表(异步确保),将分布式事务拆成本地事务处理
- 分布式事务的其中一方A把业务执行和消息放在同一个事务中,把消息保存到本地表中
- A把消息放到MQ中
- 分布式事务另一方B从消息队列中读取消息,并执行操作,通知A消息处理结果
可能有以下结果
- 若第一步出错,本地事务回滚,消息不会被放到本地表中
- 若第二步出错,消息保存在消费者表中,重新发给MQ
- 若第三步出错,B执行业务失败,通知A事务失败,A回滚事务(执行补偿逻辑)
分布式session
用户登录后,服务器会把用户的session存储在自己的内存空间中,但是如果使用分布式架构,有多个服务器,会导致下次用户请求到达其他的服务器时无法获取登录状态。分布式session解决方案有:
- session复制:即所有服务器都存一份完整的session数据
- 根据ip固定将请求hash到指定服务器
- 把用户信息存在客户端的cookie中,例如使用JWT
- 将session统一存储到中间件中,例如redis
分布式ID
分布式系统中经常需要使用唯一ID对数据进行标记,例如用户身份标识,消息标识,且分库分表时自增ID也无法使用,需要使用分布式ID。分布式ID要求全局唯一,在高并发下保证ID生成效率,另外根据不同场景可能还要求有序性和安全性(无规则ID,避免泄露业务信息)
- UUID:32个16机制字符,由五部分组成,用-分隔,时间戳-时钟序列-变体标识-版本号-机器MAC地址。UUID在本地生成,效率高,但是不安全(可能泄露MAC地址),无序(对数据库插入不友好),时钟错误可能导致UUID重复。
- 数据库自增ID:使用一个MySQL表来专门生成ID,需要分布式ID时向表中插入数据并返回主键ID,天然有序但是不安全,且高并发时可能受限于数据库瓶颈。
- 数据库号段模式:也是基于数据库自增ID,但不是一次获取一个ID,而是批量获取ID(号段的步长)然后缓存到本地,后续业务需要获取ID就去本地获取,直到这些ID用完后再去数据库取。不安全,且数据库有瓶颈(通过集群优化或使用redis)(许多大厂用的是这种方式)
- 雪花算法:时间戳+机器编号+序列号。雪花算法保证唯一性和顺序性,支持高并发生成ID。但是强依赖机器时钟,如果时钟回拨可能导致ID重复。
分布式锁
本地锁只能锁住位于同一个机器(JVM)上的不同线程,分布式锁指对多个JVM的上的线程都可见的锁,对于部署在多个服务器上的微服务起作用。常见的分布式锁实现有zookeeper,redis。
设计分布式锁的要点(以redis分布式锁为例)
- 独占性:任何时刻只能有一个线程有锁,所以获取锁和释放锁时都要保证原子性。
- redis中的setnx就是没有这个值时才可以创建,保证获取锁是原子性操作
- 使用lua脚本确保解锁是原子性操作
- 防死锁:不能出现死锁问题,必须有超时重试机制或撤销操作
- setnx时设置过期时间
- 不乱抢:每个线程只能解锁自己的锁,不能释放别人的锁
- setnx的key要带上线程id来区分是否是自己的锁
- 重入性:同一节点的同一线程获得锁之后,这个线程可以再次获得锁
- setnx不满足,因为要记录持有锁的线程进入了几次锁,可以使用redis中的hset
- 超时解锁导致并发:线程A执行时间过长导致锁过期自动释放,此时线程B获取到锁,线程A和B并发执行。
- 可以使用一个守护线程,若执行线程在正常执行,则对锁续期。也可用使用watchdog
- 高可用:集群环境下不能因为某个节点挂了而出现获取锁或释放锁失败的情况,且保证高并发下性能依旧良好。即单点故障问题,当存储锁的那台服务器宕机时,就无法再正常加锁解锁了。但是也不能使用redis集群存储锁,因为当主机宕机,从机上位时,这个复制的过程是异步的,很有可能主机已经设置了锁,但是尚未同步到从机时,主机挂了,此时从机上位但是没有锁,线程又在从机这里拿到了锁,出现了多个线程拿着锁的问题。(一句话,主从延迟导致数据丢失)
- 此时需要使用RedLock
RedLock
针对单点故障问题,redis官方提供了RedLock算法,用于实现多个实例的分布式锁。不使用主从结构,而使用N个独立master节点(N为奇数),容错数量为N/2,即最多容忍N/2个redis实例失效。 Redisson实现了RedLock
分布式锁的其他实现
- MySQL实现分布式锁,通过加锁读(for update)来获取锁
- 优点是实现简单
- 缺点是没走索引会锁表,MySQL性能差,获取锁失败后需要轮询(消耗资源)
- redis实现分布式锁,通过setnx指令+lua脚本实现
- 优点是实现简单,可抗住高并发
- 缺点是单点故障,不是强一致性的,key过期时间不明确,获取锁失败后需要轮询(消耗资源)
- Zookeeper实现分布式锁,通过在同一个目录下创建临时顺序节点实现
- 优点是强一致性,获取锁失败后可以使用监听器监听锁(不用轮询)
- 缺点是性能不如redis
- 如何实现?
- 线程通过在zk中创建一个临时顺序节点来获取锁,如果当前线程创建的节点最小则成功获取到锁,如果有比自己小的节点则监听这个节点,线程通过删除节点来释放锁,并通知监听这个节点的线程。zk的临时节点名称不能重复,且会随着客户端的退出而销毁
- zk是强一致性的(cp),即如果主从数据不一致就不对外提供服务,保证用户读取到的数据始终是一致的,故zk实现的分布式锁可靠性较强 而redis只保证最终一致性,可能有主从延迟导致多个线程获取到锁等问题,可靠性较差。
负载均衡
负载均衡算法用于确定流量应该被分发到哪一个健康的服务器上,常见的几个算法如下
- 轮询:轮流将请求负载给每台机器,适用于各服务器处理能力相同而且每个业务处理量差不多的场景。
- 最小链接:选择连接数量最少(压力最小)的服务器
- hash:根据请求的源Ip地址均匀散列到服务器上(保证同一个IP的消息会被发送到同一个服务器)