微服务拆分的原则
对于复杂的单体架构,可以看做是一个大的蜘蛛网,不同功能模块,错综复杂地交织在一起,方法之间调用关系非常的复杂,导致修复了一个 Bug,可能会引起另外多个 Bug,整体的维护成本非常高。同时,数据库较弱的扩展性,也限制了服务的扩展能力。
拆分原则:
原则一,做到单一服务内部功能的高内聚,和低耦合。也就是说,每个服务只完成自己职责之内的任务,对于不是自己职责的功能,交给其它服务来完成。
原则二,你需要关注服务拆分的粒度,先粗略拆分,再逐渐细化。在服务拆分的初期,你其实很难确定,服务究竟要拆分成什么样。但是,从“微服务”这几个字来看,服务的粒度貌似应该足够小,甚至有“一方法一服务”的说法。不过,服务多了也会带来问题,像是服务个数的增加会增加运维的成本。再比如,原本一次请求只需要调用进程内的多个方法,现在则需要跨网络调用多个 RPC 服务,在性能上肯定会有所下降。
拆分初期可以把服务粒度拆的粗一些,后面随着团队对于业务和微服务理解的加深,再考虑把服务粒度细化。比如说,对于一个社区系统来说,你可以先把和用户关系相关的业务逻辑,都拆分到用户关系服务中,之后,再把比如黑名单的逻辑独立成黑名单服务。
原则三,拆分的过程,要尽量避免影响产品的日常功能迭代,也就是说,要一边做产品功能迭代,一边完成服务化拆分。
剥离的顺序,你可以参考以下几点:
- 优先剥离比较独立的边界服务(比如短信服务、地理位置服务),从非核心的服务出发,减少拆分对现有业务的影响,也给团队一个练习、试错的机会;
- 当两个服务存在依赖关系时,优先拆分被依赖的服务。比方说,内容服务依赖于用户服务获取用户的基本信息,那么如果先把内容服务拆分出来,内容服务就会依赖于一体化架构中的用户模块,这样还是无法保证内容服务的快速部署能力。
原则四,服务接口的定义要具备可扩展性。服务拆分之后,由于服务是以独立进程的方式部署,所以服务之间通信,就不再是进程内部的方法调用,而是跨进程的网络通信了。在这种通信模型下需要注意,服务接口的定义要具备可扩展性,否则在服务变更时,会造成意想不到的错误。(比如可以把参数封装成类,这样以后只需要添加字段就可以,参数数量不会改变)
微服务化带来的问题和解决思路
- 服务接口的调用,不再是同一进程内的方法调用,而是跨进程的网络调用,这会增加接口响应时间的增加。此时,我们就要选择高效的服务调用框架,同时,接口调用方需要知道服务部署在哪些机器的哪个端口上,这些信息需要存储在一个分布式一致性的存储中,于是就需要引入服务注册中心。
- 多个服务之间有着错综复杂的依赖关系。一个服务会依赖多个其它服务,也会被多个服务所依赖,那么一旦被依赖的服务的性能出现问题,产生大量的慢请求,就会导致依赖服务的工作线程池中的线程被占满,那么依赖的服务也会出现性能问题。接下来,问题就会沿着依赖网,逐步向上蔓延,直到整个系统出现故障为止。
为了避免这种情况的发生,我们需要引入服务治理体系,针对出问题的服务,采用熔断、降级、限流、超时控制的方法,使得问题被限制在单一服务中,保护服务网络中的其它服务不受影响。
- 服务拆分到多个进程后,一条请求的调用链路上,涉及多个服务,那么一旦这个请求的响应时间增长,或者是出现错误,我们就很难知道,是哪一个服务出现的问题。另外,整体系统一旦出现故障,很可能外在的表现是所有服务在同一时间都出现了问题,你在问题定位时,很难确认哪一个服务是源头,这就需要引入分布式追踪工具,以及更细致的服务端监控报表。
RPC 框架
来思考这样一个场景:你的系统的 QPS 已经达到了每秒 2 万次,在做了服务化拆分之后,由于我们把业务逻辑,都拆分到了单独部署的服务中,那么假设你在完成一次完整的请求时,需要调用 4~5 次服务,计算下来,RPC 服务需要承载大概每秒 10 万次的请求。此时要做的是:
选择合适的网络模型,有针对性地调整网络参数,以优化网络传输性能;
选择合适的序列化方式,以提升封包、解包的性能。
RPC 并不是互联网时代的产物,也不是服务化之后才衍生出来的技术,而是一种规范,只要是封装了网络调用的细节,能够实现远程调用其他服务,就可以算作是一种 RPC 技术了。
电商系统中,商品详情页面需要商品数据、评论数据还有店铺数据,如果在一体化的架构中,你只需要从商品库,评论库和店铺库获取数据就可以了,不考虑缓存的情况下有三次网络请求。
但是,如果独立出商品服务、评论服务和店铺服务之后,那么就需要分别调用这三个服务,而这三个服务又会分别调用各自的数据库,这就是六次网络请求。如果你服务拆分的更细粒度,那么多出的网络调用就会越多,请求的延迟就会更长,而这就是你为了提升系统的扩展性,在性能上所付出的代价。

1.在一次 RPC 调用过程中,客户端首先会将调用的类名、方法名、参数名、参数值等信息,序列化成二进制流;
2.然后客户端将二进制流,通过网络发送给服务端;
3.服务端接收到二进制流之后,将它反序列化,得到需要调用的类名、方法名、参数名和参数值,再通过动态代理的方式,调用对应的方法得到返回值;
4.服务端将返回值序列化,再通过网络发送给客户端;
5.客户端对结果反序列化之后,就可以得到调用的结果了。

从这张图中你可以看到,有网络传输的过程,也有将请求序列化和反序列化的过程, 所以,如果要提升 RPC 框架的性能,需要从网络传输和序列化两方面来优化。
为了优化网络IO,使用IO多路复用优化传输
选择合适的序列化方式
一次 RPC 调用需要经历两次数据序列化的过程,和两次数据反序列化的过程,可见它们对于 RPC 的性能影响是很大的。
首先需要考虑的肯定是性能,性能包括时间上的开销和空间上的开销,时间上的开销就是序列化和反序列化的速度,这是显而易见需要重点考虑的,而空间上的开销则是序列化后的二进制串的大小,过大的二进制串也会占据传输带宽,影响传输效率。
除去性能之外,我们需要考虑的是它是否可以跨语言,跨平台,这一点也非常重要,因为一般的公司的技术体系都不是单一的,使用的语言也不是单一的,那么如果你的 RPC 框架中传输的数据只能被一种语言解析,那么这无疑限制了框架的使用。
我们的序列化备选方案主要有以下几种:
首先是大家熟知的 JSON,它起源于 JavaScript,是一种最广泛使用的序列化协议,它的优势简单易用,人言可读,同时在性能上相比 XML 有比较大的优势。
另外的 Thrift 和 Protobuf 都是需要引入 IDL(Interface description language)的,也就是需要按照约定的语法写一个 IDL 文件,然后通过特定的编译器将它转换成各语言对应的代码,从而实现跨语言的特点。
Thrift 是 Facebook 开源的高性能的序列化协议,也是一个轻量级的 RPC 框架;Protobuf 是谷歌开源的序列化协议。它们的共同特点是,无论在空间上还是时间上都有着很高的性能,缺点就是由于 IDL 存在带来一些使用上的不方便。
如果对于性能要求不高,在传输数据占用带宽不大的场景下,可以使用 JSON 作为序列化协议;
如果对于性能要求比较高,那么使用 Thrift 或者 Protobuf 都可以。而 Thrift 提供了配套的 RPC 框架,所以想要一体化的解决方案,你可以优先考虑 Thrift;
在一些存储的场景下,比如说你的缓存中存储的数据占用空间较大,那么你可以考虑使用 Protobuf 替换 JSON,作为存储数据的序列化方式。
注册中心
注册中心的基本功能有两点:
其一是提供了服务地址的存储;
其二是当存储内容发生变化时,可以将变更的内容推送给客户端。
(还需进行服务状态的维护)
服务状态管理如何来做
第一种思路是主动探测,方法是这样的:
你的 RPC 服务要打开一个端口,然后由注册中心每隔一段时间(比如 30 秒)探测这些端口是否可用,如果可用就认为服务仍然是正常的,否则就可以认为服务不可用,那么注册中心就可以把服务从列表里面删除了。

此方法存在的问题:
第一个问题是:所有的 RPC 服务端都需要,开放一个统一的端口给注册中心探测,如果没有容器化,一台物理机上会混合部署很多的服务,你需要开放的端口很可能已经被占用,这样会造成 RPC 服务启动失败。
还有一个问题是:如果 RPC 服务端部署的实例比较多,那么每次探测的成本也会比较高,(由注册中心发起,对注册中心负担较大)探测的时间也比较长,这样当一个服务不可用时,可能会有一段时间的延迟,才会被注册中心探测到。
心跳模式
注册中心为每一个连接上来的 RPC 服务节点,记录最近续约的时间,RPC 服务节点在启动注册到注册中心后,就按照一定的时间间隔(比如 30 秒),向注册中心发送心跳包。注册中心在接受到心跳包之后,会更新这个节点的最近续约时间。然后,注册中心会启动一个定时器,定期检测当前时间和节点,最近续约时间的差值,如果达到一个阈值(比如说 90 秒),那么认为这个服务节点不可用。(客户端只需向同一个节点端口发送心跳就可以,解决了第一种方式的弊端)。

慢请求要如何排查
一体化架构排查
可以在调用服务前后增加打印日志,如果接口很多,手动添加很复杂,所以通过代理实现。
切面编程的实现分为两类:
一类是静态代理,典型的代表是 AspectJ,它的特点是在编译期做切面代码注入;
另一类是动态代理,典型的代表是 Spring AOP,它的特点是在运行期做切面代码注入。
这两者有什么差别呢?以 Java 为例,源代码 Java 文件先被 Java 编译器,编译成 Class 文件,然后 Java 虚拟机将 Class 装载进来之后,进行必要的验证和初始化后就可以运行了。
静态代理是在编译期插入代码,增加了编译的时间,给你的直观感觉就是启动的时间变长了,但是一旦在编译期插入代码完毕之后,在运行期就基本对于性能没有影响。
而动态代理不会去修改生成的 Class 文件,而是会在运行期生成一个代理对象,这个代理对象对源对象做了字节码增强,来完成切面所要执行的操作。由于在运行期需要生成代理对象,所以动态代理的性能要比静态代理要差。
如果系统的 QPS 是 10000 的话,就是每秒钟会产生十几万条日志,对于磁盘 I/O 的负载是巨大的,那么这时,你就要考虑如何减少日志的数量。(并且 假如需要上报阿里云查询,对于公网带宽占用很大,可能会影响业务)。
可以考虑对请求做采样,采样的方式也简单,比如你想采样 10% 的日志,那么你可以只打印“requestId%10==0”的请求。
有了这些日志之后,当给你一个 requestId 的时候,你发现自己并不能确定这个请求到了哪一台服务器上,所以你不得不登陆所有的服务器,去搜索这个 requestId 才能定位请求。这样无疑会增加问题排查的时间。
你可以考虑的解决思路是:把日志不打印到本地文件中,而是发送到消息队列里,再由消息处理程序写入到集中存储中,比如 Elasticsearch。这样,你在排查问题的时候,只需要拿着 requestId 到 Elasticsearch 中查找相关的记录就好了。(elk)

分布式 Trace
我们采用 traceId + spanId 这两个数据维度来记录服务之间的调用关系(这里 traceId 就是 requestId),也就是使用 traceId 串起单次请求,用 spanId 记录每一次 RPC 调用。

用户到 A 服务之后会初始化一个 traceId 为 100,spanId 为 1;
A 服务调用 B 服务时,traceId 不变,而 spanId 用 1.1 标识,代表上一级的 spanId 是 1,这一级的调用次序是 1;
A 调用 C 服务时,traceId 依然不变,spanId 则变为了 1.2,代表上一级的 spanId 还是 1,而调用次序则变成了 2,以此类推。
通过这种方式,我们可以在日志中,清晰地看出服务的调用关系是如何的,方便在后续计算中调整日志顺序,打印出完整的调用链路。
首先,A 服务在发起 RPC 请求服务 B 前,先从线程上下文中获取当前的 traceId 和 spanId,然后,依据上面的逻辑生成本次 RPC 调用的 spanId,再将 spanId 和 traceId 序列化后,装配到请求体中,发送给服务方 B。
服务方 B 获取请求后,从请求体中反序列化出 spanId 和 traceId,同时设置到线程上下文中,以便给下次 RPC 调用使用。在服务 B 调用完成返回响应前,计算出服务 B 的执行时间发送给消息队列。
当然,在服务 B 中,你依然可以使用切面编程的方式,得到所有调用的数据库、缓存、HTTP 服务的响应时间,只是在发送给消息队列的时候,要加上当前线程上下文中的 spanId 和 traceId。
这样,无论是数据库等资源的响应时间,还是 RPC 服务的响应时间就都汇总到了消息队列中,在经过一些处理之后,最终被写入到 Elasticsearch 中以便给开发和运维同学查询使用。
因为引入了分布式追踪中间件,导致对于磁盘 I/O 和网络 I/O 的影响,如果使用开源的组件,可以开始设置一个较低的日志采样率,观察系统性能情况再调整到一个合适的数值。
负载均衡
负载均衡服务大体上可以分为两大类:
一类是代理类的负载均衡服务;
另一类是客户端负载均衡服务。

代理类的负载均衡服务,以单独的服务方式部署,所有的请求都要先经过负载均衡服务,在负载均衡服务中,选出一个合适的服务节点后,再由负载均衡服务,调用这个服务节点来实现流量的分发。
由于这类服务需要承担全量的请求,所以对于性能的要求极高。代理类的负载均衡服务有很多开源实现,比较著名的有 LVS,Nginx 等等。LVS 在 OSI 网络模型中的第四层,传输层工作,所以 LVS 又可以称为四层负载;而 Nginx 运行在 OSI 网络模型中的第七层,应用层,所以又可以称它为七层负载。
在项目的架构中,我们一般会同时部署 LVS 和 Nginx 来做 HTTP 应用服务的负载均衡。也就是说,在入口处部署 LVS,将流量分发到多个 Nginx 服务器上,再由 Nginx 服务器分发到应用服务器上。
LVS 是在网络栈的四层做请求包的转发,请求包转发之后,由客户端和后端服务直接建立连接,后续的响应包不会再经过 LVS 服务器,所以相比 Nginx,性能会更高,也能够承担更高的并发。
可 LVS 缺陷是工作在四层,而请求的 URL 是七层的概念,不能针对 URL 做更细致地请求分发,而且 LVS 也没有提供探测后端服务是否存活的机制;而 Nginx 虽然比 LVS 的性能差很多,但也可以承担每秒几万次的请求,并且它在配置上更加灵活,还可以感知后端服务是否出现问题。
因此,LVS 适合在入口处,承担大流量的请求分发,而 Nginx 要部署在业务服务器之前做更细维度的请求分发。
如果QPS 在十万以内,那么可以考虑不引入 LVS 而直接使用 Nginx 作为唯一的负载均衡服务器,这样少维护一个组件,也会减少系统的维护成本。
不过这两个负载均衡服务适用于普通的 Web 服务,对于微服务架构来说,它们是不合适的。因为微服务架构中的服务节点存储在注册中心里,使用 LVS 就很难和注册中心交互,获取全量的服务节点列表。另外,一般微服务架构中,使用的是 RPC 协议而不是 HTTP 协议,所以 Nginx 也不能满足要求。
负载均衡服务
它一般和客户端应用,部署在一个进程中,提供多种选择节点的策略,最终为客户端应用提供一个最佳的,可用的服务端节点。这类服务一般会结合注册中心来使用,注册中心提供服务节点的完整列表,客户端拿到列表之后使用负载均衡服务的策略选取一个合适的节点,然后将请求发到这个节点上。

常见的负载均衡策略有哪些
一类是静态策略,也就是说负载均衡服务器在选择服务节点时,不会参考后端服务的实际运行的状态。
一类是动态策略,也就是说负载均衡服务器会依据后端服务的一些负载特性,来决定要选择哪一个服务节点。
静态策略
常见的静态策略有几种,其中使用最广泛的是轮询的策略(RoundRobin,RR),这种策略会记录上次请求后端服务的地址或者序号,然后在请求时,按照服务列表的顺序,请求下一个后端服务节点。
轮询的策略可以做到将请求尽量平均地分配到所有服务节点上,但是,它没有考虑服务节点的具体配置情况。比如,你有三个服务节点,其中一个服务节点的配置是 8 核 8G,另外两个节点的配置是 4 核 4G,那么如果使用轮询的方式来平均分配请求的话,8 核 8G 的节点分到的请求数量和 4 核 4G 的一样多,就不能发挥性能上的优势了。
所以,我们考虑给节点加上权重值,比如给 8 核 8G 的机器配置权重为 2,那么就会给它分配双倍的流量,这种策略就是带有权重的轮询策略。
除了这两种策略之外,目前开源的负载均衡服务还提供了很多静态策略:
Nginx 提供了 ip_hash 和 url_hash 算法;
LVS 提供了按照请求的源地址,和目的地址做 hash 的策略;
Dubbo 也提供了随机选取策略,以及一致性 hash 的策略。
动态策略
在负载均衡服务器上会收集对后端服务的调用信息,比如从负载均衡端到后端服务的活跃连接数,或者是调用的响应时间,然后从中选择连接数最少的服务,或者响应时间最短的后端服务。我举几个具体的例子:
Dubbo 提供的 LeastAcive 策略,就是优先选择活跃连接数最少的服务;
Spring Cloud 全家桶中的 Ribbon 提供了 WeightedResponseTimeRule 是使用响应时间,给每个服务节点计算一个权重,然后依据这个权重,来给调用方分配服务节点。
这些策略的思考点是从调用方的角度出发,选择负载最小、资源最空闲的服务来调用,以期望能得到更高的服务调用性能,也就能最大化地使用服务器的空闲资源,请求也会响应地更迅速。
网关
对于服务系统,会有一些鉴权,限流,或者黑名单策略,如果在不同的服务都写这样的一段代码会很冗余,即便通过打jar包的方式复用,只能在java使用,正常系统不是只有java一种语言,所以需要网关进行统一的处理,包括一些业务路由。
1、它提供客户端一个统一的接入地址,API 网关可以将用户的请求动态路由到不同的业务服务上,并且做一些必要的协议转换工作。在你的系统中,你部署的微服务对外暴露的协议可能不同:有些提供的是 HTTP 服务;有些已经完成 RPC 改造,对外暴露 RPC 服务;有些遗留系统可能还暴露的是 Web Service 服务。API 网关可以对客户端屏蔽这些服务的部署地址,以及协议的细节,给客户端的调用带来很大的便捷。
2、另一方面,在 API 网关中,我们可以植入一些服务治理的策略,比如服务的熔断、降级,流量控制和分流等等。
3、客户端的认证和授权的实现,也可以放在 API 网关中。你要知道,不同类型的客户端使用的认证方式是不同的。在我之前项目中,手机 APP 使用 Oauth 协议认证,HTML5 端和 Web 端使用 Cookie 认证,内部服务使用自研的 Token 认证方式。这些认证方式在 API 网关上,可以得到统一处理,应用服务不需要了解认证的细节。
4、另外,API 网关还可以做一些与黑白名单相关的事情,比如针对设备 ID、用户 IP、用户 ID 等维度的黑白名单。
5、最后,在 API 网关中也可以做一些日志记录的事情,比如记录 HTTP 请求的访问日志,以及分布式链路追踪,提到的标记一次请求的 traceId,也可以在网关中来生成。

提升 API 网关性能的关键还是在 I/O 模型上。
Netfix 开源的 API 网关 Zuul,在 1.0 版本的时候使用的是同步阻塞 I/O 模型,整体系统其实就是一个 servlet,在接收到用户的请求,然后执行在网关中配置的认证、协议转换等逻辑之后,调用后端的服务获取数据返回给用户。
而在 Zuul2.0 中,Netfix 团队将 servlet 改造成了一个 netty server(netty 服务),采用 I/O 多路复用的模型处理接入的 I/O 请求,并且将之前同步阻塞调用后端服务的方式,改造成使用 netty client(netty 客户端)非阻塞调用的方式。改造之后,Netfix 团队经过测试发现性能提升了 20% 左右。
API 网关中执行的动作有些是可以预先定义好的,比如黑白名单的设置,接口动态路由;有些则是需要业务方依据自身业务来定义。所以,API 网关的设计要注意扩展性,也就是你可以随时在网关的执行链路上,增加一些逻辑,也可以随时下掉一些逻辑(也就是所谓的热插拔)。
所以一般来说,我们可以把每一个操作定义为一个 filter(过滤器),然后使用“责任链模式”将这些 filter 串起来。责任链可以动态地组织这些 filter,解耦 filter 之间的关系,无论是增加还是减少 filter,都不会对其他的 filter 有任何的影响。
为了提升网关对于请求的并行处理能力,我们一般会使用线程池来并行的执行请求。不过,这就带来一个问题:如果商品服务出现问题,造成响应缓慢,那么调用商品服务的线程就会被阻塞无法释放,久而久之,线程池中的线程就会被商品服务所占据,那么其他服务也会受到级联的影响。因此,我们需要针对不同的服务做线程隔离,或者保护。(比如线程池或者信号量隔离)