Dunwu Blog

大道至简,知易行难

分布式调度面试

服务注册和发现

【基础】什么是服务注册和发现?

:::details 要点

服务定义是服务提供者和服务消费者之间的约定,但是在微服务架构中,如何达成这个约定呢?这就依赖于服务注册和发现机制。

在微服务架构下,服务注册和发现机制中主要有三种角色:

  • 服务提供者(RPC Server / Provider)
  • 服务消费者(RPC Client / Consumer)
  • 服务注册中心(Registry)

服务发现通常依赖于注册中心来协调服务发现的过程,其步骤如下:

  1. 服务提供者将接口信息以注册到注册中心。
  2. 服务消费者从注册中心读取和订阅服务提供者的地址信息。
  3. 如果有可用的服务,注册中心会主动通知服务消费者。
  4. 服务消费者根据可用服务的地址列表,调用服务提供者的接口。

这个过程很像是生活中的房屋租赁,房东将租房信息挂到中介公司,房客从中介公司查找租房信息。房客如果想要租房东的房子,通过中介公司牵线搭桥,联系上房东,双方谈妥签订协议,就可以正式建立起租赁关系。

img

:::

【中级】注册中心有哪些基本功能?

:::details 要点

从服务注册和发现的流程,可以看出,注册中心是服务发现的核心组件。常见的注册中心组件有:Nacos、Consul、Zookeeper 等。

注册中心的实现主要涉及几个问题:注册中心需要提供哪些接口,该如何部署;如何存储服务信息;如何监控服务提供者节点的存活;如果服务提供者节点有变化如何通知服务消费者,以及如何控制注册中心的访问权限。

元数据定义

构建微服务的首要问题是:服务提供者和服务消费者通信时,如何达成共识。具体来说,就是这个服务的接口名是什么?调用这个服务需要传递哪些参数?接口的返回值是什么类型?以及一些其他接口描述信息。

常见的定义服务元数据的方式有:

  • XML 文件 - 如果只是企业内部之间的服务调用,并且都是 Java 语言的话,选择 XML 配置方式是最简单的。
  • IDL 文件 - 如果企业内部存在多个跨语言服务,建议使用 IDL 文件方式进行描述服务。
  • REST API - 如果存在对外开放服务调用的情形的话,使用 REST API 方式则更加通用。

元数据存储

注册中心本质上是一个用于保存元数据的分布式存储。你如果明白了这一点,就会了解实现一个注册中心的所有要点都是围绕这个目标去构建的。

想要构建微服务,首先要解决的问题是,服务提供者如何发布一个服务,服务消费者如何引用这个服务。具体来说,就是这个服务的接口名是什么?调用这个服务需要传递哪些参数?接口的返回值是什么类型?以及一些其他接口描述信息。

服务的元数据信息通常有以下信息:

  • 服务节点信息,如 IP、端口等。
  • 接口定义,如接口名、请求参数、响应参数等。
  • 请求失败的重试次数
  • 序列化方式
  • 压缩方式
  • 通信协议
  • 等等

在具体存储时,注册中心一般会按照“服务 - 分组 - 节点信息”的层次化的结构来存储。

注册中心 API

既然是分布式存储,势必要提供支持读写数据的接口,也就是 API,一般来说,需要支持以下功能:

  • 服务注册接口:服务提供者通过调用服务注册接口来完成服务注册。
  • 服务反注册接口:服务提供者通过调用服务反注册接口来完成服务注销。
  • 心跳汇报接口:服务提供者通过调用心跳汇报接口完成节点存活状态上报。
  • 服务订阅接口:服务消费者通过调用服务订阅接口完成服务订阅,获取可用的服务提供者节点列表。
  • 服务变更查询接口:服务消费者通过调用服务变更查询接口,获取最新的可用服务节点列表。

除此之外,为了便于管理,注册中心还必须提供一些后台管理的 API,例如:

  • 服务查询接口:查询注册中心当前注册了哪些服务信息。
  • 服务修改接口:修改注册中心中某一服务的信息。

服务健康检测

注册中心除了要支持最基本的服务注册和服务订阅功能以外,还必须具备对服务提供者节点的健康状态检测功能,这样才能保证注册中心里保存的服务节点都是可用的。注册中心通常使用长连接或心跳探测方式检查服务健康状态

还是以 ZooKeeper 为例,它是基于 ZooKeeper 客户端和服务端的长连接和会话超时控制机制,来实现服务健康状态检测的。在 ZooKeeper 中,客户端和服务端建立连接后,会话也随之建立,并生成一个全局唯一的 Session ID。服务端和客户端维持的是一个长连接,在 SESSION_TIMEOUT 周期内,服务端会检测与客户端的链路是否正常,具体方式是通过客户端定时向服务端发送心跳消息(ping 消息),服务器重置下次 SESSION_TIMEOUT 时间。如果超过 SESSION_TIMEOUT 后服务端都没有收到客户端的心跳消息,则服务端认为这个 Session 就已经结束了,ZooKeeper 就会认为这个服务节点已经不可用,将会从注册中心中删除其信息。

服务状态变更通知

一旦注册中心探测到有服务提供者节点新加入或者被剔除,就必须立刻通知所有订阅该服务的服务消费者,刷新本地缓存的服务节点信息,确保服务调用不会请求不可用的服务提供者节点。注册中心通常基于服务状态订阅来实现服务状态变更通知。

继续以 ZooKeeper 为例,基于 ZooKeeper 的 Watcher 机制,来实现服务状态变更通知给服务消费者的。服务消费者在调用 ZooKeeper 的 getData 方法订阅服务时,还可以通过监听器 Watcher 的 process 方法获取服务的变更,然后调用 getData 方法来获取变更后的数据,刷新本地缓存的服务节点信息。

集群部署

注册中心作为服务提供者和服务消费者之间沟通的桥梁,它的重要性不言而喻。所以注册中心一般都是采用集群部署来保证高可用性,并通过分布式一致性协议来确保集群中不同节点之间的数据保持一致。根据 CAP 理论,三种特性无法同时达成,必须在可用性和一致性之间做取舍。于是,根据不同侧重点,注册中心可以分为 CP 和 AP 两个阵营:

  • CP 型注册中心 - 牺牲可用性来换取数据强一致性,最典型的例子就是 ZooKeeper,etcd,Consul 了。ZooKeeper 集群内只有一个 Leader,而且在 Leader 无法使用的时候通过算法选举出一个新的 Leader。这个 Leader 的目的就是保证写信息的时候只向这个 Leader 写入,Leader 会同步信息到 Followers,这个过程就可以保证数据的强一致性。但如果多个 ZooKeeper 之间网络出现问题,造成出现多个 Leader,发生脑裂的话,注册中心就不可用了。而 etcd 和 Consul 集群内都是通过 Raft 协议来保证强一致性,如果出现脑裂的话, 注册中心也不可用。
  • AP 型注册中心 - 牺牲一致性(只保证最终一致性)来换取可用性,最典型的例子就是 Eureka、Nacos 了。对比下 Zookeeper,Eureka 不用选举一个 Leader,每个 Eureka 服务器单独保存服务注册地址,因此有可能出现数据信息不一致的情况。但是当网络出现问题的时候,每台服务器都可以完成独立的服务。

:::

【高级】注册中心有哪些扩展功能?

:::details 要点

多注册中心

对于服务消费者来说,要能够同时从多个注册中心订阅服务;

对于服务提供者来说,要能够同时向多个注册中心注册服务。

并行订阅服务

如果只支持串行订阅,如果服务消费者订阅的服务较多,并且某些服务节点的初始化连接过程中出现连接超时的情况,则后续所有的服务节点的初始化连接都需要等待它完成,这就会导致消费者启动非常慢。

可以每订阅一个服务就单独用一个线程来处理,这样的话即使遇到个别服务节点连接超时,其他服务节点的初始化连接也不受影响,最慢也就是这个服务节点的初始化连接耗费的时间,最终所有服务节点的初始化连接耗时控制在了 30 秒以内。

批量注销服务

在与注册中心的多次交互中,可能由于网络抖动、注册中心集群异常等原因,导致个别调用失败。对于注册中心来说,偶发的注册调用失败对服务调用基本没有影响,其结果顶多就是某一个服务少了一个可用的节点。但偶发的反注册调用失败会导致不可用的节点残留在注册中心中,变成“僵尸节点”。

需要定时去清理注册中心中的“僵尸节点”,如果支持批量注销服务,就可以一次调用就把该节点上提供的所有服务同时注销掉。

服务变更信息增量更新

为了减少服务消费者从注册中心中拉取的服务可用节点信息的数据量,这个时候可以通过增量更新的方式,注册中心只返回变化的那部分节点信息。尤其在只有少数节点信息变更时,此举可以大大减少服务消费者从注册中心拉取的数据量,从而最大程度避免产生网络风暴。

心跳开关保护机制

在网络频繁抖动的情况下,注册中心中可用的节点会不断变化,这时候服务消费者会频繁收到服务提供者节点变更的信息,于是就不断地请求注册中心来拉取最新的可用服务节点信息。当有成百上千个服务消费者,同时请求注册中心获取最新的服务提供者的节点信息时,可能会把注册中心的带宽给占满,尤其是注册中心是百兆网卡的情况下。

所以针对这种情况,需要一种保护机制,即使在网络频繁抖动的时候,服务消费者也不至于同时去请求注册中心获取最新的服务节点信息

我曾经就遇到过这种情况,一个可行的解决方案就是给注册中心设置一个开关,当开关打开时,即使网络频繁抖动,注册中心也不会通知所有的服务消费者有服务节点信息变更,比如只给 10% 的服务消费者返回变更,这样的话就能将注册中心的请求量减少到原来的 1/10。

当然打开这个开关也是有一定代价的,它会导致服务消费者感知最新的服务节点信息延迟,原先可能在 10s 内就能感知到服务提供者节点信息的变更,现在可能会延迟到几分钟,所以在网络正常的情况下,开关并不适合打开;可以作为一个紧急措施,在网络频繁抖动的时候,才打开这个开关。

服务节点摘除保护机制

服务提供者在进程启动时,会注册服务到注册中心,并每隔一段时间,汇报心跳给注册中心,以标识自己的存活状态。如果隔了一段固定时间后,服务提供者仍然没有汇报心跳给注册中心,注册中心就会认为该节点已经处于“dead”状态,于是从服务的可用节点信息中移除出去。

如果遇到网络问题,大批服务提供者节点汇报给注册中心的心跳信息都可能会传达失败,注册中心就会把它们都从可用节点列表中移除出去,造成剩下的可用节点难以承受所有的调用,引起“雪崩”。但是这种情况下,可能大部分服务提供者节点是可用的,仅仅因为网络原因无法汇报心跳给注册中心就被“无情”的摘除了。

这个时候就需要根据实际业务的情况,设定一个阈值比例,即使遇到刚才说的这种情况,注册中心也不能摘除超过这个阈值比例的节点

这个阈值比例可以根据实际业务的冗余度来确定,我通常会把这个比例设定在 20%,就是说注册中心不能摘除超过 20% 的节点。因为大部分情况下,节点的变化不会这么频繁,只有在网络抖动或者业务明确要下线大批量节点的情况下才有可能发生。而业务明确要下线大批量节点的情况是可以预知的,这种情况下可以关闭阈值保护;而正常情况下,应该打开阈值保护,以防止网络抖动时,大批量可用的服务节点被摘除。

白名单机制

在实际的微服务测试和部署时,通常包含多套环境,比如生产环境一套、测试环境一套。开发在进行业务自测、测试在进行回归测试时,一般都是用测试环境,部署的 RPC Server 节点注册到测试的注册中心集群。但经常会出现开发或者测试在部署时,错误的把测试环境下的服务节点注册到了线上注册中心集群,这样的话线上流量就会调用到测试环境下的 RPC Server 节点,可能会造成意想不到的后果。

为了防止这种情况发生,注册中心需要提供一个保护机制,你可以把注册中心想象成一个带有门禁的房间,只有拥有门禁卡的 RPC Server 才能进入。在实际应用中,注册中心可以提供一个白名单机制,只有添加到注册中心白名单内的 RPC Server,才能够调用注册中心的注册接口,这样的话可以避免测试环境中的节点意外跑到线上环境中去。

静态注册中心

因为服务提供者是向服务消费者提供服务的,服务是否可用,服务消费者应该比注册中心更清楚。因此,可以直接在服务消费者端,根据调用服务提供者是否成功来判定服务提供者是否可用。如果服务消费者调用某一个服务提供者节点连续失败超过一定次数,可以在本地内存中将这个节点标记为不可用。并且每隔一段固定时间,服务消费者都要向标记为不可用的节点发起保活探测,如果探测成功了,就将标记为不可用的节点再恢复为可用状态,重新发起调用。

:::

负载均衡

【基础】什么是负载均衡?为什么需要负载均衡?

:::details 要点

“负载均衡(Load Balance,简称 LB)”是一种技术,用来在多个计算机、网络连接、CPU、磁盘驱动器或其他资源中分配负载,以达到优化资源利用率、最大化吞吐率、最小化响应时间、同时避免过载的目的

负载均衡的主要作用如下:

  • 高并发:负载均衡可以优化资源使用率,通过算法调整负载,尽力均匀的分配资源,以此提高资源利用率、从而提升整体吞吐量。
  • 伸缩性:发生增减资源时,负载均衡可以自动调整分发,使得应用集群具备伸缩性。
  • 高可用:负载均衡器可以监控候选机器,当某机器不可用时,自动跳过,将请求分发给可用的机器。这使得应用集群具备高可用的特性。
  • 安全防护:有些负载均衡软件或硬件提供了安全性功能,如:黑白名单、防火墙,防 DDos 攻击等。

:::

【中级】负载均衡技术有哪些分类?

:::details 要点

支持负载均衡的技术很多,我们可以通过不同维度去进行分类。

载体维度分类

从支持负载均衡的载体来看,可以将负载均衡分为两类:

  • 硬件负载均衡
  • 软件负载均衡
硬件负载均衡

硬件负载均衡,一般是在定制处理器上运行的独立负载均衡服务器,价格昂贵,土豪专属

硬件负载均衡的主流产品有:F5A10

硬件负载均衡的优点

  • 功能强大:支持全局负载均衡并提供较全面的、复杂的负载均衡算法。
  • 性能强悍:硬件负载均衡由于是在专用处理器上运行,因此吞吐量大,可支持单机百万以上的并发。
  • 安全性高:往往具备防火墙,防 DDos 攻击等安全功能。

硬件负载均衡的缺点

  • 成本昂贵:购买和维护硬件负载均衡的成本都很高。
  • 扩展性差:当访问量突增时,超过限度不能动态扩容。
软件负载均衡

软件负载均衡,应用最广泛,无论大公司还是小公司都会使用。

软件负载均衡从软件层面实现负载均衡,一般可以在任何标准物理设备上运行。

软件负载均衡的 主流产品 有:NginxHAProxyLVS

  • LVS 可以作为四层负载均衡器。其负载均衡的性能要优于 Nginx。
  • HAProxy 可以作为 HTTP 和 TCP 负载均衡器。
  • NginxHAProxy 可以作为四层或七层负载均衡器。

软件负载均衡的 优点

  • 扩展性好:适应动态变化,可以通过添加软件负载均衡实例,动态扩展到超出初始容量的能力。
  • 成本低廉:软件负载均衡可以在任何标准物理设备上运行,降低了购买和运维的成本。

软件负载均衡的 缺点

  • 性能略差:相比于硬件负载均衡,软件负载均衡的性能要略低一些。

网络通信分类

软件负载均衡从通信层面来看,又可以分为四层和七层负载均衡。

  • 七层负载均衡:就是可以根据访问用户的 HTTP 请求头、URL 信息将请求转发到特定的主机。
    • DNS 重定向
    • HTTP 重定向
    • 反向代理
  • 四层负载均衡:基于 IP 地址和端口进行请求的转发。
    • 修改 IP 地址
    • 修改 MAC 地址
DNS 负载均衡

DNS 负载均衡一般用于互联网公司,复杂的业务系统不适合使用。大型网站一般使用 DNS 负载均衡作为 第一级负载均衡手段,然后在内部使用其它方式做第二级负载均衡。DNS 负载均衡属于七层负载均衡。

DNS 即 域名解析服务,是 OSI 第七层网络协议。DNS 被设计为一个树形结构的分布式应用,自上而下依次为:根域名服务器,一级域名服务器,二级域名服务器,… ,本地域名服务器。显然,如果所有数据都存储在根域名服务器,那么 DNS 查询的负载和开销会非常庞大。

因此,DNS 查询相对于 DNS 层级结构,是一个逆向的递归流程,DNS 客户端依次请求本地 DNS 服务器,上一级 DNS 服务器,上上一级 DNS 服务器,… ,根 DNS 服务器(又叫权威 DNS 服务器),一旦命中,立即返回。为了减少查询次数,每一级 DNS 服务器都会设置 DNS 查询缓存。

DNS 负载均衡的工作原理就是:基于 DNS 查询缓存,按照负载情况返回不同服务器的 IP 地址

img

DNS 重定向的 优点

  • 使用简单:负载均衡工作,交给 DNS 服务器处理,省掉了负载均衡服务器维护的麻烦
  • 提高性能:可以支持基于地址的域名解析,解析成距离用户最近的服务器地址(类似 CDN 的原理),可以加快访问速度,改善性能;

DNS 重定向的 缺点

  • 可用性差:DNS 解析是多级解析,新增/修改 DNS 后,解析时间较长;解析过程中,用户访问网站将失败;
  • 扩展性差:DNS 负载均衡的控制权在域名商那里,无法对其做更多的改善和扩展;
  • 维护性差:也不能反映服务器的当前运行状态;支持的算法少;不能区分服务器的差异(不能根据系统与服务的状态来判断负载)
HTTP 负载均衡

HTTP 负载均衡是基于 HTTP 重定向实现的。HTTP 负载均衡属于七层负载均衡。

HTTP 重定向原理是:根据用户的 HTTP 请求计算出一个真实的服务器地址,将该服务器地址写入 HTTP 重定向响应中,返回给浏览器,由浏览器重新进行访问

img

HTTP 重定向的 优点方案简单

HTTP 重定向的 缺点

  • 额外的转发开销:每次访问需要两次请求服务器,增加了访问的延迟。
  • 降低搜索排名:使用重定向后,搜索引擎会视为 SEO 作弊。
  • 如果负载均衡器宕机,就无法访问该站点。

由于其缺点比较明显,所以这种负载均衡策略实际应用较少。

反向代理负载均衡

反向代理(Reverse Proxy)方式是指以 代理服务器 来接受网络请求,然后 将请求转发给内网中的服务器,并将从内网中的服务器上得到的结果返回给网络请求的客户端。反向代理负载均衡属于七层负载均衡。

反向代理服务的主流产品:NginxApache

正向代理与反向代理有什么区别?

  • 正向代理:发生在 客户端,是由用户主动发起的。翻墙软件就是典型的正向代理,客户端通过主动访问代理服务器,让代理服务器获得需要的外网数据,然后转发回客户端。
  • 反向代理:发生在 服务端,用户不知道代理的存在。

img

反向代理是如何实现负载均衡的呢?以 Nginx 为例,如下所示:

img

首先,在代理服务器上设定好负载均衡规则。然后,当收到客户端请求,反向代理服务器拦截指定的域名或 IP 请求,根据负载均衡算法,将请求分发到候选服务器上。其次,如果某台候选服务器宕机,反向代理服务器会有容错处理,比如分发请求失败 3 次以上,将请求分发到其他候选服务器上。

反向代理的 优点

  • 多种负载均衡算法:支持多种负载均衡算法,以应对不同的场景需求。
  • 可以监控服务器:基于 HTTP 协议,可以监控转发服务器的状态,如:系统负载、响应时间、是否可用、连接数、流量等,从而根据这些数据调整负载均衡的策略。

反向代理的 缺点

  • 额外的转发开销:反向代理的转发操作本身是有性能开销的,可能会包括创建连接,等待连接响应,分析响应结果等操作。

  • 增加系统复杂度:反向代理常用于做分布式应用的水平扩展,但反向代理服务存在以下问题,为了解决以下问题会给系统整体增加额外的复杂度和运维成本:

  • 反向代理服务如果自身宕机,就无法访问站点,所以需要有 高可用 方案,常见的方案有:主备模式(一主一备)、双主模式(互为主备)。

    • 反向代理服务自身也存在性能瓶颈,随着需要转发的请求量不断攀升,需要有 可扩展 方案。
IP 负载均衡

IP 负载均衡是在网络层通过修改请求目的地址进行负载均衡。

img

如上图所示,IP 均衡处理流程大致为:

  1. 客户端请求 192.168.137.10,由负载均衡服务器接收到报文。
  2. 负载均衡服务器根据算法选出一个服务节点 192.168.0.1,然后将报文请求地址改为该节点的 IP。
  3. 真实服务节点收到请求报文,处理后,返回响应数据到负载均衡服务器。
  4. 负载均衡服务器将响应数据的源地址改负载均衡服务器地址,返回给客户端。

IP 负载均衡在内核进程完成数据分发,较反向代理负载均衡有更好的处理性能。但是,由于所有请求响应都要经过负载均衡服务器,集群的吞吐量受制于负载均衡服务器的带宽。

数据链路层负载均衡

数据链路层负载均衡是指在通信协议的数据链路层修改 mac 地址进行负载均衡。

img

在 Linux 平台上最好的链路层负载均衡开源产品是 LVS (Linux Virtual Server)。

LVS 是基于 Linux 内核中 netfilter 框架实现的负载均衡系统。netfilter 是内核态的 Linux 防火墙机制,可以在数据包流经过程中,根据规则设置若干个关卡(hook 函数)来执行相关的操作。

LVS 的工作流程大致如下:

  • 当用户访问 www.sina.com.cn 时,用户数据通过层层网络,最后通过交换机进入 LVS 服务器网卡,并进入内核网络层。
  • 进入 PREROUTING 后经过路由查找,确定访问的目的 VIP 是本机 IP 地址,所以数据包进入到 INPUT 链上
  • IPVS 是工作在 INPUT 链上,会根据访问的 vip+port 判断请求是否 IPVS 服务,如果是则调用注册的 IPVS HOOK 函数,进行 IPVS 相关主流程,强行修改数据包的相关数据,并将数据包发往 POSTROUTING 链上。
  • POSTROUTING 上收到数据包后,根据目标 IP 地址(后端服务器),通过路由选路,将数据包最终发往后端的服务器上。

开源 LVS 版本有 3 种工作模式,每种模式工作原理截然不同,说各种模式都有自己的优缺点,分别适合不同的应用场景,不过最终本质的功能都是能实现均衡的流量调度和良好的扩展性。主要包括三种模式:DR 模式、NAT 模式、Tunnel 模式。

:::

【高级】负载均衡有哪些算法?

:::details 要点

负载均衡器的实现可以分为两个部分:

  • 根据负载均衡算法在候选机器列表选出一个机器;
  • 将请求数据发送到该机器上。

负载均衡算法是负载均衡服务核心中的核心。负载均衡产品多种多样,但是各种负载均衡算法原理是共性的。

负载均衡算法有很多种,分别适用于不同的应用场景。本章节将由浅入深的,逐一讲解各种负载均衡算法的策略和特性,并根据算法之间的互补关系将它们串联起来。

注:负载均衡算法的实现,推荐阅读 Dubbo 官方负载均衡算法说明 ,源码讲解非常详细,非常值得借鉴。

下文中的各种算法的可执行示例已归档在 Github 仓库:java-load-balance,可以通过执行 io.github.dunwu.javatech.LoadBalanceDemo 查看各算法执行效果。

轮询算法

“轮询算法(Round Robin)”的策略是:将请求“依次”分发到候选机器

如下图所示,轮询负载均衡器收到来自客户端的 6 个请求,编号为 1、4 的请求会被发送到服务端 0;编号为 2、5 的请求会被发送到服务端 1;编号为 3、6 的请求会被发送到服务端 2。

img

轮询算法适合的场景需要满足:各机器处理能力相近,且每个请求工作量差异不大

随机算法

“随机算法(Random)” 将请求“随机”分发到候选机器

如下图所示,随机负载均衡器收到来自客户端的 6 个请求,会随机分发请求,可能会出现:编号为 1、5 的请求会被发送到服务端 0;编号为 2、4 的请求会被发送到服务端 1;编号为 3、6 的请求会被发送到服务端 2。

img

随机算法适合的场景需要满足:各机器处理能力相近,且每个请求工作量差异不大

学习过概率论的都知道,调用量较小的时候,可能负载并不均匀,调用量越大,负载越均衡

加权轮询/随机算法

轮询/随机算法适合的场景都需要满足:各机器处理能力相近,且每个请求工作量差异不大。

在理想状况下,假设每个机器的硬件条件相同,如:CPU、内存、网络 IO 等配置都相同;并且每个请求的耗时一样(请求传输时间、请求访问数据时间、计算时间等),这时轮询算法才能真正做到负载均衡。显然,要满足以上条件都相同是几乎不可能的,更不要说实际的网络通信中还有更多复杂的情况。

以上,如果有一点不能满足,都无法做到真正的负载均衡。个体存在较大差异,当请求量较大时,处理较慢的机器可能会逐渐积压请求,从而导致过载甚至宕机。

如下图所示,假设存在这样的场景:

  • 服务端 1 的处理能力远低于服务端 0 和服务端 2;
  • 轮询/随机算法可以保证将请求尽量均匀的分发给两个机器;
  • 编号为 1、4 的请求被发送到服务端 0;编号为 3、6 的请求被发送到服务端 2;二者处理能力强,应对游刃有余;
  • 编号为 2、5 的请求被发送到服务端 1,服务端 1 处理能力弱,应对捉襟见肘,导致过载。

img

《蜘蛛侠》电影中有一句经典台词:能力越大,责任越大。显然,以上情况不符合这句话,处理能力强的机器并没有被分发到更多的请求,它的处理能力被闲置了。那么,如何解决这个问题呢?

一种比较容易想到的思路是:引入权重属性,可以根据机器的硬件条件为其设置合理的权重值,负载均衡时,优先将请求分发到权重较高的机器。

“加权轮询算法(Weighted Round Robbin)” 和“加权随机算法(Weighted Random)” 都采用了加权的思路,在轮询/随机算法的基础上,引入了权重属性,优先将请求分发到权重较高的机器。这样,就可以针对性能高、处理速度快的机器设置较高的权重,让其处理更多的请求;而针对性能低、处理速度慢的机器则与之相反。一言以蔽之,加权策略强调了——能力越大,责任越大。

如下图所示,服务端 0 设置权重为 3,服务端 1 设置权重为 1,服务端 2 设置权重为 2。负载均衡器收到来自客户端的 6 个请求,那么编号为 1、2、5 的请求会被发送到服务端 0,编号为 4 的请求会被发送到服务端 1,编号为 3、6 的请求会被发送到机器 2。

img

最少连接数算法

加权轮询/随机算法虽然一定程度上解决了机器处理能力不同时的负载均衡场景,但它最大的问题在于不能动态应对网络中负载不均的场景。加权的思路是在负载均衡处理的事前,预设好不同机器的权重,然后分发。然而,每个请求的连接时长不同,负载均衡器也不可能准确预估出请求的连接时长。因此,采用加权轮询/随机算法算法,都无法动态应对连接时长不均的网络场景,可能会出现某些机器当前连接数过多,而另一些机器的连接过少的情况,即并非真正的流量负载均衡。

如下图所示,假设存在这样的场景:

  • 3 个服务端的处理能力相同;
  • 编号为 1、4 的请求被发送到服务端 0,但是 1 很快就断开连接,此时只有 4 请求连接服务端 0;
  • 编号为 2、5 的请求被发送到服务端 1,但是 2 始终保持长连接;该系统继续运行时,服务端 1 发生过载;
  • 编号为 3、6 的请求被发送到服务端 2,但是 3 很快就断开连接,此时只有 6 请求连接服务端 2;

img

既然,请求的连接时长不同,会导致有的服务端处理慢,积压大量连接数;而有的服务端处理快,保持的连接数少。那么,我们不妨想一下,如果负载均衡器监控一下服务端当前所持有的连接数,优先将请求分发给连接数少的服务端,不就能有效提高分发效率了吗?最少连接数算法正是采用这个思路去设计的。

“最少连接数算法(Least Connections)” 将请求分发到连接数/请求数最少的候选机器

要根据机器连接数分发,显然要先维护机器的连接数。因此,最少连接数算法需要实时追踪每个候选机器的活跃连接数;然后,动态选出连接数最少的机器,优先分发请求。最少连接数算法会记录当前时刻,每个候选节点正在处理的连接数,然后选择连接数最小的节点。该策略能够动态、实时地反应机器的当前状况,较为合理地将负责分配均匀,适用于对当前系统负载较为敏感的场景。

由此可见,最少连接数算法适用于对系统负载较为敏感且请求连接时长相差较大的场景

如下图所示,假设存在这样的场景:

  • 服务端 0 和服务端 1 的处理能力相同;
  • 编号为 1、3 的请求被发送到服务端 0,但是 1、3 很快就断开连接;
  • 编号为 2、4 的请求被发送到服务端 1,但是 2、4 保持长连接;
  • 由于服务端 0 当前连接数最少,编号为 5、6 的请求被分发到服务端 0。

img

“加权最少连接数算法(Weighted Least Connection)”在最少连接数算法的基础上,根据机器的性能为每台机器分配权重,再根据权重计算出每台机器能处理的连接数。

最少响应时间算法

“最少响应时间算法(Least Time)” 将请求分发到响应时间最短的候选机器。最少响应时间算法和最少连接数算法二者的目标其实是殊途同归,都是动态调整,将请求尽量分发到处理能力强的机器上。不同点在于,最少连接数关注的维度是机器持有的连接数,而最少响应时间关注的维度是机器上一次响应时间哪个最短。理论上来说,持有的连接数少,响应时间短,都可以表明机器潜在的处理能力比较强。

最少响应时间算法具有高度的敏感性、自适应性。但是,由于它需要持续监控候选机器的响应时延,相比于监控候选机器的连接数,会显著增加监控的开销。此外,请求的响应时延并不一定能完全反应机器的处理能力,有可能某机器上一次处理的请求恰好是一个开销非常小的请求。

img

哈希算法

前面提到的负载均衡算法,都只适用于无状态应用。所谓无状态应用,意味着:请求无论分发到集群中的任意机器上,得到的响应都是相同的:然而,有状态服务则不然:请求分发到不同的机器上,得到的结果是不一样的。典型的无状态应用是普通的 Web 服务器;典型的有状态应用是各种分布式数据库(如:Redis、ElasticSearch 等),这些数据库存储了大量,乃至海量的数据,无法全部存储在一台机器上,为了提高整体容量以及吞吐量,采用了分区(分片)的设计,将数据化整为零的存储在不同机器上。

对于有状态应用,不仅仅需要保证负载的均衡,更为重要的是,需要保证针对相同数据的请求始终访问的是相同的机器,否则,就无法获取到正确的数据。

那么,如何解决有状态应用的负载均衡呢?有一种方案是哈希算法。

“哈希算法(Hash)” 根据一个 key (可以是唯一 ID、IP、URL 等),通过哈希函数计算得到一个数值,用该数值在候选机器列表的进行取模运算,得到的结果便是选中的机器

img

这种算法可以保证,同一关键字(IP 或 URL 等)的请求,始终会被转发到同一台机器上。哈希负载均衡算法常被用于实现会话粘滞(Sticky Session)。

但是 ,哈希算法的问题是:当增减节点时,由于哈希取模函数的基数发生变化,会影响大部分的映射关系,从而导致之前的数据不可访问。要解决这个问题,就必须根据新的计算公式迁移数据。显然,如果数据量很大的情况下,迁移成本很高;并且,在迁移过程中,要保证业务平滑过渡,需要使用数据双写等较为复杂的技术手段。

img

一致性哈希算法

哈希算法的缺点是:当集群中出现增减节点时,由于哈希取模函数的基数发生变化,会导致大量集群中的机器不可用;需要通过代价高昂的数据迁移,来解决问题。那么,我们自然会希望有一种更优化的方案,来尽量减少影响的机器数。一致性哈希算法就是为了这个目标而应运而生。

一致性哈希算法对哈希算法进行了改良。“一致性哈希算法(Consistent Hash)”,根据哈希算法将对应的 key 哈希到一个具有 2^32 个桶的空间,并且头尾相连(0 到 2^32-1),即一个闭合的环形,这个圆环被称为“哈希环”。哈希算法是对节点的数量进行取模运算;而一致性哈希算法则是对 2^32 进行取模运算。

哈希环的空间是按顺时针方向组织的,需要对指定 key 的数据进行读写时,会执行两步:

  1. 先对节点进行哈希计算,计算的关键字通常是 IP 或其他唯一标识(例:hash(ip)),然后对 2^32 取模,以确定节点在哈希环上的位置。
  2. 先对 key 进行哈希计算(hash(key)),然后对 2^32 取模,以确定 key 在哈希环上的位置。
  3. 然后根据 key 的位置,顺时针找到的第一个节点,就是 key 对应的节点。

所以,一致性哈希是将“存储节点”和“数据”都映射到一个顺时针排序的哈希环上

img

一致性哈希算法会尽可能保证,相同的请求被分发到相同的机器上。当出现增减节点时,只影响哈希环中顺时针方向的相邻的节点,对其他节点无影响,不会引起剧烈变动

  • 相同的请求是指:一般在使用一致性哈希时,需要指定一个 key 用于 hash 计算,可能是:用户 ID、请求方 IP、请求服务名称,参数列表构成的串
  • 尽可能是指:哈希环上出现增减节点时,少数机器的变化不应该影响大多数的请求。

(1)增加节点

如下图所示,假设,哈希环中新增了一个节点 S4,新增节点经过哈希计算映射到图中位置:

img

此时,只有 K1 收到影响;而 K0、K2 均不受影响。

(2)减少节点

如下图所示,假设,哈希环中减少了一个节点 S0:

img

此时,只有 K0 收到影响;而 K1、K2 均不受影响。

一致性哈希算法并不保证节点能够在哈希环上分布均匀,由此而产生一个问题,哈希环上可能有大量的请求集中在一个节点上。从概率角度来看,哈希环上的节点越多,分布就越均匀。正因为如此,一致性哈希算法不适用于节点数过少的场景。

如下图所示:极端情况下,可能由于节点在哈希环上分布不均,有大量请求计算得到的 key 会被集中映射到少数节点,甚至某一个节点上。此外,节点分布不均匀的情况下,进行容灾与扩容时,哈希环上的相邻节点容易受到过大影响,从而引发雪崩式的连锁反应。

img

虚拟一致性哈希算法

在一致性哈希算法中,如果节点数过少,可能会分布不均,从而导致负载不均衡。在实际生产环境中,一个分布式系统应该具备良好的伸缩性,既能从容的扩展到大规模的集群,也要能支持小规模的集群。为此,又产生了虚拟哈希算法,进一步对一致性哈希算法进行了改良。

虚拟哈希算法的解决思路是:虽然实际的集群可能节点数较少,但是在哈希环上引入大量的虚拟哈希节点。具体来说,“虚拟哈希算法”有二次映射:先将虚拟节点映射到哈希环上,再将虚拟节点映射到实际节点上。

如下图所示,假设存在这样的场景:

  • 分布式集群中有 4 个真实节点,分别是:S0、S1、S2、S3;
  • 我们不妨先假定分配给哈希环 12 个虚拟节点,并将虚拟节点映射到真实节点上,映射关系如下:
    • S0 - S0_0、S0_1、S0_2、S0_3
    • S1 - S1_0、S1_1、S1_2、S1_3
    • S2 - S2_0、S2_1、S2_2、S2_3
    • S3 - S3_0、S3_1、S3_2、S3_3

img

通过引入虚拟哈希节点,是的哈希环上的节点分布相对均匀了。举例来说,假如此时,某请求的 key 哈希取模后,先映射到哈希环的 [S3_2, S0_0]、[S3_0, S0_1]、[S3_1, S0_2] 这三个区间的任意一点;接下来的二次映射都会匹配到真实节点 S0。

在实际应用中,虚拟哈希节点数一般都比较大(例如:Redis 的虚拟哈希槽有 16384 个),较大的数量保证了虚拟哈希环上的节点分布足够均匀。

虚拟节点除了会提高节点的均衡度,还会提高系统的稳定性。当节点变化时,会有不同的节点共同分担系统的变化,因此稳定性更高。例如,当某个节点被移除时,分配给该节点的多个虚拟节点会被一并移除,而这些虚拟节点按顺时针方向的下一个虚拟节点,可能会对应不同的真实节点,即这些不同的真实节点共同分担了节点变化导致的压力。

此外,有了虚拟节点后,可以通过调整分配给真实节点的虚拟节点数,来达到设置权重一样的效果,使得负载均衡更加灵活。

综上所述,虚拟一致性哈希算法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景

:::

流量控制

【基础】什么是流量控制?为什么需要流量控制?

:::details 要点

流量控制(Flow Control),根据流量、并发线程数、响应时间等指标,把随机到来的流量调整成合适的形状,即流量塑形。避免应用被瞬时的流量高峰冲垮,从而保障应用的高可用性。

复杂的分布式系统架构中的应用程序往往具有数十个依赖项,每个依赖项都会不可避免地在某个时刻失败。 如果主机应用程序未与这些外部故障隔离开来,则可能会被波及。

例如,对于依赖于 30 个服务的应用程序,假设每个服务的正常运行时间为 99.99%,则可以期望:

99.9930 = 99.7% 的正常运行时间

10 亿个请求中的 0.3%= 3,000,000 个失败

即使所有依赖项都具有出色的正常运行时间,每月也会有 2 个小时以上的停机时间。

然而,现实情况一般比这种估量情况更糟糕。


当一切正常时,整体系统如下所示:

img

图片来自 Hystrix Wiki

在分布式系统架构下,这些强依赖的子服务稳定与否对系统的影响非常大。但是,依赖的子服务可能有很多不可控问题:如网络连接、资源繁忙、服务宕机等。例如:下图中有一个 QPS 为 50 的依赖服务 I 出现不可用,但是其他依赖服务是可用的。

img

图片来自 Hystrix Wiki

当流量很大的情况下,某个依赖的阻塞,会导致上游服务请求被阻塞。当这种级联故障愈演愈烈,就可能造成整个线上服务不可用的雪崩效应,如下图。这种情况若持续恶化,如果上游服务本身还被其他服务所依赖,就可能出现多米洛骨牌效应,导致多个服务都无法正常工作。

img

图片来自 Hystrix Wiki

:::

【基础】流量控制有哪些衡量指标?

:::details 要点

:::

【中级】流量控制有哪些保护机制?

:::details 要点

流量控制常见的手段就是限流、熔断、降级。

什么是降级?

降级是保障服务能够稳定运行的一种保护方式:面对突增的流量,牺牲一些吞吐量以换取系统的稳定。常见的降级实现方式有:开关降级、限流降级、熔断降级。

什么是限流?

限流一般针对下游服务,当上游流量较大时,避免被上游服务的请求撑爆。

限流就是限制系统的输入和输出流量,以达到保护系统的目的。一般来说系统的吞吐量是可以被测算的,为了保证系统的稳定运行,一旦达到的需要限制的阈值,就需要限制流量并采取一些措施以完成限制流量的目的。比如:延迟处理,拒绝处理,或者部分拒绝处理等等。

限流规则包含三个部分:时间粒度,接口粒度,最大限流值。限流规则设置是否合理直接影响到限流是否合理有效。

什么是熔断?

熔断一般针对上游服务,当下游服务超时/异常较多时,避免被下游服务拖垮。

当调用链路中某个资源出现不稳定,例如,超时异常比例升高的时候,则对这个资源的调用进行限制,并让请求快速失败,避免影响到其它的资源,最终产生雪崩的效果。

熔断尽最大的可能去完成所有的请求,容忍一些失败,熔断也能自动恢复。熔断的常见策略有:

  • 在每秒请求异常数超过多少时触发熔断降级
  • 在每秒请求异常错误率超过多少时触发熔断降级
  • 在每秒请求平均耗时超过多少时触发熔断降级

:::

流量控制有哪些衡量指标

:::details 要点

流量控制有以下几个角度:

  • 流量指标,例如 QPS、并发线程数等。
  • 资源的调用关系,例如资源的调用链路,资源和资源之间的关系,调用来源等。
  • 控制效果,例如排队等待、直接拒绝、Warm Up(预热)等。

:::

【中级】流量控制有哪些隔离模式?

:::details 要点

线程池隔离

信号量隔离

资源隔离

:::

【高级】有哪些限流算法?

:::details 要点

常见的限流算法有:固定窗口限流算法、滑动窗口限流算法、漏桶限流算法、令牌桶限流算法。

固定窗口限流算法

固定窗口限流算法的原理

固定窗口限流算法的基本策略是:

  1. 设置一个固定时间窗口,以及这个固定时间窗口内的最大请求数;
  2. 为每个固定时间窗口设置一个计数器,用于统计请求数;
  3. 一旦请求数超过最大请求数,则请求会被拦截。

img

固定窗口限流算法的利弊

固定窗口限流算法的优点是:实现简单。

固定窗口限流算法的缺点是:存在临界问题。所谓临界问题,是指:流量分别集中在一个固定时间窗口的尾部和一个固定时间窗口的头部。举例来说,假设限流规则为每分钟不超过 100 次请求。在第一个时间窗口中,起初没有任何请求,在最后 1 s,收到 100 次请求,由于没有达到阈值,所有请求都通过;在第二个时间窗口中,第 1 秒就收到 100 次请求,而后续没有任何请求。虽然,这两个时间窗口内的流量都符合限流要求,但是在两个时间窗口临界的这 2s 内,实际上有 200 次请求,显然是超过预期吞吐量的,存在压垮系统的可能。

img

滑动窗口限流算法

滑动窗口限流算法的原理

滑动窗口限流算法是对固定窗口限流算法的改进,解决了临界问题。

滑动窗口限流算法的基本策略是:

  • 将固定时间窗口分片为多个子窗口,每个子窗口的访问次数独立统计;
  • 当请求时间大于当前子窗口的最大时间时,则将当前子窗口废弃,并将计时窗口向前滑动,并将下一个子窗口置为当前窗口。
  • 要保证所有子窗口的统计数之和不能超过阈值。

滑动窗口限流算法就是针对固定窗口限流算法的更细粒度的控制,分片越多,则限流越精准。

img

滑动窗口限流算法的利弊

滑动窗口限流算法的优点是:在滑动窗口限流算法中,临界位置的突发请求都会被算到时间窗口内,因此可以解决计数器算法的临界问题。

滑动窗口限流算法的缺点是:

  • 额外的内存开销 - 滑动时间窗口限流算法的时间窗口是持续滑动的,并且除了需要一个计数器来记录时间窗口内接口请求次数之外,还需要记录在时间窗口内每个接口请求到达的时间点,所以存在额外的内存开销。
  • 限流的控制粒度受限于窗口分片粒度 - 滑动窗口限流算法,只能在选定的时间粒度上限流,对选定时间粒度内的更加细粒度的访问频率不做限制。但是,由于每个分片窗口都有额外的内存开销,所以也并不是分片数越多越好的。

漏桶限流算法

漏桶限流算法的原理

漏桶限流算法的基本策略是:

  • 水(请求)以任意速率由入口进入到漏桶中;
  • 水以固定的速率由出口出水(请求通过);
  • 漏桶的容量是固定的,如果水的流入速率大于流出速率,最终会导致漏桶中的水溢出(这意味着请求拒绝)。

img

漏桶限流算法的利弊

漏桶限流算法的优点是:流量速率固定——即无论流量多大,即便是突发的大流量,处理请求的速度始终是固定的。

漏桶限流算法的缺点是:不能灵活的调整流量。例如:一个集群通过增减节点的方式,弹性伸缩了其吞吐能力,漏桶限流算法无法随之调整。

漏桶策略适用于间隔性突发流量且流量不用即时处理的场景

令牌桶限流算法

令牌桶限流算法的原理

img

令牌桶算法的原理

  1. 接口限制 T 秒内最大访问次数为 N,则每隔 T/N 秒会放一个 token 到桶中
  2. 桶内最多存放 M 个 token,如果 token 到达时令牌桶已经满了,那么这个 token 就会被丢弃
  3. 接口请求会先从令牌桶中取 token,拿到 token 则处理接口请求,拿不到 token 则进行限流处理

令牌桶限流算法的利弊

因为令牌桶存放了很多令牌,那么大量的突发请求会被执行,但是它不会出现临界问题,在令牌用完之后,令牌是以一个恒定的速率添加到令牌桶中的,因此不能再次发送大量突发请求。

规定固定容量的桶,token 以固定速度往桶内填充,当桶满时 token 不会被继续放入,每过来一个请求把 token 从桶中移除,如果桶中没有 token 不能请求。

令牌桶算法适用于有突发特性的流量,且流量需要即时处理的场景

扩展

Guava 的 RateLimiter 工具类就是基于令牌桶算法实现,其源码分析可以参考:RateLimiter 基于漏桶算法,但它参考了令牌桶算法

:::

网关路由

【基础】什么是服务路由?路由有什么用?

:::details 要点

服务路由是指通过一定的规则从集群中选择合适的节点。

负载均衡的作用和服务路由的功能看上去很近似,二者有什么区别呢?

负载均衡的目标是提供服务分发而不是解决路由问题,常见的静态、动态负载均衡算法无法实现精细化的路由管理,但是负载均衡也可以简单看做是路由方案的一种。

服务路由通常用于以下场景,目的在于实现流量隔离:

  • 分组调用:一般来讲,为了保证服务的高可用性,实现异地多活的需求,一个服务往往不止部署在一个数据中心,而且出于节省成本等考虑,有些业务可能不仅在私有机房部署,还会采用公有云部署,甚至采用多家公有云部署。服务节点也会按照不同的数据中心分成不同的分组,这时对于服务消费者来说,选择哪一个分组调用,就必须有相应的路由规则。
  • 蓝绿发布:蓝绿发布场景中,一共有两套服务群组:一套是提供旧版功能的服务群组,标记为绿色;另一套是提供新版功能的服务群组,标记为蓝色。两套服务群组都是功能完善的,并且正在运行的系统,只是服务版本和访问流量不同。新版群组(蓝色)通常是为了做内部测试、验收,不对外部用户暴露。
    • 如果新版群组(蓝色)运行稳定,并测试、验收通过后,则通过服务路由、负载均衡等手段逐步将外部用户流量导向新版群组(蓝色)。
    • 如果新版群组(蓝色)运行不稳定,或测试、验收不通过,则排查、解决问题后,再继续测试、验收。
  • 灰度发布:灰度发布(又名金丝雀发布)是指在黑与白之间,能够平滑过渡的一种发布方式。在其上可以进行 A/B 测试,即让一部分用户使用特性 A,一部分用户使用特性 B:如果用户对 B 没有什么反对意见,那么逐步扩大发布范围,直到把所有用户都迁移到 B 上面来。灰度发布可以保证整体系统的稳定,在初始灰度的时候就可以发现、调整问题,以保证其影响度。要支持灰度发布,就要求服务能够根据一定的规则,将流量隔离。
  • 流量切换:在业务线上运行过程中,经常会遇到一些不可抗力因素导致业务故障,比如某个机房的光缆被挖断,或者发生着火等事故导致整个机房的服务都不可用。这个时候就需要按照某个指令,能够把原来调用这个机房服务的流量切换到其他正常的机房。
  • 线下测试联调:线下测试时,可能会缺少相应环境。可以将测试应用注册到线上,然后开启路由规则,在本地进行测试。
  • 读写分离。对于大多数互联网业务来说都是读多写少,所以在进行服务部署的时候,可以把读写分开部署,所有写接口可以部署在一起,而读接口部署在另外的节点上。

:::

服务路由有哪些常见规则?

:::details 要点

条件路由

条件路由是基于条件表达式的路由规则。各个 RPC 框架的条件路由表达式各不相同。

我们不妨参考一下 Dubbo 的条件路由。Dubbo 的条件路由有两种配置粒度,如下:

  • 应用粒度

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # app1 的消费者只能消费所有端口为 20880 的服务实例
    # app2 的消费者只能消费所有端口为 20881 的服务实例
    ---
    scope: application
    force: true
    runtime: true
    enabled: true
    key: governance-conditionrouter-consumer
    conditions:
    - application=app1 => address=*:20880
    - application=app2 => address=*:20881
  • 服务粒度

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # DemoService 的 sayHello 方法只能消费所有端口为 20880 的服务实例
    # DemoService 的 sayHi 方法只能消费所有端口为 20881 的服务实例
    ---
    scope: service
    force: true
    runtime: true
    enabled: true
    key: org.apache.dubbo.samples.governance.api.DemoService
    conditions:
    - method=sayHello => address=*:20880
    - method=sayHi => address=*:20881

其中,conditions 定义具体的路由规则内容。conditions 部分是规则的主体,由 1 到任意多条规则组成。详见:Dubbo 路由规则

Dubbo 的条件路由规则由两个条件组成,分别用于对服务消费者和提供者进行匹配。条件路由规则的格式如下:

1
[服务消费者匹配条件] => [服务提供者匹配条件]
  • 服务消费者匹配条件:所有参数和消费者的 URL 进行对比,当消费者满足匹配条件时,对该消费者执行后面的过滤规则。
  • 服务提供者匹配条件:所有参数和提供者的 URL 进行对比,消费者最终只拿到过滤后的地址列表。

condition:// 代表了这是一段用条件表达式编写的路由规则,下面是一个条件路由规则示例:

1
host = 10.20.153.10 => host = 10.20.153.11

该条规则表示 IP 为 10.20.153.10 的服务消费者只可调用 IP 为 10.20.153.11 机器上的服务,不可调用其他机器上的服务。

下面列举一些 Dubbo 条件路由的典型应用场景:

  • 如果服务消费者的匹配条件为空,就表示所有的服务消费者都可以访问,就像下面的表达式一样。
1
=> host != 10.20.153.11
  • 如果服务提供者的过滤条件为空,就表示禁止所有的服务消费者访问,就像下面的表达式一样。
1
host = 10.20.153.10 =>
  • 排除某个服务节点
1
=> host != 172.22.3.91
  • 白名单
1
register.ip != 10.20.153.10,10.20.153.11 =>
  • 黑名单
1
register.ip = 10.20.153.10,10.20.153.11 =>
  • 只暴露部分机器节点
1
=> host = 172.22.3.1*,172.22.3.2*
  • 为重要应用提供额外的机器节点
1
application != kylin => host != 172.22.3.95,172.22.3.96
  • 读写分离
1
2
method = find*,list*,get*,is* => host = 172.22.3.94,172.22.3.95,172.22.3.96
method != find*,list*,get*,is* => host = 172.22.3.97,172.22.3.98
  • 前后台分离
1
2
application = bops => host = 172.22.3.91,172.22.3.92,172.22.3.93
application != bops => host = 172.22.3.94,172.22.3.95,172.22.3.96
  • 隔离不同机房网段
1
host != 172.22.3.* => host != 172.22.3.*
  • 提供者与消费者部署在同集群内,本机只访问本机的服务
1
=> host = $host

脚本路由

脚本路由是基于脚本语言的路由规则,常用的脚本语言比如 JavaScript、Groovy、JRuby 等。

1
2
'script://0.0.0.0/com.foo.BarService?category=routers&dynamic=false&rule=' +
URL.encode('(function route(invokers) { ... } (invokers))')

这里面 script:// 就代表了这是一段脚本语言编写的路由规则,具体规则定义在脚本语言的 route 方法实现里,比如下面这段用 JavaScript 编写的 route() 方法表达的意思是,只有 IP 为 10.20.153.10 的服务消费者可以发起服务调用。

1
2
3
4
5
6
7
8
9
function route(invokers){
var result = new java.util.ArrayList(invokers.size());
for(i =0; i < invokers.size(); i ++){
if("10.20.153.10".equals(invokers.get(i).getUrl().getHost())){
result.add(invokers.get(i));
}
}
return result;
} (invokers));

标签路由

标签路由通过将某一个或多个服务的提供者划分到同一个分组,约束流量只在指定分组中流转,从而实现流量隔离的目的,可以作为蓝绿发布、灰度发布等场景的能力基础。

标签主要是指对服务提供者的分组,目前有两种方式可以完成实例分组,分别是动态规则打标静态规则打标。一般,动态规则优先级比静态规则更高,当两种规则同时存在且出现冲突时,将以动态规则为准。

以 Dubbo 的标签路由用法为例

(1)动态规则打标,可随时在服务治理控制台下发标签归组规则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# governance-tagrouter-provider 应用增加了两个标签分组 tag1 和 tag2
# tag1 包含一个实例 127.0.0.1:20880
# tag2 包含一个实例 127.0.0.1:20881
---
force: false
runtime: true
enabled: true
key: governance-tagrouter-provider
tags:
- name: tag1
addresses: ["127.0.0.1:20880"]
- name: tag2
addresses: ["127.0.0.1:20881"]
...

(2)静态规则打标

1
<dubbo:provider tag="tag1"/>

or

1
<dubbo:service tag="tag1"/>

or

1
java -jar xxx-provider.jar -Ddubbo.provider.tag={the tag you want, may come from OS ENV}

(3)服务消费者指定标签路由

1
RpcContext.getContext().setAttachment(Constants.REQUEST_TAG_KEY,"tag1");

请求标签的作用域为每一次 invocation,使用 attachment 来传递请求标签,注意保存在 attachment 中的值将会在一次完整的远程调用中持续传递,得益于这样的特性,我们只需要在起始调用时,通过一行代码的设置,达到标签的持续传递。

路由规则获取方式

路由规则的获取方式主要有三种:

  • 本地静态配置:顾名思义就是路由规则存储在服务消费者本地上。服务消费者发起调用时,从本地固定位置读取路由规则,然后按照路由规则选取一个服务节点发起调用。
  • 配置中心管理:这种方式下,所有的服务消费者都从配置中心获取路由规则,由配置中心来统一管理。
  • 注册中心动态下发:这种方式下,一般是运维人员或者开发人员,通过服务治理平台修改路由规则,服务治理平台调用配置中心接口,把修改后的路由规则持久化到配置中心。因为服务消费者订阅了路由规则的变更,于是就会从配置中心获取最新的路由规则,按照最新的路由规则来执行。

一般来讲,服务路由最好是存储在配置中心,由配置中心来统一管理。这样的话,所有的服务消费者就不需要在本地管理服务路由,因为大部分的服务消费者并不关心服务路由的问题,或者说也不需要去了解其中的细节。通过配置中心,统一给各个服务消费者下发统一的服务路由,节省了沟通和管理成本。

但也不排除某些服务消费者有特定的需求,需要定制自己的路由规则,这个时候就适合通过本地配置来定制。

而动态下发可以理解为一种高级功能,它能够动态地修改路由规则,在某些业务场景下十分有用。比如某个数据中心存在问题,需要把调用这个数据中心的服务消费者都切换到其他数据中心,这时就可以通过动态下发的方式,向配置中心下发一条路由规则,将所有调用这个数据中心的请求都迁移到别的地方。

:::

分布式任务

【中级】在 Java 中,实现一个进程内定时任务有哪些方案?

:::details 要点

定时器有非常多的使用场景,例如生成年/月/周/日统计报表、财务对账、会员积分结算、邮件推送等,都是定时器的使用场景。定时器一般有三种表现形式:按固定周期定时执行、延迟一定时间后执行、指定某个时刻执行。

定时器的本质是设计一种数据结构,能够存储和调度任务集合,而且 deadline 越近的任务拥有更高的优先级。那么定时器如何知道一个任务是否到期了呢?定时器需要通过轮询的方式来实现,每隔一个时间片去检查任务是否到期。

所以定时器的内部结构一般需要一个任务队列和一个异步轮询线程,并且能够提供三种基本操作:

  • Schedule 新增任务至任务集合;
  • Cancel 取消某个任务;
  • Run 执行到期的任务。

JDK 原生提供了三种常用的定时器实现方式,分别为 TimerDelayedQueueScheduledThreadPoolExecutor

JDK 内置的三种实现定时器的方式,实现思路都非常相似,都离不开任务任务管理任务调度三个角色。三种定时器新增和取消任务的时间复杂度都是 O(logn),面对海量任务插入和删除的场景,这三种定时器都会遇到比较严重的性能瓶颈。对于性能要求较高的场景,一般都会采用时间轮算法来实现定时器

Timer

Timer 属于 JDK 比较早期版本的实现,它可以实现固定周期的任务,以及延迟任务。Timer 会启动一个异步线程去执行到期的任务,任务可以只被调度执行一次,也可以周期性反复执行多次。我们先来看下 Timer 是如何使用的,示例代码如下。

1
2
3
4
5
6
7
Timer timer = new Timer();
timer.scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
// do something
}
}, 10000, 1000); // 10s 后调度一个周期为 1s 的定时任务

可以看出,任务是由 TimerTask 类实现,TimerTask 是实现了 Runnable 接口的抽象类,Timer 负责调度和执行 TimerTask。接下来我们看下 Timer 的内部构造。

1
2
3
4
5
6
7
8
9
10
public class Timer {

private final TaskQueue queue = new TaskQueue();
private final TimerThread thread = new TimerThread(queue);

public Timer(String name) {
thread.setName(name);
thread.start();
}
}

TaskQueue 是由数组结构实现的小根堆,deadline 最近的任务位于堆顶端,queue[1] 始终是最优先被执行的任务。所以使用小根堆的数据结构,Run 操作时间复杂度 O(1),新增(Schedule)和取消(Cancel)操作的时间复杂度都是 O(logn)

Timer 内部启动了一个 TimerThread 异步线程,不论有多少任务被加入数组,始终都是由 TimerThread 负责处理。TimerThread 会定时轮询 TaskQueue 中的任务,如果堆顶的任务的 deadline 已到,那么执行任务;如果是周期性任务,执行完成后重新计算下一次任务的 deadline,并再次放入小根堆;如果是单次执行的任务,执行结束后会从 TaskQueue 中删除。

Timer 只使用一个线程来执行任务意味着同一时间只能有一个任务得到执行,而前一个任务的延迟或者异常会影响到之后的任务。如果有一个定时任务在运行时,产生未处理的异常,那么当前这个线程就会停止,那么所有的定时任务都会停止,受到影响。

不推荐使用 Timer ,因为 Timer 存在以下设计缺陷:

  • Timer 是单线程模式。如果某个 TimerTask 执行时间很久,会影响其他任务的调度。
  • Timer 的任务调度是基于系统绝对时间的,如果系统时间不正确,可能会出现问题。
  • TimerTask 如果执行出现异常,Timer 并不会捕获,会导致线程终止,其他任务永远不会执行。

ScheduledExecutorService

为了解决 Timer 的设计缺陷,JDK 提供了功能更加丰富的 ScheduledThreadPoolExecutorScheduledThreadPoolExecutor 提供了周期执行任务和延迟执行任务的特性。

1
2
3
4
5
6
7
public class ScheduledExecutorServiceTest {
public static void main(String[] args) {
ScheduledExecutorService executor = Executors.newScheduledThreadPool(5);
// 1s 延迟后开始执行任务,每 2s 重复执行一次
executor.scheduleAtFixedRate(() -> System.out.println("Hello World"), 1000, 2000, TimeUnit.MILLISECONDS);
}
}

ScheduledThreadPoolExecutor 继承于 ThreadPoolExecutor,因此它具备线程池异步处理任务的能力。线程池主要负责管理创建和管理线程,并从自身的阻塞队列中不断获取任务执行。线程池有两个重要的角色,分别是任务和阻塞队列。ScheduledThreadPoolExecutorThreadPoolExecutor 的基础上,重新设计了任务 ScheduledFutureTask 和阻塞队列 DelayedWorkQueueScheduledFutureTask 继承于 FutureTask,并重写了 run() 方法,使其具备周期执行任务的能力。DelayedWorkQueue 内部是优先级队列,deadline 最近的任务在队列头部。对于周期执行的任务,在执行完会重新设置时间,并再次放入队列中。

DelayedQueue

DelayedQueue 是 JDK 中一种可以延迟获取对象的阻塞队列,其内部是采用优先级队列 PriorityQueue 存储对象。DelayQueue 中的每个对象都必须实现 Delayed 接口,并重写 compareTogetDelay 方法。DelayedQueue 的使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class DelayQueueTest {

public static void main(String[] args) throws Exception {

BlockingQueue<SampleTask> delayQueue = new DelayQueue<>();
long now = System.currentTimeMillis();
delayQueue.put(new SampleTask(now + 1000));
delayQueue.put(new SampleTask(now + 2000));
delayQueue.put(new SampleTask(now + 3000));
for (int i = 0; i < 3; i++) {
System.out.println(new Date(delayQueue.take().getTime()));
}
}

static class SampleTask implements Delayed {

long time;

public SampleTask(long time) {
this.time = time;
}

public long getTime() {
return time;
}

@Override

public int compareTo(Delayed o) {
return Long.compare(this.getDelay(TimeUnit.MILLISECONDS), o.getDelay(TimeUnit.MILLISECONDS));
}

@Override

public long getDelay(TimeUnit unit) {
return unit.convert(time - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
}

}

DelayQueue 提供了 put()take() 的阻塞方法,可以向队列中添加对象和取出对象。对象被添加到 DelayQueue 后,会根据 compareTo() 方法进行优先级排序。getDelay() 方法用于计算消息延迟的剩余时间,只有 getDelay <=0 时,该对象才能从 DelayQueue 中取出。

DelayQueue 在日常开发中最常用的场景就是实现重试机制。例如,接口调用失败或者请求超时后,可以将当前请求对象放入 DelayQueue,通过一个异步线程 take() 取出对象然后继续进行重试。如果还是请求失败,继续放回 DelayQueue。为了限制重试的频率,可以设置重试的最大次数以及采用指数退避算法设置对象的 deadline,如 2s、4s、8s、16s ……以此类推。

相比于 TimerDelayQueue 只实现了任务管理的功能,需要与异步线程配合使用。DelayQueue 使用优先级队列实现任务的优先级排序,新增(Schedule)和取消(Cancel)操作的时间复杂度也是 O(logn)

时间轮

JDK 内置的三种实现定时器的方式,实现思路都非常相似,都离不开任务任务管理任务调度三个角色。三种定时器新增和取消任务的时间复杂度都是 O(logn),面对海量任务插入和删除的场景,这三种定时器都会遇到比较严重的性能瓶颈。对于性能要求较高的场景,一般都会采用时间轮算法来实现定时器

时间轮(Timing Wheel)是 George Varghese 和 Tony Lauck 在 1996 年的论文 Hashed and Hierarchical Timing Wheels: data structures to efficiently implement a timer facility 实现的,它在 Linux 内核中使用广泛,是 Linux 内核定时器的实现方法和基础之一。

时间轮可以理解为一种环形结构,像钟表一样被分为多个 slot 槽位。每个 slot 代表一个时间段,每个 slot 中可以存放多个任务,使用的是链表结构保存该时间段到期的所有任务。时间轮通过一个时针随着时间一个个 slot 转动,并执行 slot 中的所有到期任务。

图片 22.png

任务是如何添加到时间轮当中的呢?可以根据任务的到期时间进行取模,然后将任务分布到不同的 slot 中。如上图所示,时间轮被划分为 8 个 slot,每个 slot 代表 1s,当前时针指向 2。假如现在需要调度一个 3s 后执行的任务,应该加入 2+3=5 的 slot 中;如果需要调度一个 12s 以后的任务,需要等待时针完整走完一圈 round 零 4 个 slot,需要放入第 (2+12)%8=6 个 slot。

那么当时针走到第 6 个 slot 时,怎么区分每个任务是否需要立即执行,还是需要等待下一圈 round,甚至更久时间之后执行呢?所以我们需要把 round 信息保存在任务中。例如图中第 6 个 slot 的链表中包含 3 个任务,第一个任务 round=0,需要立即执行;第二个任务 round=1,需要等待 1*8=8s 后执行;第三个任务 round=2,需要等待 2*8=8s 后执行。所以当时针转动到对应 slot 时,只执行 round=0 的任务,slot 中其余任务的 round 应当减 1,等待下一个 round 之后执行。

上面介绍了时间轮算法的基本理论,可以看出时间轮有点类似 HashMap,如果多个任务如果对应同一个 slot,处理冲突的方法采用的是拉链法。在任务数量比较多的场景下,适当增加时间轮的 slot 数量,可以减少时针转动时遍历的任务个数。

时间轮定时器最大的优势就是,任务的新增和取消都是 O(1) 时间复杂度,而且只需要一个线程就可以驱动时间轮进行工作。

HashedWheelTimer 是 Netty 中时间轮算法的实现类。

:::

【中级】分布式定时任务有哪些方案?

:::details 要点

分布式定时任务常见方案有:

  • Quartz
  • XXL-Job
  • ElasticJob

Quartz

Quartz 是一个经典的开源定时调度框架。它支持进程内调度和分布式调度。

Quartz 提供两种基本作业存储类型:

  • RAMJobStore - 在默认情况下 Quartz 将任务调度的运行信息保存在内存中,这种方法提供了最佳的性能,因为内存中数据访问最快。不足之处是缺乏数据的持久性,当程序路途停止或系统崩溃时,所有运行的信息都会丢失。
  • JobStoreTX - 所有的任务信息都会保存到数据库中,可以控制事物,还有就是如果应用服务器关闭或者重启,任务信息都不会丢失,并且可以恢复因服务器关闭或者重启而导致执行失败的任务

XXL-Job

xxl-job 是一个分布式任务调度平台。

设计思想

将调度行为抽象形成“调度中心”公共平台,而平台自身并不承担业务逻辑,“调度中心”负责发起调度请求。

将任务抽象成分散的 JobHandler,交由“执行器”统一管理,“执行器”负责接收调度请求并执行对应的 JobHandler 中业务逻辑。

因此,“调度”和“任务”两部分可以相互解耦,提高系统整体稳定性和扩展性;

系统组成

  • 调度模块(调度中心)
    负责管理调度信息,按照调度配置发出调度请求,自身不承担业务代码。调度系统与任务解耦,提高了系统可用性和稳定性,同时调度系统性能不再受限于任务模块;
    支持可视化、简单且动态的管理调度信息,包括任务新建,更新,删除,GLUE 开发和任务报警等,所有上述操作都会实时生效,同时支持监控调度结果以及执行日志,支持执行器 Failover。
  • 执行模块(执行器)
    负责接收调度请求并执行任务逻辑。任务模块专注于任务的执行等操作,开发和维护更加简单和高效;
    接收“调度中心”的执行请求、终止请求和日志请求等。

输入图片说明

ElasticJob

两个相互独立的子项目 ElasticJob-Lite 和 ElasticJob-Cloud 组成。 它通过弹性调度、资源管控、以及作业治理的功能,打造一个适用于互联网场景的分布式调度解决方案,并通过开放的架构设计,提供多元化的作业生态。 它的各个产品使用统一的作业 API,开发者仅需一次开发,即可随意部署。

ElasticJob 采用去中心化架构,没有作业调度中心。它以框架的形式,集成到应用中,提供调度服务。

ElasticJob-Lite 定位为轻量级无中心化解决方案,使用 jar 的形式提供分布式任务的协调服务。

ElasticJob Architecture

ElasticJob-Cloud 采用自研 Mesos Framework 的解决方案,额外提供资源治理、应用分发以及进程隔离等功能。

ElasticJob-Cloud Architecture

ElasticJob-Lite 和 ElasticJob-Cloud 对比:

ElasticJob-Lite ElasticJob-Cloud
无中心化
资源分配 不支持 支持
作业模式 常驻 常驻 + 瞬时
部署依赖 ZooKeeper ZooKeeper + Mesos

ElasticJob-Cloud 的优势在于对资源细粒度治理,适用于需要削峰填谷的大数据系统。

:::

分布式协同面试

复制

【基础】什么是复制?复制有什么作用?

:::details 要点

复制主要指通过网络在多台机器上保存相同数据的副本

复制数据,可能出于各种各样的原因:

  • 提高可用性 - 当部分组件出现位障,系统依然可以继续工作,系统依然可以继续工作。
  • 降低访问延迟 - 使数据在地理位置上更接近用户。
  • 提高读吞吐量 - 扩展至多台机器以同时提供数据访问服务。

:::

【中级】复制有哪些模式?

:::details 要点

复制的模式有以下几种:

  • 主从复制 - 所有的写入操作都发送到主节点,由主节点负责将数据更改事件发送到从节点。每个从节点都可以接收读请求,但内容可能是过期值。支持主从复制的系统:
    • 数据库:Mysql、PostgreSQL、MongoDB 等
    • 消息队列:Kafka、RabbitMQ 等
  • 多主复制 - 系统存在多个主节点,每个都可以接收写请求,客户端将写请求发送到其中的一个主节点上,由该主节点负责将数据更改事件同步到其他主节点和自己的从节点。
  • 无主复制 - 系统中不存在主节点,每一个节点都能接受客户端的写请求。此外,读取时从多个节点上并行读取,以此检测和纠正某些过期数据。支持无主复制的系统:
    • 数据库:Cassandra

此外,复制还需要考虑以下问题:

  • 同步还是异步
  • 如何处理失败的副本
  • 如何保证数据一致

:::

【中级】主从复制是如何工作的?

:::details 要点

最常见的解决方案就是主从复制,其原理如下:

主从复制模式中只有一个主副本(或称为主节点) ,其余称为从副本(或称为从节点)。

  1. 所有的写请求只能发送给主副本,主副本首先将新数据写入本地存储。
  2. 然后,主副本将数据更改作为复制的日志或更新流发送给所有从副本。每个从副本获得更新数据之后将其应用到本地,且严格保持与主副本相同的写入顺序。
  3. 读请求既可以在主副本上,也可以在从副本上执行。

再次强调,只有主副本才可以接受写请求:从客户端的角度来看,从副本都是只读的。如果由于某种原因,例如与主节点之间的网络中断而导致主节点无法连接,主从复制方案就会影响所有的写入操作。

主从复制系统

:::

【中级】同步复制、半同步复制、异步复制有什么差异?

:::details 要点

主从复制——同步和异步

一般,复制速度会非常快;但是,系统不能保证复制多久能完成。有些情况下,从节点可能落后主节点几分钟甚至更长时间,例如:从节点刚从故障中恢复;或系统已经接近最大设计上限;或节点之间的网络出现问题。

全同步复制的优缺点:

  • 优点:只有所有从节点都完成复制,才视为成功,因此是强一致的
  • 缺点:即使只有一个从节点未完成复制,写入都不能视为成功。所有从节点完成复制过程之前,主节点会阻塞后续所有的写操作。

因此,把所有从节点都配置为同步复制有些不切实际。因为这样的话,任何一个同步节点的中断都会导致整个系统更新停滞不前。

全异步复制的优缺点:

  • 优点:不管从节点上数据多么滞后,主节点总是可以继续响应写请求,系统的性能更好
  • 缺点:如果主节点发生故障且不可恢复,则所有尚未复制到从节点的写请求都会丢失

还有一种折中的方案——半同步复制只要有一个从节点或半数以上的从节点同步成功,就视为同步,直接返回结果;剩下的节点都通过异步方式同步。万一同步的从节点变得不可用或性能下降,则将另一个异步的从节点提升为同步模式。这样可以保证至少有两个节点(即主节点和一个同步从节点)拥有最新的数据副本。

:::

【中级】新的从节点如何复制主节点数据?

:::details 要点

两种不可行的方案:

  • 由于主节点会源源不断接受新的写入数据,数据始终处于变化中,因此一次性从主节点复制数据到从节点是无法保证数据一致的
  • 另一种思路是:考虑锁定数据库(使其不可写)来使磁盘上的文件保持一致,但这会违反高可用的设计目标

可行的方案:

  1. 生成主节点某时刻的快照,避免长时间锁定数据库。
  2. 将快照复制到从节点。
  3. 从节点复制主节点快照过程中,所有的数据变更写入一个日志中(这个数据变更日志在不同数据库中有着不同的称呼,Mysql 称其为 binlog;Redis 称其为 AOF)。
  4. 从节点复制完主节点的快照后,请求数据变更日志中的数据,并基于此补全数据,这个过程称为追赶,直至主从数据一致。井重复步骤 1 ~步骤 4 。

:::

【高级】如何通过主从复制技术来实现系统高可用呢?

:::details 要点

从节点失效:追赶式恢复

从节点的本地磁盘上都保存了副本收到的数据变更日志。如果从节点从故障中恢复,可以和主节点对比数据变更日志的偏移量,从而确认数据是否滞后。如果数据存在滞后,则向主节点请求数据变更日志,并补全数据。这个过程称为追赶

主节点失效:节点切换

主节点失效后,需要选举出新主节点。然后,客户端需要更新路由,将所有写请求发送给新的主节点;其他从节点要接受来自新的主节点上的变更数据。这个过程称之为切换

主节点切换可以手动或自动进行。自动切换的步骤通常如下:

  1. 确认主节点失效。有很多种出错可能性,很难准确检测出问题的原因。所以,大多数系统都基于超时机制来确认主节点是否失效:节点间频繁地互相发生发送心跳存活悄息,如果发现某一个节点在一段比较长时间内没有响应,即认为该节点发生失效。
  2. 选举新的主节点。基于多数派共识选主。候选节点最好与原主节点的数据差异最小,这样可以最小化数据丢失的风险。
  3. 重新配置系统使新主节点生效。客户端现在需要将写请求发送给新的主节点。原主节点若恢复,需降级处理,避免脑裂。

:::

【高级】复制日志如何实现?

:::details 要点

复制日志的视线方式:

  • 基于语句的复制 - 将数据写操作写入日志。主要缺点是必须完全按照相同顺序执行,否则可能会产生不同的结果。
  • 基于预写日志(WAL)传输 - 通常每个写操作都是以追加写的方式写入到日志中。主要缺点是日志描述的数据结果非常底层,如果数据库不同版本的存储格式存在差异,就可能无法兼容。
    • 对于日志结构存储引擎,日志是主要的存储方式。日志段在后台压缩井支持垃圾回收。
    • 对于采用覆写磁盘的 BTree 结构,每次修改会预先写入日志,如系统发生崩溃,通过索引更新的方式迅速恢复到此前一致状态。
  • 基于行的逻辑日志复制 - 如果复制和存储引擎采用不同的日志格式,这样复制与存储的逻辑就可以剥离。这种复制日志称为逻辑日志,以区分物理存储引擎的数据表示。
  • 基于触发器的复制 - 这种方式很灵活,可以定制化控制复制逻辑。主要缺点是复制开销更高,也更容易出错

:::

【高级】多主复制是如何工作的?

:::details 要点

对主从复制模型进行自然的扩展,则可以配置多个主节点,每个主节点都可以接受写操作,后面复制的流程类似:处理写的每个主节点都必须将该数据更改转发到所有其他节点。这就是多主节点( 也称为主-主,或主动/主动)复制。此时,每个主节点还同时扮演其他主节点的从节点。

在一个数据中心内部使用多主节点基本没有太大意义,其复杂性已经超过所能带来的好处。

但是,以下场景这种配置则是合理的:

  • 多数据中心
  • 离线客户端操作
  • 协作编辑

多数据中心

有了多主节点复制模型,则可以在每个数据中心都配置主节点。在每个数据中心内,采用常规的主从复制方案;而在数据中心之间,由各个数据中心的主节点来负责同其他数据中心的主节点进行数据的交换、更新。

img

部署单主节点的主从复制方案与多主复制方案之间的差异

  • 性能:对于主从复制,每个写请求都必须经由广域网传送至主节点所在的数据中心。这会大大增加写入延迟,井基本偏离了采用多数据中心的初衷(即就近访问)。而在多主节点模型中,每个写操作都可以在本地数据中心快速响应,然后采用异步复制方式将变化同步到其他数据中心。因此,对上层应用有效屏蔽了数据中心之间的网络延迟,使得终端用户所体验到的性能更好。
  • 容忍数据中心失效:对于主从复制,如果主节点所在的数据中心发生故障,必须切换至另一个数据中心,将其中的一个从节点被提升为主节点。在多主节点模型中,每个数据中心则可以独立于其他数据中心继续运行,发生故障的数据中心在恢复之后更新到最新状态。
  • 容忍网络问题:数据中心之间的通信通常经由广域网,它往往不如数据中心内的本地网络可靠。对于主从复制模型,由于写请求是同步操作,对数据中心之间的网络性能和稳定性等更加依赖。多主节点模型则通常采用异步复制,可以更好地容忍此类问题,例如临时网络闪断不会妨碍写请求最终成功。

:::

【高级】无主复制是如何工作的?

:::details 要点

无主复制模式,系统中不存在主节点,每一个节点都能接受客户端的写请求。此外,读取时从多个节点上并行读取,以此检测和纠正某些过期数据

读修复和反熵

复制模型应确保所有数据最终复制到所有的副本。当一个失效的节点重新上线之后,如何赶上中间错过的那些写请求呢?

有以下两种机制:

  • 读修复 - 客户端并行读取多个副本,根据版本识别过期返回值并更新最新值到相应副本。这种方法主要适合那些被频繁读取的场景。
  • 反熵 - 利用后台进程不断查找副本间的数据差异,将任何缺少的数据从一个副本复制到另一个副本。与基于主节点复制的复制日志不同,反熵过程并不保证以特定的顺序复制写入,并且会引入明显的同步滞后。

QuorumNWR 算法

无主复制模式中,究竟多少个副本完成才可以认为写成功?

如果有 n 个副本,写人需要 w 个节点确认,读取必须至少查询 r 个节点, 则只要 w+r>n ,读取的节点中一定会包含最新值。

并发写冲突

无主模式中,并发向多副本写操作,以及读时修复或数据回传都会导致并发写冲突。如何解决冲突呢?有以下几种机制:

  • 最后写入者获胜(丢弃并发写入) - 每个副本总是保存最新值,允许覆盖井丢弃旧值。
  • Happens Before - 利用全序的逻辑时钟来确定事件发生的前后顺序。
  • 向量时钟、版本向量时钟 - 本质上是将全序的逻辑时钟改造为维护所有副本版本号的合集,基于此合集可以进行偏序比较。

:::

分区

【基础】什么是分区?为什么要分区?

:::details 要点

分区通常是这样定义的,即每一条数据(或者每条记录,每行或每个文档)只属于某个特定分区。实际上,每个分区都可以视为一个完整的小型数据库,虽然数据库可能存在一些跨分区的操作。

在不同系统中,分区有着不同的称呼,例如它对应于 MongoDB, Elasticsearch 和 SolrCloud 中的 shard, HBase 的 region, Bigtable 中的 tablet, Cassandra 和 Riak 中的 vnode ,以及 Couch base 中的 vBucket。总体而言,分区是最普遍的术语。

数据量如果太大,单台机器进行存储和处理就会成为瓶颈,因此需要引入数据分区机制。

分区的目地是通过多台机器均匀分布数据和查询负载,避免出现热点。这需要选择合适的数据分区方案,在节点添加或删除时重新动态平衡分区。

:::

【中级】分区有哪些模式?

:::details 要点

分区通常与复制结合使用,即每个分区在多个节点都存有副本。这意味着某条记录属于特定的分区,而同样的内容会保存在不同的节点上以提高系统的容错性。

一个节点上可能存储了多个分区。每个分区都有自己的主副本,例如被分配给某节点,而从副本则分配在其他一些节点。一个节点可能既是某些分区的主副本,同时又是其他分区的从副本。

分区主要有两种模式:

  • 基于关键字区间的分区 - 先对关键字进行排序,每个分区只负责一段包含最小到最大关键字范围的一段关键字。对关键字排序的优点是可以支持高效的区间查询,但是如果应用程序经常访问与排序一致的某段关键字,就会存在热点的风险。采用这种方怯,当分区太大时,通常将其分裂为两个子区间,从而动态地再平衡分区。典型代表:HBase
  • 哈希分区 - 将哈希函数作用于每个关键字,每个分区负责一定范围的哈希值。这种方法打破了原关键字的顺序关系,它的区间查询效率比较低,但可以更均匀地分配负载。采用哈希分区时,通常事先创建好足够多(但固定数量)的分区, 让每个节点承担多个分区,当添加或删除节点时将某些分区从一个节点迁移到另一个节点,也可以支持动态分区。典型代表:Elasticsearch、Redis。

:::

【高级】二级索引如何分区?

:::details 要点

二级索引是关系数据库的必备特性,在文档数据库中应用也非常普遍。但考虑到其复杂性,许多键值存储(如 HBase 和 Voldemort)并不支持二级索引。此外, 二级索引技术也是 Solr 和 Elasticsearch 等搜索引擎数据库存在之根本。

分区不仅仅是针对数据,二级索引也需要分区。通常有两种方法:

基于文档来分区二级索引(本地索引) - 二级索引存储在与关键字相同的分区中,这意味着写入时我们只需要更新一个分区,但缺点是读取二级索引时需要在所有分区上并行执行。它广泛用于实践: MongoDB 、Riak、Cassandra、Elasticsearch 、SolrCloud 和 VoltDB 都支持基于文档分区二级索引。

img

基于词条来分区二级索引(全局索引) - 它是基于索引的值而进行的独立分区。二级索引中的条目可能包含来自关键字的多个分区里的记录。在写入时,不得不更新二级索引的多个分区;但读取时,则可以从单个分区直接快速提取数据。

img

:::

【基础】什么是分区再均衡?

:::details 要点

集群节点数变化,数据规模增长等情况,都会导致分区的分布不均。要保持分区的均衡,势必要将数据和请求进行迁移,这样一个迁移负载的过程称为分区再均衡

:::

【高级】分区再均衡有哪些策略?

:::details 要点

固定数量的分区

创建远超实际节点数的分区数,然后为每个节点分配多个分区。接下来, 如果集群中添加了一个新节点,该新节点可以从每个现有的节点上匀走几个分区,直到分区再次达到全局平衡。

选中的整个分区会在节点之间迁移,但分区的总数量仍维持不变,也不会改变关键字到分区的映射关系。这里唯一要调整的是分区与节点的对应关系。考虑到节点间通过网络传输数据总是需要些时间,这样调整可以逐步完成,在此期间,旧分区仍然可以接收读写请求。

原则上,也可以将集群中的不同的硬件配置因素考虑进来,即性能更强大的节点将分配更多的分区,从而分担更多的负载。

目前,Riak、Elasticsearch、Couchbase 和 Voldemort 都支持这种动态平衡方法。

使用该策略时,分区的数量往往在数据库创建时就确定好,之后不会改变。原则上也可以拆分和合并分区(稍后介绍),但固定数量的分区使得相关操作非常简单,因此许多采用固定分区策略的数据库决定不支持分区拆分功能。所以,在初始化时,已经充分考虑将来扩容增长的需求(未来可能拥有的最大节点数),设置一个足够大的分区数。而每个分区也有些额外的管理开销,选择过高的数字可能会有副作用。

动态分区

对于采用关键宇区间分区的数据库,如果边界设置有问题,最终可能会出现所有数据都挤在一个分区而其他分区基本为空,那么设定固定边界、固定数量的分区将非常不便:而手动去重新配置分区边界又非常繁琐。

因此, 一些数据库如 HBase 和 RethinkDB 等采用了动态创建分区。当分区的数据增长超过一个可配的参数阔值(HBase 上默认值是 10GB),它就拆分为两个分区,每个承担一半的数据量。相反,如果大量数据被删除,并且分区缩小到某个阈值以下,则将其与相邻分区进行合井。该过程类似于 B 树的分裂操作。

每个分区总是分配给一个节点,而每个节点可以承载多个分区,这点与固定数量的分区一样。当一个大的分区发生分裂之后,可以将其中的一半转移到其他某节点以平衡负载。对于 HBase,分区文件的传输需要借助 HDFS。

动态分区的一个优点是分区数量可以自动适配数据总量。如果只有少量的数据,少量的分区就足够了,这样系统开销很小;如果有大量的数据,每个分区的大小则被限制在一个可配的最大值。

但是,需要注意的是,对于一个空的数据库, 因为没有任何先验知识可以帮助确定分区的边界,所以会从一个分区开始。可能数据集很小,但直到达到第一个分裂点之前,所有的写入操作都必须由单个节点来处理, 而其他节点则处于空闲状态。为了缓解这个问题,HBase 和 MongoDB 允许在一个空的数据库上配置一组初始分区(这被称为预分裂)。对于关键字区间分区,预分裂要求已经知道一些关键字的分布情况。

动态分区不仅适用于关键字区间分区,也适用于基于哈希的分区策略。MongoDB 从版本 2.4 开始,同时支持二者,井且都可以动态分裂分区。

按节点比例分区

采用动态分区策略,拆分和合并操作使每个分区的大小维持在设定的最小值和最大值之间,因此分区的数量与数据集的大小成正比关系。另一方面,对于固定数量的分区方式,其每个分区的大小也与数据集的大小成正比。两种情况,分区的数量都与节点数无关。

Cassandra 和 Ketama 则采用了第三种方式,使分区数与集群节点数成正比关系。换句话说,每个节点具有固定数量的分区。此时, 当节点数不变时,每个分区的大小与数据集大小保持正比的增长关系; 当节点数增加时,分区则会调整变得更小。较大的数据量通常需要大量的节点来存储,因此这种方法也使每个分区大小保持稳定。

当一个新节点加入集群时,它随机选择固定数量的现有分区进行分裂,然后拿走这些分区的一半数据量,将另一半数据留在原节点。随机选择可能会带来不太公平的分区分裂,但是当平均分区数量较大时(Cassandra 默认情况下,每个节点有 256 个分区),新节点最终会从现有节点中拿走相当数量的负载。Cassandra 在 3.0 时推出了改进算洁,可以避免上述不公平的分裂。

随机选择分区边界的前提要求采用基于哈希分区(可以从哈希函数产生的数字范围里设置边界)。这种方法也最符合本章开头所定义一致性哈希。一些新设计的哈希函数也可以以较低的元数据开销达到类似的效果。

:::

【高级】如何确定读写请求发往哪个节点?

:::details 要点

当数据集分布到多个节点上,需要解决一个问题:当客户端发起请求时,如何知道应该连接哪个节点?如果发生了分区再平衡,分区与节点的对应关系随之还会变化。

这其实属于一类典型的服务发现问题,任何通过网络访问的系统都有这样的需求,尤其是当服务目标支持高可用时(在多台机器上有冗余配置)。

服务发现有以下处理策略:

  1. 允许客户端链接任意的节点(例如,采用循环式的负载均衡器)。如果某节点恰好拥有所请求的分区,则直接处理该请求:否则,将请求转发到下一个合适的节点,接收答复,并将答复返回给客户端。
  2. 将所有客户端的请求都发送到一个路由层,由后者负责将请求转发到对应的分区节点上。路由层本身不处理任何请求,它仅充一个分区感知的负载均衡器。
  3. 客户端感知分区和节点分配关系。此时,客户端可以直接连接到目标节点,而不需要任何中介。

img

许多分布式数据系统依靠独立的协调服务(如 ZooKeeper )跟踪集群范围内的元数据。每个节点都向 ZooKeeper 中注册自己, ZooKeeper 维护了分区到节点的最终映射关系。其他参与者(如路由层或分区感知的客户端)可以向 ZooKeeper 订阅此信息。一旦分区发生了改变,或者添加、删除节点, ZooKeeper 就会主动通知路由层,这样使路由信息保持最新状态。

例如,HBase、SolrCloud 和 Kafka 也使用 ZooKeeper 来跟踪分区分配情况。MongoDB 有类似的设计,但它依赖于自己的配置服务器和 mongos 守护进程来充当路由层。

Cassandra 和 Redis 则采用了不同的方法,它们在节点之间使用 gossip 协议来同步群集状态的变化。请求可以发送到任何节点,由该节点负责将其转发到目标分区节点。这种方式增加了数据库节点的复杂性,但是避免了对 ZooKeeper 之类的外部协调服务的依赖。

img

:::

共识

分布式事务

扩展:

【基础】什么是事务?什么是分布式事务?

:::details 要点

事务将多个读、写操作捆绑在一起成为一个逻辑操作单元事务中的所有读写是一个执行的整体,整个事务要么成功(提交)、要么失败(中止或回滚)

在单一数据节点中,事务仅限于对单一数据库资源的访问控制,称之为本地事务。几乎所有的成熟的关系型数据库都提供了对本地事务的原生支持。

分布式事务指的是事务操作跨越多个节点,并且要求满足事务的 ACID 特性。

:::

【基础】什么是 ACID?什么是 BASE?二者有何区别?

:::details 要点

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”,此外它几乎没有承诺任何东西。

:::

【基础】什么是一致性?什么是最终一致性?

:::details 要点

一致性(Consistency)指的是多个数据副本是否能保持一致的特性。

数据一致性又可以分为以下几点:

  • 强一致性 - 数据更新操作结果和操作响应总是一致的,即操作响应通知更新失败,那么数据一定没有被更新,而不是处于不确定状态。
  • 最终一致性 - 即物理存储的数据可能是不一致的,终端用户访问到的数据可能也是不一致的,但系统经过一段时间的自我修复和修正,数据最终会达到一致。

在分布式领域,要实现强一致性,代价非常高昂。因此,有人基于 CAP 理论以及 BASE 理论,有人就提出了柔性事务的概念。柔性事务是指:在不影响系统整体可用性的情况下 (Basically Available 基本可用),允许系统存在数据不一致的中间状态 (Soft State 软状态),在经过数据同步的延时之后,达到最终一致性并不是完全放弃了 ACID,而是通过放宽一致性要求,借助本地事务来实现最终分布式事务一致性的同时也保证系统的吞吐

:::

【中级】有哪些分布式事务解决方案?各有什么利弊?

:::details 要点

分布式事务的常见方案如下:

  • 两阶段提交(2PC) - 将事务的提交过程分为两个阶段来进行处理:准备阶段和提交阶段。参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈情报决定各参与者是否要提交操作还是中止操作。
  • 三阶段提交(3PC) - 与二阶段提交不同的是,引入超时机制。同时在协调者和参与者中都引入超时机制。将二阶段的准备阶段拆分为 2 个阶段,插入了一个 preCommit 阶段,使得原先在二阶段提交中,参与者在准备之后,由于协调者发生崩溃或错误,而导致参与者处于无法知晓是否提交或者中止的“不确定状态”所产生的可能相当长的延时的问题得以解决。
  • 补偿事务(TCC)
    • Try - 操作作为一阶段,负责资源的检查和预留。
    • Confirm - 操作作为二阶段提交操作,执行真正的业务。
    • Cancel - 是预留资源的取消。
  • 本地消息表 - 在事务主动发起方额外新建事务消息表,事务发起方处理业务和记录事务消息在本地事务中完成,轮询事务消息表的数据发送事务消息,事务被动方基于消息中间件消费事务消息表中的事务。
  • 消息事务 - 基于 MQ 的分布式事务方案其实是对本地消息表的封装。
  • SAGA - Saga 事务核心思想是将长事务拆分为多个本地短事务,由 Saga 事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。

分布式事务方案对比:

  • 2PC/3PC 依赖于数据库,能够很好的提供强一致性和强事务性,但相对来说延迟比较高,比较适合传统的单体应用,在同一个方法中存在跨库操作的情况,不适合高并发和高性能要求的场景。
  • TCC 适用于执行时间确定且较短,实时性要求高,对数据一致性要求高,比如互联网金融企业最核心的三个服务:交易、支付、账务。
  • 本地消息表/消息事务都适用于事务中参与方支持操作幂等,对一致性要求不高,业务上能容忍数据不一致到一个人工检查周期,事务涉及的参与方、参与环节较少,业务上有对账/校验系统兜底。
  • Saga 事务不能保证隔离性,需要在业务层控制并发,适合于业务场景事务并发操作同一资源较少的情况。Saga 相比缺少预提交动作,导致补偿动作的实现比较麻烦,例如业务是发送短信,补偿动作则得再发送一次短信说明撤销,用户体验比较差。Saga 事务较适用于补偿动作容易处理的场景。
2PC 3PC TCC 本地消息表 MQ 事务 SAGA
数据一致性
容错性
复杂性
性能
维护成本

:::

【中级】2PC 是如何工作的?

:::details 要点

二阶段提交协议(Two-phase Commit,即 2PC)将事务的提交过程分为两个阶段来进行处理:准备阶段和提交阶段。事务的发起者称协调者,事务的执行者称参与者。二阶段提交的思路可以概括为:参与者将操作成败通知协调者,再由协调者根据所有参与者的反馈,决定提交或回滚

阶段 1:准备阶段

  1. 协调者向所有参与者发送事务内容,询问是否可以提交事务,并等待所有参与者答复。
  2. 各参与者执行事务操作,将 undo 和 redo 信息记入事务日志中(但不提交事务)。
  3. 如参与者执行成功,给协调者反馈 yes,即可以提交;如执行失败,给协调者反馈 no,即不可提交。

阶段 2:提交阶段

如果协调者收到了参与者的失败消息或者超时,直接给每个参与者发送回滚 (rollback) 消息;否则,发送提交 (commit) 消息;参与者根据协调者的指令执行提交或者回滚操作,释放所有事务处理过程中使用的锁资源。(注意:必须在最后阶段释放锁资源) 接下来分两种情况分别讨论提交阶段的过程。

情况 1,当所有参与者均反馈 yes,提交事务

img

  1. 协调者向所有参与者发出正式提交事务的请求(即 commit 请求)。
  2. 参与者执行 commit 请求,并释放整个事务期间占用的资源。
  3. 各参与者向协调者反馈 ack(应答)完成的消息。
  4. 协调者收到所有参与者反馈的 ack 消息后,即完成事务提交。

情况 2,当任何阶段 1 一个参与者反馈 no,中断事务

img

  1. 协调者向所有参与者发出回滚请求(即 rollback 请求)。
  2. 参与者使用阶段 1 中的 undo 信息执行回滚操作,并释放整个事务期间占用的资源。
  3. 各参与者向协调者反馈 ack 完成的消息。
  4. 协调者收到所有参与者反馈的 ack 消息后,即完成事务中断。

方案总结:

2PC 方案实现起来简单,实际项目中使用比较少,主要因为以下问题:

  • 性能问题 - 所有参与者在事务提交阶段处于同步阻塞状态,占用系统资源,容易导致性能瓶颈。
  • 可靠性问题 - 如果协调者存在单点故障问题,如果协调者出现故障,参与者将一直处于锁定状态。
  • 数据一致性问题 - 在阶段 2 中,如果发生局部网络问题,一部分事务参与者收到了提交消息,另一部分事务参与者没收到提交消息,那么就导致了节点之间数据的不一致。

:::

【中级】3PC 是如何工作的?

:::details 要点

三阶段提交协议(Three-phase Commit,3PC),是二阶段提交协议的改进版本,与二阶段提交不同的是,引入超时机制。同时在协调者和参与者中都引入超时机制。

阶段 1:canCommit

协调者向参与者发送 commit 请求,参与者如果可以提交就返回 yes 响应(参与者不执行事务操作),否则返回 no 响应:

  1. 协调者向所有参与者发出包含事务内容的 canCommit 请求,询问是否可以提交事务,并等待所有参与者答复。
  2. 参与者收到 canCommit 请求后,如果认为可以执行事务操作,则反馈 yes 并进入预备状态,否则反馈 no。

阶段 2:preCommit

协调者根据阶段 1 canCommit 参与者的反应情况来决定是否可以基于事务的 preCommit 操作。根据响应情况,有以下两种可能。

情况 1:阶段 1 所有参与者均反馈 yes,参与者预执行事务

img

  1. 协调者向所有参与者发出 preCommit 请求,进入准备阶段。
  2. 参与者收到 preCommit 请求后,执行事务操作,将 undo 和 redo 信息记入事务日志中(但不提交事务)。
  3. 各参与者向协调者反馈 ack 响应或 no 响应,并等待最终指令。

情况 2:阶段 1 任何一个参与者反馈 no,或者等待超时后协调者尚无法收到所有参与者的反馈,即中断事务

img

  1. 协调者向所有参与者发出 abort 请求。
  2. 无论收到协调者发出的 abort 请求,或者在等待协调者请求过程中出现超时,参与者均会中断事务。

阶段 3:doCommit

该阶段进行真正的事务提交,也可以分为以下两种情况:

情况 1:阶段 2 所有参与者均反馈 ack 响应,执行真正的事务提交

img

  1. 如果协调者处于工作状态,则向所有参与者发出 doCommit 请求。
  2. 参与者收到 doCommit 请求后,会正式执行事务提交,并释放整个事务期间占用的资源。
  3. 各参与者向协调者反馈 ack 完成的消息。
  4. 协调者收到所有参与者反馈的 ack 消息后,即完成事务提交。

情况 2:任何一个参与者反馈 no,或者等待超时后协调者尚无法收到所有参与者的反馈,即中断事务

img

  1. 如果协调者处于工作状态,向所有参与者发出 abort 请求。
  2. 参与者使用阶段 1 中的 undo 信息执行回滚操作,并释放整个事务期间占用的资源。
  3. 各参与者向协调者反馈 ack 完成的消息。
  4. 协调者收到所有参与者反馈的 ack 消息后,即完成事务中断。

注意:进入阶段 3 后,无论协调者出现问题,或者协调者与参与者网络出现问题,都会导致参与者无法接收到协调者发出的 doCommit 请求或 abort 请求。此时,参与者都会在等待超时之后,继续执行事务提交。

方案总结

  • 优点:相比二阶段提交,三阶段降低了阻塞范围,在等待超时后协调者或参与者会中断事务。避免了协调者单点问题,阶段 3 中协调者出现问题时,参与者会继续提交事务。

  • 缺点:数据不一致问题依然存在,当在参与者收到 preCommit 请求后等待 doCommit 指令时,此时如果协调者请求中断事务,而协调者无法与参与者正常通信,会导致参与者继续提交事务,造成数据不一致。

:::

【中级】TCC 是如何工作的?

:::details 要点

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 阶段服务全部正常执行, 执行确认业务逻辑操作

img

这里使用的资源一定是 Try 阶段预留的业务资源。在 TCC 事务机制中认为,如果在 Try 阶段能正常的预留资源,那 Confirm 一定能完整正确的提交。Confirm 阶段也可以看成是对 Try 阶段的一个补充,Try+Confirm 一起组成了一个完整的业务逻辑。

Cancel:当 Try 阶段存在服务执行失败, 进入 Cancel 阶段

img

Cancel 取消执行,释放 Try 阶段预留的业务资源,上面的例子中,Cancel 操作会把冻结的库存释放,并更新订单状态为取消。

方案总结

TCC 事务机制相比于上面介绍的 XA 事务机制,有以下优点:

  • 性能提升 - 具体业务来实现控制资源锁的粒度变小,不会锁定整个资源。
  • 数据最终一致性 - 基于 Confirm 和 Cancel 的幂等性,保证事务最终完成确认或者取消,保证数据的一致性。
  • 可靠性 - 解决了 XA 协议的协调者单点故障问题,由主业务方发起并控制整个业务活动,业务活动管理器也变成多点,引入集群。

缺点: TCC 的 Try、Confirm 和 Cancel 操作功能要按具体业务来实现,业务耦合度较高,提高了开发成本。

:::

【高级】本地消息表是如何工作的?

:::details 要点

本地消息表的核心思路是将分布式事务拆分成本地事务进行处理。

方案通过在事务主动发起方额外新建事务消息表,事务发起方处理业务和记录事务消息在本地事务中完成,轮询事务消息表的数据发送事务消息,事务被动方基于消息中间件消费事务消息表中的事务。

这样设计可以避免”业务处理成功 + 事务消息发送失败“,或”业务处理失败 + 事务消息发送成功“的棘手情况出现,保证 2 个系统事务的数据一致性。

事务的主动方需要额外新建事务消息表,用于记录分布式事务的消息的发生、处理状态。

整个业务处理流程如下:

img

  1. 步骤 1、事务主动方处理本地事务。 事务主动发在本地事务中处理业务更新操作和写消息表操作。 上面例子中库存服务阶段再本地事务中完成扣减库存和写消息表(图中 1、2)。
  2. 步骤 2、事务主动方通过 MQ 通知事务被动方处理事务。 消息中间件可以基于 Kafka、RocketMQ 消息队列,事务主动方法主动写消息到消息队列,事务消费方消费并处理消息队列中的消息。 上面例子中,库存服务把事务待处理消息写到消息中间件,订单服务消费消息中间件的消息,完成新增订单(图中 3 - 5)。
  3. 步骤 3、事务被动方通过 MQ 返回处理结果。 上面例子中,订单服务把事务已处理消息写到消息中间件,库存服务消费中间件的消息,并将事务消息的状态更新为已完成(图中 6 - 8)

为了数据的一致性,当处理错误需要重试,事务发送方和事务接收方相关业务处理需要支持幂等。具体保存一致性的容错处理如下:

  • 当步骤 1 处理出错,事务回滚,相当于什么都没发生。
  • 当步骤 2、步骤 3 处理出错,由于未处理的事务消息还是保存在事务发送方,事务发送方可以定时轮询超时 d 的消息数据,再次发送消息到 MQ 进行处理。事务被动方消费事务消息重试处理。
  • 如果是业务上的失败,事务被动方可以发消息给事务主动方进行回滚。
  • 如果多个事务被动方已经消费消息,事务主动方需要回滚事务时需要通知事务被动方回滚。

方案总结

方案的优点如下:

  • 从应用设计开发的角度实现了消息数据的可靠性,消息数据的可靠性不依赖于消息中间件,弱化了对 MQ 中间件特性的依赖。
  • 方案简单,容易实现。

缺点如下:

  • 与具体的业务场景绑定,耦合性高,不可复用
  • 需要额外维护消息数据的传输,占用业务系统资源。
  • 业务系统在使用关系型数据库的情况下,消息服务性能会受到关系型数据库并发性能的局限。

:::

【高级】消息事务是如何工作的?

:::details 要点

MQ 事务方案本质是利用 MQ 功能实现的本地消息表。事务消息需要消息队列提供相应的功能才能实现,Kafka 和 RocketMQ 都提供了事务相关功能。

  • Kafka 的解决方案是:直接抛出异常,让用户自行处理。用户可以在业务代码中反复重试提交,直到提交成功,或者删除之前修改的数据记录进行事务补偿。
  • RocketMQ 的解决方案是:通过事务反查机制来解决事务消息提交失败的问题。如果 Producer 在提交或者回滚事务消息时发生网络异常,RocketMQ 的 Broker 没有收到提交或者回滚的请求,Broker 会定期去 Producer 上反查这个事务对应的本地事务的状态,然后根据反查结果决定提交或者回滚这个事务。为了支撑这个事务反查机制,业务代码需要实现一个反查本地事务状态的接口,告知 RocketMQ 本地事务是成功还是失败。

RocketMQ 事务消息实现

事务消息是 Apache RocketMQ 提供的一种高级消息类型,支持在分布式场景下保障消息生产和本地事务的最终一致性。

事务消息处理流程

事务消息交互流程如下图所示。

img

  1. 生产者将消息发送至 Apache RocketMQ 服务端。
  2. Apache RocketMQ 服务端将消息持久化成功之后,向生产者返回 Ack 确认消息已经发送成功,此时消息被标记为”暂不能投递”,这种状态下的消息即为半事务消息。
  3. 生产者开始执行本地事务逻辑。
  4. 生产者根据本地事务执行结果向服务端提交二次确认结果(Commit 或是 Rollback),服务端收到确认结果后处理逻辑如下:
    • 二次确认结果为 Commit:服务端将半事务消息标记为可投递,并投递给消费者。
    • 二次确认结果为 Rollback:服务端将回滚事务,不会将半事务消息投递给消费者。
  5. 在断网或者是生产者应用重启的特殊情况下,若服务端未收到发送者提交的二次确认结果,或服务端收到的二次确认结果为 Unknown 未知状态,经过固定时间后,服务端将对消息生产者即生产者集群中任一生产者实例发起消息回查。 说明 服务端回查的间隔时间和最大回查次数,请参见 参数限制
  6. 生产者收到消息回查后,需要检查对应消息的本地事务执行的最终结果。
  7. 生产者根据检查到的本地事务的最终状态再次提交二次确认,服务端仍按照步骤 4 对半事务消息进行处理。

MQ 事务方案总结

相比本地消息表方案,MQ 事务方案优点是:

  • 业务解耦 - 消息数据独立存储 ,降低业务系统与消息系统之间的耦合。
  • 吞吐量优于本地消息表方案。

缺点是:

  • 一次消息发送需要两次网络请求 (half 消息 + commit/rollback 消息)
  • 业务处理服务需要实现消息状态回查接口

:::

【高级】SAGA 事务是如何工作的?

:::details 要点

Saga 事务的核心思想是:将长事务拆分为多个本地短事务,由 Saga 事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序依次调用补偿操作。

Saga 事务基本协议如下

  • 将长事务拆分为多个有序子事务 - 每个 Saga 事务由一系列幂等的有序子事务 (sub-transaction) Ti 组成。
  • 每个子事务 Ti 都有对应的幂等补偿动作 Ci,补偿动作用于撤销 Ti 造成的结果。

可以看到,和 TCC 相比,Saga 没有“预留”动作,它的 Ti 就是直接提交到库。

下面以下单流程为例,整个操作包括:创建订单、扣减库存、支付、增加积分 Saga 的执行顺序有两种:

img

  • 事务正常执行完成 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)

img

对应于上面第一种执行顺序,适用于必须要成功的场景失败需要进行重试,执行顺序是类似于这样的:T1, T2, …, Tj(失败), Tj(重试),…, Tn,其中 j 是发生错误的子事务 (sub-transaction)。该情况下不需要 Ci。

  • 向后恢复 (backward recovery)

img

对应于上面提到的第二种执行顺序,其中 j 是发生错误的子事务 (sub-transaction),这种做法的效果是撤销掉之前所有成功的子事务,使得整个 Saga 的执行结果撤销。

Saga 事务常见的有两种不同的实现方式:命令协调和事件编排。

命令协调

  • 命令协调 (Order Orchestrator):中央协调器负责集中处理事件的决策和业务逻辑排序。

中央协调器(Orchestrator,简称 OSO)以命令/回复的方式与每项服务进行通信,全权负责告诉每个参与者该做什么以及什么时候该做什么。

img

以电商订单的例子为例:

  1. 事务发起方的主业务逻辑请求 OSO 服务开启订单事务。
  2. OSO 向库存服务请求扣减库存,库存服务回复处理结果。
  3. OSO 向订单服务请求创建订单,订单服务回复创建结果。
  4. OSO 向支付服务请求支付,支付服务回复处理结果。
  5. 主业务逻辑接收并处理 OSO 事务处理结果回复。

中央协调器必须事先知道执行整个订单事务所需的流程(例如通过读取配置)。如果有任何失败,它还负责通过向每个参与者发送命令来撤销之前的操作来协调分布式的回滚。基于中央协调器协调一切时,回滚要容易得多,因为协调器默认是执行正向流程,回滚时只要执行反向流程即可。

事件编排

  • 事件编排 (Event Choreography0:没有中央协调器(没有单点风险)时,每个服务产生并观察其他服务的事件,并决定是否应采取行动

在事件编排方法中,第一个服务执行一个事务,然后发布一个事件。该事件被一个或多个服务进行监听,这些服务再执行本地事务并发布(或不发布)新的事件。

当最后一个服务执行本地事务并且不发布任何事件时,意味着分布式事务结束,或者它发布的事件没有被任何 Saga 参与者听到都意味着事务结束。

以电商订单的例子为例:

img

  1. 事务发起方的主业务逻辑发布开始订单事件
  2. 库存服务监听开始订单事件,扣减库存,并发布库存已扣减事件
  3. 订单服务监听库存已扣减事件,创建订单,并发布订单已创建事件
  4. 支付服务监听订单已创建事件,进行支付,并发布订单已支付事件
  5. 主业务逻辑监听订单已支付事件并处理。

事件编排是实现 Saga 模式的自然方式,它很简单,容易理解,不需要太多的代码来构建。如果事务涉及 2 至 4 个步骤,则可能是非常合适的。

方案总结

命令协调设计的优点和缺点:

优点如下:

  • 服务之间关系简单,避免服务之间的循环依赖关系,因为 Saga 协调器会调用 Saga 参与者,但参与者不会调用协调器
  • 程序开发简单,只需要执行命令/回复(其实回复消息也是一种事件消息),降低参与者的复杂性。
  • 易维护扩展,在添加新步骤时,事务复杂性保持线性,回滚更容易管理,更容易实施和测试

缺点如下:

  • 中央协调器容易处理逻辑容易过于复杂,导致难以维护。
  • 存在协调器单点故障风险。

事件/编排设计的优点和缺点

优点如下:

  • 避免中央协调器单点故障风险。
  • 当涉及的步骤较少服务开发简单,容易实现。

缺点如下:

  • 服务之间存在循环依赖的风险。
  • 当涉及的步骤较多,服务间关系混乱,难以追踪调测。

值得补充的是,由于 Saga 模型中没有 Prepare 阶段,因此事务间不能保证隔离性,当多个 Saga 事务操作同一资源时,就会产生更新丢失、脏数据读取等问题,这时需要在业务层控制并发,例如:在应用层面加锁,或者应用层面预先冻结资源。

:::

分布式锁

扩展:

【初级】什么是分布式锁?为什么需要分布式锁?

:::details 要点

在计算机科学中,锁是在并发场景下用于强行限制资源访问的一种同步机制,即用于在并发控制中通过互斥手段来保证数据同步安全。

在 Java 进程中,可以使用 Lock、synchronized 等来支持并发锁。如果是同一台机器的不同进程,想要同时操作一个共享资源(例如修改同一个文件),可以使用操作系统提供的「文件锁」或「信号量」来做互斥。这些发生在同一台机器上的互斥操作,可以称为本地锁

本地锁无法协同不同机器间的互斥操作。为了解决这个问题,需要引入分布式锁。

分布式锁,顾名思义,应用于分布式场景下,它和单进程中的锁并没有本质上的不同,只是控制对象由一个进程中的多个线程变成了多个进程中的多个线程。此外,临界区的资源也由进程内共享资源变成了分布式系统内部共享资源。

:::

【高级】实现分布式锁有哪些要点?

:::details 要点

分布式锁的实现要点如下:

  • 互斥 - 分布式锁必须是独一无二的,表现形式为:向数据存储插入一个唯一的 key,一旦有一个线程插入这个 key,其他线程就不能再插入了。
    • 保证 key 唯一性的最简单的方式是使用 UUID。
    • 此外,可以参考 Snowflake ID(雪花算法),将机器地址(IP 地址、机器 ID、MAC 地址)、Jvm 进程 ID(应用 ID、服务 ID)、时间戳等关键信息拼接起来作为唯一标识。
  • 避免死锁 - 在分布式锁的场景中,部分失败和异步网络这两个问题是同时存在的。如果一个进程获得了锁,但是这个进程与锁服务之间的网络出现了问题,导致无法通信,那么这个情况下,如果锁服务让它一直持有锁,就会导致死锁的发生。常见的解决思路都是引入超时机制,即成功申请锁后,超过一定时间,锁失效(删除 key),原因在于它们无法感知申请锁的客户端节点状态。而 ZooKeeper 由于其 znode 以目录、文件形式组织,天然就存在物理空间隔离,只要 znode 存在,即表示客户端节点还在工作,所以不存在这种问题。
  • 可重入 - 可重入指的是:同一个线程在没有释放锁之前,能否再次获得该锁。其实现方案是:只需在加锁的时候,记录好当前获取锁的节点 + 线程组合的唯一标识,然后在后续的加锁请求时,如果当前请求的节点 + 线程的唯一标识和当前持有锁的相同,那么就直接返回加锁成功;如果不相同,则按正常加锁流程处理。
  • 公平性 - 当多个线程请求同一锁时,它们必须按照请求的顺序来获取锁,即先来先得的原则。锁的公平性的实现也非常简单,对于被阻塞的加锁请求,我们只要先记录好它们的顺序,在锁被释放后,按顺序颁发就可以了。
  • 重试 - 有时候,加锁失败可能只是由于网络波动、请求超时等原因,稍候就可以成功获取锁。为了应对这种情况,加锁操作需要支持重试机制。常见的做法是,设置一个加锁超时时间,在该时间范围内,不断自旋重试加锁操作,超时后再判定加锁失败。
  • 容错 - 分布式锁若存储在单一节点,一旦该节点宕机或失联,就会导致锁失效。将分布式锁存储在多数据库实例中,加锁时并发写入 N 个节点,只要 N / 2 + 1 个节点写入成功即视为加锁成功。

:::

【中级】数据库分布式锁如何实现?

:::details 要点

数据库分布式锁原理

(1)创建锁表

1
2
3
4
5
6
7
8
9
10
11
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:

1
insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)

因为我们对 method_name 做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。

成功插入则获取锁。

(3)释放锁

当方法执行完毕之后,想要释放锁的话,需要执行以下 Sql:

1
delete from methodLock where method_name ='method_name'

数据库分布式锁小结

数据库分布式锁的问题

  • 死锁:一旦释放锁操作失败,或持有锁的机器宕机、断连,就会导致锁记录一直存在,其他线程无法再获得锁。解决办法:为锁增加失效时间字段,启动一个定时任务,隔一段时间清除一次过期的数据。
  • 非阻塞:因为 insert 操作一旦失败就会报错,因此未获得锁的线程并不会进入排队队列,要想获得锁就要再次触发加锁操作。解决办法:循环重试,直到插入成功,这么做会产生一定额外开销。
  • 非重入:同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。解决办法:在数据库表中加个字段,记录当前获得锁的节点信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
  • 单点问题:如果数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。解决办法:单点问题可以用多数据库实例,同时写入 N 个节点,N / 2 + 1 个成功就加锁成功。

数据库分布式锁的利弊

  • 优点:直接借助数据库,简单易懂。
  • 缺点:会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。此外,数据库性能易成为瓶颈。

:::

【高级】ZooKeeper 分布式锁如何实现?

:::details 要点

ZooKeeper 分布式锁实现原理

ZooKeeper 分布式锁的实现基于 ZooKeeper 的两个重要特性:

  • 顺序临时节点:ZooKeeper 的存储类似于 DNS 那样的具有层级的命名空间。ZooKeeper 节点类型可以分为持久节点(PERSISTENT)、临时节点(EPHEMERAL),每个节点还能被标记为有序性(SEQUENTIAL),一旦节点被标记为有序性,那么整个节点就具有顺序自增的特点。
  • Watch 机制:ZooKeeper 允许用户在指定节点上注册一些 Watcher,并且在特定事件触发的时候,ZooKeeper 服务端会将事件通知给用户。

下面是 ZooKeeper 分布式锁的工作流程:

  1. 创建一个目录节点,比如叫做 /locks
  2. 线程 A 想获取锁,就在 /locks 目录下创建临时顺序 zk 节点;
  3. 获取 /locks目录下所有的子节点,检查是否存在比自己顺序更小的节点:若不存在,则说明当前线程创建的节点顺序最小,获取锁成功;
  4. 此时,线程 B 试图获取锁,发现自己的节点顺序不是最小,设置监听锁号在自己前一位的节点;
  5. 线程 A 处理完,删除自己的节点。线程 B 监听到变更事件,判断自己是不是最小的节点,如果是则获得锁。

ZooKeeper 分布式锁小结

ZooKeeper 分布式锁的优点是较为可靠

  • 避免死锁:ZooKeeper 通过临时节点 + 监听机制,可以保证:如果持有临时节点的线程主动解锁或断连,Zk 会自动删除临时节点,这意味着锁的释放。所以,不存在锁永久不释放从而导致死锁的问题。
  • 单点问题:ZooKeeper 采用主从架构,并确保主从同步是强一致的,因此不会出现单点问题。

ZooKeeper 分布式锁的缺点是:加锁、解锁操作,本质上是对 ZooKeeper 的写操作,全部由 ZooKeeper 主节点负责。如果加锁、解锁的吞吐量很大,容易出现单点写入瓶颈。

:::

【高级】Redis 分布式锁如何实现?

:::details 要点

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 的组合语义呢?还真有,可以通过下面的命令来实现:

1
2
3
# 下面两条命令是等价的
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 值保持一致,以此确认锁归属于自己。解锁的伪代码如下:

1
2
if (redis.get("key") == id)
redis.del("key");

这里依然存在一个问题,由于需要在 Redis 中,先 get,后 del 操作,所以无法保证操作的原子性。为了保证原子性,可以将这段伪代码用 lua 脚本来实现,这么做的理由是 Redis 中支持原子性的执行 lua 脚本。下面是安全解锁的 lua 脚本代码:

1
2
3
4
5
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
自旋重试

有时候,加锁失败可能只是由于网络波动、请求超时等原因,稍候就可以成功获取锁。为了应对这种情况,加锁操作需要支持重试机制。常见的做法是,设置一个加锁超时时间,在该时间范围内,不断自旋重试加锁操作,超时后再判定加锁失败。

下面是一个自旋重试获取锁的伪代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 分布式锁如何实现?

:::details 要点

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 加锁、解锁需要处理多个节点,代价太高

总结来说,已知的分布式锁,无论采用什么解决方案,在极端情况下,都无法保证百分百的安全。

:::

【高级】分布式锁如何进行技术选型?

:::details 要点

下面是主流分布式锁技术方案的对比,可以在技术选型时作为参考:

数据库分布式锁 Redis 分布式锁 ZooKeeper 分布式锁
方案要点 1. 维护一张锁表,为锁的唯一标识字段添加唯一性约束。
2. 只要 insert 成功,即视为加锁成功。
set lockKey randomValue NX PX/EX time 当且仅当 key 不存在时才可以写入,并且设定超时时间,以避免死锁。 加锁本质上是在 zk 中指定目录创建顺序临时接节点,序号最小即加锁成功。节点删除时,有监听通知机制告知申请锁的线程。
方案难度 实现简单、易于理解 较为简单,但要使其更可靠,需要有一些完善策略 应用简单,但 zk 内部机制并不简单
性能 性能最差,易成为瓶颈 性能最高 性能弱于 Redis
可靠性 有锁表的风险 较为可靠(需要一些完善策略) 可靠性最高
适用场景 一般不采用 适用于高并发的场景 适用于要求可靠,但并发量不高的场景
开源实现 Redisson Apache Curator

:::

分布式 ID

扩展:

【初级】什么是分布式 ID?为什么需要分布式 ID?

:::details 要点

ID是Identity的缩写,用于唯一的标识一条数据。分布式 ID,顾名思义,是用于在分布式系统中唯一标识数据的ID

传统数据库基本都支持针对单表生成唯一性的自增主键。随着数据的膨胀,单机成为了性能和容量的瓶颈。为了解决这个问题,有了分库分表技术。分库分表所要面临的第一个问题是:数据分布在不同机器上,数据库无法保证多个节点上产生的主键唯一。 这就需要用到分布式 ID 了,它起到了分布式系统中全局 ID 的作用。

:::

【中级】有哪些生成分布式 ID 的方式?

:::details 要点

生成分布式 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 类型提供 INCRINCRBY 命令将 key 中储存的数字原子递增
      • 优点:高性能、有序
      • 缺点:和数据库自增序列方案的缺点类似
    • ZooKeeper 顺序节点 - 利用 ZooKeeper 数据模型中的顺序节点作为分布式 ID。
      • 优点:简单、可靠性高
      • 缺点:性能不高
  • Snowflak(雪花算法) - Snowflake ID 生成过程包含多个组件:时间戳、机器 ID 和序列号。第一位未使用,以确保 ID 正确。此生成器不需要通过网络与 ID 生成器通信,因此速度快且可扩展。Snowflake 的实现各不相同。例如,可以将数据中心 ID 添加到“MachineID”组件中,以保证全局唯一性。

:::

分布式会话

【初级】Cookie 和 Session 有什么区别?

:::details 要点

由于 Http 是一种无状态的协议,服务器单从网络连接上无从知道客户身份。

所以服务器与浏览器为了进行会话跟踪(知道是谁在访问我),就必须主动的去维护一个状态,这个状态用于告知服务端前后两个请求是否来自同一浏览器。而这个状态需要通过 cookie 或者 session 去实现。

Cookie 实际上是存储在用户浏览器上的文本信息,并保留了各种跟踪的信息。生成 Cookie 后,用户后续每次请求都会携带 Cookie。

Cookie 通常有大小限制(4KB)。用户可以选择在浏览器中禁用 Cookie。

一个简单的 cookie 设置如下:

1
Set-Cookie: <cookie-name>=<cookie-value>
1
2
3
4
5
6
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 超时都会失效。

:::

:::details 要点

既然服务端是根据 Cookie 中的信息判断用户是否登录,那么如果浏览器中禁止了 Cookie,如何保障整个机制的正常运转。

  • 第一种方案,每次请求中都携带一个 SessionID 的参数,也可以 Post 的方式提交,也可以在请求的地址后面拼接 xxx?SessionID=123456...

  • 第二种方案,Token 机制。Token 机制多用于 App 客户端和服务器交互的模式,也可以用于 Web 端做用户状态管理。

Token 的意思是“令牌”,是服务端生成的一串字符串,作为客户端进行请求的一个标识。Token 机制和 Cookie 和 Session 的使用机制比较类似。

当用户第一次登录后,服务器根据提交的用户信息生成一个 Token,响应时将 Token 返回给客户端,以后客户端只需带上这个 Token 前来请求数据即可,无需再次登录验证。

:::

【中级】分布式 Session 有几种实现方案?

:::details 要点

在分布式场景下,一个用户的 Session 如果只存储在一个服务器上,那么当负载均衡器把用户的下一个请求转发到另一个服务器上,该服务器没有用户的 Session,就可能导致用户需要重新进行登录等操作。

分布式 Session 的几种实现策略:

  1. 粘性 session
  2. 应用服务器间的 session 复制共享
  3. 基于缓存的 session 共享 ✅

推荐:基于缓存的 session 共享

粘性 Session

粘性 Session(Sticky Sessions)需要配置负载均衡器,使得一个用户的所有请求都路由到一个服务器节点上,这样就可以把用户的 Session 存放在该服务器节点中。

缺点:当服务器节点宕机时,将丢失该服务器节点上的所有 Session

Session 复制共享

Session 复制共享(Session Replication)在服务器节点之间进行 Session 同步操作,这样的话用户可以访问任何一个服务器节点。

缺点:占用过多内存同步过程占用网络带宽以及服务器处理器时间

基于缓存的 session 共享

使用一个单独的存储服务器存储 Session 数据,可以存在 MySQL 数据库上,也可以存在 Redis 或者 Memcached 这种内存型数据库。

缺点:需要去实现存取 Session 的代码。

:::

参考资料

Dubbo 面试之架构

调用流程

【简单】Dubbo 支持哪些序列化方式?

  • Hessian(默认)
    • 特点:二进制格式,速度较快,体积较小
    • 适用场景:通用 RPC 调用(Dubbo 默认方案)
    • 缺点:对复杂对象支持有限
  • JSON
    • 特点:文本格式,可读性强,跨语言支持好
    • 适用场景:前后端交互、多语言系统
    • 缺点:性能较差,数据体积大
  • Java 原生序列化
    • 特点:JDK 内置,使用简单
    • 适用场景:Java 单体应用调试
    • 缺点:性能差,体积大,仅限 Java
  • Kryo
    • 特点:高性能二进制,速度极快,体积小
    • 适用场景:高并发、低延迟场景
    • 缺点:API 复杂,需注册类
  • Protobuf(推荐)
    • 特点:Google 出品,高效跨语言,可扩展
    • 适用场景:微服务跨语言通信
    • 缺点:需预定义。proto 文件
  • FST
    • 特点:类似 Kryo,高性能二进制
    • 适用场景:替代 Hessian 的高性能需求
    • 缺点:兼容性较弱

选型建议

序列化方式 性能 体积 跨语言 易用性 适用场景
Hessian 部分 默认 RPC 调用
JSON 前后端交互
Java 调试/兼容旧系统
Kryo 纯 Java 高性能场景
Protobuf 跨语言微服务(推荐)
FST 替代 Hessian 优化性能

推荐选择

  • 默认场景 → Hessian
  • 跨语言微服务 → Protobuf
  • 纯 Java 高性能 → Kryo/FST
  • 调试/兼容 → Java 原生
  • 前后端交互 → JSON

【简单】Dubbo 支持哪些通信协议?

Dubbo 框架提供了自定义的高性能 RPC 通信协议:基于 HTTP/2 的 Triple 协议 和 基于 TCP 的 Dubbo2 协议。除此之外,Dubbo 框架支持任意第三方通信协议,如官方支持的 gRPC、Thrift、REST、JsonRPC、Hessian2 等,更多协议可以通过自定义扩展实现。这对于微服务实践中经常要处理的多协议通信场景非常有用。

Dubbo 框架不绑定任何通信协议,在实现上 Dubbo 对多协议的支持也非常灵活,它可以让你在一个应用内发布多个使用不同协议的服务,并且支持用同一个 port 端口对外发布所有协议。

protocols

Dubbo 官方支持的协议如下:

  • HTTP/2 (Triple) - Dubbo3 新增,基于 HTTP/2 并且完全兼容 gRPC 协议,原生支持 Streaming 通信语义,Triple 可同时运行在 HTTP/1 和 HTTP/2 传输协议之上,让你可以直接使用 curl、浏览器访问后端 Dubbo 服务。自 Triple 协议开始,Dubbo 还支持基于 Protocol Buffers 的服务定义与数据传输,但 Triple 实现并不绑定 IDL。Triple 具备更好的网关、代理穿透性,因此非常适合于跨网关、代理通信的部署架构,如服务网格等。更多详情见:Triple 协议详情见 Triple 协议开发任务Triple 设计思路与协议规范
  • Dubbo2 - Dubbo2 协议是基于 TCP 传输层协议之上构建的一套 RPC 通信协议,具有紧凑、灵活、高性能等特点。它是 Dubbo 的默认通信协议,采用单一长连接和 NIO 异步通信,基于 hessian 作为序列化协议。Dubbo2 协议适合于小数据量大并发的服务调用,以及服务消费者机器数远大于服务提供者机器数的情况。反之,Dubbo 缺省协议不适合传送大数据量的服务,比如传文件,传视频等,除非请求量很低。Dubbo 协议详情见 Dubbo2 协议开发任务Dubbo2 设计思路与协议规范
  • gRPC - gRPC 是谷歌开源的基于 HTTP/2 的通信协议。gRPC 的定位是通信协议与实现,是一款纯粹的 RPC 框架,而 Dubbo 定位是一款微服务框架,为微服务实践提供解决方案。在 Dubbo 体系下使用 gRPC 协议是一个非常高效和轻量的选择,它让你既能使用原生的 gRPC 协议通信,又避免了基于 gRPC 进行二次定制与开发的复杂度。gRPC 协议详情见 gRPC over Dubbo 示例
  • REST - 微服务领域常用的一种通信模式是 HTTP + JSON,包括 Spring Cloud、Microprofile 等一些主流的微服务框架都默认使用的这种通信模式,Dubbo 同样提供了对基于 HTTP 的编程、通信模式的支持。REST 协议详情见 HTTP over Dubbo 示例Dubbo 与 Spring Cloud 体系互通
  • Hessian - hessian 协议用于集成 Hessian 的服务,Hessian 底层采用 Http 通讯,采用 Servlet 暴露服务,Dubbo 缺省内嵌 Jetty 作为服务器实现。Dubbo 的 Hessian 协议可以和原生 Hessian 服务互操作,即:
    • 提供者用 Dubbo 的 Hessian 协议暴露服务,消费者直接用标准 Hessian 接口调用
    • 或者提供方用标准 Hessian 暴露服务,消费方用 Dubbo 的 Hessian 协议调用。
  • Thrift - dubbo 支持的 thrift 协议是对 thrift 原生协议的扩展,在原生协议的基础上添加了一些额外的头信息,比如 service name,magic number 等。使用 dubbo thrift 协议同样需要使用 thrift 的 idl compiler 编译生成相应的 java 代码。

扩展:Dubbo 官方文档之通信协议

【困难】动态代理在 Dubbo 中有哪些应用?

Dubbo 广泛使用 动态代理 技术来实现 远程调用(RPC)延迟加载(Lazy Loading)AOP 增强(如负载均衡、容错等),主要涉及 JDK 动态代理CGLIB 两种方式。

核心应用场景

(1)远程调用(RPC)

Dubbo 的 核心 RPC 调用 依赖动态代理。消费者(Consumer) 调用服务时,Dubbo 生成一个 代理对象Proxy),代理负责:

  • 封装网络通信(序列化/反序列化、TCP 传输)。
  • 负载均衡(从多个 Provider 中选择一个)。
  • 容错机制(失败重试、熔断降级)。

示例代码(消费者调用远程服务):

1
2
3
4
5
6
@Reference  // Dubbo 自动生成代理
private OrderService orderService;

public void createOrder() {
orderService.create(); // 实际调用的是代理对象,代理处理远程通信
}

底层实现

  • 如果服务是 接口 → 使用 JDK 动态代理(基于 InvocationHandler)。
  • 如果服务是 (无接口)→ 使用 CGLIB 生成子类代理。

(2)延迟加载(Lazy Loading)

Dubbo 支持 懒初始化,即服务 首次调用时才实例化,减少启动时间。

  • 代理拦截:Dubbo 返回代理对象,真正调用时才初始化真实服务
  • 适用场景:初始化成本高的服务(如数据库连接、大数据计算)。

配置方式(XML/注解):

1
<dubbo:service interface="com.example.UserService" lazy="true" />

1
2
3
@Service
@org.apache.dubbo.config.annotation.Service(lazy = true)
public class UserServiceImpl implements UserService {}

(3)AOP 增强(Filter 机制)

Dubbo 的 Filter 链(如监控、日志、权限校验)基于动态代理实现:

  • 代理包装真实服务,在调用前后插入逻辑(类似 Spring AOP)。
  • 示例
    • MonitorFilter:统计调用耗时。
    • TokenFilter:权限校验。

实现方式

1
2
3
4
5
6
7
8
9
public class MyFilter implements Filter {
@Override
public Result invoke(Invoker<?> invoker, Invocation invocation) {
System.out.println("Before RPC call");
Result result = invoker.invoke(invocation); // 真实调用
System.out.println("After RPC call");
return result;
}
}

Dubbo 会通过 代理机制 自动应用这些 Filter。

  1. JDK 动态代理 vs. CGLIB
对比项 JDK 动态代理 CGLIB
适用场景 代理接口(如 Dubbo 的 @Reference 代理类(无接口)
性能 较快(基于反射) 略慢(生成子类)
依赖 无需额外库 需引入 cglib 依赖
示例 Proxy.newProxyInstance() Enhancer.create()

Dubbo 默认优先使用 JDK 动态代理,如果目标类没有接口,则降级为 CGLIB。

动态代理的底层实现

(1)JDK 动态代理(接口代理)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class JdkProxyDemo {
public static void main(String[] args) {
OrderService proxy = (OrderService) Proxy.newProxyInstance(
OrderService.class.getClassLoader(),
new Class[]{OrderService.class},
(proxyObj, method, args1) -> {
System.out.println("Before method call");
// 模拟远程调用
Object result = "Mock Result";
System.out.println("After method call");
return result;
}
);
proxy.createOrder(); // 调用代理方法
}
}

(2)CGLIB(类代理)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class CglibProxyDemo {
public static void main(String[] args) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(UserServiceImpl.class);
enhancer.setCallback((MethodInterceptor) (obj, method, args1, proxy) -> {
System.out.println("Before method call");
Object result = proxy.invokeSuper(obj, args1);
System.out.println("After method call");
return result;
});
UserService proxy = (UserService) enhancer.create();
proxy.getUser(); // 调用代理方法
}
}

总结

应用场景 动态代理的作用 实现方式
远程调用(RPC) 封装网络通信、负载均衡、容错 JDK/CGLIB
延迟加载 首次调用时才初始化服务 JDK/CGLIB
AOP(Filter) 实现日志、监控、权限等增强逻辑 JDK/CGLIB

工作原理

【中等】Dubbo 的工作原理是什么?

Dubbo 通过 注册中心解耦 + 动态代理透明化调用 + 集群容错保障可用性,实现高效 RPC 通信。

核心架构

  • Provider:暴露服务接口,注册到注册中心
  • Consumer:从注册中心订阅服务,发起远程调用
  • Registry:服务发现与元数据管理(如 Zookeeper/Nacos)
  • Monitor :统计调用次数和耗时

调用流程

  1. 服务注册:Provider 启动 → 注册服务到 Registry
  2. 服务发现:Consumer 启动 → 从 Registry 订阅 Provider 列表
  3. 远程调用:Consumer 通过 动态代理 发起调用 → 经负载均衡选择 Provider → 网络传输(Netty/HTTP)
  4. 结果返回:Provider 处理请求 → 返回结果给 Consumer

关键机制

  • 动态代理:生成接口代理类,屏蔽远程调用细节
  • 负载均衡:内置随机/轮询/最少活跃调用等算法
  • 集群容错:失败自动切换(Failover)/快速失败(Failfast)等策略
  • 异步通信:基于 Netty 的 NIO 长连接,支持异步调用
  • SPI 机制:可插拔式扩展(如替换注册中心/协议)
  • Filter 链:支持 AOP 式拦截(日志/限流/鉴权)

性能优化设计

  • 元数据缓存:Consumer 本地缓存 Provider 列表
  • 长连接复用:减少 TCP 握手开销
  • 线程池隔离:业务逻辑与 IO 线程分离

【简单】Dubbo 有哪些核心组件?

Dubbo 是一个高性能分布式服务框架,它有三个核心组件

  • Provider:服务提供者。
    • 启动时,向注册中心注册自己提供的服务。
    • 接收 Consumer 的远程调用请求并返回结果。
  • Consumer:服务消费者。
    • 启动时,向注册中心订阅自己所需的服务,获取 Provider 地址列表。
    • 通过负载均衡策略选择 Provider 发起远程调用。
  • Registry:注册中心。
    • 负责服务的注册与发现(如 Zookeeper、Nacos)。
    • 动态维护 Provider 和 Consumer 的映射关系。

扩展组件

  • Monitor:监控中心。统计服务调用次数、耗时、成功率等指标,便于运维和优化。
  • Container:服务容器。管理服务生命周期(如 Spring 容器),提供依赖注入和环境支持。
  • Protocol:通信协议。定义数据传输方式(如 Dubbo 协议、HTTP、REST),影响性能和兼容性。
  • Cluster:集群容错。提供故障转移(Failover)、快速失败(Failfast)等机制,保障高可用。

重要知识点总结

  • 注册中心负责服务地址的注册与查找,相当于元数据管理服务,服务提供者和消费者只在启动时与注册中心交互,注册中心不转发请求,压力较小。
  • 监控中心负责统计各服务调用次数,调用时间等,统计先在内存汇总后每分钟一次发送到监控中心服务器,并以报表展示。
  • 注册中心,服务提供者,服务消费者三者之间均为长连接,监控中心除外。
  • 注册中心通过长连接感知服务提供者的存在,服务提供者宕机,注册中心将立即推送事件通知消费者。
  • 注册中心和监控中心全部宕机,不影响已运行的提供者和消费者,消费者在本地缓存了提供者列表。
  • 注册中心和监控中心都是可选的,服务消费者可以直连服务提供者。
  • 服务提供者无状态,任意一台宕掉后,不影响使用。
  • 服务提供者全部宕掉后,服务消费者应用将无法使用,并无限次重连等待服务提供者恢复。

【困难】Dubbo 框架整体如何设计的?

Dubbo 的整体设计原则如下:

  • 采用 Microkernel + Plugin 模式,Microkernel 只负责组装 Plugin,Dubbo 自身的功能也是通过扩展点实现的,也就是 Dubbo 的所有功能点都可被用户自定义扩展所替换。
  • 采用 URL 作为配置信息的统一格式,所有扩展点都通过传递 URL 携带配置信息。

::: info 整体设计

:::

总设计图

  • 图中左边淡蓝背景的为服务消费方使用的接口,右边淡绿色背景的为服务提供方使用的接口,位于中轴线上的为双方都用到的接口。
  • 图中从下至上分为十层,各层均为单向依赖,右边的黑色箭头代表层之间的依赖关系,每一层都可以剥离上层被复用,其中,Service 和 Config 层为 API,其它各层均为 SPI。
  • 图中绿色小块的为扩展接口,蓝色小块为实现类,图中只显示用于关联各层的实现类。
  • 图中蓝色虚线为初始化过程,即启动时组装链,红色实线为方法调用过程,即运行时调时链,紫色三角箭头为继承,可以把子类看作父类的同一个节点,线上的文字为调用的方法。

::: info 分层架构

:::

  • config 配置层:对外配置接口,以 ServiceConfigReferenceConfig 为中心,可以直接初始化配置类,也可以通过 Spring 解析配置生成配置类
  • proxy 服务代理层:服务接口透明代理,生成服务的客户端 Stub 和服务器端 Skeleton,以 ServiceProxy 为中心,扩展接口为 ProxyFactory
  • registry 注册中心层:封装服务地址的注册与发现,以服务 URL 为中心,扩展接口为 RegistryFactoryRegistryRegistryService
  • cluster 路由层:封装多个提供者的路由及负载均衡,并桥接注册中心,以 Invoker 为中心,扩展接口为 ClusterDirectoryRouterLoadBalance
  • monitor 监控层:RPC 调用次数和调用时间监控,以 Statistics 为中心,扩展接口为 MonitorFactoryMonitorMonitorService
  • protocol 远程调用层:封装 RPC 调用,以 InvocationResult 为中心,扩展接口为 ProtocolInvokerExporter
  • exchange 信息交换层:封装请求响应模式,同步转异步,以 RequestResponse 为中心,扩展接口为 ExchangerExchangeChannelExchangeClientExchangeServer
  • transport 网络传输层:抽象 mina 和 netty 为统一接口,以 Message 为中心,扩展接口为 ChannelTransporterClientServerCodec
  • serialize 数据序列化层:可复用的一些工具,扩展接口为 SerializationObjectInputObjectOutputThreadPool

::: info 组件间的关系

:::

  • 在 RPC 中,**Protocol 是核心层,也就是只要有 Protocol + Invoker + Exporter 就可以完成非透明的 RPC 调用**,然后在 Invoker 的主过程上设置拦截点(Filter)。
  • 图中的 ConsumerProvider 是抽象概念,只是想让看图者更直观的了解哪些类分属于客户端与服务器端,不用 Client 和 Server 的原因是 Dubbo 在很多场景下都使用 ProviderConsumer、Registry、Monitor 划分逻辑拓普节点,保持统一概念。
  • 而 Cluster 是外围概念,所以 Cluster 的目的是将多个 Invoker 伪装成一个 Invoker,这样其它人只要关注 Protocol 层 Invoker 即可,加上 Cluster 或者去掉 Cluster 对其它层都不会造成影响,因为只有一个提供者时,是不需要 Cluster 的。
  • Proxy 层封装了所有接口的透明化代理。在其它层都以 Invoker 为中心,只有到了暴露给用户使用时,才用 ProxyInvoker 转成接口,或将接口实现转成 Invoker,也就是去掉 Proxy 层 RPC 是可以 Run 的,只是不那么透明,不那么看起来像调本地服务一样调远程服务。
  • 而 Remoting 实现是 Dubbo 协议的实现,如果你选择 RMI 协议,整个 Remoting 都不会用上,Remoting 内部再划为 Transport 传输层和 Exchange 信息交换层,Transport 层只负责单向消息传输,是对 Mina, Netty, Grizzly 的抽象,它也可以扩展 UDP 传输,而 Exchange 层是在传输层之上封装了 Request-Response 语义
  • Registry 和 Monitor 实际上不算一层,而是一个独立的节点,只是为了全局概览,用层的方式画在一起。

::: info 核心组件交互

:::

依赖关系

  • 图中小方块 Protocol, Cluster, Proxy, Service, Container, Registry, Monitor 代表层或模块,蓝色的表示与业务有交互,绿色的表示只对 Dubbo 内部交互。
  • 图中背景方块 Consumer, Provider, Registry, Monitor 代表部署逻辑拓扑节点。
  • 图中蓝色虚线为初始化时调用,红色虚线为运行时异步调用,红色实线为运行时同步调用。
  • 图中只包含 RPC 的层,不包含 Remoting 的层,Remoting 整体都隐含在 Protocol 中。

::: info 调用链路

:::

展开总设计图的红色调用链,如下:

总设计图的红色调用链

扩展阅读:Dubbo 框架设计

【中等】Dubbo 中用到哪些设计模式?

单例模式

Dubbo 中大量使用单例模式来确保一些特定类在整个应用中只有一个实例。举例来说,ExtensionLoader 是 Dubbo SPI 加载器,负责管理 Dubbo 中的扩展点。ExtensionLoader 使用了单例模式来确保 ExtensionLoader 在整个应用中只有一个实例。

1
2
3
4
5
6
7
8
9
10
11
12
public class ExtensionLoader<T> {
private static final ConcurrentMap<Class<?>, ExtensionLoader<?>> EXTENSION_LOADERS = new ConcurrentHashMap<>();

public static <T> ExtensionLoader<T> getExtensionLoader(Class<T> type) {
ExtensionLoader<T> loader = (ExtensionLoader<T>) EXTENSION_LOADERS.get(type);
if (loader == null) {
EXTENSION_LOADERS.putIfAbsent(type, new ExtensionLoader<T>(type));
loader = (ExtensionLoader<T>) EXTENSION_LOADERS.get(type);
}
return loader;
}
}

责任链模式

Dubbo 的调用链是基于责任链模式组织起来的。责任链中的每个节点实现 Filter 接口,然后由 ProtocolFilterWrapper 将所有 Filter 串连起来。Dubbo 的许多功能都是通过 Filter 扩展实现的,比如监控、日志、缓存、安全等。

装饰器模式

Dubbo 中大量用到了修饰器模式。比如 ProtocolFilterWrapper 类是对 Protocol 类的修饰。在 exportrefer 方法中,配合责任链模式,把 Filter 组装成责任链,实现对 Protocol 功能的修饰。其他还有 ProtocolListenerWrapperListenerInvokerWrapperInvokerWrapper 等。

策略模式

Dubbo 中的负载均衡器采用了策略模式,以便灵活的替换算法。在 Dubbo 中,LoadBalance 接口定义了负载均衡的策略接口,它有以下具体实现:AdaptiveLoadBalanceConsistentHashLoadBalanceLeastActiveLoadBalanceRandomLoadBalanceRoundRobinLoadBalanceServerCpuLoadBalance2ShortestResponseLoadBalance

1
2
3
public interface LoadBalance {
<T> Invoker<T> select(List<Invoker<T>> invokers, URL url, Invocation invocation) throws RpcException;
}

抽象工厂模式

Dubbo 中的 ProxyFactory 采用了抽象工厂模式AbstractProxyFactory 实现了 ProxyFactory 接口,并且有 JdkProxyFactoryJavassistProxyFactory 两个子类,可以分别生产不同序列化方式的 ProxyInvoke

代理模式

Dubbo 使用代理模式隐藏远程调用的细节。ProxyFactory 接口及其实现类负责为服务创建代理对象,使得调用者无需关心实际的服务调用过程。

适配器模式

Dubbo 中 RegistryProtocol 类负责将不同的注册中心协议适配到统一的接口 Protocol 中,以便在不同的注册中心下工作。RegistryProtocol 通过适配不同的注册中心实现,使得 Dubbo 能够在多种注册中心协议下工作,而不必修改客户端代码。

扩展:长文详解:DUBBO 源码使用了哪些设计模式

可用性设计

【困难】Dubbo 如何保证服务的高可用性?

Dubbo 高可用设计核心思想:

  • 冗余:多注册中心、多服务节点
  • 故障检测:心跳检测 + 主动剔除
  • 容错处理:超时 + 重试 + 容错处理策略
  • 流量控制:限流 + 熔断 + 降级
  • 隔离:线程/协议/分组隔离避免连锁故障

实际生产中需结合 压测监控 持续调优参数(如超时时间、重试次数)。

Dubbo 通过 多级容错设计 确保服务高可用,主要依赖以下机制:

注册中心容错

机制 说明 配置示例
多注册中心 同时接入多个注册中心(如 Zookeeper + Nacos),避免单点故障。 <dubbo:registry address="zookeeper://ip1:2181,nacos://ip2:8848" />
心跳检测 注册中心定时检测服务存活状态,自动剔除失效节点(默认 30 秒)。 <dubbo:provider heartbeat="60000" />
本地缓存 消费者缓存服务列表,即使注册中心宕机仍能调用服务。 默认启用,无需配置

服务调用容错

策略 说明 适用场景
集群容错 - failover(默认):失败自动切换其他节点
- failfast:快速失败
- failsafe:忽略异常
<dubbo:reference cluster="failover" retries="2" />
负载均衡 - random(默认随机)
- roundrobin(轮询)
- leastactive(最少活跃调用)
<dubbo:reference loadbalance="leastactive" />
限流、熔断、降级 集成 Sentinel/Hystrix,在服务异常时触发熔断或返回降级结果。 需额外引入依赖并配置规则

通信容错

机制 说明
长连接复用 默认复用 TCP 长连接,减少握手开销,通过心跳保活(heartbeat 参数控制)。
多协议支持 支持 Dubbo/HTTP/gRPC 等协议,根据网络环境选择最优协议。
IO 线程隔离 业务逻辑与网络 IO 线程分离,避免阻塞导致雪崩。

运维级保障

措施 说明
灰度发布 通过路由规则(如 tag)逐步切流,避免全量发布风险。
压力测试 使用 JMeter 模拟高并发,提前暴露性能瓶颈。
日志监控 对接 Prometheus + Grafana 监控 QPS/RT/错误率,实时告警。

典型配置示例

服务提供者(超时与重试):

1
2
3
4
<dubbo:service interface="com.example.UserService"
timeout="3000"
retries="2"
cluster="failover" />

服务消费者(熔断降级):

1
2
3
4
5
@Reference(version = "1.0.0",
timeout = 2000,
cluster = "failfast",
mock = "com.example.UserServiceMock") // 降级实现类
private UserService userService;

性能优化设计

【困难】Dubbo 有哪些性能优化设计?

Dubbo 作为一款高性能的 Java RPC 框架,在性能优化方面做了许多设计,主要包括以下几个方面:

通信

  • Netty NIO 异步通信:默认使用 Netty 作为通信框架,基于 NIO 实现异步非阻塞通信。
  • 长连接复用:避免频繁建立和断开连接的开销。
  • 支持多种协议:(Dubbo2、Http2、Thrift等)
  • 序列化优化
    • 支持多种高性能序列化协议(Hessian2、Kryo、FST、Protobuf等)
    • 提供序列化缓存机制

线程模型

  • Dispatcher 线程派发策略:提供多种线程派发策略(all, direct, message, execution, connection)。
  • 线程池配置:可配置不同业务使用不同线程池,避免相互影响。
  • IO线程与业务线程分离:Netty的IO线程只负责编解码,业务逻辑交给业务线程池。
  • 异步调用:使用CompletableFuture或回调避免线程阻塞,提升吞吐量。

路由与负载均衡

  • 支持多种负载均衡算法:随机(Random)、轮询(RoundRobin)、最少活跃(LeastActive)、一致性哈希(ConsistentHash)等,可以根据业务场景灵活选择。
  • 服务路由、分组:可以根据业务模块进行隔离
  • 服务预热:新上线的服务提供者逐步增加流量权重

其他优化

  • 流量控制:可以集成 Hystrix/Sentinel,实现限流、熔断、降级。
  • 参数回调:支持参数级别的回调,减少不必要的数据传输
  • 本地存根:客户端生成服务存根,部分逻辑可在本地执行
  • 本地伪装:服务降级时返回本地Mock数据
  • 动态代理:支持 JDK 动态代理和 CGLIB 动态代理
  • 服务引用缓存:避免重复创建代理对象
  • 结果缓存:支持方法级结果缓存,减少重复调用

【中等】Dubbo 如何支持异步调用?

建议对耗时超过 100ms 的接口采用异步调用,同时做好超时控制和异常处理。

不关心返回值异步调用

1
2
3
4
5
6
7
8
9
10
// 服务接口声明
public interface UserService {
CompletableFuture<User> getUserAsync(Long id);
}

// 消费者调用(自动识别Future返回类型)
UserService userService = ...;
CompletableFuture<User> future = userService.getUserAsync(1L);

// 不阻塞主线程,继续其他操作

关心返回值异步调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 开启异步模式(需配置)
RpcContext.getContext().setAttachment("async", "true");

// 发起调用(立即返回null)
UserService userService = ...;
userService.getUser(1L);

// 获取Future对象
Future<User> future = RpcContext.getContext().getFuture();

// 异步回调
future.whenComplete((user, exception) -> {
if (exception != null) {
// 异常处理
} else {
// 使用结果
}
});

注解配置方式

1
2
3
4
5
6
// 服务提供方接口定义
@DubboService
public interface OrderService {
@AsyncFor(interfaceClass = OrderService.class)
CompletableFuture<Order> createOrderAsync(OrderReq req);
}

配置注意事项

服务端配置:

1
<dubbo:protocol name="dubbo" threadpool="cached" threads="200"/>

消费者配置:

1
2
3
<dubbo:reference interface="com.example.UserService">
<dubbo:method name="getUser" async="true"/>
</dubbo:reference>

性能调优参数:

1
2
3
# 异步线程池配置
dubbo.consumer.threadpool=fixed
dubbo.consumer.threads=50

关键特性对比

特性 同步调用 异步调用
调用方式 阻塞等待返回结果 立即返回 Future 对象
性能 吞吐量较低 高吞吐量
适用场景 短耗时接口 长耗时/高并发接口

实现原理

  • 基于 Netty 的 NIO 非阻塞通信
  • 消费方发起请求后立即返回 Future
  • 服务方处理完成后通过回调通知结果

适用场景

  • 高并发且响应时间较长的服务
  • 需要并行调用多个服务的场景
  • 不要求严格顺序执行的业务逻辑

注意事项

  • 异步方法需返回CompletableFuture类型
  • 避免在回调中执行阻塞操作
  • 超时时间需合理设置(建议比同步调用略长)

【困难】Dubbo 中的线程模型是如何设计的?

:::info Consumer 线程模型

:::

对 2.7.5 版本之前的 Dubbo 应用,尤其是一些消费端应用,当面临需要消费大量服务且并发数比较大的大流量场景时(典型如网关类场景),经常会出现消费端线程数分配过多的问题,具体问题讨论可参见 Need a limited Threadpool in consumer side #2013

改进后的消费端线程池模型,通过复用业务端被阻塞的线程,很好的解决了这个问题。

老的线程池模型

消费端线程池.png

我们重点关注 Consumer 部分:

  1. 业务线程发出请求,拿到一个 Future 实例。
  2. 业务线程紧接着调用 future.get 阻塞等待业务结果返回。
  3. 当业务数据返回后,交由独立的 Consumer 端线程池进行反序列化等处理,并调用 future.set 将反序列化后的业务结果置回。
  4. 业务线程拿到结果直接返回

当前线程池模型

消费端线程池新.png

  1. 业务线程发出请求,拿到一个 Future 实例。
  2. 在调用 future.get() 之前,先调用 ThreadlessExecutor.wait()wait 会使业务线程在一个阻塞队列上等待,直到队列中被加入元素。
  3. 当业务数据返回后,生成一个 Runnable Task 并放入 ThreadlessExecutor 队列
  4. 业务线程将 Task 取出并在本线程中执行:反序列化业务数据并 setFuture
  5. 业务线程拿到结果直接返回

这样,相比于老的线程池模型,由业务线程自己负责监测并解析返回结果,免去了额外的消费端线程池开销。

:::info Provider 线程模型

:::

Dubbo 协议的和 Triple 协议目前的线程模型还并没有对齐。

Dubbo 对 channel 上的操作抽象成了五种行为:

  • 建立连接(connected) - 主要是的职责是在 channel 记录 read、write 的时间,以及处理建立连接后的回调逻辑,比如 dubbo 支持在断开后自定义回调的 hook(onconnect),即在该操作中执行。
  • 断开连接(disconnected) - 主要是的职责是在 channel 移除 read、write 的时间,以及处理端开连接后的回调逻辑,比如 dubbo 支持在断开后自定义回调的 hook(ondisconnect),即在该操作中执行。
  • 发送消息(sent) - 包括发送请求和发送响应。记录 write 的时间。
  • 接收消息(received) - 包括接收请求和接收响应。记录 read 的时间。
  • 异常捕获(caught) - 用于处理在 channel 上发生的各类异常。

Dubbo 框架的线程模型与以上这五种行为息息相关,Dubbo 协议 Provider 线程模型可以分为五类,也就是 AllDispatcher、DirectDispatcher、MessageOnlyDispatcher、ExecutionDispatcher、ConnectionOrderedDispatcher。

All Dispatcher

所有消息都派发到 Dubbo 线程池。

dubbo-provider-alldispatcher

在 IO 线程中执行的操作有:

  1. sent 操作在 IO 线程上执行。
  2. 序列化响应在 IO 线程上执行。

在 Dubbo 线程中执行的操作有:

  1. receivedconnecteddisconnectedcaught 都是在 Dubbo 线程上执行的。
  2. 反序列化请求的行为在 Dubbo 中做的。

Direct Dispatcher

所有消息都不派发到 Dubbo 线程池,全部在 IO 线程上直接执行。

dubbo-provider-directDispatcher

在 IO 线程中执行的操作有:

  1. receivedconnecteddisconnectedcaughtsent 操作在 IO 线程上执行。
  2. 反序列化请求和序列化响应在 IO 线程上执行。

并没有在 Dubbo 线程操作的行为。

Execution Dispatcher

只有请求消息派发到 Dubbo 线程池,不含响应,响应和其它连接断开事件,心跳等消息,直接在 IO 线程上执行。

dubbo-provider-ExecutionDispatcher

在 IO 线程中执行的操作有:

  1. sentconnecteddisconnectedcaught 操作在 IO 线程上执行。
  2. 序列化响应在 IO 线程上执行。

在 Dubbo 线程中执行的操作有:

  1. received 都是在 Dubbo 线程上执行的。
  2. 反序列化请求的行为在 Dubbo 中做的。

Message Only Dispatcher

在 Provider 端,Message Only Dispatcher 和 Execution Dispatcher 的线程模型是一致的,所以下图和 Execution Dispatcher 的图一致,区别在 Consumer 端。见下方 Consumer 端的线程模型。

dubbo-provider-ExecutionDispatcher

在 IO 线程中执行的操作有:

  1. sentconnecteddisconnectedcaught 操作在 IO 线程上执行。
  2. 序列化响应在 IO 线程上执行。

在 Dubbo 线程中执行的操作有:

  1. received 都是在 Dubbo 线程上执行的。
  2. 反序列化请求的行为在 Dubbo 中做的。

Connection Ordered Dispatcher

dubbbo-provider-connectionOrderedDispatcher

在 IO 线程中执行的操作有:

  1. sent 操作在 IO 线程上执行。
  2. 序列化响应在 IO 线程上执行。

在 Dubbo 线程中执行的操作有:

  1. receivedconnecteddisconnectedcaught 都是在 Dubbo 线程上执行的。但是 connecteddisconnected 两个行为是与其他两个行为通过线程池隔离开的。并且在 Dubbo connected thread pool 中提供了链接限制、告警灯能力。
  2. 反序列化请求的行为在 Dubbo 中做的。

【中等】Dubbo 中的连接数过多如何处理?

核心优化手段

方法 配置示例 作用
限制最大连接数 <dubbo:protocol accepts="100"/> 防止服务端过载
共享连接池 <dubbo:protocol threadpool="cached"/> 提高连接复用率
连接数控制 <dubbo:reference connections="10"/> 限制单服务连接数
超时设置 <dubbo:reference timeout="3000"/> 避免僵死连接
重试策略 <dubbo:reference retries="2"/> 控制失败重试次数

关键配置详解

(1)服务端配置

1
2
3
4
<!-- 限制单服务最大连接数 -->
<dubbo:protocol name="dubbo" port="20880" accepts="200"/>
<!-- 设置 IO 线程数 -->
<dubbo:protocol threads="50"/>

(2)客户端配置

1
2
3
4
<!-- 限制单服务连接数 -->
<dubbo:reference interface="com.xx.Service" connections="5"/>
<!-- 设置连接超时 -->
<dubbo:reference timeout="2000"/>

高级优化方案

  • 连接池选择

    • 默认使用 Netty 连接池
    • 可集成第三方连接池(如 HikariCP)
  • 动态调整策略

1
2
// 运行时动态调整连接数
ReferenceConfig.cacheConnections(false);
  • 熔断保护
1
<dubbo:reference cluster="failfast"/>

监控与治理

工具 功能
Dubbo-Admin 实时监控连接数
Prometheus+Grafana 可视化监控
Skywalking 调用链分析

最佳实践建议

  • 生产环境配置

    • 服务端 accepts=CPU 核心数、*2
    • 客户端 connections=2~5
    • 超时时间≥3000ms
  • 异常处理

1
2
3
4
5
6
7
try {
service.method();
} catch (RpcException e) {
if(e.isTimeout()) {
// 超时处理
}
}
  • 压测建议
    • 使用 JMeter 模拟高并发
    • 逐步增加连接数观察性能拐点

典型问题排查流程

  1. 监控发现连接数异常
  2. 分析调用链路定位问题服务
  3. 调整连接池参数
  4. 增加服务实例水平扩展

【困难】Dubbo 中的时钟轮机制是如何设计的?

::: info JDK 中定时任务的实现

:::

在很多开源框架中,都需要定时任务的管理功能,例如 ZooKeeper、Netty、Quartz、Kafka 以及 Linux 操作系统。

定时器的本质是设计一种数据结构,能够存储和调度任务集合,而且 deadline 越近的任务拥有更高的优先级。那么定时器如何知道一个任务是否到期了呢?定时器需要通过轮询的方式来实现,每隔一个时间片去检查任务是否到期。

所以定时器的内部结构一般需要一个任务队列和一个异步轮询线程,并且能够提供三种基本操作:

  • Schedule 新增任务至任务集合;
  • Cancel 取消某个任务;
  • Run 执行到期的任务。

JDK 原生提供了三种常用的定时器实现方式,分别为 TimerDelayedQueueScheduledThreadPoolExecutor

JDK 内置的三种实现定时器的方式,实现思路都非常相似,都离不开任务任务管理任务调度三个角色。三种定时器新增和取消任务的时间复杂度都是 O(logn),面对海量任务插入和删除的场景,这三种定时器都会遇到比较严重的性能瓶颈。

对于性能要求较高的场景,一般都会采用时间轮算法来实现定时器。时间轮(Timing Wheel)是 George Varghese 和 Tony Lauck 在 1996 年的论文 Hashed and Hierarchical Timing Wheels: data structures to efficiently implement a timer facility 实现的,它在 Linux 内核中使用广泛,是 Linux 内核定时器的实现方法和基础之一。

::: info 时间轮的基本原理

:::

时间轮是一种高效的、批量管理定时任务的调度模型。时间轮可以理解为一种环形结构,像钟表一样被分为多个 slot 槽位。每个 slot 代表一个时间段,每个 slot 中可以存放多个任务,使用的是链表结构保存该时间段到期的所有任务。时间轮通过一个时针随着时间一个个 slot 转动,并执行 slot 中的所有到期任务。

图片 22.png

任务是如何添加到时间轮当中的呢?可以根据任务的到期时间进行取模,然后将任务分布到不同的 slot 中。如上图所示,时间轮被划分为 8 个 slot,每个 slot 代表 1s,当前时针指向 2。假如现在需要调度一个 3s 后执行的任务,应该加入 2+3=5 的 slot 中;如果需要调度一个 12s 以后的任务,需要等待时针完整走完一圈 round 零 4 个 slot,需要放入第 (2+12)%8=6 个 slot。

那么当时针走到第 6 个 slot 时,怎么区分每个任务是否需要立即执行,还是需要等待下一圈,甚至更久时间之后执行呢?所以我们需要把 round 信息保存在任务中。例如图中第 6 个 slot 的链表中包含 3 个任务,第一个任务 round=0,需要立即执行;第二个任务 round=1,需要等待 1*8=8s 后执行;第三个任务 round=2,需要等待 2*8=8s 后执行。所以当时针转动到对应 slot 时,只执行 round=0 的任务,slot 中其余任务的 round 应当减 1,等待下一个 round 之后执行。

上面介绍了时间轮算法的基本理论,可以看出时间轮有点类似 HashMap,如果多个任务如果对应同一个 slot,处理冲突的方法采用的是拉链法。在任务数量比较多的场景下,适当增加时间轮的 slot 数量,可以减少时针转动时遍历的任务个数。

时间轮定时器最大的优势就是,任务的新增和取消都是 O(1) 时间复杂度,而且只需要一个线程就可以驱动时间轮进行工作。

::: info Dubbo 中的时间轮

:::

org.apache.dubbo.common.timer.HashedWheelTimer 是 Dubbo 中时间轮的算法实现。它主要应用于以下方面:

  • 失败重试, 例如,Provider 向注册中心进行注册失败时的重试操作,或是 Consumer 向注册中心订阅时的失败重试等。
  • 周期性定时任务, 例如,定期发送心跳请求,请求超时的处理,或是网络连接断开后的重连机制。

扩展性设计

【困难】Dubbo 架构是如何实现高度可扩展的?

::: info 微内核+插件架构

:::

Dubbo 的架构设计采用微内核+插件架构,高度支持可扩展。

基于扩展点,用户完全可以基于自身需求,替换 Dubbo 原生实现,来满足自身业务需求。

Admin 效果图

  • 协议与编码扩展。通信协议、序列化编码协议等
  • 流量管控扩展。集群容错策略、路由规则、负载均衡、限流降级、熔断策略等
  • 服务治理扩展。注册中心、配置中心、元数据中心、分布式事务、全链路追踪、监控系统等
  • 诊断与调优扩展。流量统计、线程池策略、日志、QoS 运维命令、健康检查、配置加载等

::: info 基于扩展的生态

:::

Dubbo 调用链路中几乎所有核心节点都被定义为扩展点。

extensibility-echosystem.png

以上是按架构层次划分的 Dubbo 内的一些核心扩展点定义及实现,可以从三个层次来展开:

(1)协议通信层

  • Protocol - Protocol 定义了 RPC 协议,利用这个扩展点可以实现灵活切换通信协议。Dubbo 官方提供了 Triple、gRPC、Dubbo2、REST 等 RPC 协议。
  • Serialization - Serialization 定义了序列化协议,利用这个扩展点可以实现灵活切换序列化协议。Dubbo 官方提供了 Fastjson、Protobuf、Hessian2、Kryo、FST 等序列化协议。

协议与编码原理图

(2)流量管控层

Dubbo 在服务调用链路上预置了大量扩展点,通过这些扩展点用户可以控制运行态的流量走向、改变运行时调用行为等,包括 Dubbo 内置的一些负载均衡策略、流量路由策略、超时等很多流量管控能力都是通过这类扩展点实现的。

协议与编码原理图

  • Filter - Filter 流量拦截器是 Dubbo 服务调用之上的 AOP 设计模式,Filter 用来对每次服务调用做一些预处理、后处理动作,使用 Filter 可以完成访问日志、加解密、流量统计、参数验证等任务,Dubbo 中的很多生态适配如限流降级 Sentinel、全链路追踪 Tracing 等都是通过 Fitler 扩展实现的。Filter 以链式串联工作,彼此独立。
    • 从消费端视角,它在请求发起前基于请求参数等做一些预处理工作,在接收到响应后,对响应结果做一些后置处理;
    • 从提供者视角则,在接收到访问请求后,在返回响应结果前做一些预处理,
  • Router - Router 将符合一定条件的流量转发到特定分组的地址子集,是 Dubbo 中一些关键能力如按比例流量转发、流量隔离等的基础。每次服务调用请求都会流经一组路由器 (路由链),每个路由器根据预先设定好的规则、全量地址列表以及当前请求上下文计算出一个地址子集,再传给下一个路由器,重复这一过程直到最后得出一个有效的地址子集。
  • Load Balance - 在 Dubbo 中,Load Balance 负载均衡工作在 Router 之后,对于每次服务调用,负载均衡负责在 Router 链输出的地址子集中选择一台机器实例进行访问,保证一段时间内的调用都均匀的分布在地址子集的所有机器上。Dubbo 官方提供了加权随机、加权轮询、一致性哈希、最小活跃度优先、最短响应时间优先等负载均衡策略,还提供了根据集群负载自适应调度的负载均衡算法。

(3)服务治理层

Dubbo3 由注册中心 (服务发现)、配置中心和元数据中心构成了整个服务治理的核心。

服务治理架构图

Dubbo 很多服务治理的核心能力都是通过上图描述的几个关键组件实现的。用户通过控制面或者 Admin 下发的各种规则与配置、各类微服务集群状态的展示等都是直接与注册中心、配置中心和元数据中心交互。在具体实现或者部署上,注册中心、配置中心和元数据中心可以是同一组件,比如 Zookeeper 可同时作为注册、配置和元数据中心,Nacos 也是如此。因此,三个中心只是从架构职责上的划分,你甚至可以用同一个 Zookeeper 集群来承担所有三个职责,只需要在应用里将他们设置为同一个集群地址就可以了。

  • Registry - 注册中心是 Dubbo 实现服务发现能力的基础。Dubbo 官方支持 Zookeeper、Nacos、Etcd、Consul、Eureka 等注册中心。通过对 Consul、Eureka 的支持,Dubbo 也实现了与 Spring Cloud 体系在地址和通信层面的互通,让用户同时部署 Dubbo 与 Spring Cloud,或者从 Spring Cloud 迁移到 Dubbo 变得更容易。
  • Config Center - 配置中心是用户实现动态控制 Dubbo 行为的关键组件。Dubbo 所有的路由规则,都是先下发到配置中心保存起来,进而 Dubbo 实例通过监听配置中心的变化,收到路由规则并达到控制流量的行为。Dubbo 官方支持 Zookeeper、Nacos、Etcd、Redis、Apollo 等配置中心实现。
  • Metadata Center - 与配置中心相反,从用户视角来看元数据中心是只读的,元数据中心唯一的写入方是 Dubbo 进程实例,Dubbo 实例会在启动之后将一些内部状态(如服务列表、服务配置、服务定义格式等)上报到元数据中心,供一些治理能力作为数据来源,如服务测试、文档管理、服务状态展示等。Dubbo 官方支持 Zookeeper、Nacos、Etcd、Redis 等元数据中心实现。

扩展阅读:Dubbo 官方文档之扩展适配

【中等】如何自定义一个 Dubbo 的 SPI 扩展?

核心开发步骤

(1)定义SPI接口

1
2
3
4
@SPI("default")  // 指定默认实现
public interface MyFilter {
Result filter(Invoker<?> invoker, Invocation invocation);
}

(2)实现扩展类

1
2
3
4
5
6
7
public class LogFilter implements MyFilter {
@Override
public Result filter(Invoker<?> invoker, Invocation invocation) {
System.out.println("Before invocation");
return invoker.invoke(invocation);
}
}

(3)注册扩展实现

1
2
3
-- 文件位置:META-INF/dubbo/com.xxx.MyFilter
log=com.xxx.LogFilter
cache=com.xxx.CacheFilter

(4)加载使用扩展

1
2
3
MyFilter filter = ExtensionLoader
.getExtensionLoader(MyFilter.class)
.getExtension("log"); // 指定扩展名

高级特性

特性 实现方式 应用场景
自适应扩展 @Adaptive注解方法/类 运行时动态选择实现
自动激活 @Activate(group={"provider"}, order=1) 根据条件自动激活扩展
Wrapper类 实现类构造函数包含扩展接口参数 AOP增强

关键注解详解

  • @SPI
1
2
3
4
@SPI("netty")  // 默认实现
public interface Transporter {
Server bind(URL url, ChannelHandler handler);
}
  • @Adaptive
1
2
3
4
5
6
// 方法级适配
@Adaptive("transport")
public interface Transporter {
@Adaptive
Server bind(URL url, ChannelHandler handler);
}
  • @Activate
1
2
3
4
@Activate(group = "consumer", order = 100)
public class TokenFilter implements Filter {
// 消费者端自动激活
}

典型扩展点

  • 协议扩展 (Protocol)
  • 过滤器扩展 (Filter)
  • 负载均衡扩展 (LoadBalance)
  • 序列化扩展 (Serialization)

最佳实践

  • 配置建议

    • 扩展点命名全小写,多个单词用.分隔
    • 每个扩展点单独建立配置文件
  • 调试技巧

1
2
3
4
// 查看所有已注册扩展
Set<String> exts = ExtensionLoader
.getExtensionLoader(MyFilter.class)
.getSupportedExtensions();
  • 注意事项
    • 避免扩展类循环依赖
    • 线程安全需自行保证
    • 生产环境建议禁用动态编译(-Ddubbo.compiler.disable=true

示例项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
src
├── main
│ ├── java
│ │ └── com
│ │ └── xxx
│ │ ├── MyFilter.java
│ │ └── filter
│ │ ├── LogFilter.java
│ │ └── CacheFilter.java
│ └── resources
│ └── META-INF
│ └── dubbo
│ └── com.xxx.MyFilter

【困难】Dubbo 的 SPI 机制是如何设计的?

SPI 全称 Service Provider Interface,旨在由第三方实现或扩展的 API,它是一种用于动态加载服务的机制。SPI 的本质是将接口实现类的全限定名配置在文件中,并由服务加载器读取配置文件,加载实现类。这样可以在运行时,动态为接口替换实现类。

Java 中提供了 SPI 机制,但是由于存在一些不足,Dubbo 自行实现了一套 Dubbo SPI 机制。

::: info Java SPI

:::

Java 中 SPI 机制主要思想是将装配的控制权移到程序之外,在模块化设计中这个机制尤其重要,其核心思想就是 解耦

Java SPI 有四个要素:

  • SPI 接口:为服务提供者实现类约定的的接口或抽象类。
  • SPI 实现类:实际提供服务的实现类。
  • SPI 配置:Java SPI 机制约定的配置文件,提供查找服务实现类的逻辑。配置文件必须置于 META-INF/services 目录中,并且,文件名应与服务提供者接口的完全限定名保持一致。文件中的每一行都有一个实现服务类的详细信息,同样是服务提供者类的完全限定名称。
  • **ServiceLoader**:Java SPI 的核心类,用于加载 SPI 实现类。 ServiceLoader 中有各种实用方法来获取特定实现、迭代它们或重新加载服务。

Java SPI 存在一些不足:

  • 不能按需加载,需要遍历所有的实现并实例化,然后在循环中才能找到我们需要的实现。如果不想用某些实现类,或者某些类实例化很耗时,它也被载入并实例化了,这就造成了浪费。
  • 获取某个实现类的方式不够灵活,只能通过 Iterator 形式获取,不能根据某个参数来获取对应的实现类。
  • 并发多线程使用 ServiceLoader 类的实例是不安全的。

::: info Dubbo SPI

:::

正是有 Java SPI 存在以上不足点,Dubbo 并未使用 Java 原生的 SPI 机制,而是对其进行了增强,使其能够更好的满足需求。在 Dubbo 中,SPI 是一个非常重要的模块。基于 SPI,我们可以很容易的对 Dubbo 进行拓展。

Dubbo SPI 所需的配置文件需放置在 META-INF/dubbo 路径下。配置内容形式如下:

1
2
optimusPrime = org.apache.spi.OptimusPrime
bumblebee = org.apache.spi.Bumblebee

与 Java SPI 实现类配置不同,Dubbo SPI 是通过键值对的方式进行配置,这样可以按需加载指定的实现类。Dubbo SPI 除了支持按需加载接口实现类,还增加了 IOC 和 AOP 等特性。

Dubbo SPI 的相关逻辑被封装在了 ExtensionLoader 类中,通过 ExtensionLoader,可以加载指定的实现类。ExtensionLoadergetExtension 方法是其入口方法。

扩展阅读:

【中等】什么是 Dubbo 的 Filter 机制?

Filter 是 Dubbo 的核心扩展点之一,通过拦截 RPC 调用实现横切逻辑(如日志、鉴权、监控),其设计遵循 责任链模式,与 Spring AOP 理念相似但更轻量级。

通过 Filter 机制,Dubbo 实现了业务逻辑与横切关注点的解耦,结合 SPI 扩展能力,可灵活适应各类微服务治理需求。

核心工作原理

  • 拦截链路:请求和响应会依次通过所有激活的 Filter,形成双向处理链。
1
2
3
graph LR
Consumer -->|Request| Filter1 --> Filter2 --> ... --> FilterN --> Provider
Provider -->|Response| FilterN --> ... --> Filter2 --> Filter1 --> Consumer
  • 每个 Filter 可通过 invoker.invoke() 决定是否继续传递或中断调用。
  • 内置 Filter: Dubbo 默认包含多个 Filter(如 ActiveLimitFilter 限流、TokenFilter 鉴权),可通过 <dubbo:provider filter="-default" /> 禁用默认链。

自定义 Filter 开发

步骤 1:实现 Filter 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Activate(group = {Constants.PROVIDER, Constants.CONSUMER}) // 自动激活条件
public class TraceIdFilter implements Filter {
@Override
public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException {
// 请求前:生成TraceID
String traceId = UUID.randomUUID().toString();
RpcContext.getContext().setAttachment("traceId", traceId);

try {
System.out.printf("[TRACE] Start call %s#%s, traceId=%s\n",
invoker.getInterface().getSimpleName(),
invocation.getMethodName(),
traceId);

// 执行后续调用链
Result result = invoker.invoke(invocation);

// 响应后:记录耗时
System.out.printf("[TRACE] End call, traceId=%s, cost=%dms\n",
traceId, System.currentTimeMillis() - startTime);
return result;
} catch (Exception e) {
// 异常处理
System.err.printf("[TRACE] Call failed, traceId=%s, error=%s\n", traceId, e.getMessage());
throw e;
}
}
}

步骤 2:注册 Filter

  • 方式1:SPI 自动加载
    META-INF/dubbo/com.alibaba.dubbo.rpc.Filter 文件中添加:
1
traceIdFilter=com.your.package.TraceIdFilter
  • 方式2:XML 显式配置
1
2
3
4
5
6
<!-- 全局生效 -->
<dubbo:provider filter="traceIdFilter" />
<dubbo:consumer filter="traceIdFilter" />

<!-- 单个服务生效 -->
<dubbo:service interface="com.example.UserService" filter="traceIdFilter" />

高级配置技巧

  • Filter 执行顺序:通过 @Activate(order = -100) 指定优先级(值越小越早执行)。
1
2
@Activate(order = -100, group = Constants.PROVIDER)
public class AuthFilter implements Filter { ... }
  • 条件生效:使用 @Activategroupvalue 参数控制生效场景:
1
2
3
// 仅当消费者指定参数validation=true时激活
@Activate(group = Constants.CONSUMER, value = "validation")
public class ValidationFilter implements Filter { ... }
  • 异步支持:Filter 默认兼容异步调用(如 CompletableFuture),可通过 RpcContext.isAsync() 判断当前调用模式。

典型应用场景

场景 实现方案 相关 Filter
分布式链路追踪 透传 TraceID 和 SpanID 自定义 TraceIdFilter
接口鉴权 校验 RpcContext 中的 Token AuthFilter + TokenManager
限流熔断 统计 QPS 并触发限流逻辑 结合 Sentinel/Dubbo 限流插件
参数校验 使用 JSR-303 校验方法参数 ValidationFilter
日志脱敏 拦截请求/响应数据,过滤敏感字段 SensitiveDataFilter

常见问题排查

  • Filter 未生效
    • 检查是否配置了 <dubbo:provider filter="-default" /> 覆盖了默认链。
    • 确认 SPI 文件路径和内容是否正确。
  • 执行顺序异常:通过 @Activate(order=1) 显式指定优先级,避免依赖默认顺序。
  • 性能瓶颈:避免在 Filter 中执行阻塞 IO 操作,异步场景推荐使用 CompletableFuture

最佳实践

  • 生产建议
    • 为关键 Filter 添加 @SPI 注解,支持动态替换实现。
    • 使用 RpcContext.getContext().get() 传递跨调用参数,而非 ThreadLocal。
  • 调试技巧
    • 启用 Dubbo QOS(telnet 127.0.0.1 22222)实时查看 Filter 链:
1
2
3
4
> ls filter
traceIdFilter
authFilter
> invoke traceIdFilter status

分布式特性

【困难】Dubbo 中如何实现分布式事务?

在 Dubbo 分布式系统中实现事务,主要面临跨服务数据一致性问题。以下是主流解决方案:

事务消息

适用场景:异步解耦场景(如订单创建后通知库存)

1
2
3
4
5
6
7
sequenceDiagram
参与者 订单服务->>MQ: 1.发送预备消息(半事务消息)
MQ-->>订单服务: 2.返回发送成功
订单服务->>DB: 3.执行本地事务
订单服务->>MQ: 4.提交/回滚消息
MQ->>库存服务: 5.投递消息
库存服务->>DB: 6.执行库存操作

实现步骤

  1. 集成 RocketMQ 事务消息
1
2
3
4
5
6
7
8
9
10
11
12
13
// 订单服务
TransactionMQProducer producer = new TransactionMQProducer("order_group");
producer.setTransactionListener(new LocalTransactionListener() {
@Override
public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
try {
orderDao.createOrder(); // 本地事务
return LocalTransactionState.COMMIT_MESSAGE;
} catch (Exception e) {
return LocalTransactionState.ROLLBACK_MESSAGE;
}
}
});

Seata AT(推荐)

架构原理

1
2
3
4
5
6
graph TD
TC(Seata Server)
A[订单服务] -->|1.注册分支| TC
B[库存服务] -->|2.注册分支| TC
TC -->|3.全局事务管理| A
TC -->|3.全局事务管理| B

集成方式

  1. 添加依赖
1
2
3
4
5
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.6.1</version>
</dependency>
  1. 配置全局事务
1
2
3
4
5
@GlobalTransactional
public void createOrder(OrderDTO order) {
orderService.create(order); // 本地事务
stockService.reduce(order.getProductId()); // 远程Dubbo调用
}

数据源代理配置

1
2
3
4
5
6
7
seata:
enabled: true
application-id: order-service
tx-service-group: my_tx_group
service:
vgroup-mapping:
my_tx_group: default

TCC(两阶段提交)

适用于复杂业务。

阶段划分

  1. Try:预留资源
  2. Confirm:确认操作
  3. Cancel:取消预留

Dubbo 服务定义

1
2
3
4
5
6
7
public interface StockService {
@TwoPhaseBusinessAction(name = "reduceStock", commitMethod = "confirm", rollbackMethod = "cancel")
boolean tryReduceStock(BusinessActionContext context, Long productId, int count);

boolean confirm(BusinessActionContext context);
boolean cancel(BusinessActionContext context);
}

SAGA(长事务)

适用场景:跨多服务的业务流程(如旅行订票)

1
2
3
4
graph LR
A[订机票] --> B[订酒店]
B --> C[租车]
C --> D[支付]

实现方案

  1. 使用 Apache ServiceComb Saga
  2. 定义补偿方法:
1
2
3
4
5
6
7
8
9
10
@SagaStart
public void bookTravel(TravelOrder order) {
flightService.book(order);
hotelService.reserve(order);
}

@Compensate
public void cancelFlight(TravelOrder order) {
flightService.cancel(order);
}

方案对比

方案 一致性 性能 复杂度 适用场景
事务消息 最终 异步通知场景
Seata AT 强一致 常规分布式事务
TCC 强一致 较高 资金类高敏感业务
SAGA 最终 跨多服务长流程

生产建议

  1. Seata AT 模式作为默认选择,平衡易用性与一致性
  2. 重要资金操作采用 TCC 模式,如支付、转账
  3. 配合 Dubbo 的 集群容错 策略:
1
<dubbo:reference cluster="failover" retries="2"/>
  1. 必须实现 幂等接口 应对重试场景

监控配置

1
2
3
4
5
seata:
metrics:
enabled: true
registry-type: compact
exporter-list: prometheus

通过以上方案,Dubbo 系统可在保证性能的同时实现不同级别的事务一致性。实际选型需根据业务特点权衡。

参考资料

Elasticsearch 架构

存储流程

ES 存储数据的流程可以从三个角度来阐述:

  • 集群的角度来看,数据写入会先路由到主分片,在主分片上写入成功后,会并发写副本分片,最后响应给客户端。
  • 分片的角度来看,数据到达分片后需要对内容进行格式校验、分词处理然后再索引数据。
  • 节点的角度来看,ES 数据持久化的步骤可归纳为:Refresh、写 Translog、Flush、Merge。

文档分布式存储流程

ES 的索引有一个或者多个分片,而分片又分为主分片和副本分片两种。将要写入的数据存储在哪个分片是第一个要考虑的问题。

首先需要找到存储文档的主分片,并在主分片的节点上写入对应数据,数据在主分片写入成功后再将数据分发到副分片进行存储。文档的新增、更新、删除等操作都属于写入操作。

从集群层面来看,存储数据的流程如下:

  1. 请求 - 客户端选择一个 node(示例中是 node1)发送请求过去,这个 node 就是 coordinating node(协调节点)。
  2. 路由转发 - coordinating node 根据文档 ID 或 routing key 计算出文档应该被保存到哪个分片(这里是分片 3),并且从集群状态的路由表信息中获取分片 3 的主分片所在的节点为 node3。coordinating node 将请求转发给 node3。
  3. 复制 - node3 存储数据后,将请求并发转发到 分片 3 的所有副本分片,即数据复制。
  4. 响应 - 当所有副分片都写入成功后,node3 会向 coordinating node 返回写入成功的消息,coordinating node 再将响应返回给客户端。

数据索引流程

文档分布式存储流程中的描述,隐藏了一个细节:如果是全文本数据,ES 需要使用 analyzer(分析器) 先对内容进行分析(如果数据是精确值,如实体 ID、日期等,则无需处理)。

在 Elasticsearch 中,分析器是用于对文本进行分词的组件。分析器用于将文本分解为更小的单元,称为分词。然后,这些分词用于索引和搜索文本。分析器的主要目标是将原始文本转换为可以有效搜索和分析的结构化格式 (分词)。

analyzer(分析器) 由三个组件组成:零个或多个 Character Filters(字符过滤器)、有且仅有一个 Tokenizer(分词器)、零个或多个 Token Filters(分词过滤器)。分析的执行顺序为:character filters -> tokenizer -> token filters

对全文本数据来说,数据索引时会对文本内容进行分析处理,分析器的处理流程如下:

  1. character flters 先对字符进行过滤,例如:把一些 HTML 元素、转义标签清除;
  2. tokenizer 会将字符串按不同的策略进行切分,分割得到的单词称为 token(词条);
  3. token filters 对 token 再进行过滤,例如:删除停用词(and、is 等),转换近义词等;

经过以上一系列处理后,ES 会将数据存储到名为倒排索引的结构中。

当需要全文检索存储数据时,需要先使用搜索分析器对搜索内容进行分析,这个处理过程和存储时使用的分析器相似。通过分析得到的分词列表,再去和倒排索引中的数据去进行匹配,最后返回匹配度最高的数据。

数据持久化流程

ES 的数据持久化流程主要有以下几个过程:Refresh、写 Translog、Flush、Merge。

Refresh

在文档写入的时候,ES 会将文档先写入到 Index Buffer 中。

当 Index Buffer 大小达到阈值(默认为 JVM 的 10%),或间隔一段时间(默认每秒执行一次,可以通过 index.refresh_interval 进行设置),ES 会将 Index Buffer 中的数据写入到一个新的 Segment 文件中。此时的 Segment 文件存在于 OS Cache 中。这个过程称为 Refresh

refresh 写完 segment 后,会更新 shard 的 commit point。commit point 在 shard 中以 segments_xxx 形式名字的文件存在,用来记录每个 shard 中 segment 相关的信息。

此外,ES 也支持通过 API 手动触发 Refresh 操作。

Refresh 过程有几点需要注意:

  • 在 Index Buffer 中的数据是搜索不到的;Refresh 后,数据进入 OS Cache,这时数据就可以搜索了。由于,刷新默认间隔一秒,写入的数据需要一秒后才可见,因此,ES 被称为近实时搜索数据库。
  • Index Buffer 的设计是为了通过批量写入,提高写入效率。但是,这种设计也带来了新的问题:一旦 ES 节点发生断点,Index Buffer 中的数据就丢失了。为了避免数据丢失,ES 的解决方案就是下文要提到的 Translog
  • Index Buffer 每次 Refresh 时,都会创建一个新的 Segment 文件。随着时间推移,Segment 文件会越来越多。这些 Segment 都要消耗文件句柄和内存,每次搜索都要检查每个 Segment 然后再合并结果。因此,Segment 越多、搜索也越慢。为了减少 Segment 文件数,ES 的解决方案就是下文要提到的 Merge 操作。

写 Translog

ES 通过 Translog(事务日志)来保证数据不丢失

数据写入 Index Buffer 后,ES 会将数据也写入 Translog,写入完毕后即可以返回客户端写入成功。Translog 只允许追加写入,并且默认是调用 fsync 进行刷盘的。每个分片都会有自己的 Translog,在 Refresh 的时候系统会清空 Index Buffer,但不会清空 Translog。一旦机器宕机,再次重启的时候, ES 会自动读取 Translog 中的数据,恢复到 Index Buffer 和 OS Cache 中。

Translog 其实也是先写入 OS Cache 的,默认每 5 秒刷一次到磁盘中去(由 index.translog.interval 控制)。所以,如果机器宕机,可能会丢失 5 秒的数据。这样设计的目的,还是基于写入效率的考虑。如果每条数据都直接写入磁盘,开销是比较高的,所以这里设计为延时批量写入。

通过 Refresh 和 写 Translog 两节的内容,我们可以总结为:

  • ES 之所以被称为近实时查询,是由于数据写入后,需要刷新(默认间隔 1 秒)后,才可以搜索到;
  • ES 虽然有 Translog 机制,但依然有丢失数据的风险——有 5 秒的数据,是暂存在 index buffer、translog(os cache)、segment file(os cache) 中,此时尚未保存到磁盘。如果此时发生宕机或断电,会丢失 5 秒的数据

Flush

Flush 操作本质上就是 commit 操作,即 ES 的数据持久化操作。

  1. Flush 操作的第一步,就是将 index buffer 中现有数据 refreshOS Cache 中去,清空 buffer。
  2. 然后,将一个 commit point 写入磁盘文件,里面标识着这个 commit point 对应的所有 Segment 文件。同时,强行将 OS Cache 中目前所有的数据都 fsync 到磁盘中去。
  3. 最后,删除当前的 translog,新建一个 translog,此时 commit 操作完成。

以下两个条件满足任意一个,就会触发 Flush 操作:

  • 默认每 30 分钟触发执行一次(由 index.translog.flush_threshold_period 控制)
  • Translog 写满时触发执行,默认容量为 512M(由 index.translog.flush_threshold_size 控制)。

Merge

Elasticsearch 的 document 的物理存储是 Luncene segment,而 segment 不允许变更。那么,如何处理删除和更新呢?

  • 如果是删除操作,commit 的时候会生成一个 .del 文件,里面将某个 doc 标识为 deleted 状态,那么搜索的时候根据 .del 文件就知道这个 doc 是否被删除了。

  • 如果是更新操作,就是将原来的 doc 标识为 deleted 状态,然后新写入一条数据。

Index Buffer 每次 Refresh 时,都会创建一个新的 Segment 文件。随着时间推移,Segment 文件会越来越多。这些 Segment 都要消耗文件句柄和内存,每次搜索都要检查每个 Segment 然后再合并结果。因此,Segment 越多、搜索也越慢。

Elasticsearch 会定期执行 merge 操作,将多个 segment file 合并成一个。合并时会将标识为 deleted 的 doc 给物理删除掉,然后将新的 segment file 写入磁盘,这里会写一个 commit point,标识所有新的 segment file,然后打开 segment file 供搜索使用,同时删除旧的 segment file

搜索流程

在 Elasticsearch 中,搜索一般分为两个阶段,query 和 fetch 阶段。可以简单的理解,query 阶段确定要取哪些 doc,fetch 阶段取出具体的 doc。

Query 阶段

Query 阶段会根据搜索条件遍历每个分片(主分片或者副分片中的其一)中的数据,返回符合条件的前 N 条数据的 ID 和排序值,然后在协调节点中对所有分片的数据进行排序,获取前 N 条数据的 ID。

Query 阶段的流程如下:

  1. 客户端选择一个节点发送请求,这个 node 成为 coordinate node(协调节点)。coordinate node 创建一个大小为 from + size 的优先级队列用来存放结果。
  2. coordinate node 将请求转发到索引的每个主分片或者副分片中。
  3. 每个分片在本地执行搜索请求,并将查询结果打分排序,然后将结果保存到 from + size 大小的有序队列中。
  4. 接着,每个分片将结果返回给 coordinate node,coordinate node 对数据进行汇总处理:合并、排序、分页,将汇总数据存到一个大小为 from + size 的全局有序队列。

需要注意的是,在协调节点转发搜索请求的时候,如果有 N 个 Shard 位于同一个节点时,并不会合并这些请求,而是发生 N 次请求!

Fetch 阶段

在 Fetch 阶段,协调节点会从 Query 阶段产生的全局排序列表中确定需要取回的文档 ID 列表,然后通过路由算法计算出各个文档对应的分片,并且用 multi get 的方式到对应的分片上获取文档数据。

Fetch 阶段的流程如下:

  1. coordinate node 确定需要获取哪些文档,然后向相关节点发起 multi get 请求;
  2. 分片所在节点读取文档数据,并且进行 _source 字段过滤、处理高亮参数等,然后把处理后的文档数据返回给协调节点;
  3. coordinate node 汇总所有数据后,返回给客户端。

深度分页问题

在 Elasticsearch 中,支持三种分页查询方式:

  • from + size - 可以使用 fromsize 参数分别指定查询的起始页和每页记录数。
  • search_after - 不支持指定页数,只能向下翻页;并且需要指定 sort,并保证值是唯一的。然后,可以反复使用上次结果中最后一个文档的 sort 值进行查询。
  • scroll - 类似于 RDBMS 中的游标,只允许向下翻页。每次下一页查询后,使用返回结果的 scroll id 来作为下一次翻页的标记。scroll 查询会在搜索初始化阶段会生成快照,后续数据的变化无法及时体现在查询结果,因此更加适合一次性批量查询或非实时数据的分页查询。

前文中,我们已经了解了 ES 两阶段搜索流程(Query 和 Fetch)。从中不难发现,这种搜索方式在分页查询时会出现以下情况:

  • 每个 shard 要扫描 from + size 条数据;
  • coordinate node 需要接收并处理 (from + size) * primary_shard_num 条数据。

如果 from 或 size 很大,需要处理的数据量也会很大,代价很高,这就是深分页产生的原因。为了避免深分页,ES 默认限制 from + size 不能超过 10000,可以通过 index.max_result_window 设置。

如何解决 Elasticsearch 深分页问题?

ES 官方提供了另外两种分页查询方式 search_after + PIT 和 scroll(注意:官方已不再推荐) 来避免深分页问题。

计算偏差

在 ES 中,不仅仅是普通搜索,相关性计算(评分)和聚合计算也是先在每个 shard 的本地进行计算,再由 coordinate node 进行汇总。由于分片的本地计算是独立的,只能基于数据子集来进行计算,所以难免出现数据偏差。

解决这个问题的方式也有多种:

  • 当数据量不大的情况下,设置主分片数为 1,这意味着在数据全集上进行聚合。 但这种方案不太现实。
  • 设置 shard_size 参数,将计算数据范围变大,牺牲整体性能,提高精准度。shard_size 的默认值是 size * 1.5 + 10
  • 使用 DFS Query Then Fetch, 在 URL 参数中指定:_search?search_type=dfs_query_then_fetch。这样设定之后,系统先会把每个分片的词频和文档频率的数据汇总到协调节点进行处理,然后再进行相关性算分。这样的话会消耗更多的 CPU 和内存资源,效率低下!
  • 尽量保证数据均匀地分布在各个分片中。

数据路由

为了避免出现数据倾斜,系统需要一种高效的方式把数据均匀分散到各个节点上存储,并且在检索的时候可以快速找到文档所在的节点与分片。这就需要确立路由算法,使得数据可以映射到指定的节点上。

常见的路由方式如下:

算法 描述
随机算法 写数据时,随机写入到一个节点中;读数据时,由于不知道查询数据存在于哪个节点,所以需要遍历所有节点。
路由表 由中心节点统一维护数据的路由表,以保证唯一性;但是,中心化产生了新的问题:单点故障、数据越大,路由表越大、单点容易称为性能瓶颈、数据迁移复杂等。
哈希取模 对 key 值进行哈希计算,然后根据节点数取模,以确定节点。

ES 的数据路由算法是根据文档 ID 和 routing key 来确定 Shard ID 的过程。默认的情况下 routing key 为文档 ID,路由算法一般情况下的计算公式如下:

1
shard_number = hash(_routing) % numer_of_primary_shards

也可以在请求中指定 routing key,下面是新增数据的时候指定 routing 的方式:

1
2
3
4
5
PUT <index>/_doc/<id>?routing=routing_key
{
"field1": "xxx",
"field2": "xxx"
}

添加数据时,如果不指定文档 ID,ES 会自动分片一个随机 ID。这种情况下,结合 Hash 算法,可以保证数据被均匀分布到各个分片中。如果指定文档 ID,或指定 routing key,Hash 计算得出的值可能会不够随机,从而导致数据倾斜。

index 一旦设置了主分片数就不能修改,如果要修改就需要 reindex(即数据迁移)。之所以如此,就是因为:一旦修改了主分片数,即等于修改了原 Hash 计算中的变量,无法再通过 Hash 计算正确路由到数据存储的分片。

参考资料

Elasticsearch 搜索(上)

搜索简介

Elasticsearch 支持多种搜索:

  • 精确搜索(词项搜索):搜索数值、日期、IP 或字符串的精确值或范围。
  • 全文搜索:搜索非结构化文本数据并查找与查询项最匹配的文档。
  • 向量搜索:存储向量,并使用 ANN 或 KNN 搜索来查找相似的向量,从而支持 语义搜索 等场景。

可以使用 _search API 来搜索和聚合 Elasticsearch 数据流或索引中的数据。API 的 query 请求采用 DSL 语义来进行查询。

Elasticsearch 支持两种搜索方式:URI Query 和 Request Body Query(DSL)

::: details URI Query 示例

1
2
3
GET /kibana_sample_data_ecommerce/_search?q=customer_first_name:Eddie
GET /kibana*/_search?q=customer_first_name:Eddie
GET /_all/_search?q=customer_first_name:Eddie

:::

::: details Request Body Query(DSL)示例

1
2
3
4
5
6
POST /kibana_sample_data_ecommerce/_search
{
"query": {
"match_all": {}
}
}

:::

当文档存储在 Elasticsearch 中时,它会在 1 秒内近乎实时地被索引和完全搜索。

Elasticsearch 基于 Lucene 开发,并引入了分段搜索的概念。分段类似于倒排索引,但 Lucene 中的单词 index 表示“段的集合加上提交点”。提交后,将向提交点添加新分段并清除缓冲区。

位于 Elasticsearch 和磁盘之间的是文件系统缓存。内存中索引缓冲区的文档会被写入新的分段,然后写入文件系统缓存,然后才刷新到磁盘。

Lucene 允许写入和打开新分段,使其包含的文档对搜索可见,而无需执行完全提交。这是一个比提交到磁盘要轻松得多的过程,并且可以频繁地完成而不会降低性能。

在 Elasticsearch 中,写入和打开新分段的这一过程称为刷新。刷新使自上次刷新以来对索引执行的所有操作都可用于搜索。

默认情况下,Elasticsearch 每秒定期刷新一次索引,但仅限于在过去 30 秒内收到一个或多个搜索请求的索引。这就是我们说 Elasticsearch 具有近实时搜索能力的原因:文档更改不会立即对搜索可见,但会在此时间范围内变得可见。

排序

在 Elasticsearch 中,默认排序是按照相关性的评分(_score进行降序排序。_score 是浮点数类型,_score 评分越高,相关性越高。评分模型的选择可以通过 similarity 参数在映射中指定。

在 5.4 版本以前,默认的相关性算法是 TF-IDF。TF 是词频(term frequency),IDF 是逆文档频率(inverse document frequency)。一个简短的解释是,一个词条出现在某个文档中的次数越多,它就越相关;但是,如果该词条出现在不同的文档的次数越多,它就越不相关。5.4 版本以后,默认的相关性算法 BM25。

此外,也可以通过 sort 自定排序规则,如:按照字段的值排序、多级排序、多值字段排序、基于 geo(地理位置)排序以及自定义脚本排序。

::: details 排序示例

单字段排序

1
2
3
4
5
6
7
8
9
10
POST /kibana_sample_data_ecommerce/_search
{
"size": 5,
"query": {
"match_all": {}
},
"sort": [
{"order_date": {"order": "desc"}}
]
}

多字段排序

1
2
3
4
5
6
7
8
9
10
11
12
POST /kibana_sample_data_ecommerce/_search
{
"size": 5,
"query": {
"match_all": {}
},
"sort": [
{"order_date": {"order": "desc"}},
{"_doc":{"order": "asc"}},
{"_score":{ "order": "desc"}}
]
}

:::

详情参考:Sort search results

分页

默认情况下,Elasticsearch 搜索会返回前 10 个匹配的匹配项。

Elasticsearch 支持三种分页查询方式。

  • from + size
  • search after
  • scroll

from + size

可以使用 fromsize 参数分别指定起始页和每页记录数。

当一个查询:from = 990, size = 10,会在每个分片上先获取 1000 个文档。然后,通过协调节点聚合所有结果。最后,再通过排序选取前 1000 个文档。

页数越深,占用内存越多。为了避免深分页问题,ES 默认限定最多搜索 10000 个文档,可以通过 index.max_result_window 进行设置。

::: details from + size 分页查询示例

1
2
3
4
5
6
7
8
POST /kibana_sample_data_ecommerce/_search
{
"from": 2,
"size": 5,
"query": {
"match_all": {}
}
}

:::

scroll

scroll 搜索方式类似于 RDBMS 中的游标,只允许向下翻页。每次下一页查询后,使用返回结果的 scroll id 来作为下一次翻页的标记。

scroll 在搜索初始化阶段会生成快照,后续数据的变化无法及时体现在查询结果,因此更加适合一次性批量查询或非实时数据的分页查询。

启用游标查询时,需要注意设定期望的过期时间(scroll = 1m),以降低维持游标查询窗口所需消耗的资源。

注意:Elasticsearch 官方不再建议使用 scroll 查询方式进行深分页,而是推荐使用 search_after 和时间点(PIT)一起使用。

::: details scroll 分页查询示例

1
2
3
4
5
6
7
8
9
10
POST /kibana_sample_data_ecommerce/_search?scroll=1m
{
"size": 3,
"query": {
"match": {
"currency": "EUR"
}
}
}

响应结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"_scroll_id": "DXF1ZXJ5QW5kRmV0Y2gBAAAAAAAAmTkWRTMzNmxBYmZUbUdsdFNqMnJoTl84Zw==",
"took": 0,
"timed_out": false,
"_shards": {
// 略
},
"hits": {
"total": {
"value": 4675,
"relation": "eq"
},
"max_score": 1,
"hits": [] // 略
}
}

:::

详情参考:Paginate search results

search after

search after 搜索方式不支持指定页数,只能向下翻页;并且需要指定 sort,并保证值是唯一的。然后,可以反复使用上次结果中最后一个文档的 sort 值进行查询。

search after 实现的思路同 scroll 方式基本一致,通过记录上一次分页的位置标识,来进行下一次分页查询。相比于 scroll 方式,它的优点是可以实时获取数据的变化,解决了查询快照导致的查询结果延迟问题。

::: details search after 分页查询示例

第一次查询

1
2
3
4
5
6
7
8
9
10
11

POST /kibana_sample_data_ecommerce/_search
{
"size": 5,
"query": {
"match_all": {}
},
"sort": [
{"order_date": {"order": "desc"}}
]
}

响应结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"took": 2609,
"timed_out": false,
"_shards": {
// 略
},
"hits": {
"total": {
"value": 4675,
"relation": "eq"
},
"max_score": null,
"hits": [
// 略多条记录
// 最后一条记录
{
// 略
"sort": [1642893235000]
}
]
}
}

从上次查询的响应中获取 sort 值,然后将 sort 值插入 search after 数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /kibana_sample_data_ecommerce/_search
{
"size": 5,
"query": {
"match_all": {}
},
"search_after": [
1642893235000
],
"sort": [
{
"order_date": {
"order": "desc"
}
}
]
}

:::

限定字段

默认情况下,搜索响应中的每个点击都包含 _source,该字段保存了原始文本的 JSON 对象。有两种推荐的方法可以从搜索查询中检索所选字段:

  • 使用 fields 选项指定响应结果中返回的值。
  • 如果需要在查询时返回原始文本数据,可以使用 _source 选项。

折叠搜索结果

Elasticsearch 中,可以通过 collapse 对搜索结果进行分组,且每个分组只显示该分组的一个代表文档。

::: details collapse 查询示例

1
2
3
4
5
6
7
8
9
10
POST /kibana_sample_data_ecommerce/_search
{
"size": 10,
"query": {
"match_all": {}
},
"collapse": {
"field": "day_of_week"
}
}

响应结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
{
"took": 106,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 4675,
"relation": "eq"
},
"max_score": null,
"hits": [
{
"_index": "kibana_sample_data_ecommerce",
"_type": "_doc",
"_id": "yZUtBX4BU8KXl1YJRBrH",
"_score": 1,
"fields": {
"day_of_week": ["Monday"]
}
},
{
"_index": "kibana_sample_data_ecommerce",
"_type": "_doc",
"_id": "ypUtBX4BU8KXl1YJRBrH",
"_score": 1,
"fields": {
"day_of_week": ["Sunday"]
}
},
{
"_index": "kibana_sample_data_ecommerce",
"_type": "_doc",
"_id": "1JUtBX4BU8KXl1YJRBrH",
"_score": 1,
"fields": {
"day_of_week": ["Tuesday"]
}
},
{
"_index": "kibana_sample_data_ecommerce",
"_type": "_doc",
"_id": "1ZUtBX4BU8KXl1YJRBrH",
"_score": 1,
"fields": {
"day_of_week": ["Wednesday"]
}
},
{
"_index": "kibana_sample_data_ecommerce",
"_type": "_doc",
"_id": "2JUtBX4BU8KXl1YJRBrH",
"_score": 1,
"fields": {
"day_of_week": ["Saturday"]
}
},
{
"_index": "kibana_sample_data_ecommerce",
"_type": "_doc",
"_id": "2ZUtBX4BU8KXl1YJRBrH",
"_score": 1,
"fields": {
"day_of_week": ["Thursday"]
}
},
{
"_index": "kibana_sample_data_ecommerce",
"_type": "_doc",
"_id": "35UtBX4BU8KXl1YJRBrI",
"_score": 1,
"fields": {
"day_of_week": ["Friday"]
}
}
]
}
}

:::

过滤搜索结果

使用带有 filter 子句的布尔查询,可以过滤搜索和聚合的结果。

使用 post_filter 可以过滤搜索的结果,但不能过滤聚合结果。

:::details filter 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /kibana_sample_data_ecommerce/_search
{
"size": 10,
"query": {
"bool": {
"filter": {
"range": {
"taxful_total_price": {
"gte": 0,
"lte": 10
}
}
}
}
}
}

:::

高亮

Elasticsearch 的高亮(highlight)可以从搜索结果中的一个或多个字段中获取突出显示的摘要,以便向用户显示查询匹配的位置。当请求突出显示(即高亮)时,响应结果的 highlight 字段中包括高亮的字段和高亮的片段。Elasticsearch 默认会用 <em></em> 标签标记关键字。

Elasticsearch 提供了三种高亮器,分别是默认的 highlighter 高亮器postings-highlighter 高亮器fast-vector-highlighter 高亮器

::: details 高亮结果示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
POST /kibana_sample_data_ecommerce/_search
{
"size": 10,
"query": {
"match_all": {}
},
"highlight": {
"fields": {
"user": {
"pre_tags": [
"<strong>"
],
"post_tags": [
"</strong>"
]
}
}
}
}

:::

详情参考:Highlighting

分片路由搜索

Elasticsearch 可以在多个节点上的多个分片中存储索引数据的副本。在运行搜索请求时,Elasticsearch 会选择包含索引数据副本的节点,并将搜索请求转发到该节点的分片。此过程称为路由

默认情况下,Elasticsearch 使用自适应副本选择来路由搜索请求。默认情况下,自适应副本选择从所有符合条件的节点和分片中进行选择。如果要限制符合搜索请求条件的节点和分片集,可以使用 preference 查询参数。

详情参考:Search shard routing

查询规则

Elasticsearch 允许自定义查询规则来进行搜索。

详情参考:Searching with query rules

搜索模板

搜索模板是可以使用不同变量运行的存储搜索。

详情参考:Search templates

参考资料