📊分布式面试题
type
status
date
slug
summary
tags
category
icon
password
Status
CAP原则
CAP理论是分布式系统,特别是分布式存储领域中杯讨论最多的理论,C一致性,A可用性,P分区容错性
- CA:单点集群,满足一致性可用性的系统,(意义不大)
- CP:满足一致性,分区容错性的系统,性能不是特别高(Zookeeper选主回短暂停止对外的服务,几秒内选出新主,一致性为最终一致性,也可以配置为强一致)
- AP:满足可用性,分区容错性(redis)
Redis cluster集群死一个master剩下的master节点还能提供服务吗
一般情况下,对于Redis集群而言,redis主节点主要进行数据的读写操作,而从节点默认为只读权限。如果想要使得从节点也拥有写入权限,也是可以进行设置的。
在Redis集群中,当主节点下线或出现故障时,会发生自动故障转移(Automatic Failover)的过程,即从备用节点中选举出一个新的主节点来代替原先的主节点,以保证集群的可用性。
在进行自动故障转移期间,集群仍然可用。在Redis集群中,主节点的下线或故障被检测到后,备用节点中的某个节点会被选举为新的主节点,然后集群会重新分配槽位,以确保数据的正确路由和负载均衡。在这个过程中,虽然会有一段时间内无法进行写操作,但读操作仍然可以继续执行。
需要注意的是,在自动故障转移期间,可能会出现一些数据的丢失或重复,因此在应用程序中需要进行一些处理,以确保数据的一致性。例如,在写入数据之前,可以先检查主节点是否正常工作,如果不正常,可以将数据写入到备用节点中,以避免数据丢失。同时,还可以使用Redis的哨兵机制,实时监控主节点的状态,以便尽早发现并处理故障。
总之,当Redis集群发生自动故障转移时,集群仍然可用,但需要在应用程序中进行一些处理,以确保数据的一致性。
Nacos和Eureka的区别
CAP理论:eureka只支持AP,nacos支持CP和AP俩种
Nacos支持CP+AP模式,即Nacos可以根据配置识别为CP模式或AP模式,默认是AP模式。如果注册Nacos的client节点注册时ephemeral=true,那么Nacos集群对这个client节点的效果就是AP,采用distro协议实现;而注册Nacos的client节点注册时ephemeral=false,那么Nacos集群对这个节点的效果就是CP的,采用raft协议实现。根据client注册时的属性,AP,CP同时混合存在,只是对不同的client节点效果不同。Nacos可以很好的解决不同场景的业务需求。
节点注册时是ephemeral=true即为临时节点
distro协议
Distro协议是一种AP协议,是最终一致性的协议
- nacos每个节点都是平等的都可以处理写请求,同时把数据同步到其他节点
- 每个节点只负责部分的数据,定时发送自己负责的数据校验值到其他节点来保持数据一致性
- 每个节点处理读请求,及时从本地发出响应
新加入的 Distro 节点会进行全量数据拉取。具体操作是轮询所有的 Distro 节点,通过向其他的机器发送请求拉取全量数据。

在dostro集群启动之后各台机器会定期发送心跳来保证数据一致性
写操作
对一个启动完成的Distro集群,在一次客户端发起的写操作中,当注册非持久化的实例的写请求打到某台Nacos服务器时,Distro集群处理流程如下

- 前置filter拦截请求,根据请求中包含的ip和port信息计算所属的Distro责任节点,请将请求转发到责任节点
- 责任节点将写请求进行解析
- Distro协议定期执行sync任务,将所负责的所有实例信息同步到其他节点上
读操作
由于每台机器上的数据都存放了全量数据,因此每一次读操作,Distro机器会直接从本地拉取数据

Distro协议是Nacos对于临时实例数据开发的一致性协议。其数据存储在缓存中,并且会在启动时进行全量数据同步,并定期进行数据校验。
在Distro协议的设计思想下,每个Distro节点可以接收到读写请求。所有的Distro协议的请求场景主要分为三种情况
- 当该节点接收到属于该节点负责的实力的写请求时,直接写入
- 当该节点接收到不属于该节点负责实例的写请求时,将在集群内部路由,转发给对应的节点,从而完成读写
- 当该节点接收到任何读请求时,都直接在本机查询并返回(因为所有实例都被同步到了每台机器上)。
Distro 协议作为 Nacos 的内嵌临时实例⼀致性协议,保证了在分布式环境下每个节点上面的服务信息的状态都能够及时地通知其他节点,可以维持数十万量级服务实例的存储和⼀致性。
- 平等机制:Nacos 的每个节点是平等的,都可以处理写的请求
- 异步复制机制:Nacos 把变更的数据异步复制到其他节点。
- 健康检查机制:每个节点只存了部分数据,定期检查客户端状态保持数据一致性。
- 本地读机制: 每个节点独立处理读请求,及时从本地发出响应。
- 新节点同步机制:Nacos 启动时,从其他节点同步数据。
- 路由转发机制:客户端发送的写请求,如果属于自己则处理,否则路由转发给其他节点。
Nacos如何保证数据一致性
在Nacos集群模式下,它作为一个完整的注册中心,必须具有高可用的特性。
在集群模式下,客户端只需要和其中一个Nacos节点通信皆可以了,但是每个节点其实是包含所有客户端信息的,这样做的好处是每个Nacos节点只需要负责自己的客户端就可以(分担压力)
那么Nacos集群之间是之间是如何通过Distro协议来保证数据一致性。
V1中,采用的是定期检测元信息的方式。元信息就是当前节点包含的客户端信息的MD5值。

Nacos各个节点会有一个心跳任务,定期向其他机器发送数据检验请求,在校验的过程中,当某个节点发现其他机器上的元信息和本地数据元信息不一致,则会发起一次全量拉取请求,将数据补齐。
V2中定期经验数据已经不用了,采用的是健康检查机制,来和其他节点保持数据的同步
这样设计的好处是保证了高可用,(AP)分为俩个方面
- 读操作都能进行及时的响应,不需要到其他节点拿数据。
- 当脑裂发生时,Nacos 的节点也能正常返回数据,即使数据可能不一致,当网络恢复时,通过健康检查机制或数据检验也能达到数据一致性。
健康检查机制
Spring Cloud Alibaba Naco作为注册中心不止提供了服务注册和服务发现功能,还提供了服务可用性监测的机制。有了这机制之后,Nacos才能感知服务的健康状态,从而为服务调用者提供健康的服务实例,最终保证了业务系统能够正常的执行。
- 客户端主动上报机制
- 服务端主动下探机制
如何理解这两种机制呢?可以想象一个场景,你在学校的教室里面,遇到学业上的问题,或者是科目上的问题。那有什么办法让老师知道你有问题?
- 第一种,你主动去找老师并且告诉老师你的问题和精神状态(健康状态)
- 第二种,老师自己发现你的状态有问题,及主动询问你的问题和状态
以上这两种方法和Nacos的两种健康检查机制类似,也就是客户端主动上报机制,是客户端每隔一段时间,主动向Nacos服务器端上报自己的健康情况,而服务器端下探机制是Nacos服务器端来检测客户端是否健康
NAcos中的健康检查机制不能主动设置,对应了俩种健康检查类型
- 临时实例(非持久化实例):对应客户端主动上报机制
- 永久实例(持久化实例):对应服务器端主动下探
问什么需要俩种服务实例呢?
以淘宝为例,11.11大促期间,流量会比平时高很多,此时服务肯定需要增加更多实例来应对高并发,这些实例在双十一之后就无需继续使用了,采用临时实例比较合适。对于服务的一些常备实例,则使用永久实例更加合适。
临时实例每隔5秒主动上报一次自己的健康状态,发送的数据包叫做心跳包,发送心跳包的机制叫做心跳机制。如果心跳包的事件间隔超过了15秒,那么Nacos服务端就会将此服务实例标记为非健康,如果心跳包超过30秒,那么Nacos服务器端将会把此服务从服务列表中剔除出去

永久实例使用的服务器端主动下探机制的方式实现健康检查的,它的探测周期是2000毫秒+随件数(5000毫秒内),如果检测异常会将此服务实例,标记为非健康实例,但不会把服务实例像临时实例那样中服务列表中剔除。Nacos服务器向下探方式目前内置了3种探测协议:HTTP探测、TCP探测和Mysql探测。一般而言HTTP和TCP探测已经可以涵盖绝大多数的健康检查场景,Mysql主要用于特殊的业务场景,列如数据库的主备需要通过服务外对外提供访问,需要确定当前访问数据库是否为主库时,那么我们此时的健康检查接口,是一个检查数据库是否为主库的Mysql命令。

默认情况下,永久实例使用的是TCP探测
集群下的健康检查机制
集群下的健康检查机制可以用一句话概括,各司其职,每个服务对应了一个主注册中心,当注册中心接收到临时心跳包之后,将健康状态同步到这侧中心。而永久实例也是类似的,每个服务对应一个注册中心,当负责的注册中心下探服务实例的健康状态发生改变时,将实例的健康状态同步到其他的注册中心,从而实现了集群下的健康检查机制

Eureka
Eureka Server 启动后,会通过 Eureka Client 请求其他 Eureka Server 节点中的一个节点,获取注册的服务信息,然后复制到其他 peer 节点。
Eureka Server 每当自己的信息变更后,例如 Client 向自己发起注册、续约、注销请求, 就会把自己的最新信息通知给其他 Eureka Server,保持数据同步。

如果自己的信息变更是另一个Eureka Server同步过来的,这是再同步回去的话就出现数据同步死循环了。

Eureka Server 在执行复制操作的时候,使用 HEADER_REPLICATION 这个 http header 来区分普通应用实例的正常请求,说明这是一个复制请求,这样其他 peer 节点收到请求时,就不会再对其进行复制操作,从而避免死循环。
还有一个问题,就是数据冲突,比如 server A 向 server B 发起同步请求,如果 A 的数据比 B 的还旧,B 不可能接受 A 的数据,那么 B 是如何知道 A 的数据是旧的呢?这时 A 又应该怎么办呢?
数据的新旧一般是通过版本号来定义的,Eureka 是通过 lastDirtyTimestamp 这个类似版本号的属性来实现的。
lastDirtyTimestamp 是注册中心里面服务实例的一个属性,表示此服务实例最近一次变更时间

- Eureka 是弱数据一致性,选择了 CAP 中的 AP。
- Eureka 采用 Peer to Peer 模式进行数据复制。
- Eureka 通过 lastDirtyTimestamp 来解决复制冲突。
- Eureka 通过心跳机制实现数据修复。
微服务注册中心的注册表如何更好的防止读写并发冲突?
微服务注册中心结构如下图所示

注册中心维护了一块内存,一般是一个map数据结构,key:服务名称, value:注册服务的路径列表。
读:指读取查找服务,即读取内存中的服务列表。
写:注册服务,在内存中添加数据。
那么题目主题可以归结为,如何解决多线程情况下,内存中数据读写并发冲突的问题。
为什么会冲突?
因为在多线程情况下,共享内存如果没有互斥锁一定会存在线程安全的问题。
注册中心的注册表的服务实例信息,会随着生产者的改变发生写操作,而消费者会从注册中心拉取注册表信息,这是读操作。当读写同时进行时,就会产生读写并发问题了。常见的解决方案有:
- 加悲观锁同步。这种性能太差了,不推荐。如果加锁粒度比较小,还能凑合下。
- 加读写锁。这种性能是比上一种方案好,但是一旦需要扯上锁,永远快不到哪里去。
- COW 写时复制。这个技术不错,没有锁介入。写时复制一份副本出来操作,读操作还是操作原始数据。Redis的在bgsave 主从数据复制时,主实例继续接受处理指令,而数据又能同时复制传输,用到的技术就是这个COW写时复制。
接下来我们细细了解Nacos与Eureka的在架构设计师如恶化提高读写并发性能的
Nacos
nacos客户独断想服务端注册,将注册信息封装成instance对象,服务端接收后,存入一个内部阻塞队列,就成功响应给客户端我那成注册。这样可以极大提高 客户端的注册速度以及启动速度,不会对客户端所属服务带来影响。
Nacos服务端齐了一个只有一个线程的线程池定时调度一个任务。那就是异步从阻塞队列里拉取注册信息进行处理,完成注册信息的注册。注册时,是利用Cow写实复制,拷贝一个副本出来进行写处理,之后再回原注册表,来提高读写并发性能
还有一个亮点就是 ,我们都知道 这么多服务集群实例,肯定会存在多个同时写并发问题,难道一个实例要完成写入注册,就COW拷贝一份,那这么多实例节点,Nacos服务端怎么能够扛得住呢?
显然Nacos设计者考虑到了这一点,阿里中间件设计有这么一句话,解决问题的最好办法不是去解决他,而是规避他。所以Nacos在实现上,并没有去花精力在怎么解决同时写并发问题,而是直接选择了一个线程池只有一个线程去解决处理。这样单个想爱你成从阻塞队列里拉取一个处理一个。因为都是基于内存操作,这个处理速度是相当快的。所以不用担心一个线程消费能不能跟得上的问题。
但是这样设计也不是十全十美,会存在一个问题 :虽然提高了Nacos注册表读写性能,但是也带来客户端(消费者)拉取到的注册表信息不是最实时或者说不是最新的。但是由于客户端会定时向Nacos拉取注册表信息,可以解决这个问题。顶多就是服务没那么及时被发现,并不影响整个系统的可用性。

Eureka读写并发架构设计
eureka读写并发架构设计比较简单暴力,它是利用多级缓存来提高读写并发性能的。Eureka里的服务注册表发生变更时,同步readwrite缓存时,不是做响应条目的变更更新,而是直接把readwrite缓存清空。这点跟我们平时做业务缓存架构比较像,不去比较条目变更更新,而是直接清空它这个缓存。这样后台线程判断比较readwrite缓存与readonly缓存不一致时,就会同步更新空的给readonly缓存。这样客户端拉取时,发现readonly缓存也为空,就会从服务注册表拉取数据,再填充给这俩个缓存。
但是带来的弊端也是比较明显,那就是Eureka一直被吐槽的 服务发现太慢,太不及时了!检查同步都是30秒级别,三个一加就得1分半钟。所以Eureka部署时必须得调优,调低检查时间。但是调太低了 又会导致线程检查过于频繁,浪费CPU。要是注册表没啥变动情况下,就是“徒劳”在那里检查个寂寞。

ReadOnlyCacheMap就是一个普通的ConcurrentHashMap,而ReadWriteCacheMap是guava cache,如果ReadWriteCacheMap读不到数据,就会通过ClassLoader的load方法直接从注册表获取数据再返回
主动过期:当服务实例发生注册、下线、故障的时候,ReadWriteCacheMap中所有的缓存过期掉
定时过期:readWriteCacheMap在构建的时候,指定了一个自动过期的时间,默认值就是180秒,所以你往readWriteCacheMap中放入一个数据,180秒过后,就将这个数据给他过期了
被动过期:默认是每隔30秒,执行一个定时调度的线程任务,对readOnlyCacheMap和readWriteCacheMap中的数据进行一个比对,如果两块数据是不一致的,那么就将readWriteCacheMap中的数据放到readOnlyCacheMap中来
通过过期的机制,可以发现一个问题,就是如果ReadWriteCacheMap发生了主动过期或定时过期,此时里面的缓存就被清空或部分被过期了,但是在此之前readOnlyCacheMap刚执行了被动过期,发现两个缓存是一致的,就会接着使用里面的缓存数据
所以可能会存在30秒的时间,readOnlyCacheMap和ReadWriteCacheMap的数据不一致
Nacos高并发异步注册架构如何设计
首先分析Spring Cloud集成Nacos client的服务注册和服务拉取的逻辑。
细心的同学可能已经发现NacosAutoServiceRegistration的继承的AbstractServiceRegistration类实现了ApplicationListener接口,那么必定在AbstractAutoServiceRegistration类中监听某个event方法(实现方法:onApplicationEvent),源码中最终会调用一个register方法,这个方法就是真正向NacosServer注册了当前实例,源码中可以看出最终调用了reqApi方法,向Naocs Server/nacos/v1/ns/instance接口发哦送你通过一个post请求,把当前实例注册进去,到这里整个客户端的核心注册流程就分析完成了。
现在接着分析一下NacosServer注册中心的核心功能逻辑以及源码,首先来分析Nacos怎么能支持高并发的Intance的注册的。采用内存队列的方式进行服务注册
从源码可以看出最终会执行listener.onChange()这个方法,并把instances传入,然后进行真正的注册逻辑,这里的设计就是为了提高Nacos并发注册量。
Nacos2.X为什么性能提升了接近10倍?
Nacos2.0新架构不仅将性能大幅提升10倍,而且内核进行了分层抽象,并且实现插件扩容机制
通信层统一到gRpc协议,同时完善了客户端和服务端的流量控制和负载均衡能力,提升的整体吞吐量。将存储和一致性模型做了抽象分层,架构更简单清晰,代码更加健壮,性能更加强悍。
Nacos2.0架构下的服务发现客户端通过gRPC发起注册服务或订阅请求。服务端使用Client对象来记录客户端使用gRpc连接发布了哪些服务,又订阅了哪些服务,并将client进行服务间同步。由于实际的使用习惯是服务到客户端的映射,即服务下有哪些客户端实例;因此 2.0 的服务端会通过构建索引和元数据,快速生成类似 1.X 中的 Service 信息,并将 Service 的数据通过 gRPC Stream 进行推送。
配置管理之前用 Http1.1 的 Keep Alive 模式 30s 发一个心跳模拟长链接,协议难以理解,内存消耗大,推送性能弱,因此 2.0 通过 gRPC 彻底解决这些问题,内存消耗大量降低。
Sentinel底层滑动时间窗限流算法怎么实现的?
Sentinel的限流原理
限流效果,对应有DefaultController快速失败
WarmUpController慢启动(令牌桶算法)
RateLimiterController(漏桶算法)
固定时间窗口算法
即比如每一秒作为一个固定的时间窗口,在一秒内最多可以通过100个请求,那么在统计数据的时候,如果0-500ms没有请求,而500-1000ms有100个请求,那么这一百个请求都能通过,在1000-1500ms的时候,又有100个请求过来了,它依然能够通过,因为在1000ms的时候又开启了一个新的固定时间窗口。这样,500-1500ms这一秒内有了200个请求,但是它依然能够通过,所以这就会造成数据统计的不准确性,并不能保证在任意的一秒内都使得通过请求数小于100,。
因为固定时间窗口带来的数据同的不准确性,就会造成局部的事件压力过高,所以就需要
普通的滑动窗口做法
因为固定时间窗口带来的数据同的不准确性,就会造成可能局部的时间压力过高,所以就需要采用滑动窗口算法来进行统计,滑动窗口时间算法意思就是,从请求过来的时刻开始,统计往前一秒中的数据,通过这个数据来判断是否进行限流等操作。这样的话准确性就会有很大的提升,但是由于每一次请求过来都需要重新统计前一秒的数据,就会造成巨大的性能损失。所以这也是他的不合理的地方。
Sentinel的滑动时间窗口算法
由于固定时间窗口带来的不确定性和普通滑动窗口带来的性能损失的缺点,所以Sentinel对这俩中方案采取了这种的方法。
在Sentinel中会将原本的固定时间窗口划分成更多更小的样本窗口,每一次请求的数据都会被保存在小的样本窗口中去,而每一次获取的时候都会去获取这些样本时间窗口中的数据,从而不需要进行重新统计,就减小了性能损耗,同时时间窗口被细粒度化了,不准确性也会降低很多。
Nacos中的如何保证CP和AP
Nacos临时节点是AP(ephemeral=true)distro,持久节点是CP(raft)
如何实现Raft算法
Nacos server启动时,会通过 RunningConfig.onApplicationEvent()方法调用RaftCore.init()方法
Java
复制
在 init方法主要做了如下几件事:
获取 Raft集群节点 peers.add(NamingProxy.getServers());
Raft集群数据恢复 RaftStore.load();
Raft选举 GlobalExecutor.register(new MasterElection());
Raft心跳 GlobalExecutor.register(new HeartBeat());
Raft发布内容
Raft保证内容一致性
其中,raft集群内部节点间是通过暴露的 Restful接口,代码在 RaftController 中。RaftController控制器是 Raft集群内部节点间通信使用的,具体的信息如下
Java
复制
Raft中使用心跳机制来触发 Leader选举。心跳定时任务是在 GlobalExecutor 中,通过 GlobalExecutor.register(new HeartBeat())注册心跳定时任务,具体操作包括:
重置 Leader节点的heart timeout、election timeout;
sendBeat()发送心跳包
Java
复制
Distro
Distro协议。Distro是阿里巴巴的私有协议,目前流行的 Nacos服务管理框架就采用了 Distro协议。Distro 协议被定位为 临时数据的一致性协议 :该类型协议, 不需要把数据存储到磁盘或者数据库 ,因为临时数据通常和服务器保持一个session会话, 该会话只要存在,数据就不会丢失 。
Distro 协议保证写必须永远是成功的,即使可能会发生网络分区。当网络恢复时,把各数据分片的数据进行合并。
- 专门为了注册中心创造出来的协议
- 客户端与服务端有俩个重要的交互,服务注册与心跳发送
- 客户端以服务为维度向服务端注册,注册后每隔一段时间向服务端发送一次心跳,心跳包需要带上注册服务的全部信息,在客户端看来,服务端节点对等,所有请求的节点是随机的
- 客户端请求失败则换一个节点重新请求
- 服务端节点都存储所有数据,但每个节点只负责其中一部分服务,在接收到客户端的“写”(注册、心跳、下线等)请求后,服务端节点判断请求的服务是否为自己负责,如果是,则处理,否则交由负责的节点处理;
- 每个服务端节点主动发送健康检查到其他节点,响应的节点被该节点视为健康节点;
- 服务端在接收到客户端的服务心跳后,如果该服务不存在,则将该心跳请求当做注册请求来处理;
- 服务端如果长时间未收到客户端心跳,则下线该服务;
- 负责的节点在接收到服务注册、服务心跳等写请求后将数据写入后即返回,后台异步地将数据同步给其他节点;
- 节点在收到读请求后直接从本机获取后返回,无论数据是否为最新。

Distro协议是阿里的私有协议,但是对外开源框架只有Nacos。所有我们只能从Nacos中一窥Distro协议。Distro协议是一个比较简单的最终一致性协议。整体由节点寻址、数据全量同步、异步增量同步、定时上报client所有信息、心跳探活其他节点等组成。
一个比较简单的最终一致性协议。整体由节点寻址、数据全量同步、异步增量同步、定时上报client所有信息、心跳探活其他节点等组成。

- 当该节点接收到属于该节点负责的服务时,直接写入。
- 当该节点接收到不属于该节点负责的服务时,将在集群内部路由,转发给对应的节点,从而完成写入。
读取操作则不需要路由,因为集群中的各个节点会同步服务状态,每个节点都会有一份最新的服务数据。
而当节点发生宕机后,原本该节点负责的一部分服务的写入任务会转移到其他节点,从而保证 Nacos 集群整体的可用性。
一个比较复杂的情况是,节点没有宕机,但是出现了网络分区,即下图所示:

这个情况会损害可用性,客户端会表现为有时候服务存在有时候服务不存在。
综上,Nacos 的 distro 一致性协议可以保证在大多数情况下,集群中的机器宕机后依旧不损害整体的可用性。该可用性保证存在于 nacos-server 端。
注册中心发生故障最坏的一个情况是整个 Server 端宕机,这时候 Nacos 依旧有高可用机制做兜底。
当 Dubbo 应用运行时,Nacos 注册中心宕机,会不会影响 RPC 调用。这个题目大多数应该都能回答出来,因为 Dubbo 内存里面是存了一份地址的,一方面这样的设计是为了性能,因为不可能每次 RPC 调用时都读取一次注册中心,另一面,注册中心宕机后内存会有一份数据,这也起到了可用性的保障(尽管可能 Dubbo 设计者并没有考虑这个因素)。
那如果,我在此基础上再抛出一个问题:Nacos 注册中心宕机,Dubbo 应用发生重启,会不会影响 RPC 调用。如果了解了 Nacos 的 Failover 机制,应当得到和上一题同样的回答:不会。
Nacos 存在本地文件缓存机制,nacos-client 在接收到 nacos-server 的服务推送之后,会在内存中保存一份,随后会落盘存储一份快照。snapshot 默认的存储路径为:{USER_HOME}/nacos/naming/ 中:

这份文件有两种价值,一是用来排查服务端是否正常推送了服务;二是当客户端加载服务时,如果无法从服务端拉取到数据,会默认从本地文件中加载。
前提是构建 NacosNaming 时传入了该参数:namingLoadCacheAtStart=trueDubbo 2.7.4 及以上版本支持该 Nacos 参数;开启该参数的方式:dubbo.registry.address=nacos://127.0.0.1:8848?namingLoadCacheAtStart=true
在生产环境,推荐开启该参数,以避免注册中心宕机后,导致服务不可用,在服务注册发现场景,可用性和一致性 trade off 时,我们大多数时候会优先考虑可用性。
细心的读者还注意到
{USER_HOME}/nacos/naming/{namespace} 下除了缓存文件之外还有一个 failover 文件夹,里面存放着和 snapshot 一致的文件夹。这是 Nacos 的另一个 failover 机制,snapshot 是按照某个历史时刻的服务快照恢复恢复,而 failover 中的服务可以人为修改,以应对一些极端场景。
该可用性保证存在于 nacos-client 端。
心跳服务
心跳机制一般广泛存在于分布式通信领域,用于确认存活状态。一般心跳请求和普通请求的设计是有差异的,心跳请求一般被设计的足够精简,这样在定时探测时可以尽可能避免性能下降。而在 Nacos 中,出于可用性的考虑,一个心跳报文包含了全部的服务信息,这样相比仅仅发送探测信息降低了吞吐量,而提升了可用性,怎么理解呢?考虑以下的两种场景:
- nacos-server 节点全部宕机,服务数据全部丢失。nacos-server 即使恢复运作,也无法恢复出服务,而心跳包含全部内容可以在心跳期间就恢复出服务,保证可用性。
- nacos-server 出现网络分区。由于心跳可以创建服务,从而在极端网络故障下,依旧保证基础的可用性。
MSE Nacos 的高可用最佳实践
阿里云微服务引擎 MSE 提供了 Nacos 集群的托管能力,实现了集群部署模式的高可用。
- 当创建多个节点的集群时,系统会默认分配在不同可用区。同时,这对于用户来说又是透明的,用户只需要关心 Nacos 的功能即可,MSE 替用户兜底可用性。
- MSE 底层使用 K8s 运维模式部署 Nacos。历史上出现过用户误用 Nacos 导致部分节点宕机的问题,但借助于 K8s 的自运维模式,宕机节点迅速被拉起,以至于用户可能都没有意识到自己发生宕机。
高并发的理解
先搞清楚高并发系统设计的目标,在此基础上再讨论设计方案和实践经验才有意义和针对性。
宏观目标
高并发绝不意味着只追求高性能,这是很多人片面的理解。从宏观角度看,高并发系统设计的目标有三个:高性能、高可用,以及高可扩展。
高性能:性能体现了系统的并行处理能力,在有限的硬件投入下,提高性能意味着节省成本。同时,性能也反映了用户体验,响应时间分别是100毫秒和1秒,给用户的感受是完全不同的。
高可用:表示系统可以正常服务的时间。一个全年不停机、无故障;另一个隔三差五出线上事故、宕机,用户肯定选择前者。另外,如果系统只能做到90%可用,也会大大拖累业务。
高扩展:表示系统的扩展能力,流量高峰时能否在短时间内完成扩容,更平稳地承接峰值流量,比如双11活动、明星离婚等热点事件。
这3个目标是需要通盘考虑的,因为它们互相关联、甚至也会相互影响。比如说:考虑系统的扩展能力,你会将服务设计成无状态的,这种集群设计保证了高扩展性,其实也间接提升了系统的性能和可用性。再比如说:为了保证可用性,通常会对服务接口进行超时设置,以防大量线程阻塞在慢请求上造成系统雪崩,那超时时间设置成多少合理呢?一般,我们会参考依赖服务的性能表现进行设置。
可用性指标
- 平均响应时间:最常用,但是缺陷很明显,对于慢请求不敏感。比如1万次请求,其中9900次是1ms,100次是100ms,则平均响应时间为1.99ms,虽然平均耗时仅增加了0.99ms,但是1%请求的响应时间已经增加了100倍。
- TP90、TP99等分位值:将响应时间按照从小到大排序,TP90表示排在第90分位的响应时间, 分位值越大,对慢请求越敏感。
- 吞吐量:和响应时间呈反比,比如响应时间是1ms,则吞吐量为每秒1000次。
通常,设定性能目标时会兼顾吞吐量和响应时间,比如这样表述:在每秒1万次请求下,AVG控制在50ms以下,TP99控制在100ms以下。对于高并发系统,AVG和TP分位值必须同时要考虑。另外,从用户体验角度来看,200毫秒被认为是第一个分界点,用户感觉不到延迟,1秒是第二个分界点,用户能感受到延迟,但是可以接受。因此,对于一个健康的高并发系统,TP99应该控制在200毫秒以内,TP999或者TP9999应该控制在1秒以内。
可用性
高可用性是指系统具有较高的无故障运行能力,可用性 = 平均故障时间 / 系统总运行时间,一般使用几个9来描述系统的可用性。

对于高并发系统来说,最基本的要求是:保证3个9或者4个9。原因很简单,如果你只能做到2个9,意味着有1%的故障时间,像一些大公司每年动辄千亿以上的GMV或者收入,1%就是10亿级别的业务影响。
可扩展性指标
面对突发流量,不可能临时改造架构,最快的方式就是增加机器来线性提高系统的处理能力。
对于业务集群或者基础组件来说,扩展性 = 性能提升比例 / 机器增加比例,理想的扩展能力是:资源增加几倍,性能提升几倍。通常来说,扩展能力要维持在70%以上。
但是从高并发系统的整体架构角度来看,扩展的目标不仅仅是把服务设计成无状态就行了,因为当流量增加10倍,业务服务可以快速扩容10倍,但是数据库可能就成为了新的瓶颈。
像MySQL这种有状态的存储服务通常是扩展的技术难点,如果架构上没提前做好规划(垂直和水平拆分),就会涉及到大量数据的迁移。
因此,高扩展性需要考虑:服务集群、数据库、缓存和消息队列等中间件、负载均衡、带宽、依赖的第三方等,当并发达到某一个量级后,上述每个因素都可能成为扩展的瓶颈点。
分布式存储
分布式存储是一个大的概念,其包含的种类繁多,除了传统意义上的分布式文件系统、分布式块存储和分布式对象存储外,还包括分布式数据库和分布式缓存等。下面我们探讨一下分布式文件系统等传统意义上的存储架构,实现这种存储架构主要有三种通用的形式,其它存储架构也基本上基于上述架构,并没有太大的变化。
中间控制节点架构
分布式存储最早是由谷歌提出的,其目的是通过廉价的服务器来提供使用与大规模,高并发场景下的Web访问问题。下图是谷歌分布式存储(HDFS)的简化的模型。在该系统的整个架构中将服务器分为两种类型,一种名为namenode,这种类型的节点负责管理管理数据(元数据),另外一种名为datanode,这种类型的服务器负责实际数据的管理。

上图分布式存储中,如果客户端需要从某个文件读取数据,首先从namenode获取该文件的位置(具体在哪个datanode),然后从该位置获取具体的数据。在该架构中namenode通常是主备部署,而datanode则是由大量节点构成一个集群。由于元数据的访问频度和访问量相对数据都要小很多,因此namenode通常不会成为性能瓶颈,而datanode集群可以分散客户端的请求。因此,通过这种分布式存储架构可以通过横向扩展datanode的数量来增加承载能力,也即实现了动态横向扩展的能力。
完全无中心架构—计算模式(Ceph)
下图是Ceph存储系统的架构,在该架构中与HDFS不同的地方在于该架构中没有中心节点。客户端是通过一个设备映射关系计算出来其写入数据的位置,这样客户端可以直接与存储节点通信,从而避免中心节点的性能瓶颈。

在Ceph存储系统架构中核心组件有Mon服务、OSD服务和MDS服务等。对于块存储类型只需要Mon服务、OSD服务和客户端的软件即可。其中Mon服务用于维护存储系统的硬件逻辑关系,主要是服务器和硬盘等在线信息。Mon服务通过集群的方式保证其服务的可用性。OSD服务用于实现对磁盘的管理,实现真正的数据读写,通常一个磁盘对应一个OSD服务。
客户端访问存储的大致流程是,客户端在启动后会首先从Mon服务拉取存储资源布局信息,然后根据该布局信息和写入数据的名称等信息计算出期望数据的位置(包含具体的物理服务器信息和磁盘信息),然后该位置信息直接通信,读取或者写入数据。
一致性哈希
与Ceph的通过计算方式获得数据位置的方式不同,另外一种方式是通过一致性哈希的方式获得数据位置。一致性哈希的方式就是将设备做成一个哈希环,然后根据数据名称计算出的哈希值映射到哈希环的某个位置,从而实现数据的定位。
HTTP和RPC的区别
传输协议
RPC,可以基于TCP协议,也可以基于HTTP协议。
HTTP,基于HTTP协议。
传输效率
RPC,使用自定义的TCP协议,可以让请求报文体积更小,或者使用HTTP2协议,也可以很好的减少报文的体积,提高传输效率。
HTTP,如果是基于HTTP1.1的协议,请求中会包含很多无用的内容,如果是基于HTTP2.0,那么简单的封装一下是可以作为一个RPC来使用的,这时标准RPC框架更多的是服务治理。
性能消耗
RPC,可以基于thrift实现高效的二进制传输。
HTTP,大部分是通过json来实现的,字节大小和序列化耗时都比thrift要更消耗性能。
负载均衡
RPC,基本都自带了负载均衡策略。
HTTP,需要配置Nginx,HAProxy来实现。
服务治理
RPC,能做到自动通知,不影响上游。
HTTP,需要事先通知,修改Nginx/HAProxy配置。
什么是幂等?如何解决幂等问题
幂等性的核心思想,是保证这个接口的执行结果只影响一次,后续即便再次调用,也不能对数据产生影响,之所以要考虑到幂等性问题,是因为在网络通信中,存在俩种行为可能会导致接口被重复执行
- 用户的重复提交或者用户的恶意攻击,导致这个请求会被多次执行
- 在分布式架构中,为了避免网络通信导致的数据丢失,在服务之间通信的时候都会设计超时重试的机制,而这种机制有可能导致服务端接口被重复调用
所以在程序设计中,对于数据变更类操作的接口,需要保证接口的幂等性
- 使用redis里面提供的setNX指令,比如对于MQ的消费场景,为了避免MQ重复消费,导致数据多次被修改的问题,可以在收到MQ消息时,把这个消息通过setNX写到redis中,一旦消息被消费过就不会再次消费
- 去重表,将业务中的唯一标识字段保存到去重表,如果表中存在,则表示已经处理过了
- 版本控制,增加版本号,当版本号符合的时候,才更新数据
- 状态控制,录入的订单有状态已支付,未支付,支付失败,当处于未支付的时候,才允许被修改为支付中
举个栗子:比如添加请求的表单里,在打开添加表单页面的时候,就生成一个AddId标识,这个AddId跟着表单一起提交到后台接口。
后台接口根据这个AddId,服务端就可以进行缓存标记并进行过滤,缓存值可以是AddId作为缓存key,返回内容作为缓存Value,这样即使添加按钮被多次点下也可以识别出来。
这个AddId什么时候更新呢?只有在保存成功并且清空表单之后,才变更这个AddId标识,从而实现新数据的表单提交
分布式锁的理解和实现
分布式锁,是一种跨进程跨机器节点的互斥锁,它可以用来保证多台机器对于共享资源访问的排他性.分布式锁和线程锁的本质相似,线程锁的生命周期是单进程多线程,分布式锁的生命周期是多进程多机器节点
在本质上,他们都需要满足几个锁的特性
- 排他性,也就是说,同一时刻,只能有一个节点去访问共享资源
- 可重入性,允许一个已获得锁的进程,在没有释放锁之前再次重新获得锁
- 锁的获取,释放的方法
- 锁的失效机制,避免死锁的问题
实现方式
关系型数据库,可以使用唯一约束来实现锁的排他性,那抢锁逻辑就是:往表里插入一条数据,如果已经有其他的线程获得某个方法的锁,那这时候插入的数据会失败,从而保证了互斥性,解锁的时候就删除那条数据
redis,它里面提供了setnx命令可以实现锁的排他性,当key不存在时就返回1,存在返回0.还可以用expire命令设置锁的失效时间,从而避免死锁问题
当然有可能存在锁过期了,但是业务逻辑还没执行完的情况。 所以这种情况,可以写一个定时任务对指定的key进行续期。Redisson这个开源组件,就提供了分布式锁的封装实现,并且也内置了一个Watch Dog机制来对key做续期。Redis是一个AP模型,所以在集群架构下由于数据的一致性问题导致极端情况下出现多个线程抢占到锁的情况很难避免
zookeeper分布式锁.zk通过临时节点,解决了死锁的问题,一旦客户端获得之后突然挂掉,那么临时节点就会自动删除掉,其他客户端自动获得锁,临时顺序节点解决了惊群效应
redis看门狗机制
- 如果我们指定了锁的超时时间,就发送给redis执行脚本,进行占锁,默认超时就是我们指定的时间,不会自动续期
- 如果我们未指定锁的超时时间,就使用LockWatchdogTimeout=30*1000 (看门狗默认时间)
只要占锁成功,就会启动一个定时任务(重新给锁设置过期时间,新的过期时间,就是看门狗的默认时间),每隔10s就会再自动续成30s
Java
复制
CAP理论BASE理论,AP模式,CP模式
cap
- 一致性(Consistency):一个操作返回成功,那么只会的读请求都必须读到这个新数据;如果返回失败,那么所有读操作都不能读到这个数据。所有节点访问一份最新的数据
- 可用性(Availability):对数据更新具备高可用性,请求能够及时处理,不会一直等待,即使节点失效
- 分区容错性(Partition tolerance):系统中任意信息的丢失或失败不会影响系统的继续运作
- 放弃p:放弃分区容错性的话,放弃了分布式系统的可扩展性
- 放弃A:放弃可用性的话,遇到网络分区或其他故障时,受影响的服务需要等待一段时间,无法在对外提供服务
- 放弃C:放弃一致性的话(这里指强一致性),则系统无法保证数据实时的一致性,在数据达到最终一致性时,有个时间窗口内,数据是不一致的
对于分布式系统来说 ,分区容错性是不能放弃的,因此通常是在可用性和一致性之间权衡
BASE
basically Available(基本可用)分布式系统在出现不可预知的故障的时候,允许损失部分的可用性
Soft State(软状态):在一定时间内,允许出现中间状态,比如临时的不一致状态。
Eventually Consistent(最终一致性):虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致。
AP
各个子事务分别执行提交,允许出现结果不一致,然后采用弥补措施恢复数据即可,实现最终一致
CP
各个渍食物执行后相互等待,同时提交,同时回滚,达成强一致.但事务等待过程中,处于弱可用状态
分布式id的解决方案
UUID
复杂度最低,但会影响存储空间和性能
这种方式很简单,在每次需要新增数据的时候生成一个uuid
UUId是唯一标识码的缩写,但是在高并发下是有概重复的
优点
降低全局节点压力,使得主键生成速度更快
生成的主键"理论上"全局唯一,
跨服务器合并数据方便
缺点
UUID占用16个字符,空间占用较多,而且不是有序递增的数字,数据写入IO随机型很大,且索引效率下降
数据库主键自增
创建一个表来专门存放id
SQL
复制
在每次新增的时候,先向该表新增一条数据,然后获取返回新增的主键作为要插入的主键id,我们可以使用下面的语句生成获取到一个自增id
SQL
复制
stub字段没什么特殊意义,只是为了方便插入数据,只有插入数据才能产生自增id
而对于插入我们用的是replace,replace会先看是否存在stub指定值一样的数据,如果存在则先delete再insert,如果不存在则直接insert。
无论执行几次,数据库里都只有一条数据
优点
int和bigint类型占用空间较小
主键自动增长
IO写入的连续性好
数字类型查询速度优于字符串
缺点
并发性能不高,受限于数据库性能
分库分表需要改造,复杂
自增导致数据量泄漏
分布式的ID机制需要单独mysql实例,虽然可行,但是基于性能与可靠性来考虑的话,都不够,业务系统每次需要一个id时,都需要请求数据库获取,性能低并且如果此数据库实例下线了,那么将影响所有的业务系统
Redis自增
因为redis是单线程的,所以可以用来生成全部唯一id,通过incr,incrby实现
生产环境可能是redis集群, 假如有5个redis实例,每个rendis初始值是1,2,3,4,5然后增长都是5
A:1,6,11,16,21
B:2,7,12,17,22
C:3,8,13,18,23
D:4,9,14,19,24
E:5,10,15,20,25
这样的话,无论打到哪个redis上,都可以获得不同的id
使用redis的效率是非常高的,但是要考虑持久化的问题,redis支持RDB和AOF俩种持久化方式
RDB相当于一个定时快照,如果打完快照后,连续自增了几次,还没来得及做下一次快照持久化,这时候redis挂掉了,重启redis后会出现ID重复
AOF持久化相当于每条命令进行持久化,如果redis挂掉了,不会出现ID重复的现象,但是会由于incr命令过多,导致恢复时间变成
优点
使用内存并发性好
缺点
数据丢失,自增数据量泄漏
持续自增如果用AOF会导致大量的磁盘写入
号段模式
我们可以使用号段的方式来获取自增ID,号段可以理解成批量获取,比如DistributIdService从数据库获取ID时,如果能批量获取多个ID并缓存在本地的话,那样将大大提供业务应用获取ID的效率。
比如DistributIdService每次从数据库获取ID时,就获取一个号段,比如(1,1000],这个范围表示了1000个ID,业务应用在请求DistributIdService提供ID时,DistributIdService只需要在本地从1开始自增并返回即可,而不需要每次都请求数据库,一直到本地自增到1000时,也就是当前号段已经被用完时,才去数据库重新获取下一号段
这个数据库表用来记录自增步长以及当前自增ID的最大值(也就是当前已经被申请的号段的最后一个值),因为自增逻辑被移到DistributIdService中去了,所以数据库不需要这部分逻辑了。
这种方案不再强依赖数据库,就算数据库不可用,那么DistributIdService也能继续支撑一段时间。但是如果DistributIdService重启,会丢失一段ID,导致ID空洞。
为了提高DistributIdService的高可用,需要做一个集群,业务在请求DistributIdService集群获取ID时,会随机的选择某一个DistributIdService节点进行获取,对每一个DistributIdService节点来说,数据库连接的是同一个数据库,那么可能会产生多个
DistributIdService节点同时请求数据库获取号段,那么这个时候需要利用乐观锁来进行控制,比如在数据库表中增加一个version字段
因为newMaxId是DistributIdService中根据oldMaxId+步长算出来的,只要上面的update更新成功了就表示号段获取成功了。
为了提供数据库层的高可用,需要对数据库使用多主模式进行部署,对于每个数据库来说要保证生成的号段不重复,这就需要利用最开始的思路,再在刚刚的数据库表中增加起始值和步长,比如如果现在是两台Mysql,那么 mysql1将生成号段(1,1001],自增的时候序列为1,3,5,7… mysql2将生成号段(2,1002],自增的时候序列为2,4,6,8,10…
在TinyId中还增加了一步来提高效率,在上面的实现中,ID自增的逻辑是在DistributIdService中实现的,而实际上可以把自增的逻辑转移到业务应用本地,这样对于业务应用来说只需要获取号段,每次自增时不再需要请求调用DistributIdService了。
雪花算法snowflake
上面三种方法总的来说是基于自增思想的
我们可以换个角度对分布式ID进行思考,只要能让负责生成分布式ID的每台机器在毫秒内生成不一样的ID就行了
snowflake是twitter开源的分布式ID生成算法,是一种算法,所以和上面的三种生成分布式ID机制不太一样,它不依赖数据库
核心思想是分布式ID是一个long型的数字,一个long型占8个字节,也就是64个人bit,原始snowflake算法中对于bit的分配如下图

- 符号为0,0表示证书,ID为整数,所以固定为0
- 时间戳不用多说,用来存放时间戳,单位是ms,站41bit,这个是毫秒级的时间,一般实现实不会存储当前的时间戳,而是时间戳的差值(当前时间-固定的开始时间),这样可以使产生的ID从更小的值开始
- 工作机器id位用来存放机器的id,通常分为5个区域位+5个 服务器标识位,这里比较灵活,比如,可以使用前5位作为数据中心机房标识,后5位作为单机房机器标识,可以部署1024个节点
- 序号位是自增
雪花算法能存放多少数据
- 时间范围:2^41 / (1000L 60 60 24 365) = 69年
- 工作进程范围:2^10 = 1024
- 序列号范围:2^12 = 4096,表示1ms可以生成4096个ID。
在实际的使用场景里,很少有直接使用snowflake,而是进行改造,因为snowflake算法中最难实践的就是工作机器id,原始的snowflake算法需要人工去为每台机器去指定一个机器id,并配置在某个地方从而让snowflake从此处获取机器id。
尤其是机器是很多的时候,人力成本太大且容易出错,所以目前很多大厂对snowflake进行了改造。
百度
uid-generator使用的就是snowflake,只是在生产机器id,也叫做workId时有所不同。
uid-generator中的workId是由uid-generator自动生成的,并且考虑到了应用部署在docker上的情况,在uid-generator中用户可以自己去定义workId的生成策略,默认提供的策略是:应用启动时由数据库分配。
说的简单一点就是:应用在启动时会往数据库表(uid-generator需要新增一个WORKER_NODE表)中去插入一条数据,数据插入成功后返回的该数据对应的自增唯一id就是该机器的workId,而数据由host,port组成。
对于uid-generator中的workId,占用了22个bit位,时间占用了28个bit位,序列化占用了13个bit位,需要注意的是,和原始的snowflake不太一样,时间的单位是秒,而不是毫秒,workId也不一样,同一个应用每重启一次就会消费一个workId。
美团
美团的Leaf也是一个分布式ID生成框架。它非常全面,即支持号段模式,也支持snowflake模式。名字取自德国哲学家、数学家莱布尼茨的一句话:“There are no two identical leaves in the world.”Leaf具备高可靠、低延迟、全局唯一等特点。目前已经广泛应用于美团金融、美团外卖、美团酒旅等多个部门。
Leaf中的snowflake模式和原始snowflake算法的不同点,也主要在workId的生成,Leaf中workId是基于ZooKeeper的顺序Id来生成的,每个应用在使用Leaf-snowflake时,在启动时都会都在Zookeeper中生成一个顺序Id,相当于一台机器对应一个顺序节点,也就是一个workId。
Leaf特性如下:
全局唯一,绝对不会出现重复的ID,且ID整体趋势递增。
高可用,服务完全基于分布式架构,即使MySQL宕机,也能容忍一段时间的数据库不可用。
高并发低延时,在CentOS 4C8G的虚拟机上,远程调用QPS可达5W+,TP99在1ms内。
接入简单,直接通过公司RPC服务或者HTTP调用即可接入。
什么是数据一致性
数据更新成功返回客户端后,所有节点的数据保持一致,没有中间状态
强一致性:时刻保证数据一致性
最终一致性:一段时间后保证数据一致性
弱一致性:允许存在部分数据不一致
柔性事务和刚性事务
刚性事务
通常无业务改造,强一致性,原生支持回滚隔离性,低并发,适合短事务
比如XA,3PC,但由于同步阻塞,处理效率低,不适合大型网站分布式场景
刚性事务指的是,要使分布式事务,达到像本地式事务一样,具备数据强一致性,从CAP来看,就是说,要达到CP状态。
柔性事务
不要求强一致性,而是要求最终一致性,允许有中间状态也就是base理论(基本可用+软状态+最终一致性)
TCC/FMT、Saga(状态机模式、Aop模式)、本地事务消息、消息事务(半消息)
柔性事务分为
- 补偿型
- 异步确保型
- 最大努力通知行
介绍分布式事务
事务是由一组操作构成的可靠的独立工作单元,事务具备ACID特性,即原子性,一致性,隔离性,持久性
对于分布式系统而言需要报在系统的数据一致性,保证在子系统中始终保持一致,避免业务出现问题.要么一起成功要么一起失败
分布式锁解决了分布式资源抢占的问题,分布式事务和本地事务是解决流程化提交的问题
常见的事务模型
TCC (Try-Confirm-Cacel 补偿事务)
TCC事务模型包括三部分
- 主业务服务:主业务服务为整个业务活动发起的地方,服务的编排者,负责发起并完成整个业务活动
- 从业务服务:从业务服务是整个业务服务的参与方,负责提供TCC的业务操作,实现初始(Try)确认(Confirm),取消(Cancel)三个接口,供主业务服务调用
- 业务活动管理器:业务活动管理器控制整个业务的活动,包括维护TCC全局事务状态和每个业务服务的子事务状态,并在业务活动提交时,调用所有业务服务的Confirm操作
简单理解为,发起方,参与方,管理方
TCC具体含义
- Try:预留业务资源
- Confirm:确认执行业务操作
- Cancel:取消执行业务操作
2PC(标准XA模型)
2PC即Two-phase Commit 二阶段提交(AT模式为2PC的增强形)
广泛应用在数据库领域,为了使得基于分布式架构的所有节点可以在进行实物处理时能够保证原子性和一致性.绝大部分关系型数据库,都是基于2PC完成分布式事务的处理
2PC分为俩个阶段,处理
阶段1,提交事务请求,阶段2:执行事务提交
如果阶段1超时或出现异常,阶段2中断事务
阶段一提交事务请求
- 事务询问.协调者向所有参与者发送事务内容,询问是否可以进行提交操作,并开始等待各个参与者进行响应
- 执行事务.各参与者阶段,执行事务操作,并将redo和Redo操作计入本机事务日志
- 各参与者想协调者反馈事务询问的响应.成功返回执行yes,否则为no
阶段二执行事务提交
协调者在阶段二决定是否最终执行事务提交操作
意外
事情总会出现意外,当存在某一参与者相协调者发送No响应,或等待超时时,协调者只要无法收到所有参与者的yes响应,就会中断事务
- 发送回滚请求。协调者向所有参与者发送Rollback请求;
- 回滚。参与者收到请求后,利用本机Undo信息,执行Rollback操作。并在回滚结束后释放该事务所占用的系统资源;
- 反馈回滚结果。参与者在完成回滚操作后,向协调者发送Ack消息;
- 中断事务。协调者收到所有参与者的回滚Ack消息后,完成事务中断。
2pc方案比较适合单体里跨多个库的分布式事务,而且因为严重依赖数据库层面来搞定复杂事务,效率很低,不适合高并发场景
缺点
- 2pc的提交在执行过程中,所有参与职务操作的逻辑都处于阻塞状态,也就是说,各个参与者都在等待其他的参与者响应,无法进行其他操作
- 协调者是个单点,一旦出现问题,其他参与者无法释放事务资源,也无法完成事务操作
- 数据不一致,当执行事务的提交过程中,如果协调者向所有参与者发送commit请求后,发生网络异常,或者协调者未发送完commit请求,出现崩溃,最终导致只有部分协调者收到,执行请求,那么整个系统将出现不一致的情形
- 2pc没有提供容错机制,当参与者出现故障时,协调者无法快速得知这一失败,只能严格依赖超时设置,来决定是否进行进一步的提交或是中断
总结会出现,性能问题,单点故障问题,丢失消息导致数据不一致问题
3PC(Three-Phase)
针对2PC的缺点,研究者提出了3PC,即Three-Phase Commit
2PC将原有的俩阶段,重新划分为,CanCommit,PreCommit,和DoCommit三个阶段

阶段一canCommit
事务询问协调者向所有参与者发送包含事务内容的canCommit请求,询问是否可以提交事务,并等待应答
各参与者反馈事务询问,正常情况下,如果参与者认为可以顺利执行事务,返回yes,否则返回no
阶段二PreCommit
本阶段,协调者会根据上一阶段的反馈情况来决定是否可以执行事务的preCommit操作,有以下几种可能
执行事务预提交
- 发送预提交请求.协调者向所有节点发出preCommit请求,并进入prepared阶段
- 事务预提交.参与者收到preCommit请求后,会执行事务操作,并将undo和redo日志写入本机事务日志
- 各参与者成功执行事务操作,同时反馈以ACK响应形式发送给协调者,同时等待最终Commit或Abort指令
中断事务
假如任意一个其参与者像协调者发送No响应,或者等待超时,协调者在没有得到所有参与者响应时,即可以中断事务
- 发送中断请求.协调者向所有参与者发送abort请求
- 中断事务.无论是协调者的abort请求,还是等待协调者请求过程中出现超时,参与者都会中断事务
阶段三doCommit
在这个阶段会真正进行事务提交,同样存在俩种可能性
执行提交
- 发送提交请求.假如协调者收到了所欲参与者的ACK响应,那么将从预提交状态转换到提交状态,并向所有参与者,发送doCommit请求
- 事务提交.参与者收到docommit请求后,会正式执行事务提交操作,并在完成提交操作后释放占用资源
- 反馈事务提交结果后.参与者将完成事务提交后,向协调者发送ack消息
- 完成事务.协调者接收到所有参与者ACK后,完成事务
中断事务
在该阶段,假设正常状态的协调者接收到任一个参与者发送的No响应,或在超时时间内,仍旧没有收到反馈信息,就会中断事务
- 发送中断请求.协调者向所有的参与者发送abort请求
- 事务回滚.参与者收到abort请求后,会利用阶段二中的Undo消息执行事务回滚,并在完成回滚后释放占用资源
- 反馈事务回滚结果.参与者在完成回滚后像协调者发送ack消息
- 中断事务.协调者接收到所有参与者反馈ACK消息后事务中断
2pc和3pc区别
3pc有效降低了2pc带来的参与者阻塞范围,冰洁能够出现单点故障后继续达成一致
但3pc带来了新的问题,在参与者收到preCommit消息后,如果网络出现分区,协调者和参与者无法进行后续的通信,这种情况下,参与者在等待超时后,依旧会执行事务提交,这样会导致数据的不一致。
在doCommit阶段,如果参与者无法及时接收到来自协调者的doCommit或者abort请求时,会在等待超时之后,继续进行事务的提交。(其实这个应该是基于概率来决定的,当进入第三阶段时,说明参与者在第二阶段已经收到了PreCommit请求,那么协调者产生PreCommit请求的前提条件是他在第二阶段开始之前,收到所有参与者的CanCommit响应都是Yes。(一旦参与者收到了PreCommit,意味他知道大家其实都同意修改了)所以,一句话概括就是,当进入第三阶段时, 由于网络超时等原因,虽然参与者没有收到commit或者abort响应,但是他有理由相信:成功提交的几率很大)
3pc主要解决的问题
相对于2pc,3pc主要解决单点故障问题,并减少阻塞,应为一旦参与者无法及时收到协调者的信息后,他会默认执行commit.而不会一直持有事务资源并处于阻塞状态
但是这种机制也会导致数据一致性问题,因为,由于网络原因,协调者发送的abort响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit操作。这样就和其他接到abort命令并执行回滚的参与者之间存在数据不一致的情况。
通知形事务
异步确保
通知形事务的主流实现是通过MQ(消息队列)来通知其他事务参与者与自己事务的执行状态,引入MQ组件,有效的将事务参与者进行解耦,各参与者都可以异步执行,所以通知型事务也被称为异步事务
通知型事务主要适用于那些需要异步更新数据,并且对数据的实时性要求较低的场景,主要包含
- 异步确保型事务:主要适用于内部系统的数据最终一致性保障,因此内部相对比较可控,如订单和购物车,收货与清算,支付与结算等场景将一系列同步的事务操作修改为基于消息队列执行的异步操作,来避免分布式事务中同步阻塞带来的数据操作性能的下降
- 最大努力通知:主要用于外部系统,因为外部的网络环境更加复杂不可信,所以只能尽最大努力去通知实现最终一致性,比如重置平台与运营商,支付与对接等等跨网络级别的对接

MQ事务消息方案
基于MQ的事务消息方案主要依靠MQ的半消息机制来投递消息和参与者本地事务保证一致性.半消息机制实现原理借鉴的是2PC的思路,是二阶段提交的广义扩展
半消息:在原有队列消息执行后的逻辑,如果后面的本地逻辑出错,则不发送该消息,如果通过则告知MQ发送(半消息和普通消息的唯一区别是,在事务提交之前,对于消费者来说这个消息是不可见的)

- 事务发起方首先发送半消息到MQ
- MQ通知发送方消息发送成功
- 在发送半消息成功后执行本地事务
- 根据本地事务执行结果返回commit或者是rollback
- 如果本地消息是rollback,MQ将丢弃该消息不投递,如果是commit,MQ将会消息发送给消息订阅方
- 订阅方根据消息执行本地事务
- 订阅方执行本第事务成功后再从MQ中将该消息标记为已消费
- 如果本地事务执行过程中,执行端挂掉,或者超限,MQ服务器将不断的询问producer来获取事务状态
- Consumer端的消费成功机制有MQ保证
异步确保型事务
举个例子,假设存在业务规则,某比订单成功后为用户加一定的分
在这条规则里,订单数据源为服务事务发起方,管理几分数据源的服务为事务的跟随者
从这个过程可以看到,基于消息队列实现的事务存在以下操作:
- 订单服务创建订单,提交本地事务
- 订单服务发布一条消息
- 积分服务受到消息后加积分
可以看到该事务形态过程简单,性能消耗小,发起方与跟随方之间的流量峰谷可以使用队列填平,同时业务开发工作量也基本与单机事务没有差别,都不需要编写反向的业务逻辑过程
因此基于消息队列实现的事务是我们除了单机事务外最优先考虑使用的形态。
本地消息方案
有时候我们目前的MQ组件不支持事务消息,或者我们想尽量减少侵入业务方.这时候我们需要另外一种放方案"基于DB本地消息表"
本地消息最初由eBay提出来解决分布式事务的问题.是目前业界使用较多的的方案之一,它的核心思想就是将分布式事务拆成本地事务进行处理

发送消息方
- 需要有一个本地消息表,记录这消息状态相关信息
- 业务数据和消息在同一个数据库,要保证它俩在同一个本地事务.直接利用本地事务,将业务消息写入数据库
- 在本地事务中处理完业务数据和写消息表操作后,通过写消息到MQ消息队列.使用专门的投递工作线程进行事务消息投递到MQ,根据投递ACK去删除事务的消息记录
- 消息会发送到消息消费方,如果发送失败,即进行重试
消息消费方
- 处理消息队列中的消息,完成自己的业务逻辑
- 如果本地事务处理成功,则表明处理成功了
- 如果本第事务处理失败,那么就会重试执行
- 如果是业务层面的失败,给消息生产方发送一个业务补偿消息,通知进行回滚操作
生产方和消费方定时扫描本地消息表,把还没处理完成的消息在发送一遍.如果有靠谱的自动对账补齐逻辑,这种方式还是很实用的
本地消息表优缺点:
优点:
- 本地消息表建设成本比较低,实现了可靠消息的传递确保了分布式事务的最终一致性。
- 无需提供回查方法,进一步减少的业务的侵入。
- 在某些场景下,还可以进一步利用注解等形式进行解耦,有可能实现无业务代码侵入式的实现。
缺点:
- 本地消息表与业务耦合在一起,难于做成通用性,不可独立伸缩。
- 本地消息表是基于数据库来做的,而数据库是要读写磁盘IO的,因此在高并发下是有性能瓶颈的
共同
- 事务的消息都依赖MQ进行事务通知,所以都是异步的
- 事务消息在投递方都是存在重复投递的可能,需要有配套的机制去降低重复投递的使用率,实现更好的消息投递去重
- 事务消息的消费方,因为投递重复的无法避免,因此需要进行消费去重设计或者服务幂等设计
区别

最大努力通知
最大通知方案的目标,就是发起通知放通过一定的机制,最大努力将业务处理结果通知到接收方
最终一致性:
本质是通过通知引入定期校验最终一致性,对业务的侵入性较低,适合对最终一致性敏感度比较低,业务链路较短的场景
努力最大通知事务主要用于外部系统,业务外部的网络环境更加复杂,所以只能尽最大努力去通知实现数据最终一致性,比如充值平台与运营商支付对接,上湖通知等等跨平台,跨企业系统间业务场景交互场景
而一部确保型事务主要适用于内部系统的数据最终一致性保障,业务内部相对比较可控
普通消息是无法解决本地事务执行和消息发送的一致性问题的.因为发送是一个网络通信的过程,发送消息的过程就有可能出现发送失败,或者超时的情况.有可能发送成功了,也可能发送失败了,消息的发送方是无法确定的,所以此时消息发送方无论是提交事务还是回滚事务都有可能不一致性出现.
所以通知型事务的难度在于:投递消息和参与者本地事务保证一致性保障
因为核心要点一致,都是为了保证消息的一致性,所以流程和异步确保型一样,俩个分支
- 基于MQ自身的事务消息方案
- 基于DB的本地事务消息表方案
MQ事务消息方案
要实现最大努力通知,可以采用 MQ 的 ACK 机制。
最大努力通知事务在投递之前,跟异步确保型流程都差不多,关键在于投递后的处理。
因为异步确保型在于内部的事务处理,所以MQ和系统是直连并且无需严格的权限、安全等方面的思路设计。最大努力通知事务在于第三方系统的对接,所以最大努力通知事务有几个特性:
- 业务主动方在完成业务处理后,向业务被动房(第三方系统)发送消息,允许存在消息丢失
- 业务主动方提供递增多挡位时间间隔(5min、10min、30min、1h、24h),用于失败重试调用业务被动方的接口;在通知N次之后就不再通知,报警+记日志+人工介入。
- 业务被动房提供幂等的服务接口,防止通知重复消费
- 业务主动方需要有定期效验机制,对业务数据进行兜底;防止业务被动房履行责任时进行业务回滚,确保数据最终一致性

- 业务活动主动方,在完成业务处理后,想业务活动的被动方发送消息,允许消息丢失
- 主动方可以设置时间阶梯型通知规则,在通知失败后按规则重复通知,直到通知N次后不再通知
- 主动方提供校对接口给被动房按需校对查询,用于恢复丢失的业务信息
- 业务活动的被动方如果正常接收了数据,就正常返回响应,并结束事务
- 如果被动放没有正常接收,根据定时策略,向业务活动主动方查询,恢复丢失的业务消息。
特点
- 用到的服务模式:可查询操作、幂等操作;
- 被动方的处理结果不影响主动方的处理结果;
- 适用于对业务最终一致性的时间敏感度低的系统;
- 适合跨企业的系统间的操作,或者企业内部比较独立的系统间的操作,比如银行通知、商户通知等;
本地消息表方案
要实现努力最大通知,可以采用定期检查本地消息表的机制

发送消息方:
- 需要有一个消息表,记录着消息状态相关信息。
- 业务数据和消息表在同一个数据库,要保证它俩在同一个本地事务。直接利用本地事务,将业务数据和事务消息直接写入数据库。
- 在本地事务中处理完业务数据和写消息表操作后,通过写消息到 MQ 消息队列。使用专门的投递工作线程进行事务消息投递到MQ,根据投递ACK去删除事务消息表记录
- 消息会发到消息消费方,如果发送失败,即进行重试。
- 生产方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。如果有靠谱的自动对账补账逻辑,这种方案还是非常实用的。
最大努力通知事务在于第三方系统的对接,所以最大努力通知事务有几个特性:
- 业务主动方在完成业务处理后,向业务被动方(第三方系统)发送通知消息,允许存在消息丢失。
- 业务主动方提供递增多挡位时间间隔(5min、10min、30min、1h、24h),用于失败重试调用业务被动方的接口;在通知N次之后就不再通知,报警+记日志+人工介入。
- 业务被动方提供幂等的服务接口,防止通知重复消费。
- 业务主动方需要有定期校验机制,对业务数据进行兜底;防止业务被动方无法履行责任时进行业务回滚,确保数据最终一致性。
最大努力通知事务在于第三方系统的对接,所以最大努力通知事务有几个特性:
- 业务主动方在完成业务处理后,向业务被动方(第三方系统)发送通知消息,允许存在消息丢失。
- 业务主动方提供递增多挡位时间间隔(5min、10min、30min、1h、24h),用于失败重试调用业务被动方的接口;在通知N次之后就不再通知,报警+记日志+人工介入。
- 业务被动方提供幂等的服务接口,防止通知重复消费。
- 业务主动方需要有定期校验机制,对业务数据进行兜底;防止业务被动方无法履行责任时进行业务回滚,确保数据最终一致性。