说说大型网站分布式服务框架的设计思想
1 网站功能持续膨胀后的困境与应对方式
原先的网站架构是这样的:
现有的网站架构在业务量比较小(日均百万笔订单)的情况下,可以很好地支撑系统业务。但随着业务量的继续扩大,我们可能会想通过增加应用服务器的数量来处理这些新增的业务量,但这又给数据库的连接带来新的压力。而且,随着网站规模的增大、开发人员的增多,每个应用都变得复杂而臃肿,而且存在重复的代码。这样的状况影响到了整体的研发效率,而且对稳定性也造成了一定的影响。
这种情况下,我们可以拆分应用,把应用变小。这有两种实现方案。
【1】把大应用拆分为多个:
拆分应用这样的好处是可以相对较快地完成。问题是:数据库的连接压力仍然存在;而且系统之间会存在一些重复的代码,这可以使用共享库来解决,但使用起来不太方便。
【2】服务化:
服务化我们在中间加入了服务层。这样做的好处是:系统架构更清晰了,而且也更加立体了。甚至那些散落在应用系统中的代码也可以变为服务咯O(∩_∩)O~
服务化方式可以让小团队可以更专注于某个具体的服务或应用上,这样可以更好、更快速地开发出某个具体的应用。
2 服务框架的设计
2.1 应用从集中式走向分布式所遇到的问题
服务化之后,原先的一些本地调用会变为远程调用。这种情况下,我们最关心的是如何提高易用性和降低性能损失。
服务框架调用过程从图中可以看出,原来在单机的单个进程中,一个方法的调用会分散到两个节点(客户端与服务端)并经历多个过程。
这里会用到最基础的网络通信知识(比如使用 Socket 进行通信)。
需要对调用的请求信息进行编码,然后传送给远程节点,解码后再进行真正的调用。
2.2 服务框架原型设计
2.2.1 客户端设计
本地方法变为远程服务流程图(客户端调用服务端)如果在请求方和服务方之间有 LVS (Linux Virtual Server,Linux 虚拟服务器)或硬件负载均衡方案,那么 “获取可用服务地址列表” 过程返回的就是LVS 或硬件负载均衡的地址或端口。
如果使用名称服务,那么 “获取可用服务地址列表” 过程返回的就是当前可用服务的地址列表。一般来说,我们会使用 key 的服务名字(完整类名 + 版本号)作为接口的全名。
构造请求数据是把对象变为二进制数据,也就是序列化。可以直接使用 Java 序列化来完成编码工作。
通信方面,可以使用 Socket 做一个简单实现,把 Java 序列化后的数据发送过去。
发送结束后,等待远程服务执行然后返回结果。收到结果后,对数据进行反序列化,然后得到实际执行的结果。
2.2.2 服务端设计
服务端设计流程对于服务端,需要在启动后进行监听。因为需要持续性地接收请求并进行处理。这里最重要的是服务的名称、服务的版本号、需要调用的方法名称和参数,以及调用的连接。
“依据名称与版本号获取服务” 节点会在本地定位具体提供服务的节点。我们会有一张服务注册表,然后根据名称与版本号对服务实例进行管理。会在启动时构建对应关系的初始值,并提供运行时的修改,即支持动态发布服务。
得到具体的服务实例后,可以通过反射方式来调用服务,然后序列化结果为二进制数据,最后通过网络把结果发送给客户端。
2.3 客户端的设计与实现
客户端也就是服务调用端。
客户端工作流程2.3.1 使用服务框架
进行远程服务调用时,即可以在中间放置代理服务器,也可以使用直连,但都必须在调用端使用一个客户端程序。如果调用端的系统很多,就必须每个系统实现一遍,这样成本太高了。所以我们需要一个服务框架的通用实现。
【1】使用服务框架
大多数使用 Java 进行开发的系统都会使用 Spring 来作为组件的容器。所以服务端框架可以在请求发起端提供一个通用的 Bean。这个 Bean 会有这些配置:
- interfaceName - 接口名称。
- version - 版本号。因为接口是会变化的,使用版本号可以对新、旧方法进行区分隔离。
- group(可选) - 组名称。如果同一个接口的远程服务有很多机器,那么我们可以把这些远程服务的机器进行归组,这样就可以隔离不同的调用者。
这个 通用的 Bean,是完成本地和远程服务的通用桥梁。内部使用 Java 的动态代理技术来完成远程调用。
【2】接入服务框架会遇到的问题
(1)服务框架部署方式
-
服务框架作为应用的扩展方案
服务框架作为应用的扩展
这种方案是把服务框架作为应用的一个依赖包,并与应用一起打包。这样,服务框架就会变为应用的一个库,并随着应用一起启动。但如果要升级服务框架,就需要更新应用本身。
- 服务框架是 Web 容器的一部分
Web 应用一般使用 JBoss、Tomcat、Jetty 作为容器,因此要遵循不同容器所支持的方法,把服务框架作为容器的一部分。
- 服务框架自身是容器
但有的情况下可能不需要容器,那么服务框架自身就需要变为一个容器以提供远程调用和远程服务功能。
(2)Jar 包冲突
服务框架自身所依赖的包有可能与应用本身所依赖的包,在版本上冲突。这可以使用 ClassLoader 技术解决。
ClassLoader 结构可以把服务框架自身用到的类和应用所用到的类控制在 User-Defined Class Loader 级别,这样就可以实现相互隔离。Web 容器对于多个应用的处理,以及 OSGi 对于不同 Bundle 的处理都采用了类似的方法。
实践中,需要保证服务框架比应用优先启动,并且把一些需要统一的 jar 包放在 User-Defined Class Loader 所公用的祖先 User-Defined Class Loader 中,统一版本。
2.3.2 选择服务调用者与服务提供者之间的通信方式
服务调用者与服务提供者提供直连服务注册查找中心为调用者提供了可用的服务提供者列表。考虑到效率,我们会把列表缓存在调用者本地。当列表发生变动时,服务中心会发起通知,告知调用者需要更新缓存。
客户端得到服务提供者地址后,可以使用随机、轮询或权重进行路由选择。权重一般采用的是动态权重方式,可以根据响应时间等参数进行计算。如果服务提供者的机器能力对等,那么可以采用随机或轮询这些更容易实现的方式。如果服务提供者的机器能力不对等,那么采用权重计算更合适。
2.3.3 使用基于接口、方法、参数的路由策略
一个集群中会提供多个服务,每个服务都会有多个方法,所以我们需要提供更加细粒度的路由选择策略。
如果某个方法执行得非常慢,那么线程可能都陷入执行这个方法的状态中,这之后再进来的请求就需要排队等待,而且等待的时间可能会非常长!
有两种解决方法:
- 增加资源:提供系统的处理能力。
- 隔离资源:让执行快慢、重要级别不同的方法互不影响。
我们可以通过路由选择,让其中的某些服务请求到一部分机器,而另一部分服务请求到另一部分机器。
实践中,可以把路由规则集中管理。根据服务定位服务的集群地址与接口路由规则中的地址取交集,然后通过负载均衡算法,最终得到一个可用的地址。
可以把规则定义的更精细些,即基于接口的具体方法来进行路由选择。
2.3.4 同城多机房场景
每个机房都会有容量上限,如果网站的规模很大,那么就需要多个机房咯。
多机房场景分布在两个机房的调用者会对等地看待分布在不同机房中的服务提供者的机器。同城机房一般采用光纤直连,所以带宽足够大,延迟也可以接受。当然如果可以在同一机房更好。
服务注册查找中心为不同机房的调用者提供相同的服务提供者列表。然后在服务框架中进行地址过滤。实践中,因为每个机房的网段不同,所以可以依此区分不同的机房。
还有一个问题,机房未必既是服务调用者又是服务提供者,在机房很多时,这个问题会变得更加明显。我们可以采用虚拟机房的概念,把物理上的多个机房看做一个逻辑机房来处理路由规则。当然也可以根据业务与应用的特点把一个物理机房拆分成多个逻辑机房。
2.3.5 服务调用者的流控处理
有两种控制方式:
- 0 - 1 开关。
- 设置最大阈值 - 表示每秒可以进行的请求数,如果超过这个数的话就拒绝请求或排队。
可以基于这些维度进行控制:
- 根据服务端的接口、方法设定阈值。
- 根据来源进行控制。
2.3.6 序列化与反序列化
序列化就是把内存对象变为二进制数据;而反序列化就是把二进制数据变为内存对象。
Java 本身已经提供了序列化与反序列化方法,但要注意:
- 跨语言问题:如果整个分布式系统中的调用者或服务提供者使用 Java 之外的语言来实现,就要考虑跨语言问题。
- Java 序列化与反序列化方法自身的性能开销。
- 序列化后的长度。
可以选择 HTTP 作为通信协议,服务调用中的具体协议可以采用 XML 、JSON 或其它的二进制表示方式。
实践中需要具体考虑协议的扩展性、向后兼容性。显式地标明版本号很重要。可扩展的属性会方便我们对协议进行扩展。
2.3.7 选择网络通信的实现方式
建议采用 NIO 的方式,因为客户端与服务端的连接是可以复用的。
NIO 方式NIO 方式可以通过一个连接来进行多个并发请求操作。而对外需要提供类似阻塞的同步请求方式,所以我们需要完成异步转同步的工作,还要处理调用超时的情况以及对发送的数据进行流量保护。
客户端使用 NIO 流程示意图IO 线程只负责连接 SOCKET,进行数据收发操作。需要发送的数据都会进入数据队列,这样就可以复用 SOCKET。这里要关注数据队列的长度,因为可能会造成内存的溢出。通信对象队列保存了多个线程使用的通信对象,它用于唤醒请求线程。如果在远程调用超时前有结果返回,那么 IO 线程就会通知通信对象,由通信对象通知请求线程结束等待,并把结果发送给请求线程,以便进行后续处理。这里还设计了定时任务,它负责检查队列中那些已经超时的通信对象,并通知请求线程这些通信对象已经超时。
2.3.8 多种异步调用方式
【1】Oneway
只发送请求而不关心结果。
Oneway 调用方式这里只需要把要发送的数据放入数据队列即可,然后就可以处理后续任务咯。IO 线程也只需要从数据队列中读取数据,然后通过 SOCKET 发送出去。这种方式相当于不保证可靠送达的通知。
【2】Callback
这种方式下的请求方发送请求后,会继续执行后续操作,等对方响应后进行回调。
Callback 调用方式这里也是通过定时任务来支持超时情况。如果超时仍然需要执行回调,告知已经超时。建议使用新的线程来执行回调操作,这样不会因为执行回调时间久而影响 IO 线程或定时任务线程。
【3】Future
Java 的 Future 是一个很好的特性。
Future可以通过 Future 来获取通信结果并控制超时,是不是很方便呀O(∩_∩)O~
【4】可靠异步
保证异步请求能够在远程被执行,这一般是通过消息中间件来实现的。
总结如下:
异步通信方式 | 特性 |
---|---|
oneway | 单向通知 |
callback | 回调,被动通知 |
Future | 主动控制超时并获取结果 |
可靠异步 | 保证异步请求在远程被执行 |
2.3.9 优化调用多个远程服务(Future 方式)
实践中,会出现一个请求中调用多个远程服务的情况:
调用多个服务
完成这次请求需要 95ms(15+25+35+20),在 95ms 的大部分时间里是在等待远程结果。
可以这样优化:
并行调用多个服务我们按照顺序请求这些服务,然后再统一等待返回结果(依赖 Future 方式)。这样等待的时间就会变得更短,也就更高效。当然,前提是所调用的服务之间没有依赖关系。如果服务之间存在依赖关系,那么就只能等待之前的服务响应后才能进行后续的服务。
注意反序列化的工作一般是使用 IO 线程,但这会影响 IO 线程的效率。也可以把反序列化的工作放在其他线程进行处理。
2.4 服务提供端的设计
服务提供端有两项工作:
- 注册并管理本地服务。
- 根据请求定位服务并执行。
2.4.1 暴露远程服务
服务提供端也是通过 spring 配置一个 bean 来暴露服务的。
这个 Bean 与服务调用端相似,也有这些配置:
interfaceName - 接口名称。
version - 版本号。因为接口是会变化的,使用版本号可以对新、旧方法进行区分隔离。
group(可选) - 组名称。如果同一个接口的远程服务有很多机器,那么我们可以把这些远程服务的机器进行归组,这样就可以隔离不同的调用者。
target(新增)- 表明需要具体执行服务的 Bean。
这个 Bean 需要把自己的服务注册到服务查找中心。
2.4.2 处理请求流程
在服务框架启动时需要监听服务端口。
服务端处理请求流程在网络通信部分,会由多个 IO 线程进行通信处理。调用服务的工作是在工作线程(非 IO 线程)内进行的,而反序列化工作在 IO 线程还是工作线程则取决于具体实现。
2.4.3 隔离不同服务的线程池
服务端的工作线程是线程池,我们可以在服务端进行控制,根据服务的名称、方法和参数,来确定具体执行服务的线程池。
2.4.4 服务端的流控处理
对于不同来源的服务调用者,都需要实现 0-1 开关以及设置阈值的功能。这样就可以对不同的服务调用者进行分级,确保为优先级高的服务调用者优先提供服务,这也是确保稳定性的策略。
可以把序列化、协议、通信等公用的功能放在一起实现,形成一个完整的服务框架。因为某个服务的调用者,可能是另一个服务的提供者。
整个服务框架作为一个产品,默认使用随机选择服务地址的策略,在某些场景下再用到权重。服务框架必须做到模块化、可配置、模块可替换,并留有一定的扩展点。
2.5 升级服务
有两种情况:
- 接口不变,只是完善内部代码 - 处理简单,采用灰度发布的方式验证后就可以全部发布咯O(∩_∩)O~
- 需要修改原有接口:
【1】在接口中增加新方法 - 处理简单,让需要新方法的调用者使用新方法,旧方法可以继续使用。
【2】对接口中的某些方法修改调用的参数列表。相对复杂,有这些方法:
- 版本号 - 需要使用新版本的方法调用者使用新版本的服务。
- 设计方法时考虑参数的可扩展性 - 可行,但不太好。因为参数列表可扩展意味着采用类似 Map 的方式来传递参数,这样做不仅不直观,而且对参数的校验也会比较复杂。
3 优化
【1】 拆分服务
需要拆分的服务必须是那些能够提供公共功能的服务。
【2】 服务的粒度
根据业务的实际情况来划分服务的粒度。
【3】优雅与实用之间的平衡
有些功能直接在服务调用者的机器上实现会更合适、也更经济。
一般情况下,是由服务提供者完成与缓存和数据库的交互。
由服务提供者完成与缓存和数据库的交互
但如果服务调用者读取数据的频率很高的话,直接让服务调用者读取缓存会更合适。我们可以做一个客户端,把读取缓存的逻辑放在客户端中,如果缓存读取成功就结束操作,否则就到服务提供者那里读取数据库,更新缓存。其他的写操作还是由服务提供者进行处理。
优化后这是一个实用的方案,因为对大多数数据的请求只需读取一次缓存就可以咯O(∩_∩)O~
【4】分布式环境下的合并请求
对于热点数据,如果可以对这些任务进行合并处理,那么就会明显降低整个系统的负载。
假设在单机环境中,一个根据请求读取数据并生成报表的服务。在执行的过程中,我们会从远程读取大量的数据,然后进行复杂的统计计算,最后生成报表。我们可以增加缓存来减少数据读取和计算的工作量。如果有的数据已经计算过,那么之后的请求直接使用其结果即可。缓存的时间是由业务特性决定的。
合并请求后的流程可以依赖 Future 来实现。
而在分布式环境下,上述的思路会出现问题。因为分布式环境会涉及多个节点,很难判断这些节点是否有同样的任务在执行。在分布式环境中,需要独立于服务调用者、提供者之外的节点,即分布式锁服务来控制,这会有额外的开销,需要权衡。
也可以根据一定的路由规则把同样的请求发送到同一个服务提供者,然后再在这些服务提供者的机器上进行单机控制,这样做可以降低复杂度。
对于比较消耗系统资源的操作,都可以在服务调用者中进行单机的多线程控制。
实践中,要根据具体场景和实际数据支持来做选择。
4 治理服务
我们把服务治理分为两方面:
- 管理服务 - 控制、操作整个分布式系统中的服务。
- 查看服务 - 查看运行时的状态、信息和历史数据等等。
查看服务包含这些内容:
- 服务信息。
- 服务质量 - 被调用服务的出错率、响应时间等对服务质量进行评估。
- 服务容量 - 根据所提供服务的总能力(请求数量)以及当前所使用的容量进行评估。
- 服务依赖 - 某个服务与上下游服务的依赖关系。
- 服务分布 - 同样服务机器的具体分布情况(主要是跨机房的分布情况)。
- 统计服务 - 服务运行时的信息统计。
- 服务报表 - 非实时服务的各种统计信息报表,包括不同时段的对比以及分时统计信息。
- 监视服务 - 采集服务运行期间的关键数据、规则处理与告警。监视服务只提供决策的数据基础,并根据已定义的规则进行告警。
管理服务包含这些内容:
- 服务上下线。
- 服务路由。
- 服务限流降级。
- 归组服务。
- 管理服务线程池。
- 机房规则 - 多机房、虚拟机房规则的管理。
- 授权服务 - 使用一些重要的服务,需要有授权与鉴权的支持。
5 服务框架与 ESB 模型的不同
ESB 模型ESB 模型是从面向服务体系结构(SOA)发展过来的,它可以解耦多样化类型的服务调用者和服务提供者。
服务框架与 ESB 模型的不同点说明如下:
- | 服务框架 | ESB 模型 |
---|---|---|
模型 | 点对点 | 总线式 |
对象 | 面向同构系统 | 面向异构系统(不同的厂商) |