Dunwu Blog

大道至简,知易行难

Dubbo 快速入门

简介

Apache Dubbo 是一款高性能、轻量级的开源微服务框架。它提供了 RPC 通信微服务治理 两大关键能力。

Dubbo 的核心功能

Dubbo 提供了六大核心能力:

  • 面向接口代理的高性能 RPC 调用:提供高性能的基于代理的远程调用能力,服务以接口为粒度,为开发者屏蔽远程调用底层细节。
  • 智能容错和负载均衡:内置多种负载均衡策略,智能感知下游节点健康状况,显著减少调用延迟,提高系统吞吐量。
  • 服务自动注册和发现:支持多种注册中心服务,服务实例上下线实时感知。
  • 高度可扩展能力:遵循微内核+插件的设计原则,所有核心能力如 Protocol、Transport、Serialization 被设计为扩展点,平等对待内置实现和第三方实现。
  • 运行期流量调度:内置条件、脚本等路由策略,通过配置不同的路由规则,轻松实现灰度发布,同机房优先等功能。
  • 可视化的服务治理与运维:提供丰富服务治理、运维工具:随时查询服务元数据、服务健康状态及调用统计,实时下发路由策略、调整配置参数。

Dubbo 的优势

Dubbo 提供了一站式微服务解决方案,包括服务定义、服务发现、服务通信到流量管控等几乎所有的服务治理能力,并且尝试从使用上对用户屏蔽底层细节,以提供更好的易用性。

定义服务在 Dubbo 中非常简单与直观:

  • 使用与某种语言绑定的方式(如 Java 中可直接定义 Interface)
  • 使用 Protobuf IDL 语言的方式。

Dubbo 提供了丰富的通信模型

  • 消费端异步请求(Client Side Asynchronous Request-Response)
  • 提供端异步执行(Server Side Asynchronous Request-Response)
  • 消费端请求流(Request Streaming)
  • 提供端响应流(Response Streaming)
  • 双向流式通信(Bidirectional Streaming)

Dubbo 提供基于客户端的服务发现机制,可以采用多种方式启用服务发现:

  • 使用第三方的注册中心组件,如 Nacos、Zookeeper、Consul、Etcd 等。
  • 将服务的组织与注册交给底层容器平台,如 Kubernetes,这被理解是一种更云原生的方式

Dubbo 提供了多种流量控制手段,包括负载均衡、流量路由、请求超时、流量降级、重试等策略。基于这些基础能力可以轻松的实现更多场景化的路由方案,包括金丝雀发布、A/B 测试、权重路由、同区域优先等。

Dubbo 的扩展性良好:通过 Filter、Router、Protocol 等几乎存在于每一个关键流程上的扩展点定义,我们可以丰富 Dubbo 的功能或实现与其他微服务配套系统的对接,包括 Transaction、Tracing 目前都有通过 SPI 扩展的实现方案,具体可以参见 Dubbo 扩展性的详情,也可以在 apache/dubbo-spi-extensions 项目中发现与更多的扩展实现。

Dubbo 在支持微服务集群方面有着非常大的规模与非常久的实践经验积累,是最具有企业规模化微服务实践话语权的框架之一。

Dubbo3 的设计是面向云原生的:

  • 首先是对云原生基础设施与部署架构的支持,包括 Kubernetes、Service Mesh 等。
  • 另一方面,Dubbo 众多核心组件都已面向云原生升级,包括 Triple 协议、统一路由规则、对多语言支持。

Dubbo3 新特性

全新服务发现模型

相比于 2.x 版本中的基于接口粒度的服务发现机制,3.x 引入了全新的基于应用粒度的服务发现机制, 新模型带来两方面的巨大优势:

  • 进一步提升了 Dubbo3 在大规模集群实践中的性能与稳定性。新模型可大幅提高系统资源利用率,降低 Dubbo 地址的单机内存消耗(50%),降低注册中心集群的存储与推送压力(90%), Dubbo 可支持集群规模步入百万实例层次。
  • 打通与其他异构微服务体系的地址互发现障碍。新模型使得 Dubbo3 能实现与异构微服务体系如 Spring Cloud、Kubernetes Service、gRPC 等,在地址发现层面的互通, 为连通 Dubbo 与其他微服务体系提供可行方案。

下一代 RPC 通信协议

定义了全新的 RPC 通信协议 – Triple,一句话概括 Triple:它是基于 HTTP/2 上构建的 RPC 协议,完全兼容 gRPC,并在此基础上扩展出了更丰富的语义。 使用 Triple 协议,用户将获得以下能力

  • 更容易到适配网关、Mesh 架构,Triple 协议让 Dubbo 更方便的与各种网关、Sidecar 组件配合工作。
  • 多语言友好,推荐配合 Protobuf 使用 Triple 协议,使用 IDL 定义服务,使用 Protobuf 编码业务数据。
  • 流式通信支持。Triple 协议支持 Request Stream、Response Stream、Bi-direction Stream

云原生

Dubbo3 构建的业务应用可直接部署在 VM、Container、Kubernetes 等平台,Dubbo3 很好的解决了 Dubbo 服务与调度平台之间的生命周期对齐,Dubbo 服务发现地址 与容器平台绑定的问题。

在服务发现层面,Dubbo3 支持与 Kubernetes Native Service 的融合,目前限于 Headless Service。

Dubbo3 规划了两种形态的 Service Mesh 方案,在不同的业务场景、不同的迁移阶段、不同的基础设施保障情况下,Dubbo 都会有 Mesh 方案可供选择, 而这进一步的都可以通过统一的控制面进行治理。

  • 经典的基于 Sidecar 的 Service Mesh
  • 无 Sidecar 的 Proxyless Mesh

用户在 Dubbo2 中熟知的路由规则,在 3.x 中将被一套统一的流量治理规则取代,这套统一流量规则将覆盖未来 Dubbo3 的 Service Mesh、SDK 等多种部署形态, 实现对整套微服务体系的治理。

快速开始

定义服务

参考资料

服务容错

故障分类

从故障影响范围维度来看,分布式系统的故障可以分为三类:

  • 集群故障:根据业务量大小而定,集群规模从几台到甚至上万台都有可能。一旦某些代码出现 bug,可能整个集群都会发生故障,不能提供对外提供服务。
  • 机房故障:现在大多数互联网公司为了保证业务的高可用性,往往业务部署在不止一个机房。然而现实中,某机房的光缆因为道路施工被挖断,导致整个机房脱网的事情,也是时有发生的。并且这种事情往往容易上热搜。
  • 单机故障:集群中的个别机器出现故障,这种情况往往对全局没有太大影响,但会导致调用到故障机器上的请求都失败,影响整个系统的成功率。

集群故障应对处理

一般而言,集群故障的产生原因不外乎有两种:

  • 一种是代码 bug 所导致,比如说某一段 Java 代码不断地分配大对象,但没有及时回收导致 JVM OOM 退出;
  • 另一种是流量突刺,短时间突然而至的大量请求超出了系统的承载能力。

应付集群故障的思路,主要是采用流量控制,主要手段有:限流降级熔断

机房故障应对处理

单机房脱网的事情,多半是因为一些不可抗因素,如:机房失火、光缆被挖断等等。有句老话叫:不要把鸡蛋都放在一个篮子里。同理,不要把业务都部署在一个机房中,一旦机房出事,那就彻底完蛋了。所以,很多互联网公司的业务都采用多机房部署。如果要追求更高的可靠性,可以采用同城多活部署,甚至异地多活部署。

多机房部署的好处显而易见,即提高了系统的可用性,但是这种架构引入了其他的问题:如何保证不同机房数据的一致性,如何切换多机房的流量,等等。

针对流量切换问题,一般有两种手段:

  • 基于 DNS 解析的流量切换,一般是通过把请求访问域名解析的 VIP 从一个 IDC 切换到另外一个 IDC。
  • 基于 RPC 分组的流量切换,对于一个服务来说,如果是部署在多个 IDC 的话,一般每个 IDC 就是一个分组。假如一个 IDC 出现故障,那么原先路由到这个分组的流量,就可以通过向配置中心下发命令,把原先路由到这个分组的流量全部切换到别的分组,这样的话就可以切换故障 IDC 的流量了。

单机故障应对处理

对于大规模集群来说,出现单机故障的概率是很高的。当出现单机故障时,需要有一定的自动化处理手段。

处理单机故障一个有效的办法就是自动重启。具体来讲,你可以设置一个阈值,比如以某个接口的平均耗时为准,当监控单机上某个接口的平均耗时超过一定阈值时,就认为这台机器有问题,这个时候就需要把有问题的机器从线上集群中摘除掉,然后在重启服务后,重新加入到集群中。

容错策略

服务调用并不总是一定成功的,前面我讲过,可能因为服务提供者节点自身宕机、进程异常退出或者服务消费者与提供者之间的网络出现故障等原因。对于服务调用失败的情况,需要有手段自动恢复,来保证调用成功。

常用的手段主要有以下几种:

  • 故障转移(FailOver):当出现失败,重试其它服务器。通常用于读操作,但重试会带来更长延迟。这种策略要求服务调用的操作必须是幂等的,也就是说无论调用多少次,只要是同一个调用,返回的结果都是相同的,一般适合服务调用是读请求的场景。
  • 快速失败(FailFast):只发起一次调用,失败立即报错。通常用于非幂等性的写操作,比如新增记录。实际在业务执行时,一般非核心业务的调用,会采用快速失败策略,调用失败后一般就记录下失败日志就返回了。
  • 安全失败(Failsafe):出现异常时,直接忽略。通常用于写入审计日志等操作。
  • 静默失败(Failsilent):如果大量的请求需要等到超时(或者长时间处理后)才宣告失败,很容易由于某个远程服务的请求堆积而消耗大量的线程、内存、网络等资源,进而影响到整个系统的稳定。面对这种情况,一种合理的失败策略是当请求失败后,就默认服务提供者一定时间内无法再对外提供服务,不再向它分配请求流量,将错误隔离开来,避免对系统其他部分产生影响,此即为沉默失败策略。
  • 故障恢复(FailBack):就是服务消费者调用失败或者超时后,不再重试,而是根据失败的详细信息,来决定后续的执行策略。比如对于非幂等的调用场景,如果调用失败后,不能简单地重试,而是应该查询服务端的状态,看调用到底是否实际生效,如果已经生效了就不能再重试了;如果没有生效可以再发起一次调用。通常用于消息通知操作。
  • 并行调用(Forking):并行调用多个服务器,只要一个成功即返回。通常用于实时性要求较高的读操作,但需要浪费更多服务资源。
  • 广播调用(Broadcast):广播调用所有提供者,逐个调用,任意一台报错则报错。通常用于通知所有提供者更新缓存或日志等本地资源信息。

容错设计模式

断路器模式

断路器的基本思路是很简单的,就是通过代理(断路器对象)来一对一地(一个远程服务对应一个断路器对象)接管服务调用者的远程请求。断路器会持续监控并统计服务返回的成功、失败、超时、拒绝等各种结果,当出现故障(失败、超时、拒绝)的次数达到断路器的阈值时,它状态就自动变为“OPEN”,后续此断路器代理的远程访问都将直接返回调用失败,而不会发出真正的远程服务请求。通过断路器对远程服务的熔断,避免因持续的失败或拒绝而消耗资源,因持续的超时而堆积请求,最终的目的就是避免雪崩效应的出现。由此可见,断路器本质是一种快速失败策略的实现方式。

舱壁隔离模式

舱壁隔离模式是常用的实现服务隔离的设计模式,舱壁这个词是来自造船业的舶来品,它原本的意思是设计舰船时,要在每个区域设计独立的水密舱室,一旦某个舱室进水,也只是影响这个舱室中的货物,而不至于让整艘舰艇沉没。这种思想就很符合容错策略中失败静默策略。

Hystrix 就采用舱壁隔离模式来实现线程隔离。

重试模式

故障转移和故障恢复策略都需要对服务进行重复调用,差别是这些重复调用有可能是同步的,也可能是后台异步进行;有可能会重复调用同一个服务,也可能会调用到服务的其他副本。无论具体是通过怎样的方式调用、调用的服务实例是否相同,都可以归结为重试设计模式的应用范畴。重试模式适合解决系统中的瞬时故障,简单的说就是有可能自己恢复(Resilient,称为自愈,也叫做回弹性)的临时性失灵,网络抖动、服务的临时过载(典型的如返回了 503 Bad Gateway 错误)这些都属于瞬时故障。

参考资料

链路追踪

链路追踪简介

什么是链路追踪

链路追踪系统广义的概念是:由数据采集数据处理数据展示三个相对独立的模块所构成的分布式追踪系统;链路追踪系统狭义的概念是:特指链路追踪的数据采集。譬如 Spring Cloud Sleuth 就属于狭义的链路追踪系统,通常会搭配 Zipkin 作为数据展示,搭配 Elasticsearch 作为数据存储来组合使用;而 ZipkinPinpointSkyWalkingCAT 都属于广义的链路追踪系统。

个人理解,链路追踪的本质就是,通过全局唯一的 ID,将分布在各个服务节点上的同一次请求产生的数据串联起来,从而梳理出调用关系,进而辅助分析系统问题、分析调用数据并统计各种系统指标。

为什么需要链路追踪

链路追踪主要有以下作用

  • 分析系统瓶颈:通过记录调用经过的每一条链路上的耗时,我们能快速定位整个系统的瓶颈点在哪里。比如你访问微博首页发现很慢,肯定是由于某种原因造成的,有可能是运营商网络延迟,有可能是网关系统异常,有可能是某个服务异常,还有可能是缓存或者数据库异常。通过链路追踪,可以从全局视角上去观察,找出整个系统的瓶颈点所在,然后做出针对性的优化。
  • 分析链路调用:通过链路追踪可以分析调用所经过的路径,然后评估是否合理。比如一个服务调用下游依赖了多个服务,通过调用链分析,可以评估是否每个依赖都是必要的,是否可以通过业务优化来减少服务依赖。还有就是,一般业务都会在多个数据中心都部署服务,以实现异地容灾,这个时候经常会出现一种状况就是服务 A 调用了另外一个数据中心的服务 B,而没有调用同处于一个数据中心的服务 B。根据我的经验,跨数据中心的调用视距离远近都会有一定的网络延迟,像北京和广州这种几千公里距离的网络延迟可能达到 30ms 以上,这对于有些业务几乎是不可接受的。通过对调用链路进行分析,可以找出跨数据中心的服务调用,从而进行优化,尽量规避这种情况出现。
  • 生成网络拓扑:通过链路追踪中记录的链路信息,可以生成一张系统的网络调用拓扑图,它可以反映系统都依赖了哪些服务,以及服务之间的调用关系是什么样的,可以一目了然。除此之外,在网络拓扑图上还可以把服务调用的详细信息也标出来,也能起到服务监控的作用。
  • 透明传输数据:除了链路追踪,业务上经常有一种需求,期望能把一些用户数据,从调用的开始一直往下传递,以便系统中的各个服务都能获取到这个信息。比如业务想做一些 A/B 测试,这时候就想通过链路追踪,把 A/B 测试的开关逻辑一直往下传递,经过的每一层服务都能获取到这个开关值,就能够统一进行 A/B 测试。

链路追踪原理

Google 发布的一篇的论文 Dapper, a Large-Scale Distributed Systems Tracing Infrastructure,里面详细讲解了链路追踪的实现原理。Dapper 论文几乎成了现代链路追踪的理论基石,很多主流的链路追踪系统都是基于 Dapper 衍生出来的,比较有名的有 Twitter 的Zipkin、阿里的鹰眼、美团的MTrace等。

链路追踪核心概念

Dapper 提出了一些很重要的核心概念:Trace、Span、Annonation 等,这是理解链路追踪原理的前提。

Trace 和 Spans(图片来源于Dapper 论文

  • Trace (追踪) - 代表一次完整的请求。一次完整的请求是指,从客户端发起请求,记录请求流转的每一个服务,直到客户端收到响应为止。整个过程中,当请求分发到第一层级的服务时,就会生成一个全局唯一的 Trace ID,并且会随着请求分发到每一层级。因此,通过 Trace ID 就可以把一次用户请求在系统中调用的链路串联起来。
  • Span (跨度) - 代表一次调用,也是链路追踪的基本单元。由于每次 Trace 都可能会调用数量不定、坐标不定的多个服务,为了能够记录具体调用了哪些服务,以及调用的顺序、开始时点、执行时长等信息,每次开始调用服务前都要先埋入一个调用记录,这个记录称为一个 Span。
    • Span 的数据结构应该足够简单,以便于能放在日志或者网络协议的报文头里;也应该足够完备,起码应含有时间戳、起止时间、Trace 的 ID、当前 Span 的 ID、父 Span 的 ID 等能够满足追踪需要的信息。
    • Trace 实际上都是由若干个有顺序、有层级关系的 Span 所组成一颗 Trace Tree (追踪树)。
  • Annotation:用于业务自定义埋点数据,例如:一次请求的用户 ID,某一个支付订单的订单 ID 等。

数据埋点阶段

数据采集的作用就是在系统的各个不同模块中进行埋点,采集数据并上报给数据处理层进行处理。而一次请求可以分为四个阶段:

  • CS(Client Send)阶段 - 客户端发起请求时埋点,需要传递一些参数,比如服务的方法名等。
  • SR(Server Recieve)阶段 - 服务端接收请求时埋点,需要回填一些参数,比如 traceId,spanId。
  • SS(Server Send)阶段 - 服务端返回请求时埋点,这时会将上下文数据传递到异步上传队列中。
  • CR(Client Recieve)阶段 - 客户端接收返回结果时埋点,这时会将上下文数据传递到异步上传队列中。

下图显示了 Span 和 Trace 在系统中的样子。

img

(图片来源于 spring-cloud-sleuth 文档

图片说明:

每种颜色表示一个跨度(有七个跨度 - 从 A 到 G)

1
2
3
Trace Id = X
Span Id = D
Client Sent

类似上面的注释,表示当前跨度的跟踪 ID 设置为 X,跨度 ID 设置为 D。此外,从 RPC 的角度来看,发生了客户端发送事件。

下图显示了 span 的父子关系:

(图片来源于 spring-cloud-sleuth 文档

链路追踪实现

一个完整的数据链路系统大致可以分为三个相对独立的模块:

  • 数据采集 - 负责数据埋点并上报。
  • 数据处理 - 负责数据的存储与计算。
  • 数据展示 - 负责数据的可视化展示。

数据采集

数据采集负责数据埋点并上报。数据采集有三种主流的实现方式,分别是基于日志的追踪(Log-Based Tracing),基于服务的追踪(Service-Based Tracing)和基于边车代理的追踪(Sidecar-Based Tracing)。

基于日志的追踪

基于日志的追踪的思路是:将 Trace、Span 等信息直接输出到应用日志中,然后随着所有节点的日志采集汇聚到一起,再从全局日志信息中反推出完整的调用链拓扑关系。

基于日志的追踪有以下特点:

  • 侵入性小、性能影响低 - 对网络消息完全没有侵入性,对应用程序只有很少量的侵入性,对性能影响也非常低。
  • 依赖于日志采集过程,导致不够实时、精准 - 直接依赖于日志采集过程,日志本身不追求绝对的连续与一致,这也使得基于日志的追踪往往不如其他两种追踪实现来的精准。另外,业务服务的调用与日志的归集并不是同时完成的,也通常不由同一个进程完成,有可能发生业务调用已经顺利结束了,但由于日志归集不及时或者精度丢失,导致日志出现延迟或缺失记录,进而产生追踪失真。

日志追踪的代表产品是 Spring Cloud Sleuth,下面是一段由 Sleuth 在调用时自动生成的日志记录,可以从中观察到 TraceID、SpanID、父 SpanID 等追踪信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
# 以下为调用端的日志输出:
Created new Feign span [Trace: cbe97e67ce162943, Span: bb1798f7a7c9c142, Parent: cbe97e67ce162943, exportable:false]
2019-06-30 09:43:24.022 [http-nio-9010-exec-8] DEBUG o.s.c.s.i.web.client.feign.TraceFeignClient - The modified request equals GET http://localhost:9001/product/findAll HTTP/1.1

X-B3-ParentSpanId: cbe97e67ce162943
X-B3-Sampled: 0
X-B3-TraceId: cbe97e67ce162943
X-Span-Name: http:/product/findAll
X-B3-SpanId: bb1798f7a7c9c142

# 以下为服务端的日志输出:
[findAll] to a span [Trace: cbe97e67ce162943, Span: bb1798f7a7c9c142, Parent: cbe97e67ce162943, exportable:false]
Adding a class tag with value [ProductController] to a span [Trace: cbe97e67ce162943, Span: bb1798f7a7c9c142, Parent: cbe97e67ce162943, exportable:false]

基于服务的追踪

基于服务的追踪是目前最为常见的实现方式,被 ZipkinPinpointSkyWalking 等主流链路追踪系统广泛采用。其实现思路是:通过某些手段给目标应用注入追踪探针(Probe),针对 Java 应用一般就是通过 Java Agent 注入的。探针在结构上可视为一个寄生在目标服务身上的小型微服务系统,它一般会有自己专用的服务注册、心跳检测等功能,有专门的数据收集协议,把从目标系统中监控得到的服务调用信息,通过另一次独立的 HTTP 或者 RPC 请求发送给追踪系统。

基于服务的追踪有以下特点:

  • 侵入性强,会有性能损耗
  • 追踪更加精准、稳定

因此,基于服务的追踪会比基于日志的追踪消耗更多的资源,也有更强的侵入性,换来的收益是追踪的精确性与稳定性都有所保证,不必再依靠日志归集来传输追踪数据。

基于边车代理的追踪

基于边车代理的追踪是服务网格的专属方案,也是最理想的分布式追踪模型,它对应用完全透明,无论是日志还是服务本身都不会有任何变化;它与编程语言无关,无论应用采用什么编程语言实现,只要它还是通过网络(HTTP 或者 gRPC)来访问服务就可以被追踪到;它有自己独立的数据通道,追踪数据通过控制平面进行上报,避免了追踪对程序通信或者日志归集的依赖和干扰,保证了最佳的精确性。如果要说这种追踪实现方式还有什么缺点的话,那就是服务网格现在还不够普及,未来随着云原生的发展,相信它会成为追踪系统的主流实现方式之一。还有就是边车代理本身的对应用透明的工作原理决定了它只能实现服务调用层面的追踪,本地方法调用级别的追踪诊断是做不到的。

现在市场占有率最高的代理 Envoy 就提供了相对完善的追踪功能,但没有提供自己的界面端和存储端,所以 Envoy 和 Sleuth 一样都属于狭义的追踪系统,需要配合专门的 UI 与存储来使用,现在 ZipkinSkyWalkingJaegerLightStep Tracing 等系统都可以接受来自于 Envoy 的追踪数据,充当它的界面端。

数据处理

数据处理负责数据的存储与计算,就是将数据采集的数据按需计算,然后落地存储供查询使用。

数据处理的需求一般分为两类,一类是实时计算需求,一类是离线计算需求。

实时计算需求对计算效率要求比较高,一般要求对收集的链路数据能够在秒级别完成聚合计算,以供实时查询。而离线计算需求对计算效率要求就没那么高了,一般能在小时级别完成链路数据的聚合计算即可,一般用作数据汇总统计。针对这两类不同的数据处理需求,采用的计算方法和存储也不相同。

  • 实时数据处理:针对实时数据处理,一般采用 Flink、Storm、Spark Streaming 来对链路数据进行实时聚合加工,存储一般使用 OLTP 数据仓库,比如 HBase,使用 traceId 作为 RowKey,能天然地把一整条调用链聚合在一起,提高查询效率。
  • 离线数据处理:针对离线数据处理,一般通过运行 MapReduce 或者 Spark 批处理程序来对链路数据进行离线计算,存储一般使用 Hive。

数据展示

数据展示层的作用就是将处理后的链路信息以图形化的方式展示给用户。

实际项目中主要用到两种图形展示,一种是调用链路图,一种是调用拓扑图。

调用链路图

调用链路图一般展示服务总耗时、服务调用的网络深度、每一层经过的系统,以及多少次调用。调用链路图在实际项目中,主要是被用来做故障定位,比如某一次用户调用失败了,可以通过调用链路图查询这次用户调用经过了哪些环节,到底是哪一层的调用失败所导致。

下面是 Zipkin 的调用链路图:

img

调用拓扑图

调用拓扑图一般展示系统内都包含哪些应用,它们之间是什么关系,以及依赖调用的 QPS、平均耗时情况。调用拓扑图是一种全局视野图,在实际项目中,主要用作全局监控,用于发现系统中异常的点,从而快速做出决策。比如,某一个服务突然出现异常,那么在调用链路拓扑图中可以看出对这个服务的调用耗时都变高了,可以用红色的图样标出来,用作监控报警。

下面是 Pinpoint 的调用链路图:

img

链路追踪主流技术

链路追踪的主流开源产品比较丰富,主要有:

  • Zipkin - Zipkin 是 Twitter 开源的调用链分析工具,目前基于 spring-cloud-sleuth 得到了广泛的使用,特点是轻量,使用、部署简单。
  • Pinpoint - 是韩国人开源的基于字节码注入的调用链分析,以及应用监控分析工具。特点是支持多种插件,UI 功能强大,接入端无代码侵入。
  • SkyWalking - 是本土开源的基于字节码注入的调用链分析,以及应用监控分析工具。特点是支持多种插件,UI 功能较强,接入端无代码侵入。目前已加入 Apache 孵化器。
  • CAT - CAT 是美团点评开源的基于编码和配置的调用链分析,应用监控分析,日志采集,监控报警等一系列的监控平台工具。
  • OpenTelemetry - OpenCensus 和 OpenTracing 两个项目的合并。OpenTelemetry 是工具、API 和 SDK 的集合。用于检测、生成、收集和导出遥测数据(指标、日志和和追踪),以辅助分析软件的性能和行为。
  • OpenTracing - 是一套与平台无关、与厂商无关、与语言无关的追踪协议规范。官方提供多种语言的链路追踪库实现。目前官方已经不再维护。

参考资料

如何建设监控体系

当服务消费者与服务提供者之间建立了通信,作为管理者需要通过监控手段来观察服务是否正常,调用是否成功。服务监控是很复杂的,在微服务架构下,一次用户调用会因为服务化拆分后,变成多个不同服务之间的相互调用,这也就需要对拆分后的每个服务都监控起来。

监控的意义

  • 发现问题:当系统出现问题或故障,监控系统应根据监控对象的数据异常,及时发现问题,触发告警。
  • 定位问题:监控系统的告警提示,通常应该指明问题的影响范围(如某机器 IP、某机房),触发故障的内容(数据库、MQ 或某服务的某监控数据异常),触发时间等等。有了这些必要的信息,有利于工程师分析问题时缩小排查范围,更快找到问题原因。
  • 解决问题:一旦分析清楚故障的原因后,就需要根据故障的重要度、紧急程度、影响范围等要素,去决定应该如何应对故障。
  • 总结问题:如果发生了重大故障后,需要对故障进行复盘,总结故障的原因和应对故障时的措施,思考在事前有没有更好的防范手段;在事后的应对故障的处理有没有改进的空间。

监控目标

  • 对系统不间断实时监控:实际上是对系统不间断的实时监控(这就是监控)
  • 实时反馈系统当前状态:我们监控某个硬件、或者某个系统,都是需要能实时看到当前系统的状态,是正常、异常、或者故障
  • 保证服务的可靠性、安全性:我们监控的目的就是要保证系统、服务、业务正常运行
  • 保证业务持续稳定运行:如果我们的监控做得很完善,即使出现故障,能第一时间接收到故障告警,在第一时间处理解决,从而保证业务持续性的稳定运行。

监控方法

  • 明确监控对象:根据业务和系统的实际需要,明确需要监控的对象。
  • 确定性能基准指标:确定了监控对象,接下来,要确定该监控对象的性能基准。如:CPU 使用率、吞吐量等。
  • 定义告警阈值:监控对象什么情况是正常的,什么情况是异常的,什么情况是有故障的?
  • 故障处理流程:当监控对象达到告警阈值时,应如何应对?触发怎样的告警?有没有自动化处理机制,如弹性扩容等?有没有熔断、降级等?

监控流程

一旦明确了要监控的对象,接下就是考虑如何监控。

完整的监控流程主要包括以下环节:采集、传输、存储、分析、展示、告警、处理。

数据采集

通常有两种数据收集方式:

  • 服务主动上报:这种处理方式通过在业务代码或者服务框架里加入数据收集代码逻辑,在每一次服务调用完成后,主动上报服务的调用信息。这种方式在链路跟踪中较为常见,主流的技术方案有:Zipkin。
  • 代理收集:这种处理方式通过服务调用后把调用的详细信息记录到本地日志文件中,然后再通过代理去解析本地日志文件,然后再上报服务的调用信息。主流的技术方案有:ELK、Flume。

数据传输

数据传输最常用的方式有两种:

  • UDP 传输:这种处理方式是数据处理单元提供服务器的请求地址,数据采集后通过 UDP 协议与服务器建立连接,然后把数据发送过去。
  • Kafka 传输:这种处理方式是数据采集后发送到指定的 Topic,然后数据处理单元再订阅对应的 Topic,就可以从 Kafka 消息队列中读取到对应的数据。由于 Kafka 有非常高的吞吐能力,所以很适合作为大数据量的缓冲池。

数据存储

上报的监控数据需要存储,不同监控系统选择的存储非常多样化。比较常见的有:

  • 时序数据库:InfluxDB(如:Prometheus)
  • 列式数据库:OpenTSDB 用 Hbase 存储所有时序(无须采样)的数据,来构建一个分布式、可伸缩的时间序列数据库。它支持秒级数据采集,支持永久存储,可以做容量规划,并很容易地接入到现有的告警系统里。
  • SQL 数据库:Zabbix 使用关系型数据库 Mysql 存储数据。
  • 搜索引擎数据库:ELK 使用 Elasticsearch 存储数据,以倒排索引的数据结构存储,需要查询的时候,根据索引来查询。

数据处理

数据处理是对收集来的原始数据进行聚合计算并存储。数据聚合通常有两个维度:

  • 接口维度聚合:这个维度是把实时收到的数据按照接口名维度实时聚合在一起,这样就可以得到每个接口的每秒请求量、平均耗时、成功率等信息。
  • 机器维度聚合:这个维度是把实时收到的数据按照调用的节点维度聚合在一起,这样就可以从单机维度去查看每个接口的实时请求量、平均耗时等信息。

数据展示

数据展示是把处理后的数据以 Dashboard 的方式展示给用户。数据展示有多种方式,比如曲线图、饼状图、格子图展示等。

监控告警

监控告警的形式很多,如:电话告警、邮件告警、短信告警、IM 告警等。

此外,告警需要根据甄别故障的影响范围,以确定故障级别,如:重要度、紧急度等。根据故障的级别,通知需要介入的人员,快速响应处理。

监控对象

服务监控一定是通过观察数据来量化分析,所以首先要明确需要监控什么。

一般来说,服务监控数据有以下分类:

  • 基础层监控
    • CPU:CPU 利用率、用户态利用率、内核态利用率、单核平均负载
    • 内存:内存使用量、内存剩余量
    • 磁盘:磁盘使用量、磁盘使用率
    • 网络:网络流量、丢包数、错包数、连接数等。
    • 温度
    • 电压
    • 等等
  • 中间层监控
    • 数据库
      • Mysql:集群健康状况、磁盘使用率、连接数、慢日志等
      • Redis:集群健康状况、内存使用量、CPU 使用率、内存使用率、连接数、对象数、慢日志等
      • Elasticsearch:集群健康状况、CPU 使用率、内存使用率
      • MongoDB:集群健康状况、
      • 等等
    • 中间件
      • MQ:QPS、消息成功数、消息失败数、传输耗时、消息堆积量
      • 任务调度
      • 等等
  • 应用层监控:接口监控、访问服务、SQL、内存使用率、响应时间、TPS、QPS 等。
  • 业务监控:核心指标、登录、登出、下单、支付等。
  • 客户端监控:性能、返回码、地域、运营商、版本、系统等。

监控维度

一般来说,要从多个维度来对业务进行监控,具体来讲可以包括下面几个维度:

  • 全局维度。从整体角度监控对象的的请求量、平均耗时以及错误率,全局维度的监控一般是为了让你对监控对象的调用情况有个整体了解。
  • 机房维度。一般为了业务的高可用性,服务通常部署在不止一个机房,因为不同机房地域的不同,同一个监控对象的各种指标可能会相差很大,所以需要深入到机房内部去了解。
  • 单机维度。即便是在同一个机房内部,可能由于采购年份和批次的不同,位于不同机器上的同一个监控对象的各种指标也会有很大差异。一般来说,新采购的机器通常由于成本更低,配置也更高,在同等请求量的情况下,可能表现出较大的性能差异,因此也需要从单机维度去监控同一个对象。
  • 时间维度。同一个监控对象,在每天的同一时刻各种指标通常也不会一样,这种差异要么是由业务变更导致,要么是运营活动导致。为了了解监控对象各种指标的变化,通常需要与一天前、一周前、一个月前,甚至三个月前做比较。
  • 核心维度。业务上一般会依据重要性程度对监控对象进行分级,最简单的是分成核心业务和非核心业务。核心业务和非核心业务在部署上必须隔离,分开监控,这样才能对核心业务做重点保障。

监控技术

  • ELK 的技术栈比较成熟,应用范围也比较广,除了可用作监控系统外,还可以用作日志查询和分析。
  • Graphite 是基于时间序列数据库存储的监控系统,并且提供了功能强大的各种聚合函数比如 sum、average、top5 等可用于监控分析,而且对外提供了 API 也可以接入其他图形化监控系统如 Grafana。
  • TICK 的核心在于其时间序列数据库 InfluxDB 的存储功能强大,且支持类似 SQL 语言的复杂数据处理操作。
  • Prometheus 的独特之处在于它采用了拉数据的方式,对业务影响较小,同时也采用了时间序列数据库存储,而且支持独有的 PromQL 查询语言,功能强大而且简洁。
  • OpenTSDB 用 Hbase 存储所有时序(无须采样)的数据,来构建一个分布式、可伸缩的时间序列数据库。它支持秒级数据采集,支持永久存储,可以做容量规划,并很容易地接入到现有的告警系统里。OpenTSDB 可以从大规模的集群(包括集群中的网络设备、操作系统、应用程序)中获取相应的采集指标,并进行存储、索引和服务,从而使这些数据更容易让人理解,如 Web 化、图形化等。
  • Zabbix 是一个分布式监控系统,支持多种采集方式和采集客户端,有专用的 Agent 代理,也支持 SNMP、IPMI、JMX、Telnet、SSH 等多种协议,它将采集到的数据存放到数据库,然后对其进行分析整理,达到条件触发告警。其灵活的扩展性和丰富的功能是其他监控系统所不能比的。相对来说,它的总体功能做的非常优秀。

参考资料

服务路由

服务路由简介

什么是服务路由

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

为什么需要服务路由

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

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

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

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

服务路由的规则

条件路由

条件路由是基于条件表达式的路由规则。各个 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
"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 中的值将会在一次完整的远程调用中持续传递,得益于这样的特性,我们只需要在起始调用时,通过一行代码的设置,达到标签的持续传递。

路由规则获取方式

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

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

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

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

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

参考资料

服务注册和发现

服务元数据

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

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

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

常见的发布服务元数据的方式有:

  • REST API
  • XML 文件
  • IDL 文件

REST API

以 Eureka 为例

服务提供者定义接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
public class ProviderController {

private final DiscoveryClient discoveryClient;

public ProviderController(DiscoveryClient discoveryClient) {
this.discoveryClient = discoveryClient;
}

@GetMapping("/send")
public String send() {
String services = "Services: " + discoveryClient.getServices();
System.out.println(services);
return services;
}

}

服务消费者消费接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
public class ConsumerController {

private final LoadBalancerClient loadBalancerClient;
private final RestTemplate restTemplate;

public ConsumerController(LoadBalancerClient loadBalancerClient,
RestTemplate restTemplate) {
this.loadBalancerClient = loadBalancerClient;
this.restTemplate = restTemplate;
}

@GetMapping("/recv")
public String recv() {
ServiceInstance serviceInstance = loadBalancerClient.choose("eureka-provider");
String url = "http://" + serviceInstance.getHost() + ":" + serviceInstance.getPort() + "/send";
System.out.println(url);
return restTemplate.getForObject(url, String.class);
}

}

XML 文件

XML 文件这种方式的服务发布和引用主要分三个步骤:

(1)服务提供者定义接口,并实现接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// The demo service definition.
service DemoService {
rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
string name = 1;
}

// The response message containing the greetings
message HelloReply {
string message = 1;
}

(2)服务提供者进程启动时,通过加载 xml 配置文件将接口暴露出去。

1
2
3
4
5
6
7
8
9
10
11
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:dubbo="http://dubbo.apache.org/schema/dubbo"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://dubbo.apache.org/schema/dubbo http://dubbo.apache.org/schema/dubbo/dubbo.xsd">
<dubbo:application name="demo-provider"/>
<dubbo:registry address="zookeeper://127.0.0.1:2181"/>
<dubbo:protocol name="dubbo" port="20890"/>
<bean id="demoService" class="org.apache.dubbo.samples.basic.impl.DemoServiceImpl"/>
<dubbo:service interface="org.apache.dubbo.samples.basic.api.DemoService" ref="demoService"/>
</beans>

(3)服务消费者进程启动时,通过加载 xml 配置文件来引入要调用的接口。

consumer.xml 示例

1
2
3
4
5
6
7
8
9
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:dubbo="http://dubbo.apache.org/schema/dubbo"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://dubbo.apache.org/schema/dubbo http://dubbo.apache.org/schema/dubbo/dubbo.xsd">
<dubbo:application name="demo-consumer"/>
<dubbo:registry group="aaa" address="zookeeper://127.0.0.1:2181"/>
<dubbo:reference id="demoService" check="false" interface="org.apache.dubbo.samples.basic.api.DemoService"/>
</beans>

IDL 文件

IDL 就是接口描述语言(interface description language)的缩写,通过一种中立的方式来描述接口,使得在不同的平台上运行的对象和不同语言编写的程序可以相互通信交流。

也就是说 IDL 主要是用作跨语言平台的服务之间的调用,有两种最常用的 IDL:一个是 Facebook 开源的Thrift 协议,另一个是 Google 开源的gRPC 协议

以 gRPC 协议为例,gRPC 协议使用 Protobuf 简称 proto 文件来定义接口名、调用参数以及返回值类型。

比如文件 helloword.proto 定义了一个接口 SayHello 方法,它的请求参数是 HelloRequest,它的返回值是 HelloReply。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// The greeter service definition.
service Greeter {
// Sends a greeting
rpc SayHello (HelloRequest) returns (HelloReply) {}
rpc SayHelloAgain (HelloRequest) returns (HelloReply) {}

}

// The request message containing the user's name.
message HelloRequest {
string name = 1;
}

// The response message containing the greetings
message HelloReply {
string message = 1;
}

假如服务提供者使用的是 Java 语言,那么利用 protoc 插件即可自动生成 Server 端的 Java 代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private class GreeterImpl extends GreeterGrpc.GreeterImplBase {

@Override
public void sayHello(HelloRequest req, StreamObserver<HelloReply> responseObserver) {
HelloReply reply = HelloReply.newBuilder().setMessage("Hello " + req.getName()).build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
}

@Override
public void sayHelloAgain(HelloRequest req, StreamObserver<HelloReply> responseObserver) {
HelloReply reply = HelloReply.newBuilder().setMessage("Hello again " + req.getName()).build();
responseObserver.onNext(reply);
responseObserver.onCompleted();
}
}

假如服务消费者使用的也是 Java 语言,那么利用 protoc 插件即可自动生成 Client 端的 Java 代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void greet(String name) {
logger.info("Will try to greet " + name + " ...");
HelloRequest request = HelloRequest.newBuilder().setName(name).build();
HelloReply response;
try {
response = blockingStub.sayHello(request);
} catch (StatusRuntimeException e) {
logger.log(Level.WARNING, "RPC failed: {0}", e.getStatus());
return;
}
logger.info("Greeting: " + response.getMessage());
try {
response = blockingStub.sayHelloAgain(request);
} catch (StatusRuntimeException e) {
logger.log(Level.WARNING, "RPC failed: {0}", e.getStatus());
return;
}
logger.info("Greeting: " + response.getMessage());
}

假如服务消费者使用的是其他语言,也可以利用相应的插件生成代码。

由此可见,gRPC 协议的服务描述是通过 proto 文件来定义接口的,然后再使用 protoc 来生成不同语言平台的客户端和服务端代码,从而具备跨语言服务调用能力。

有一点特别需要注意的是,在描述接口定义时,IDL 文件需要对接口返回值进行详细定义。如果接口返回值的字段比较多,并且经常变化时,采用 IDL 文件方式的接口定义就不太合适了。一方面可能会造成 IDL 文件过大难以维护,另一方面只要 IDL 文件中定义的接口返回值有变更,都需要同步所有的服务消费者都更新,管理成本就太高了。

服务注册和发现基本原理

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

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

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

主流的服务注册与发现的解决方案,主要有两种:

  • 应用内注册与发现:注册中心提供服务端和客户端的 SDK,业务应用通过引入注册中心提供的 SDK,通过 SDK 与注册中心交互,来实现服务的注册和发现。
  • 应用外注册与发现:业务应用本身不需要通过 SDK 与注册中心打交道,而是通过其他方式与注册中心交互,间接完成服务注册与发现。

应用内注册与发现

应用内注册与发现方案是:注册中心提供服务端和客户端的 SDK,业务应用通过引入注册中心提供的 SDK,通过 SDK 与注册中心交互,来实现服务的注册和发现。最典型的案例要属 Netflix 开源的 Eureka,官方架构图如下:

Eureka 的架构主要由三个重要的组件组成:

  • Eureka Server:注册中心的服务端,实现了服务信息注册、存储以及查询等功能。
  • 服务端的 Eureka Client:集成在服务端的注册中心 SDK,服务提供者通过调用 SDK,实现服务注册、反注册等功能。
  • 客户端的 Eureka Client:集成在客户端的注册中心 SDK,服务消费者通过调用 SDK,实现服务订阅、服务更新等功能。

应用外注册与发现

应用外注册与发现方案是:业务应用本身不需要通过 SDK 与注册中心打交道,而是通过其他方式与注册中心交互,间接完成服务注册与发现。最典型的案例是开源注册中心 Consul。

Consul 实现应用外服务注册和发现主要依靠三个重要的组件:

  • Consul:注册中心的服务端,实现服务注册信息的存储,并提供注册和发现服务。
  • Registrator:一个开源的第三方服务管理器项目,它通过监听服务部署的 Docker 实例是否存活,来负责服务提供者的注册和销毁。
  • Consul Template:定时从注册中心服务端获取最新的服务提供者节点列表并刷新 LB 配置(比如 Nginx 的 upstream),这样服务消费者就通过访问 Nginx 就可以获取最新的服务提供者信息。

注册中心基本功能

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

注册中心是用来存储服务的元数据信息。服务的元数据信息通常有以下信息:

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

在具体存储时,一般会按照“服务 - 分组 - 节点信息”三层结构来存储。

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

注册中心 API

根据注册中心原理的描述,注册中心必须提供以下最基本的 API,例如:

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

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

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

集群部署

注册中心作为服务提供者和服务消费者之间沟通的桥梁,它的重要性不言而喻。所以注册中心一般都是采用集群部署来保证高可用性,并通过分布式一致性协议来确保集群中不同节点之间的数据保持一致。

以开源注册中心 ZooKeeper 为例,ZooKeeper 集群中包含多个节点,服务提供者和服务消费者可以同任意一个节点通信,因为它们的数据一定是相同的,这是为什么呢?这就要从 ZooKeeper 的工作原理说起:

  • 每个 Server 在内存中存储了一份数据,Client 的读请求可以请求任意一个 Server。
  • ZooKeeper 启动时,将从实例中选举一个 leader(Paxos 协议)。
  • Leader 负责处理数据更新等操作(ZAB 协议)。
  • 一个更新操作成功,当且仅当大多数 Server 在内存中成功修改 。

通过上面这种方式,ZooKeeper 保证了高可用性以及数据一致性。

img

元数据存储

注册中心存储服务信息一般采用层次化的目录结构,以 ZooKeeper 为例:

  • 每个目录在 ZooKeeper 中叫作 znode,并且其有一个唯一的路径标识。
  • znode 可以包含数据和子 znode。
  • znode 中的数据可以有多个版本,比如某一个 znode 下存有多个数据版本,那么查询这个路径下的数据需带上版本信息。

img

白名单机制

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

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

服务健康检测

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

还是以 ZooKeeper 为例,它是基于 ZooKeeper 客户端和服务端的长连接和会话超时控制机制,来实现服务健康状态检测的。

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

服务状态变更通知

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

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

心跳开关保护机制

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

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

一个可行的解决方案就是给注册中心设置一个开关,当开关打开时,即使网络频繁抖动,注册中心也不会通知所有的服务消费者有服务节点信息变更,比如只给 10% 的服务消费者返回变更,这样的话就能将注册中心的请求量减少到原来的 1/10。当然打开这个开关也是有一定代价的,它会导致服务消费者感知最新的服务节点信息延迟,原先可能在 10s 内就能感知到服务提供者节点信息的变更,现在可能会延迟到几分钟,所以在网络正常的情况下,开关并不适合打开;可以作为一个紧急措施,在网络频繁抖动的时候,才打开这个开关。

心跳开关保护机制,是为了防止服务提供者节点频繁变更导致的服务消费者同时去注册中心获取最新服务节点信息。

服务节点摘除保护机制

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

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

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

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

服务节点摘除保护机制,是为了防止服务提供者节点被大量摘除引起服务消费者可以调用的节点不足。

静态注册中心

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

注册中心选型

注册中心选型时最需要关注两个问题:高可用性数据一致性

高可用性

注册中心作为服务提供者和服务消费者之间沟通的纽带,它的高可用性十分重要。如果注册中心不可用了,那么服务提供者就无法对外暴露自己的服务,而服务消费者也无法知道自己想要调用的服务的具体地址。

实现高可用性的手段主要有两种

  • 集群部署,即使有部分机器宕机,将请求分发到正常的机器上就可以保证服务的正常访问。
  • 多机房部署,避免一个机房因为断电或者光缆被挖断等不可抗力因素不可用时,仍然可以通过把请求迁移到其他机房来保证服务的正常访问。

这两种手段本质上都是服务冗余。

数据一致性

服务发现的问题

多注册中心

理想情况下,如果始终只有一个注册中心,那么整个交互非常简单。但在实际工作中,往往需要对接多个注册中心,常见场景如下:

  • 服务消费者订阅多个注册中心:服务消费者可能订阅了多个服务,多个服务可能是由多个业务部门提供的,而且每个业务部门都有自己的注册中心,提供的服务只在自己的注册中心里有记录。这就要求服务消费者要具备在启动时,能够从多个注册中心订阅服务的能力。
  • 服务提供者注册多个注册中心:一个服务提供者提供了某个服务,可能作为静态服务对外提供,也可能作为动态服务对外提供,这两个服务部署在不同的注册中心,所以要求服务提供者在启动的时候,要能够同时向多个注册中心注册服务。

并行订阅服务

通常一个服务消费者订阅了不止一个服务。如果采用串行订阅方式,即每订阅一个服务,服务消费者就调用一次注册中心的订阅接口,获取这个服务的节点列表并初始化连接,就可能要执行很多次这样的过程。在某些服务节点的初始化连接过程中,出现连接超时的情况,后续所有的服务节点的初始化连接都需要等待它完成,导致服务消费者启动变慢,最后耗费了将近五分钟时间来完成所有服务节点的初始化连接过程。

由于以上问题,所以服务发现应该支持并行订阅,每订阅一个服务就单独用一个线程来处理,这样的话即使遇到个别服务节点连接超时,其他服务节点的初始化连接也不受影响,最慢也就是这个服务节点的初始化连接耗费的时间,最终所有服务节点的初始化连接耗时控制在一个可以接受的时间范围内。

批量注销服务

通常一个服务提供者节点提供不止一个服务,所以注册和反注册都需要多次调用注册中心。在与注册中心的多次交互中,可能由于网络抖动、注册中心集群异常等原因,导致个别调用失败。对于注册中心来说,偶发的注册调用失败对服务调用基本没有影响,其结果顶多就是某一个服务少了一个可用的节点。但偶发的反注册调用失败会导致不可用的节点残留在注册中心中,变成“僵尸节点”,但服务消费者端还会把它当成“活节点”,继续发起调用,最终导致调用失败。

可以通过调用注册中心提供的批量反注册接口,一次调用就可以把该节点上提供的所有服务同时注销掉,从而避免了“僵尸节点”的出现。

服务变更信息的增量更新

服务消费者端启动时,除了会查询订阅服务的可用节点列表做初始化连接,还会订阅服务的变更,每隔一段时间从注册中心获取最新的服务节点信息标记 sign,并与本地保存的 sign 值作比对,如果不一样,就会调用注册中心获取最新的服务节点信息。

一般情况下,按照这个过程是没问题的,但是在网络频繁抖动时,服务提供者上报给注册中心的心跳可能会一会儿失败一会儿成功,这时候注册中心就会频繁更新服务的可用节点信息,导致服务消费者频繁从注册中心拉取最新的服务可用节点信息,严重时可能产生网络风暴,导致注册中心带宽被打满。

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

参考资料

微服务简介

本文关键词:定义演进利弊如何拆分容量规划核心组件

什么是微服务

微服务定义

微服务是由单一应用程序构成的小服务,拥有自己的进程与轻量化处理,服务依业务功能设计,以全自动的方式部署,与其他服务使用 HTTP API 通讯。同时,服务会使用最小规模的集中管理 (例如 Docker)技术,服务可以用不同的编程语言与数据库等。

——Martin Fowler 和 James Lewis 对应微服务( Microservices)的定义

个人理解,微服务是一种架构模式,它提倡将一个单一应用拆分为一些可独立运行可协同工作小的服务。在微服务架构中,每个小服务都拥有独立的进程和轻量级通信。这些服务是围绕业务能力构建的,并且可以通过全自动化部署机制独立部署。这些服务会使用最小规模的集中式管理能力(例如 Docker) ,可以用不同的编程语言编写并使用不同的数据存储技术。

从以上定义,我们可以提炼出微服务的关键特性:

  • 可独立运行 - 微服务是一个个可以独立开发、独立部署、独立运行的系统或进程。
  • 可协同工作 - 微服务之间不是完全隔离的,彼此需要协同工作,通常是采用 RPC 方式。
  • 分而治之 - 微服务本质上是一种分治思想,即把一个复杂业务拆分为多个子业务。这使得每个子业务更加高内聚、低耦合,从而能聚焦自身的功能。

微服务的演进

互联网应用架构大致的演进方向为:单体架构 -> 服务化架构 -> 微服务架构。在演化过程中,架构越来越复杂,一个应用被拆分的服务也越来越细。

互联网早期的技术栈通常为 LAMP(Linux + Apache + MySQL + PHP)或 MVC(Spring + iBatis/Hibernate + Tomcat)。这两种架构都是典型的单体应用架构。其优点是技术栈简单,因此学习上手快,部署也容易。

随着业务越来越复杂,开发团队规模不断扩张,单体应用架构就难以适应开发迭代节奏,主要有以下问题:

  • 构建、部署效率低:代码越多,依赖资源越多,则构建、部署的耗费时间自然会越长。即使每次修改一个很小的功能点,也不得不全量构建、全部部署,耗时耗力。
  • 团队协作成本高:单体应用的代码往往在一个工程中,而一个工程中的开发人员越多,显然沟通成本越高。
  • 可用性差:因为所有的功能开发最后都部署到同一个 WAR 包里,运行在同一个 Tomcat 进程之中,一旦某一功能涉及的代码或者资源有问题,那就会影响整个 WAR 包中部署的功能。

服务化:本地方法调用 转为 远程方法调用(RPC)

微服务和服务化的差异:

  • 服务拆分粒度更细
  • 服务独立部署、维护
  • 服务治理要求高

微服务架构有以下 4 个特点:

  • 服务拆分粒度更细:根据业务拆分。
  • 独立部署:每个服务在物理上相互隔离。独立部署的好处在于:如果没有拆分服务,那么任何修改都必须重新部署才能更新;而拆分为多个服务后,只有被修改的服务需要重部署。
  • 独立维护:根据组织架构拆分,分团队维护。
  • 服务治理:服务数量变多,需要有统一的服务治理平台。

简单来说,微服务就是将庞杂臃肿的单体应用拆分成细粒度的服务,独立部署,并交给各个中小团队来负责开发、测试、上线和运维整个生命周期。

  • 通过服务组件化
  • 独立的进程
  • 独立部署
  • 轻量级通信
  • 基于业务能力
  • 无集中式管理

微服务的利弊

《人月神话》中有一个软件工程界的著名理论——“没有银弹”,即世间没有能包治百病的良药,也没有能解决所有问题的架构。微服务架构的利和弊都非常突出,在实际业务场景中是否采用,如何采用,需要具体去分析、权衡。

微服务的利弊如下:

  • 优点
    • 易于扩展
    • 部署简单
    • 技术异构性
  • 缺点
    • 分布式复杂度
    • 最终一致性
    • 测试复杂度
    • 运维复杂度

康威定律

  • 第一定律:组织沟通方式会通过系统设计表达出来
  • 第二定律:时间再多一件事情也不可能做的完美,但总有时间做完一件事情
  • 第三定律:线型系统和线型组织架构间有潜在的异质同态特性
  • 第四定律:大的系统组织总是比小系统更倾向于分解

如何拆分微服务

应用微服务化架构前,要思考几个问题:什么时候进行服务化拆分?如何拆分服务?

一般来说,当应用复杂度、开发团队膨胀到难以维护时,就该考虑服务化拆分了。从经验上来看,一般开发人员超 10 人,就可以考虑服务化拆分了。

拆分微服务的思考维度

拆分服务的思考维度:

  • 业务维度:业务和数据关系密切的应该拆分为一个微服务,而功能相对比较独立的业务适合单独拆分为一个微服务。
  • 功能维度:公共功能聚合为一个服务。标准是是否被多个其他服务调用,且依赖的资源独立不与其他业务耦合。
  • 组织架构:根据实际组织架构,天然分为不同的团队,每个团队独立维护若干微服务。

但并不是说功能拆分的越细越好,过度的拆分反而会让服务数量膨胀变得难以管理,因此找到符合自己业务现状和团队人员技术水平的拆分粒度才是可取的。

拆分微服务的原则

  • 单一职责 - 高内聚,低耦合
  • 先粗后细,逐渐细化
  • 渐进式迭代
  • 考虑扩展性

拆分微服务的前置条件

微服务主要依赖几个基本组件:

  • 服务如何定义
    • 对于单体应用来说,不同功能模块之前相互交互时,通常是以类库的方式来提供各个模块的功能。
    • 对于微服务来说,每个服务都运行在各自的进程之中,无论采用哪种通讯协议,是 HTTP 还是 RPC,服务之间的调用都通过接口来约定如何交互。约定内容包括接口名、接口参数以及接口返回值。
  • 服务如何发布和订阅
    • 单体应用由于部署在同一个 WAR 包里,接口之间的调用属于进程内的调用。
    • 对于微服务来说,服务提供者需要向注册中心发布自己提供的服务(暴露接口信息以及接口地址);服务消费者向注册中心订阅哪些服务可用。
  • 服务如何监控?通常对于一个服务,我们最关心的是 QPS(调用量)、AvgTime(平均耗时)以及 P999(99.9% 的请求性能在多少毫秒以内)这些指标。这时候你就需要一种通用的监控方案,能够覆盖业务埋点、数据收集、数据处理,最后到数据展示的全链路功能。
  • 服务如何治理?可以想象,拆分为微服务架构后,服务的数量变多了,依赖关系也变复杂了。比如一个服务的性能有问题时,依赖的服务都势必会受到影响。可以设定一个调用性能阈值,如果一段时间内一直超过这个值,那么依赖服务的调用可以直接返回,这就是熔断,也是服务治理最常用的手段之一。
  • 故障如何定位?在单体应用拆分为微服务之后,一次用户调用可能依赖多个服务,每个服务又部署在不同的节点上,如果用户调用出现问题,你需要有一种解决方案能够将一次用户请求进行标记,并在多个依赖的服务系统中继续传递,以便串联所有路径,从而进行故障定位。

应用微服务架构,必须要先解决以上问题。

服务容量规划

容量规划系统的作用是根据各个微服务部署集群的最大容量和线上实际运行的负荷,来决定各个微服务是否需要弹性扩缩容,以及需要扩缩容多少台机器

微服务容量规划的挑战

微服务容量规划的复杂度主要来自以下方面:

  • 服务数量众多
  • 服务的接口表现差异巨大
  • 服务部署的集群规模大小不同
  • 服务之间还存在依赖关系

如何评估容量

容量评估需要关注的维度:

  • 选择合适的压测指标 - 主要有两类
    • 系统类指标 - CPU 使用率、内存占有量、I/O 使用率、网卡带宽等
    • 服务类指标 - 接口响应的平均耗时、P999 耗时、错误率等
  • 压测获取单机的最大容量
    • 单机压测 - 可以采用以下流量回放手段来模拟线上流量:
      • 日志回放
      • TCP-Copy
    • 集群压测 - 一般做法是通过不断把线上集群的节点摘除,以减少机器数的方式,来增加线上节点单机的流量,从而达到压测的目的。
  • 实时获取集群的运行负荷 - 集群的运行负荷也需要通过采用区间加权的方式来计算,但是因为集群的规模可能很大,超过上千台机器,显然通过计算每台单机运行的负荷再加在一起的方式效率不高。一种参考方式是:统计每台单机在不同耗时区间内的请求数,推送到集中处理的地方进行聚合,将同一个集群内的单机位于不同耗时区间内的请求进行汇总,就得到整个集群的请求在不同耗时区间内的分布了,再利用区间加权的方式就可以计算整个集群的运行负荷。

如何伸缩容量

伸缩容量的一种参考方式是使用水位线来决策扩容或是缩容水位线就是集群的最大容量除以集群的实际运行负荷,可以实时监控集群的水位线。

通常,可以为集群监控设置两条水位线:一条是安全线,一条是致命线。当集群的水位线位于致命线以下时,就需要立即扩容;在扩容一定数量的机器后,水位线回到安全线以上并保持一段时间后,就可以进行缩容了。

  • 扩容 - 扩容有两种方式:按数量、按比例(更常见做法)。
  • 缩容 - 为了避免抖动,缩容不应该一次性完成,而应该按比例逐步完成。过程中,应该多次采集水位线,满足一定比例才继续缩容。

微服务的核心组件

微服务架构下,服务调用主要依赖下面几个核心组件:

  • 服务定义
  • 注册中心
  • 服务调用
  • 服务监控
  • 服务治理

服务定义

服务调用首先要解决的问题就是服务如何对外描述。比如,你对外提供了一个服务,那么这个服务的服务名叫什么?调用这个服务需要提供哪些信息?调用这个服务返回的结果是什么格式的?该如何解析?这些就是服务定义要解决的问题。

常用的服务定义方式包括 REST API、XML 配置以及 IDL 文件三种。

  • REST API - REST API 方式通常用于 HTTP 协议的服务定义,并且常用 Wiki 或者Swagger来进行管理。
  • XML - XML 配置方式多用作 RPC 协议的服务定义,通过 *.xml 配置文件来定义接口名、参数以及返回值类型等。
  • IDL - IDL 文件方式通常用作 Thrift 和 gRPC 这类跨语言服务调用框架中,比如 gRPC 就是通过 Protobuf 文件来定义服务的接口名、参数以及返回值的数据结构。

注册中心

有了服务的接口描述,下一步要解决的问题就是服务的发布和订阅,就是说你提供了一个服务,如何让外部想调用你的服务的人知道。这个时候就需要一个类似注册中心的角色,服务提供者将自己提供的服务以及地址登记到注册中心,服务消费者则从注册中心查询所需要调用的服务的地址,然后发起请求。

一般来讲,注册中心的工作流程是:

  • 服务提供者在启动时,根据服务发布文件中配置的发布信息向注册中心注册自己的服务。
  • 服务消费者在启动时,根据消费者配置文件中配置的服务信息向注册中心订阅自己所需要的服务。
  • 注册中心返回服务提供者地址列表给服务消费者。
  • 当服务提供者发生变化,比如有节点新增或者销毁,注册中心将变更通知给服务消费者。

服务调用

服务消费者发起调用需解决以下问题:

  • 服务通信采用什么协议?就是说服务提供者和服务消费者之间以什么样的协议进行网络通信,是采用四层 TCP、UDP 协议,还是采用七层 HTTP 协议,还是采用其他协议?
  • 数据传输采用什么方式?就是说服务提供者和服务消费者之间的数据传输采用哪种方式,是同步还是异步,是在单连接上传输,还是多路复用。
  • 序列化采用什么方式?通常数据传输都会对数据进行序列化、压缩,来减少网络传输的数据量,从而减少带宽消耗和网络传输时间,比如常见的 JSON 序列化、Java 对象序列化以及 Protobuf 序列化等。

服务监控

一旦服务消费者与服务提供者之间能够正常发起服务调用,你就需要对调用情况进行监控,以了解服务是否正常。通常来讲,服务监控主要包括三个流程。

  • 数据收集。就是要把每一次服务调用的请求耗时以及成功与否收集起来,并上传到集中的数据处理中心。
  • 数据处理。有了每次调用的请求耗时以及成功与否等信息,就可以计算每秒服务请求量、平均耗时以及成功率等指标。
  • 数据展示。数据收集起来,经过处理之后,还需要以友好的方式对外展示,才能发挥价值。通常都是将数据展示在 Dashboard 面板上,并且每隔 10s 等间隔自动刷新,用作业务监控和报警等。

除了需要对服务调用情况进行监控之外,你还需要记录服务调用经过的每一层链路,以便进行问题追踪和故障定位。

服务链路追踪的工作原理大致如下:

  • 服务消费者发起调用前,会在本地按照一定的规则生成一个 requestid,发起调用时,将 requestid 当作请求参数的一部分,传递给服务提供者。
  • 服务提供者接收到请求后,记录下这次请求的 requestid,然后处理请求。如果服务提供者继续请求其他服务,会在本地再生成一个自己的 requestid,然后把这两个 requestid 都当作请求参数继续往下传递。

以此类推,通过这种层层往下传递的方式,一次请求,无论最后依赖多少次服务调用、经过多少服务节点,都可以通过最开始生成的 requestid 串联所有节点,从而达到服务追踪的目的。

服务治理

服务监控能够发现问题,服务追踪能够定位问题所在,而解决问题就得靠服务治理了。服务治理就是通过一系列的手段来保证在各种意外情况下,服务调用仍然能够正常进行。

在生产环境中,你应该经常会遇到下面几种状况。

  • 单机故障。通常遇到单机故障,都是靠运维发现并重启服务或者从线上摘除故障节点。然而集群的规模越大,越是容易遇到单机故障,在机器规模超过一百台以上时,靠传统的人肉运维显然难以应对。而服务治理可以通过一定的策略,自动摘除故障节点,不需要人为干预,就能保证单机故障不会影响业务。
  • 单 IDC 故障。你应该经常听说某某 App,因为施工挖断光缆导致大批量用户无法使用的严重故障。而服务治理可以通过自动切换故障 IDC 的流量到其他正常 IDC,可以避免因为单 IDC 故障引起的大批量业务受影响。
  • 依赖服务不可用。比如你的服务依赖依赖了另一个服务,当另一个服务出现问题时,会拖慢甚至拖垮你的服务。而服务治理可以通过熔断,在依赖服务异常的情况下,一段时期内停止发起调用而直接返回。这样一方面保证了服务消费者能够不被拖垮,另一方面也给服务提供者减少压力,使其能够尽快恢复。

上面是三种最常见的需要引入服务治理的场景,当然还有一些其他服务治理的手段比如自动扩缩容,可以用来解决服务的容量问题。

参考资料

《从 0 开始学架构》笔记

架构到底是指什么?

系统和子系统

模块与组件

框架与架构

架构设计的历史背景

机器语言 -> 汇编语言 -> 高级语言 -> 结构化设计 -> 面向对象设计

架构设计的目的

架构设计的主要目的是为了解决软件复杂度带来的问题

复杂度来源:高性能

复杂度来源:高可用

复杂度来源:可扩展性

复杂度来源:低成本、安全、规模

架构设计三原则

架构设计原则案例

参考资料

读写分离基本原理

读写分离的基本原理是:主服务器用来处理写操作以及实时性要求比较高的读操作,而从服务器用来处理读操作

1. 为何要读写分离

  • 有效减少锁竞争 - 主服务器只负责写,从服务器只负责读,能够有效的避免由数据更新导致的行锁竞争,使得整个系统的查询性能得到极大的改善。
  • 提高查询吞吐量 - 通过一主多从的配置方式,可以将查询请求均匀的分散到多个数据副本,能够进一步的提升系统的处理能力。
  • 提升数据库可用性 - 使用多主多从的方式,不但能够提升系统的吞吐量,还能够提升数据库的可用性,可以达到在任何一个数据库宕机,甚至磁盘物理损坏的情况下仍然不影响系统的正常运行。

2. 读写分离的原理

读写分离的实现是根据 SQL 语义分析,将读操作和写操作分别路由至主库与从库。

读写分离

读写分离的基本实现是:

img

  • 数据库服务器搭建主从集群,一主一从、一主多从都可以。
  • 数据库主机负责读写操作,从机只负责读操作。
  • 数据库主机通过复制将数据同步到从机,每台数据库服务器都存储了全量数据。
  • 业务服务器将写操作发给数据库主机,将读操作发给数据库从机。
  • 主机会记录请求的二进制日志,然后推送给从库,从库解析并执行日志中的请求,完成主从复制。这意味着:复制过程存在时延,这段时间内,主从数据可能不一致。

3. 读写分离的问题

读写分离存在两个问题:数据一致性分发机制

3.1. 数据一致性

读写分离产生了主库与从库之间的数据一致性的问题。

数据分片 + 读写分离

3.2. 分发机制

数据库读写分离后,一个 SQL 请求具体分发到哪个数据库节点?一般有两种分发方式:客户端分发和中间件代理分发。

客户端分发,是基于程序代码,自行控制数据分发到哪个数据库节点。更细一点来说,一般程序中建立多个数据库的连接,根据一定的算法,选择合适的连接去发起 SQL 请求。这种方式也被称为客户端中间件,代表有:jdbc-sharding。

中间件代理分发,指的是独立一套系统出来,实现读写操作分离和数据库服务器连接的管理。中间件对业务服务器提供 SQL 兼容的协议,业务服务器无须自己进行读写分离。对于业务服务器来说,访问中间件和访问数据库没有区别,事实上在业务服务器看来,中间件就是一个数据库服务器。代表有:Mycat。

4. 参考资料

JavaAgent

Javaagent 是什么?

Javaagent 是 java 命令的一个参数。参数 javaagent 可以用于指定一个 jar 包,它利用 JVM 提供的 Instrumentation API 来更改加载 JVM 中的现有字节码。

  1. 这个 jar 包的 MANIFEST.MF 文件必须指定 Premain-Class 项。
  2. Premain-Class 指定的那个类必须实现 premain() 方法。

premain 方法,从字面上理解,就是运行在 main 函数之前的的类。当 Java 虚拟机启动时,在执行 main 函数之前,JVM 会先运行-javaagent所指定 jar 包内 Premain-Class 这个类的 premain 方法 。

在命令行输入 java可以看到相应的参数,其中有 和 java agent 相关的:

1
2
3
4
5
6
7
-agentlib:<libname>[=<选项>]
加载本机代理库 <libname>, 例如 -agentlib:hprof
另请参阅 -agentlib:jdwp=help 和 -agentlib:hprof=help
-agentpath:<pathname>[=<选项>]
按完整路径名加载本机代理库
-javaagent:<jarpath>[=<选项>]
加载 Java 编程语言代理, 请参阅 java.lang.instrument

Java Agent 技术简介

Java Agent 直译为 Java 代理,也常常被称为 Java 探针技术。

Java Agent 是在 JDK1.5 引入的,是一种可以动态修改 Java 字节码的技术。Java 中的类编译后形成字节码被 JVM 执行,在 JVM 在执行这些字节码之前获取这些字节码的信息,并且通过字节码转换器对这些字节码进行修改,以此来完成一些额外的功能。

Java Agent 是一个不能独立运行 jar 包,它通过依附于目标程序的 JVM 进程,进行工作。启动时只需要在目标程序的启动参数中添加-javaagent 参数添加 ClassFileTransformer 字节码转换器,相当于在 main 方法前加了一个拦截器。

Java Agent 功能介绍

Java Agent 主要有以下功能

  • Java Agent 能够在加载 Java 字节码之前拦截并对字节码进行修改;
  • Java Agent 能够在 Jvm 运行期间修改已经加载的字节码;

Java Agent 的应用场景

  • IDE 的调试功能,例如 Eclipse、IntelliJ IDEA ;
  • 热部署功能,例如 JRebel、XRebel、spring-loaded;
  • 各种线上诊断工具,例如 Btrace、Greys,还有阿里的 Arthas;
  • 各种性能分析工具,例如 Visual VM、JConsole 等;
  • 全链路性能检测工具,例如 Skywalking、Pinpoint 等;

Java Agent 实现原理

在了解 Java Agent 的实现原理之前,需要对 Java 类加载机制有一个较为清晰的认知。一种是在 man 方法执行之前,通过 premain 来执行,另一种是程序运行中修改,需通过 JVM 中的 Attach 实现,Attach 的实现原理是基于 JVMTI。

主要是在类加载之前,进行拦截,对字节码修改

下面我们分别介绍一下这些关键术语:

  • JVMTI 就是 JVM Tool Interface,是 JVM 暴露出来给用户扩展使用的接口集合,JVMTI 是基于事件驱动的,JVM 每执行一定的逻辑就会触发一些事件的回调接口,通过这些回调接口,用户可以自行扩展

    JVMTI 是实现 Debugger、Profiler、Monitor、Thread Analyser 等工具的统一基础,在主流 Java 虚拟机中都有实现

  • JVMTIAgent是一个动态库,利用 JVMTI 暴露出来的一些接口来干一些我们想做、但是正常情况下又做不到的事情,不过为了和普通的动态库进行区分,它一般会实现如下的一个或者多个函数:

    • Agent_OnLoad 函数,如果 agent 是在启动时加载的,通过 JVM 参数设置
    • Agent_OnAttach 函数,如果 agent 不是在启动时加载的,而是我们先 attach 到目标进程上,然后给对应的目标进程发送 load 命令来加载,则在加载过程中会调用 Agent_OnAttach 函数
    • Agent_OnUnload 函数,在 agent 卸载时调用
  • javaagent 依赖于 instrument 的 JVMTIAgent(Linux 下对应的动态库是 libinstrument.so),还有个别名叫 JPLISAgent(Java Programming Language Instrumentation Services Agent),专门为 Java 语言编写的插桩服务提供支持的

  • instrument 实现了 Agent_OnLoad 和 Agent_OnAttach 两方法,也就是说在使用时,agent 既可以在启动时加载,也可以在运行时动态加载。其中启动时加载还可以通过类似-javaagent:jar 包路径的方式来间接加载 instrument agent,运行时动态加载依赖的是 JVM 的 attach 机制,通过发送 load 命令来加载 agent

  • JVM Attach 是指 JVM 提供的一种进程间通信的功能,能让一个进程传命令给另一个进程,并进行一些内部的操作,比如进行线程 dump,那么就需要执行 jstack 进行,然后把 pid 等参数传递给需要 dump 的线程来执行

Java Agent 案例

加载 Java 字节码之前拦截

App 项目

(1)创建一个名为 javacore-javaagent-app 的 maven 工程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>io.github.dunwu.javacore</groupId>
<artifactId>javacore-javaagent-app</artifactId>
<version>1.0.1</version>
<name>JavaCore :: JavaAgent :: App</name>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>1.8</java.version>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
</properties>
</project>

(2)创建一个应用启动类

1
2
3
4
5
6
7
8
public class AppMain {

public static void main(String[] args) {
System.out.println("APP 启动!!!");
AppInit.init();
}

}

(3)创建一个模拟应用初始化的类

1
2
3
4
5
6
7
8
9
10
11
12
public class AppInit {

public static void init() {
try {
System.out.println("APP初始化中...");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}

}

(4)输出

1
2
APP 启动!!!
APP初始化中...

Agent 项目

(1)创建一个名为 javacore-javaagent-agent 的 maven 工程

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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>io.github.dunwu.javacore</groupId>
<artifactId>javacore-javaagent-agent</artifactId>
<version>1.0.1</version>
<name>JavaCore :: JavaAgent :: Agent</name>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>1.8</java.version>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
</properties>

<dependencies>
<!--javaagent 工具包-->
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.26.0-GA</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.5.1</version>
<!--指定 maven 编译的 jdk 版本。若不指定,maven3 默认用 jdk 1.5;maven2 默认用 jdk1.3-->
<configuration>
<source>8</source>
<target>8</target>
</configuration>
</plugin>

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<archive>
<!--自动添加META-INF/MANIFEST.MF -->
<manifest>
<addClasspath>true</addClasspath>
</manifest>
<manifestEntries>
<Menifest-Version>1.0</Menifest-Version>
<Premain-Class>io.github.dunwu.javacore.javaagent.RunTimeAgent</Premain-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>

(2)创建一个 Agent 启动类

1
2
3
4
5
6
7
8
public class RunTimeAgent {

public static void premain(String arg, Instrumentation instrumentation) {
System.out.println("探针启动!!!");
System.out.println("探针传入参数:" + arg);
instrumentation.addTransformer(new RunTimeTransformer());
}
}

这里每个类加载的时候都会走这个方法,我们可以通过 className 进行指定类的拦截,然后借助 javassist 这个工具,进行对 Class 的处理,这里的思想和反射类似,但是要比反射功能更加强大,可以动态修改字节码。

(3)使用 javassist 拦截指定类,并进行代码增强

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
package io.github.dunwu.javacore.javaagent;

import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;

public class RunTimeTransformer implements ClassFileTransformer {

private static final String INJECTED_CLASS = "io.github.dunwu.javacore.javaagent.AppInit";

@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
String realClassName = className.replace("/", ".");
if (realClassName.equals(INJECTED_CLASS)) {
System.out.println("拦截到的类名:" + realClassName);
CtClass ctClass;
try {
// 使用javassist,获取字节码类
ClassPool classPool = ClassPool.getDefault();
ctClass = classPool.get(realClassName);

// 得到该类所有的方法实例,也可选择方法,进行增强
CtMethod[] declaredMethods = ctClass.getDeclaredMethods();
for (CtMethod method : declaredMethods) {
System.out.println(method.getName() + "方法被拦截");
method.addLocalVariable("time", CtClass.longType);
method.insertBefore("System.out.println(\"---开始执行---\");");
method.insertBefore("time = System.currentTimeMillis();");
method.insertAfter("System.out.println(\"---结束执行---\");");
method.insertAfter("System.out.println(\"运行耗时: \" + (System.currentTimeMillis() - time));");
}
return ctClass.toBytecode();
} catch (Throwable e) { //这里要用Throwable,不要用Exception
System.out.println(e.getMessage());
e.printStackTrace();
}
}
return classfileBuffer;
}

}

(4)输出

指定 VM 参数 -javaagent:F:\code\myCode\agent-test\runtime-agent\target\runtime-agent-1.0-SNAPSHOT.jar=hello,运行 AppMain

1
2
3
4
5
6
7
8
9
探针启动!!!
探针传入参数:hello
APP 启动!!!
拦截到的类名:io.github.dunwu.javacore.javaagent.AppInit
init方法被拦截
---开始执行---
APP初始化中...
---结束执行---
运行耗时: 1014

运行时拦截(JDK 1.6 及以上)

如何实现在程序运行时去完成动态修改字节码呢?

动态修改字节码需要依赖于 JDK 为我们提供的 JVM 工具,也就是上边我们提到的 Attach,通过它去加载我们的代理程序。

首先我们在代理程序中需要定义一个名字为 agentmain 的方法,它可以和上边我们提到的 premain 是一样的内容,也可根据 agentmain 的特性进行自己逻辑的开发。

1
2
3
4
5
6
7
8
9
10
11
/**
* agentmain 在 main 函数开始运行后才启动(依赖于Attach机制)
*/
public class RunTimeAgent {

public static void agentmain(String arg, Instrumentation instrumentation) {
System.out.println("agentmain探针启动!!!");
System.out.println("agentmain探针传入参数:" + arg);
instrumentation.addTransformer(new RunTimeTransformer());
}
}

然后就是我们需要将配置中设置,让其知道我们的探针需要加载这个类,在 maven 中设置如下,如果是 META-INF/MANIFEST.MF 文件同理。

1
2
<!--<Premain-Class>com.zhj.agent.agentmain.RunTimeAgent</Premain-Class>-->
<Agent-Class>com.zhj.agent.agentmain.RunTimeAgent</Agent-Class>

这样其实我们的探针就已经改造好了,然后我们需要在目标程序的 main 方法中植入一些代码,使其可以读取到我们的代理程序,这样我们也无需去配置 JVM 的参数,就可以加载探针程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class APPMain {

public static void main(String[] args) {
System.out.println("APP 启动!!!");
for (VirtualMachineDescriptor vmd : VirtualMachine.list()) {
// 指定的VM才可以被代理
if (true) {
System.out.println("该VM为指定代理的VM");
System.out.println(vmd.displayName());
try {
VirtualMachine vm = VirtualMachine.attach(vmd.id());
vm.loadAgent("D:/Code/java/idea_project/agent-test/runtime-agent/target/runtime-agent-1.0-SNAPSHOT.jar=hello");
vm.detach();
} catch (Exception e) {
e.printStackTrace();
}
}
}
AppInit.init();
}
}

其中 VirtualMachine 是 JDK 工具包下的类,如果系统环境变量没有配置,需要自己在 Maven 中引入本地文件。

1
2
3
4
5
6
7
8
<dependency>
<groupId>com.sun</groupId>
<artifactId>tools</artifactId>
<version>1.8</version>
<scope>system</scope>
<systemPath>D:/Software/java_dev/java_jdk/lib/tools.jar</systemPath>
</dependency>
复制代码

这样我们在程序启动后再去动态修改字节码文件的简单案例就完成了。

参考资料