1 微服务架构产生的历史背景

1.1 研发成本挑战

1.1.1 代码重复率

  1. 从技术架构角度看,传统垂直架构的特点是本地 API 接口调用,不存在业务的拆分和互相调用,使用到什么功能就本地开发,非常方便,不需要过度依赖于其它功能模块。
  2. 跨地域、跨开发小组协调很困难。

1.1.2 需求变更困难

代码重复率变高之后,已有功能变更或者新需求加入都会非常困难,以充值缴费功能为例,不同的充值渠道开发了相同的限额保护功能,当限额保护功能发生变更之后,所有重复开发的限额保护功能都需要重新修改和测试,很容易出现修改不一致或者被遗漏,导致部分渠道充值功能正常:

1.2 运维成本高

1.2.1 代码维护困难

传统的业务流程是由一长串本地接口或者方法调用串联起来的,而且往往由一个负责开发和维护。随着业务的发展和需求变化,本地diamante在不断地迭代和变更,最后形成了一个个垂直的功能孤岛,只有原来的开发者才理解接口调用关系和功能需求,一旦原来的开发者离职或调到其它项目组,这些功能模块的运维就会变得非常困难:

1.2.2 部署效率低

  1. 业务没有拆分,很多功能模块都打到同一个 war 包中,一旦有一个功能发生变更,就需要重新打包和部署。
  2. 测试工作量较大,因此存在大量重复的功能类库,需要针对所有调用方进行测试,测试工作量大。

1.3 新需求上线周期长

  1. 新功能通常无法独立编译、打包、部署和上线,它可能混杂在老的系统中开发,很难剥离出来,这就无法通过服务灰度发布的形式快速上线。
  2. 由于业务没有进行水平和垂直拆分,导致代码重复率高,新需求的开发、测试、打包和部署成本都比较高。

2 微服务架构带来的改变

2.1 应用解耦

微服务化之前,一个大型的应用系统通常会包含多个子应用,不同应用之间存在很多重复的公共代码,所有应用共用一套数据库,架构图如下:

将功能A 和功能B 服务化之后,应用作为消费者直接调用服务A 和服务B ,这样就实现了对原有重复代码的收编,同时系统之间的调用关系也更加清晰,如下图:

基于服务注册中心的定于发布机制,实现服务消费者和提供者之间的解耦。

2.2 分而治之

将核心业务抽取出来,作为独立的服务,逐渐形成稳定的底层微服务。
应用的拆分分为水平拆分和垂直拆分两种,水平拆分以业务领域为维度,抽象出几个不同的业务域,每个业务域作为一个独立的服务中心对外提供服务。领域服务可以独立地伸缩和升级,快速地响应需求变化,同时与其它业务领域解耦。原理图如下:

应用的垂直拆分主要包括前后台逻辑拆分、业务逻辑和数据访问层拆分,拆分之后的效果图如下:

2.3 敏捷交付

敏捷性的产生,是将运行中的系统解耦为一系列功能单一服务的结果。微服务架构能够对系统中其它部分的依赖加以限制,这种特性能够让基于微服务架构的应用在应对 BUG 或是对新特性需求时,能够快速地进行变更。而传统的垂直架构:“要对应用程序中某个小部分进行变更,就必须对整体架构进行重新编译和构建,并且重新进行全量部署。”

3 微服务架构解析

微服务架构(MSA)是一种架构风格,旨在通过将功能分解到各个离散的服务中以实现对解决方案的解耦。对比图如下:

3.1 微服务划分原则

通用的划分原则是:微服务通常是简单、原子的微型服务,它的功能单一,只负责处理一件事,与代码行数并没有直接关系,与需要处理的业务复杂度有关。有些复杂的功能,尽管功能单一,但是代码量可能成百上千行,因此不能以代码量作为划分微服务的维度。

“微”所表达的是一种设计思想和指导方针,是需要团队或者组织共同努力找到的一个平衡点。

3.2 开发微服务

对于不同的微服务,虽然实现逻辑不同,但是开发方式、持续集成环境、测试策略和部署机制以及后续的上线运维都是类似的,为了满足 DRY 原则并消除浪费,需要搭建统一的开发打包和持续集成环境。

3.3 基于 Docker 容器部署微服务

Docker 是一套开源工具,它能够以某种方式对现有的基于容器的虚拟化技术进行封装,使得它能够在更广阔的工程社区中得到应用,主要在于快速和可移植性。

3.3.1 快速

普通的虚拟机在每次开机时都需要启动一个完整的新操作系统实例,而 Docker 容器能够通过内核共享的方式,共享一套托管操作系统。这意味着,Docker 容器的启动和停止只需要几百毫秒。这样就有更高的敏捷性。

物理机 VS Docker VS 虚拟机

3.3.2 可移植性

  1. 线上线下环境等同性:本地模拟线上环境,定位 BUG 更快。
  2. 与特定的云提供商解耦:参考 JVM。
  3. 提升运维效率:Docker 对可移植的容器部署进行标准化,节省时间与精力。如果你在构建某个应用程序,你的选择包括物理机、虚拟化的本地基础设施、公有云和私有云,以及各种可用的 PaaS 选项。而通过 Docker 标准化的容器格式,任何一种提供商都可以实现一种统一的部署体验。
  4. 敏捷性:快速启动,更敏捷。

3.4 治理和运维微服务

微服务架构对运维和部署流水线要求非常高,服务拆分的粒度越细,运维和治理成本就越高,挑战总结如下:

  1. 监控度量问题:海量微服务的各种维度性能 KPI 采集、汇总和分析,实时和历史数据同比和环比,对采集模块的实时性、汇总模块的计算能力、前端运维 Portal 多维度展示能力要求非常高。
  2. 分布式运维:服务拆分得越细,一个完整业务流程的调用链就越长,需要采集、汇总和计算的数据量就越大,分布式消息跟踪系统需要能够支撑大规模微服务化后带来的性能挑战。
  3. 海量微服务对服务注册中心的处理能力、通知的实时性也带来了巨大挑战。
  4. 微服务治理:微服务化之后,微服务相比于传统的 SOA 服务有了指数级增长,服务治理的展示界面、检索速度等需要能够支撑这种变化。
  5. 量变引起质变:当需要运维的服务规模达到一定上限后,就由量变引起质变,传统的运维框架架构可能无法支撑,需要重构。

解决微服务运维的主要措施就是:分布式和自动化。利用分布式系统的性能线性增长和弹性扩容能力,支撑大规模微服务对运维系统带来的性能冲击,包括:

  1. 分布式性能数据采集、日志采集 Agent。
  2. 分布式汇总和计算框架。
  3. 分布式文件存储服务。
  4. 分布式日志检索服务。
  5. 分布式报表展示框架。

3.5 特点总结

  1. 单一职责原则:每个服务应该负责单独的功能。
  2. 独立部署、升级、扩展和替换。
  3. 支持异构/多语言。
  4. 轻量级。

因此优点如下:

  1. 开发、测试和运维更加简单。
  2. 局部修改很容易部署,有利于持续集成和持续交付。
  3. 技术选择更灵活,不与特定语言和工具绑定。
  4. 有利于小团队作战,敏捷交付。

4 个人总结

微服务涉及到了组织架构、涉及、交付、运维等方面的变革,核心目标是为了解决系统的交付周期,降低维护成本和研发成本。
但是带来了运维成本、服务管理成本等。
不可脱离业务实际而强制使用微服务。

相对于传统的本地 Java API 调用,跨进程的分布式服务调用面临的故障风险更高:

  1. 网络类故障:链路闪断、读写超时等。
  2. 序列化和反序列化失败。
  3. 畸形码流。
  4. 服务端流控和拥塞保护导致的服务调用失败。
  5. 其它异常。

对于应用而言,分布式服务框架需要具备足够的健壮性,在平台底层能够拦截并向上屏蔽故障,业务只需要配置容错策略,即可实现高可靠性。

1 服务状态监测

在分布式服务调用时,某个服务提供者可能已经宕机,如果采用随机路由策略,消息会继续发送给已经宕机的服务提供者,导致消息发送失败。为了保证路由的正确性,消费者需要能够实时获取服务提供者的状态,当某个服务提供者不可用时,将它从缓存的路由表中删除掉,不再向其发送消息,直到对方恢复正常。

1.1 基于服务注册中心状态监测

以 ZooKeeper 为例,ZooKeeper 服务端利用与 ZooKeeper 客户端之间的长链接会话做心跳检测。

1.2 链路有效性状态监测机制

分布式服务框架的服务消费者和提供者之间默认往往采用长链接,并且通过双向心跳检测保障链路的可靠性。
在一些特殊的场景中,服务提供者和注册中心之间网络可达,服务消费者和注册中心网络也可达,但是服务提供者和消费者之间网络不可达,或者服务提供者和消费者之间链路已经断连。此时,服务注册中心并不能检测到服务提供者异常,但是如果消费者仍旧向链路中断的提供者发送消息,写操作将会失败。

为了解决该问题,通常需要使用服务注册中心检测 + 服务提供者和消费者之间的链路有效性检测双重检测来保障系统的可靠性,它工作原理如下:

当消费者通过双向心跳检测发现链路故障之后,会主动释放链接,并将对应的服务提供者从路由缓存表中删除。当链路恢复之后,重新将恢复的故障服务提供者地址信息加入地址缓存表中。

2 服务健康度监测

在集群组网环境下,由于硬件性能差异、各服务提供者的负载不均等原因,如果采用随机路由分发策略,会导致负载较重的服务提供者不堪重负被压垮。
利用服务的健康度监测,可以对集群的所有服务实例进行体检,根据体检加过对健康度做打分,得分较低的亚健康服务节点,路由权重会被自动调低,发送到对应节点的消息会少很多。这样实现“能者多劳、按需分配”,实现更合理的资源分配和路由调度。

服务的健康度监测通常需要采集如下性能 KPI 指标:

  1. 服务调用时延。
  2. 服务 QPS。
  3. 服务调用成功率。
  4. 基础资源使用情况,例如堆内存、CPU 使用率等。

原理如下:

3 服务故障隔离

分为四个层次:

  1. 进程级故障隔离
  2. VM 级故障隔离
  3. 物理机故障隔离
  4. 机房故障隔离

3.1 进程级故障隔离

个人理解为线程级。即通过将服务部署到不同的线程池实现故障隔离。对于订单、购物车等核心服务可以独立部署到一个线程池中,与其它服务做线程调度隔离。对于非核心服务,可以合设共享同一个/多个线程池,防止因为服务数过多导致线程数过度膨胀。

服务发布的时候,可以指定服务发布到哪个线程池中,分布式服务框架拦截 Spring 容器的启动,解析 XML 标签,生成服务和线程池的映射关系,通信框架将解码后的消息投递到后端时,根据服务名选择对应的线程池,将消息投递到映射线程池的消息队列中。
原理图如下:

如果故障服务发生了内存泄漏异常,它会导致整个进程不可用。

3.2 VM 级故障隔离

将基础设施层虚拟化、服务化,将应用部署到不同的 VM 中,利用 VM 对资源层的隔离,实现高层次的服务故障隔离,工作原理如下:

3.3 物理机故障隔离

当组网规模足够大、硬件足够多的时候,硬件的故障就由小概率事件转变为普通事件。如何保证在物理机故障时,应用能够正常工作,是一个不小的挑战。
利用分布式服务框架的集群容错功能,可以实现位置无关的自动容错,工作原理如下:

如果要保证当前服务器宕机时不影响部署在上面运行的服务,需要采用分布式集群部署,而且要采用非亲和性安装:即服务实例需要部署到不同的物理机上,通常至少需要 3 台物理机,假如单台物理机的故障发生概率为 0.1 %,则 3 台同时发生故障的概率为 0.001%,服务的可靠性将会达到 99.999%,完全可以满足大多数应用场景的可靠性要求。
物理机故障重启之后,通过扩展插件通知 Watch Dog 重新将应用拉起,应用启动时会重新发布服务,服务发布成功之后,故障服务器节点就能重新恢复正常工作。 ·`

3.4 机房故障隔离

同城容灾时,都需要使用多个机房,下面针对跨机房的容灾和故障隔离方案进行探讨。

机房1 和机房2 对等部署了2套应用集群,每个机房部署一套服务注册中心集群,服务订阅和发布同时针对两个注册中心,对于机房1 或者机房2 的 Web 应用,可以同时看到两个机房的服务提供者列表。
理由时,优先访问同一个机房的服务提供者,当本机房的服务提供者大面积不可用或者全部不可用时,根据跨机房路由策略,访问另一个机房的服务提供者,待本机房服务提供者集群恢复到正常状态之后,重新切换到本机房访问模式。
当整个机房宕机之后,由前端的 SLB\F5 负载均衡器自动将流量切换到容灾机房,由于主机房整个瘫掉了,容灾机房的消费者通过服务状态监测将主机房的所有服务提供者从路由缓存表中删除,服务调用会自动切换到本机房调用模式,实现故障的自动容灾切换。

上面的方案需要分布式服务框架支持多注册中心,同一个服务实例,可以同时注册到多个服务注册中心中,实现跨机房的服务调用。两个机房共用一套服务注册中心也可以,但是如果服务注册中心所在的机房整个宕掉,则分布式服务框架的服务注册中心将不可用。已有的服务调用不受影响,新的依赖服务注册中心的操作江辉失败,例如服务治理、运行期参数调整、服务的状态监测等功能将不可用。

4 其它可靠性特性

4.1 服务注册中心

服务注册中心需要采用对等集群设计,任意一台宕机之后,需要能够自动切换到下一台可用的注册中心。例如 ZooKeeper ,如果某个 Leader 节点宕机,通过选举算法会重新选举出一个新的 Leader,只要集群组网实例数不小于 3,整个集群就能够正常工作。

4.2 监控中心

监控中心集群宕机之后,只丢失部分采样数据,依赖性能 KPI 采样数据的服务健康度监测功能不能正常使用,服务提供者和消费者依然能够正常运行,业务不会中断。

4.3 服务提供者

某个服务提供者宕机之后,利用集群容错策略,会舱室不同的容错恢复手段,例如使用 FailOver 容错策略,自动切换到下一个可用的服务,直到找到可用的服务为止。
如果整个服务提供者集群都宕机,可以利用服务放通、故障引流、容灾切换等手段。

5 个人总结

任何假设的宕机情况都会出现,解决手段不外乎:

  1. 对等集群(例如跨机房)。
  2. 服务放通(远程错误直接切换为本地调用)。
  3. 隔离。

0 前言

关于 ZooKeeper实现分布式锁,笔者在武汉小米一面(结果挂了)被问到过,因此记录如下。

以下的理论知识源自《 从Paxos到Zookeeper分布式一致性原理与实践 》第六章,代码 完全根据书本理论进行实现,并且经多线程测试,在正常情况可行。

源码:https://github.com/LiWenGu/MySourceCode/tree/master/example/src/main/java/com/lwg/zk_project

1 ZooKeeper实现排他锁

1.1 原理

核心点:

  1. 抢占式创建相同名称的临时节点,谁成功创建节点,则代表谁获得了锁。
  2. 没有创建成功该节点,并且该节点存在,则对该名称的节点进行删除监听。
  3. 如果该节点被删除了,则继续重复第 1步。

1.2 流程图

原书流程图:

我自己理解的流程:

1.3 代码实现

统一接口:

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
/**
* @Author liwenguang
* @Date 2018/6/15 下午9:16
* @Description
*/
public interface DistributedLock {

/**
* @Author liwenguang
* @Date 2018/6/15 下午9:17
* @Description 获取锁,默认等待时间
*/
default void tryRead() throws ZkException { throw new RuntimeException("子类不支持"); }

/**
* @Author liwenguang
* @Date 2018/6/15 下午9:18
* @Description 获取锁,指定超时时间
*/
default void tryRead(long time, TimeUnit unit) { throw new RuntimeException("子类不支持"); }

void tryWrite() throws ZkException;

default void tryWrite(long time, TimeUnit unit) { throw new RuntimeException("子类不支持"); }

/**
* @Author liwenguang
* @Date 2018/6/15 下午9:18
* @Description 释放锁
*/
void release() throws ZkException;

}

核心代码:

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
private void tryGetLock() {
CountDownLatch countDownLatch = new CountDownLatch(1);
while (true) {
try {
zkClient.createEphemeral(EXCLUSIVE_LOCK_NAMESPACE + lockPath);
log.info(Thread.currentThread().getName() + "获取锁成功");
break;
} catch (ZkNodeExistsException e) {
// log.warn(Thread.currentThread().getName() + "获取锁失败");
if (zkClient.exists(EXCLUSIVE_LOCK_NAMESPACE + lockPath)) {
MyIZkDataListener myIZkChildListener = new MyIZkDataListener(countDownLatch);
zkClient.subscribeDataChanges(EXCLUSIVE_LOCK_NAMESPACE + lockPath, myIZkChildListener);
} else {
countDownLatch.countDown();
}
}
try {
// 这里需要阻塞式通知,因此使用 countDownLatch实现
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.info("获取到了锁");
}

class MyIZkDataListener implements IZkDataListener {

private CountDownLatch countDownLatch;


public MyIZkDataListener(CountDownLatch countDownLatch) {
this.countDownLatch = countDownLatch;
}

@Override
public void handleDataChange(String dataPath, Object data) throws Exception { }

@Override
public void handleDataDeleted(String dataPath) throws Exception {
//log.info(Thread.currentThread().getName() + "被回调了");
zkClient.unsubscribeDataChanges(EXCLUSIVE_LOCK_NAMESPACE + lockPath, this);
countDownLatch.countDown();
}
}

2 ZooKeeper共享锁

2.1 原理

核心点:

  1. 无论是读请求(读锁)还是写请求(写锁)都进行创建顺序临时节点,只看后缀的数字我们可以理解为 一种从小到大的队列(例:我们在做订单请求的时候,对订单A做创建-> 支付-> 完成三个操作,对应 ZK节点则节点A下有三个子节点,这时候节点A可以理解为一个队列)。
  2. 创建完成之后,对读锁,则判断该队列之前是否有写锁,如果有写锁,则对写锁做删除监听。对写锁,判断队列之前是否有锁,如果有锁,则对序号最大的锁做删除监听。
  3. 删除监听触发,获取该锁节点下所有的子节点(一个节点即代表锁),重复第 2步。

2.2 流程图

原书流程图:

我自己理解的流程:

2.3 代码实现

核心代码:

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
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
@Override
public void tryRead() throws ZkException {
if (!zkClient.exists(SHARED_LOCK_NAMESPACE + lockPath)) {
zkClient.createPersistent(SHARED_LOCK_NAMESPACE + lockPath);
}
CountDownLatch countDownLatch = new CountDownLatch(1);
curNode = zkClient.createEphemeralSequential(SHARED_LOCK_NAMESPACE + lockPath + "/" + SHARED_READ_PRE, null);
String curSequence = curNode.split(SHARED_READ_PRE)[1];
log.info(curSequence + "创建读锁-R");
while (true) {
List<String> children = zkClient.getChildren(SHARED_LOCK_NAMESPACE + lockPath);
// 记录序号比自己小的写请求
List<String> writers = new ArrayList<>();
for (String brother : children) {
if (brother.startsWith(SHARED_WRITE_PRE)) {
String sequence = brother.split(SHARED_WRITE_PRE)[1];
if (curSequence.compareTo(sequence) > 0) {
writers.add(brother);
}
}
}
if (writers.isEmpty()) {
// 没有比自己序号小的写请求,说明自己获取到了读锁
//log.info(Thread.currentThread().getName() + "没有比自己序号小的写请求-R");
break;
} else {
// 获取最近的那个写锁
String lastWriter = SHARED_LOCK_NAMESPACE + lockPath + "/" + writers.get(writers.size() - 1);
// 判断最近的那个写锁期间是否已经释放了
if (zkClient.exists(lastWriter)) {
MyReadIZkChildListener myReadIZkChildListener = new MyReadIZkChildListener(lastWriter, countDownLatch);
zkClient.subscribeDataChanges(lastWriter, myReadIZkChildListener);
} else {
countDownLatch.countDown();
}
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.info("获取到了锁-R");
}

class MyReadIZkChildListener implements IZkDataListener {

private String lastWriter;
private CountDownLatch countDownLatch;

public MyReadIZkChildListener(String lastWriter, CountDownLatch countDownLatch) {
this.lastWriter = lastWriter;
this.countDownLatch = countDownLatch;
}

@Override
public void handleDataChange(String dataPath, Object data) throws Exception { }

@Override
public void handleDataDeleted(String dataPath) throws Exception {
//log.info(Thread.currentThread().getName() + "比自己序号小的那个写请求被释放了-R");
zkClient.unsubscribeDataChanges(lastWriter, this);
// 最近的那个写锁被释放了,但是不排除释放过程中,有其它写锁新加入,因此读锁需要重新获取列表
countDownLatch.countDown();
}
}

@Override
public void tryWrite() throws ZkException {
CountDownLatch countDownLatch = new CountDownLatch(1);
if (!zkClient.exists(SHARED_LOCK_NAMESPACE + lockPath)) {
zkClient.createPersistent(SHARED_LOCK_NAMESPACE + lockPath);
}
curNode = zkClient.createEphemeralSequential(SHARED_LOCK_NAMESPACE + lockPath + "/" + SHARED_WRITE_PRE, null);
String curSequence = curNode.split(SHARED_WRITE_PRE)[1];
log.info(curSequence + "创建写锁-W");
while (true) {
List<String> children = zkClient.getChildren(SHARED_LOCK_NAMESPACE + lockPath);
// 记录序号比自己小的请求
List<String> writersOrReader = new ArrayList<>();
for (String brother : children) {
if (brother.equals(SHARED_WRITE_PRE + curSequence)) {
// 排除自己
continue;
}
String sequence = "";
if (brother.contains(SHARED_WRITE_PRE)) {
sequence = brother.split(SHARED_WRITE_PRE)[1];
} else if (brother.contains(SHARED_READ_PRE)) {
sequence = brother.split(SHARED_READ_PRE)[1];
} else {
// 异常名称节点的处理
}
if (curSequence.compareTo(sequence) > 0) {
writersOrReader.add(brother);
}
}
if (writersOrReader.isEmpty()) {
// 没有比自己序号小的请求,说明自己获取到了读锁
//log.info(Thread.currentThread().getName() + "没有比自己序号小的请求-W");
break;
} else {
// 获取最近的那个锁
String lastWriterOrReader = SHARED_LOCK_NAMESPACE + lockPath + "/" + writersOrReader.get(writersOrReader.size() - 1);
// 判断最近的那个锁期间是否已经释放了
if (zkClient.exists(lastWriterOrReader)) {
MyWriteIZkChildListener myWriteIZkChildListener = new MyWriteIZkChildListener(lastWriterOrReader, countDownLatch);
zkClient.subscribeDataChanges(lastWriterOrReader, myWriteIZkChildListener);
} else {
countDownLatch.countDown();
}
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.info("获取到了锁-W");
}

class MyWriteIZkChildListener implements IZkDataListener {

private String lastWriterOrReader;
private CountDownLatch countDownLatch;

public MyWriteIZkChildListener(String lastWriterOrReader, CountDownLatch countDownLatch) {
this.lastWriterOrReader = lastWriterOrReader;
this.countDownLatch = countDownLatch;
}

@Override
public void handleDataChange(String dataPath, Object data) throws Exception {
}

@Override
public void handleDataDeleted(String dataPath) throws Exception {
//log.info(Thread.currentThread().getName() + "比自己序号小的那个请求被释放了,循环-W");
zkClient.unsubscribeDataChanges(lastWriterOrReader, this);
countDownLatch.countDown();
}
}

3. 待续

readlock

随着业务分布式架构的发展,系统间的系统调用日趋复杂,以电商的商品购买为例,前台界面的购买操作设计到底层上百次服务调用,涉及到的中间件包括:

  1. 分布式服务框架
  2. 消息队列
  3. 分布式缓存
  4. 分布式数据访问中间件
  5. 分布式文件存储系统
  6. 分布式日志采集
  7. 其它……

如果无法有效清理后端的分布式调用和依赖关系,故障定界将会非常困难。利用分布式消息跟踪系统可以有效解决服务化之后系统面临的运维挑战,提高运维效率。

1 业务场景分析

以下为分布式调用示意图:

1.1 故障的快速定界定位

传统应用软件发生故障时,往往通过接口日志手工从故障节点采集日志进行问题分析定位,分布式服务化之后,一次业务调用可能涉及到后台上百次服务调用,每个服务又是集群组网,传统人工到各个服务节点人肉搜索的方式效率很低。
希望能够通过调用链跟踪,将一次业务调用的完整轨迹以调用链的形式展示出来,通过图形化界面查看每次服务调用结果,以及故障信息。

通过在业务日志中增加调用链 ID ,可以实现业务日志和调用链的动态关联。通过调用链进行快速故障定界,然后通过 ID 关联查询,可以快速定位到业务日志相关信息。

1.2 调用路径分析

通过对调用链调用路径的分析,可以识别应用的关键路径:应用被调用得最多的入口、服务是哪些,找出服务的热点、耗时瓶颈和易故障点。同时为性能优化、容量规划等提供数据支撑。

1.3 调用来源和去向分析

通过调用去向分析,可以对服务的依赖关系进行梳理:

  1. 应用直接和间接依赖了哪些服务。
  2. 各层次依赖的调用时延、QPS、成功率等性能 KPI指标。
  3. 识别不合理的强依赖,或者冗余依赖,反向要求开发进行依赖解耦和优化。

通过对调用来源进行 TOP排序,识别当前服务的消费来源,以及获取各消费者的 QPS、平均时延、出错率等,针对特定的消费者,可以做针对性治理,例如针对某个消费者的限流降级、路由策略修改等,保障服务的 SLA。

2 分布式消息跟踪系统设计

消息跟踪系统的核心就是调用链:每次业务请求都生成一个全局唯一的 TraceID,通过跟踪 ID将不同节点间的日志串接起来,形成一个完整的日志调用链,通过对调用链日志做实时采集、汇总和大数据分析,提取各种维度的价值数据,为系统运维和运营提供大数据支撑。

2.1 系统架构

分布式消息跟踪系统的整体架构如下,由四部分组成:

  1. 调用链埋点日志生成
  2. 分布式采集和存储埋点日志
  3. 在线、离线大数据计算,对调用链数据进行分析和汇总
  4. 调用链的界面展示、排序和检索等

2.2 埋点日志

埋点就是分布式消息跟踪系统在当前节点的上下文信息,埋点可以分为两类:

  1. 客户端埋点,客户端发送请求消息时生成的调用上下文,通常包括 TraceID、调用方 IP、调用方接口或者业务名称、调用的发起时间、被调用的服务名、方法名、IP 地址和端口等信息。
  2. 服务端埋点,服务端返回应答消息时在当前节点生成的上下文,包括 TraceID、调用方上下文信息、服务端处理的耗时、处理结果等信息。

埋点日志的实现,通常会包含如下几个功能:

  1. 埋点规范,主要用于业务二次定制开发和第三方中间件/系统对接。
  2. 埋点日志类库,服务生成埋点上下文,打印埋点日志等。
  3. 中间件预置埋点功能,应用不需要开发任何业务代码即可直接使用,也可以通过埋点类库将应用自身的业务字段携带到调用链上下文中,例如终端类型、手机号等。


消息跟踪 ID 通常由调用首节点负责生成(各种门户 Portal),本 JVM 之内通常线程上下文传递 TraceID,跨节点传递时,往往通过分布式服务框架的显式传参传递到下游节点,实现消息跟踪上下文的跨节点传递。
埋点日志上下文通常需要包含如下内容:

  1. TraceID、RPCID、调用的开始时间、调用类型、协议类型、调用方 IP 和端口、被调用方 IP 和端口、请求方接口名、被调用方服务名等信息。
  2. 调用耗时、调用结果、异常信息、处理的消息报文大小等。
  3. 可扩展字段,通常用于应用扩展埋点上下文信息。

消息跟踪ID(TraceID)是关联一次完整应用调用的唯一标识,需要在整个集群内唯一,它的取值策略有很多,例如 UUID,UUID(Universally Unique Identifier)即全局唯一标识符,是指在一台机器上生成的数字,它保证对在同一时空中所有机器都是唯一的。按照开发软件基金会(OSF)制定的标准计算,用到了以太网卡地址、纳秒级时间、芯片 ID 码和许多可能的数字。由以下几部分组合:当前日期和时间(UUID 的第一部分与时间有关,如果你在生成一个 UUID 之后,过几秒又生成一个 UUID,则第一部分不同,其余相同),时钟序列,全局唯一的 IEEE 机器识别号(如果有网卡,从网卡获得,没有网卡以其它方式获得),UUID 的唯一缺陷在于生成的结果串会比较长。

  1. IP 地址和端口:调用发起方和被调用方 IP 地址、端口号
  2. 时间戳:埋点上下文的生成时间
  3. 顺序号:标识链路传递序列的 RpcID
  4. 进程号:应用的进程 ID
  5. 随机数:例如可以选择 8 位数的随机数

原理上,埋点日志比较简单,实现起来并不复杂。但是在实际工作中,埋点日志也会面临一些技术挑战,举例如下:

  1. 异步调用:业务服务中直接调用 MQ 客户端,或者其它中间件的客户端时,可能会发生线程切换,通常线程上下文传递的埋点信息丢失,MQ 客户端会认为自己是首节点,重新生成 TraceID,导致调用链串接不起来。
  2. 性能影响:由于 Java I/O 操作通常都是同步的,如果磁盘的 WIO 比较高,会导致写埋点日志阻塞应用线程,导致时延增大。频繁地写埋点日志,也会占用大量的 CPU、带宽等系统资源,影响正常业务的运行。

对于线程切换问题,在切换时需求做线程上下文的备份,将埋点上下文复制到切换的线程上下文中,即可解决问题。
频繁写埋点日志影响性能问题,可以通过如下措施改善该问题:

  1. 支持异步写日志,防止写埋点日志慢阻塞服务线程。具体实现上可以通过采用 log4j 的异步 Appender、独立的日志线程池甚至是 JDK1.7 之后提供的异步文件操作接口。
  2. 提供可灵活配置的埋点采样率,控制埋点日志量。
  3. 批量写日志,日志流控机制。

2.3 采样率

对于高 QPS 的应用,服务调用埋点本身的性能损耗也不容忽视,为了解决 100% 全采样的性能损耗,可以通过采样率来实现埋点低损耗的目标。
采样包括静态采用和动态采样两种,静态采样就是系统上线时设置一个采样率,无论负载高低,均按照采样率执行。动态采样率根据系统的负载可以自动调整,当负载比较低的时候可以实现 100% 全采样,在负载非常重时甚至可以降低到 0 采样。

是否采样由调用链的首节点进行判断,首节点根据采样率算法,决定某个业务访问是否采样,如果需要采样,则把采样标识、TraceID 等采样上下文发送到下游服务节点,下游服务节点根据采样标识做判断,如果采样则获取调用链上下文并补充完整,反之则不埋点。

2.4 采集和存储埋点日志

开源 的 ELK,原理如下:

需要考虑:

  1. 采集过程中发生宕机,如何在中断点恢复采集。
  2. 采集过程中如果埋点日志发生了文件切换(例如达到单个日志文件 100MB 上限之后,自动进行文件切换),如何正确应对。
  3. 采集 Channel 发生网络故障,导致采集的日志部分发送失败,故障恢复之前,日志如何缓存,故障恢复之后,已采集尚未发送的日志如何发送。
  4. 考虑到性能,是不是单条采集、批量发送性能更优。

3 个人总结

通过对业务流程的记录和采集,进行在线和离线的大数据计算,数据清洗获取有价值的数据。同时还能根据运行情况做服务的调整。

随着业务发展,服务越来越多,如何协调线上运行的各个服务,保障服务的 SLA,以及小服务资源浪费的问题,需要能够基于服务调用的性能 KPI数据进行容量管理,合理分配各个服务的资源占用。
线上业务发生故障,需要对故障业务做服务降级、流量控制,快速恢复业务。
为了满足服务线下管控、保障线上高效运行,需要有一个统一的服务治理框架对服务进行统一、有效管控,保障服务的高效、健康运行。

1 服务治理技术的历史变迁

  1. 第一代服务治理 SOA Governance:以 IBM为首的 SOA解决方案提供商推出的针对企业 IT系统的服务治理框架,它主要聚焦在对企业 IT系统中异构服务的质量管理、服务发布审批流程管理和服务建模、开发、测试以及运行的全生命周期管理。
  2. 第二代以分布式服务框架为中心的服务治理:随着电商和移动互联网的快速发展,以阿里为首的基于同一分布式服务框架的全新服务治理理念诞生,它聚焦于对内部同构服务的线上治理,保障线上服务的运行质量。相对比传统 IT架构的服务治理,由于服务的开发模式、部署规模、组网类型、业务特点等差异巨大,因此服务治理的重点也从线下转移到了线上服务质量保障。
  3. 微服务架构+云端服务治理:2013年至今,随着云计算和微服务架构的发展,以 AWS为首的基于微服务架构+云服务化的云端服务治理体系诞生,它的核心理念是服务微自治,利用云调度的弹性和敏捷,逐渐消除人工治理。

微服务架构可以实现服务一定程度的自治,例如服务独立打包、独立部署、独立升级和独立扩容。通过云计算的弹性伸缩、单点故障迁移、服务健康度管理和自动容量规划等措施,结合微服务治理,逐步实现微服务的自治。

1.1 SOA Governance

SOA Governance的定位:面向企业 IT系统异构服务的治理和服务生命周期管理,它治理的服务通常是 SOA服务。
传统的 SOA Governance包含以下四部分内容:

  1. 服务建模:验证功能需求与业务需求,发现和评估当前服务,服务建模和性能需求,开发治理规范。
  2. 服务组装:创建服务更新计划,创建和修改服务以满足所有业务需求,根据治理策略评估服务,批准组装完成。
  3. 服务部署:确保服务的质量,措施包括功能测试、性能测试和满足度测试,批准服务部署。
  4. 服务管理:在整个生命周期内管理和监控服务,跟踪服务注册表中的服务,根据 SLA上报服务的性能 KPI数据进行服务质量管理。

SOA Governance 工作原理如下:

传统 SOA Governance 缺点如下:

  1. 分布式服务框架的发展,内部服务框架需要统一,服务治理也需要适应新的架构,能够由表及里,对服务进行细粒度的管控。
  2. 微服务架构的发展和业务规模的扩大,导致服务规模量变引起质变,服务治理的特点和难点也随之发生变化。
  3. 缺少服务运行时动态治理能力,面对突发的流量高峰和业务冲击,传统的服务治理在响应速度、故障快速恢复等方面存在不足,无法更敏捷地应对业务需求。

1.2 分布式服务框架服务治理

  1. 分布式服务矿机的服务治理定位:面向互联网业务的服务治理,聚焦在对内部采用统一服务框架服务化的业务运行态、细粒度的敏捷治理体系。
  2. 治理的对象:基于统一分布式服务框架开发的业务服务,与协议本身无关,治理的可以是 SOA服务,也可以是基于内部服务框架私有协议开发的各种服务。
  3. 治理策略:针对互联网业务的特点,例如突发的流量高峰、网络延时、机房故障等,重点针对大规模跨机房的海量服务进行运行态治理,保障线上服务的高 SLA,满足用户的体验。常用的治理策略包括服务的限流降级、服务迁入迁出、服务动态路由和灰度发布等。

以分布式服务框架 Dubbo为例,它的服务治理体系如下:

1.3 AWS 云端微服务治理

随着云计算的发展,Dev&Ops 逐渐流行起来,基础设施服务化(IaaS)为大规模、批量流水线式软件交付提供了便利,AWS 作为全球最大的云计算解决方案提供商,在微服务云化开发和治理方面积累了非常多的经验,具体总结如下:

  1. 全公司统一服务化开发环境,统一简单化服务框架(Coral Service),统一运行平台,快速高效服务开发。
  2. 所有后端应用服务化,系统由多项服务化组件构成。
  3. 服务共享、原子化、重用。
  4. 服务由小研发团队负责服务开发、测试、部署和治理,运维整个生命周期支撑。
  5. 高度自动化和 Dev&Ops 支持,一键式服务部署和回退。
  6. 超大规模支持:后台几十万个服务,成千上万开发者同时使用,平均每秒钟有 1~2 个服务部署。
  7. 尝试基于 Docket 容器部署微服务。
  8. 服务治理是核心:服务性能 KPI统计、告警、服务健康度管理、灵活的弹性伸缩策略、故障自动迁移、服务限流和服务降级等多种治理手段,保障服务高质量运行。

2 应用服务化后面临的挑战

2.1 跨团队协作问题

  1. 服务提供者 S分布式部署,存在多个服务实例,如果做端点调试,路由模块会动态分发消息,随机路由,服务提供者 S无法确定要连接的 IP地址。
  2. 如果打断点,其它消费者也正在进行服务调用,调试会被干扰,需要通知所有的开发者不要调用服务 S,显然不可能。

2.2 服务的上下线管控

需要结束某些服务的生命周期,服务提供者直接将服务下线,导致依赖该服务的应用不能正常工作。服务下线时,应先标记为过时,然后通知调用方尽快修改调用,通过性能 KPI接口和调用链分析,确认没有消费者再调用此服务,才能下线。

2.3 服务安全

针对内部应用,服务框架通常采用长链接管理客户端连接,针对非信任的第三方应用,或者恶意消费者,需要具备黑白名单访问控制机制,防止客户端非法链路过多,占用大量的句柄、线程和缓存资源,影响服务提供者的运行质量。

2.4 服务 SLA 保障

由于非核心服务跟系统其它服务打包部署在同一个 Tomcat等容器进程中,一旦非核心服务需要停止,也影响其它合设的服务,如何高效的关停非核心服务,但又不影响其它合设的服务,需要服务治理框架统一考虑。
另外超时时间也要方便的在线可视化的修改,不需要重启即可动态生效。

2.5 故障快速定界定位

由于分布式和大规模的部署,导致服务的 SLA将很难有效保障。

3 服务治理

分布式服务框架的服务治理目标如下:

  1. 防止业务服务架构腐化:通过服务注册中心对服务强弱依赖进行分析,结合运行时服务调用链关系分析,梳理不合理的依赖和调用路径,优化服务化架构,防止代码腐化。
  2. 快速故障定界定位:通过 Flume 等分布式日志采集框架,实时收集服务调用链日志、服务性能 KPI数据、服务接口日志、运行日志等,实时汇总和在线分析,集中存储和展示,实现故障的自动发现、自动分析和在线条件检索,方便运维人员、研发人员进行实时故障诊断。
  3. 服务微管控:细粒度的运行期服务治理,包括限流降级、服务迁入迁出、服务超时控制、智能路由、统一配置、优先级调度和流量迁移等,提供方法级治理和动态生效功能,通过一系列细粒度的治理策略,在故障发生时可以多管齐下,在线调整,快速恢复业务。
  4. 服务生命周期管理:包括服务的上线审批、下线通知,服务的在线升级,以及线上和线下服务文档库的建设。

3.1 服务治理架构设计

分布式服务框架的服务治理分三层:

第二层为服务治理 SDK层,主要由如下组成:

  1. 服务治理元数据:服务治理元数据主要包括服务治理实体对象,包括服务模型、应用模型、治理组织模型、用户权限模型、数据展示模型等。元数据模型通过 DataMapper和模型扩展,向上层界面屏蔽底层服务框架的数据模型,实现展示层和服务架构的解耦,元数据也可以用于展示界面的定制扩展。
  2. 服务治理接口:服务治理 Portal调用服务治理接口,实现服务治理。例如服务降级接口、服务流控接口、服务路由权重调整接口、服务迁移接口等。服务接口与具体的协议无关,它通常基于分布式服务框架自身实现,可以是 Restful接口,也可以是内部的私有协议。
  3. 服务治理客户端类库:由于服务治理服务本身通常也是基于分布式服务框架开发,因此服务治理 Portal需要继承分布式服务框架的客户端类库,实现服务的自动发现和调用。
  4. 调用示例:客户端 SDK需要提供服务治理接口的参数说明、注意事项以及给出常见的调用示例,方便前端开发人员使用。
  5. 继承开发指南:服务治理 SDK需要提供继承开发指南。

3.2 运行态服务治理功能设计

运行态服务治理首先要做到可视:当前系统发布了哪些服务,这些服务部署在哪些机器上,性能 KPI数据如何,指标是否正常等。
由于性能 KPI数据的统计周期、统计指标和报表呈现方式差异比较大,因此服务框架很难抽象出一套放之四海而皆准的性能统计功能,因此在设计的时候需要注意以下两点:

  1. 扩展性:服务性能 KPI数据采集由插件 Handler 负责,平台和业务均可以通过扩展性能统计插件 Handler的方式扩展采集指标和采集周期等。
  2. 原子性:服务提供者和消费者只负责原始数据的采集和上报,不在本节点内做复杂的汇总操作,汇总和计算由性能汇聚节点的 Spark等大数据流式框架负责。

3.3 线下服务治理

为了解决消费者提供者之间的文档过时、错误问题,需要简历服务文档中心,方便线上运维人员查看和多团队之间的协作,它的工作原理如下:

基于 java DOC工具进行扩展,将规则内置到 IDE开发模板中,并通过 CI构建工具做编译检测,将不符合要求的服务接口输出到 CI构建报告并邮件发送给服务责任人。
服务的上线审批、下线通知机制需要建立并完善起来,工作原理如下:

3.4 安全和权限管理

安全涉及到两个层面:

  1. 服务的开放和鉴权机制。
  2. 服务治理的安全和权限管理。

服务治理的使用者通常分三类:

  1. 开发或者测试:主要定位问题,协助运维人员做服务治理。
  2. 运维人员:主要日常运维巡检,查看服务性能 KPI是否正常,是否有报警,利用服务治理进行故障恢复。
  3. 管理者:主要关心运营层面的 KPI数据,只看不管。

4 个人总结

服务治理总体结构图如下:

当系统当前资源非常有限时,为了保证高优先级的服务能够正常运行,保障服务 SLA,需要降低一些非核心服务的调度频次,释放不防资源占用,保障系统的整体运行平稳。

1 设置服务优先级

服务优先级调度有多种策略:

  1. 基于线程调度器的优先级调度策略。
  2. 基于优先级队列的优先级调度策略。
  3. 基于加权配置的优先级调度策略。
  4. 基于服务迁入迁出的优先级调度策略。

2 线程调度器方案

Thread.setPriority()。
线程优先级被线程调度器用来判定何时运行哪个线程,理论上,优先级高的线程比优先级低的线程获得更多的 CPU时间。而实际上,线程获得的 CPU时间通常由包括优先级在内的多个因素决定。
服务在发布的时候,可以根据用户的优先级配置策略,将服务优先级映射到线程优先级中,然后创建多个不同的优先级线程,分别调度对应的服务,工作原理如下:
![][1]
算法简单,开发工作量小,但是不同的操作系统上,有自己不同的策略,这对于需要某些精确控制执行比例的服务是不可接受的。

线程优先级可以用来提高一个已经能够正常工作的服务的运行质量,但是却无法保证精确性和跨平台移植性。因此,通常不建议使用线程调度器实现服务的优先级调度。

3 Java 优先级队列

Java的 PriorityQueue是一个基于优先级堆的无界优先级队列。
![][2]
缺点在于:如果持续有优先级高的消息需要处理,会导致优先级低的消息得不到及时处理而积压。而积压到一定程度之后,低优先级的消息可能已经超时,即便后续得到执行机会,由于已经超时也需要丢弃掉,在此之前,它灰一直占用优先级队列的堆内存,同时导致客户端业务线程被挂住等待应答消息直到超时,从资源调度层面看,PriorityQueue 的算法并不太适合分布式服务框架。

4 加权优先级队列

分布式服务框架的服务优先级调度并不是只处理高优先级的消息,而是按照一定比例优先调度高优先级的服务,采用加权优先级队列可以很好地满足这个需求。
原理如下:它由一系列的普通队列组成,每个队列与服务优先级 1:1 对应。当服务端接收到客户端请求消息时,根据消息对应的服务优先级取值将消息投递到指定的优先级队列中。
工作线程按照服务优先级的加权值,按比例从各个优先级队列中获取消息,然后按照优先级的高低将消息设置到工作线程的待处理消息数组中,由于只有本工作线程会读写消息数组,因此该数组是线程安全的。
![][3]
缺点在于:如果优先级等级比较多,对应的优先级队列就会膨胀,如果优先级队列发生积压,这将导致内存占用迅速飙升。

5 服务迁入迁出

前面的几种优先级调度策略是比较传统的做法,基于服务迁入迁出的则是利用分布式服务框架的服务动态发现机制,通过调整服务运行实例数来实现优先级调度。
原理如下:

  1. 当系统资源紧张时,通过服务治理 Portal的服务迁入迁出界面,将低优先级服务的部分运行实例从服务注册中心中迁出,也就是动态去注册。
  2. 消费者动态发现去注册的服务,将这部分服务实例的地址信息从路由表中删除,后续消息将不会路由到已经迁出的服务实例上。
  3. 由于只迁出了部分服务实例,被迁出的低优先级服务仍然能够正常处理,只不过由于部署实例的减少,得到调度的机会就同比降低了很多,释放的资源将被高优先级服务使用。通过资源的动态调配,实现服务的优先级调度。
  4. 当业务高峰期结束之后,通过服务治理 Portal将迁出的服务重新迁入,低优先级的消息恢复正常执行,优先级调度结束。

缺点:自动化程度较低,对运维人员的要求较高。

6 个人总结

服务的优先级调度与动态流控不同,流控最终会拒绝消息,导致部分请求失败。优先级调度是在资源紧张时,优先执行高优先级的服务,在保障高优先级服务能够被合理调度的同时,也兼顾处理部分优先级低的消息,它们之间存在一定的比例关系。

优先级调度本身并不拒绝消息,但是如果在运行过程中发生了流控,则由流控负责拒消息。通常对于高优先级的管理类消息,例如心跳消息、指令消息等们不能被流控掉。

  1. 业务高峰期,为了保证核心服务的 SLA,往往需要停掉一些不太重要的业务,例如商品评论、论坛或者粉丝积分等。
  2. 某些服务因为某种原因不可用,但是流程不能直接失败,需要本地 Mock 服务端实现,做流程放通。例如图书阅读,如果用户登录余额鉴权服务不能正常工作,需要做业务放通,记录消费话单,允许用户继续阅读,而不是返回失败。

这就是服务降级,分为容错降级和屏蔽降级两种模式。

1 屏蔽降级

在一个应用中,服务往往是合设的,尽管可以通过线程池隔离等方式保证服务之间的资源隔离,但是 100%的隔离是不现实的。特别是对缓存、网络 I/O、磁盘 I/O、数据库连接资源等公共依赖无法隔离,在业务高峰期时,服务往往存在激烈的竞争,导致订购等核心服务运行质量下降,影响系统的稳定运行和客户体验。

此时需要对非核心服务做强制降级,不发起远程服务调用,直接返回空、异常或者执行特定的本地逻辑,减少自身对公共资源的消费,把资源释放出来供核心服务使用。

1.1 屏蔽降级的流程

1.2 屏蔽降级的设计实现

屏蔽降级通常用于服务运行态治理,开发时不会配置,当外界的触发条件达到某个临界值时,由运维人员/开发人员决策,通过服务治理控制台,进行人工降级操作,它的取值有如下三种:

  1. mock = force: return null。不发起远程服务调用,直接返回空对象。
  2. mock = force: throw Exception。不发起远程服务调用,直接抛出指定异常。
  3. mock = force: execute Bean。不发起远程服务调用,直接执行本地模拟接口实现类。

屏蔽降级操作是可逆的,当系统压力恢复正常水平或者不再需要屏蔽降级时,可以对已经屏蔽降级的服务恢复正常。恢复之后,消费者重新调用远程的服务提供者,同时服务状态被修改为正常状态。

2 容错降级

当非核心服务不可用时,可以对故障服务做业务逻辑放通,分布式服务框架的业务放通实际属于容错降级的一种。
容错降级不仅仅只用于业务放通,它也常用于服务提供方在客户端执行容错逻辑,容错逻辑主要包括两种:

  1. RPC异常:通常指超时异常、消息解码异常、流控异常、系统拥塞保护异常等。
  2. Service异常:例如登录校验失败异常、数据库操作失败异常等。

2.1 容错降级的工作原理


容错降级与屏蔽降级的主要差异:

  1. 触发条件不同:容错降级时根据服务调用结果,自动匹配触发的;而屏蔽降级往往是通过人工根据系统运行情况手工操作触发的。
  2. 作用不同:容错降级时当服务提供者不可用时,让消费者执行业务放通:屏蔽降级的主要目的是将原属于降级业务的资源调配出来供核心业务使用。
  3. 调用机制不同:一个发起远程服务调用,一个只做本地调用。

业务放通的 Mock接口实现往往放在消费者端,主要在于提供端可能为多个消费者服务,为了解耦,单独的消费者自己进行放通。

  1. mock = fail: throw Exception。将异常转义。
  2. mock = fail: execute Bean。将异常屏蔽掉,直接执行本地模拟解耦实现类,返回 Mock接口的执行结果。

与屏蔽降级不同的是,通常在开发态,就需要指定容错降级的策略。

无论是屏蔽降级还是容错降级,都支持从消费者或者服务提供者两个维度去配置,从而消费端配置更灵活,实现差异化降级策略。
服务降级策略配置的优先级:消费者配置策略 > 服务提供者配置策略。屏蔽降级 > 容错降级。

2.2 运行时容错降级


如果开发态没有指定容错降级策略,系统上线运行后,需要临时增加容错降级策略,服务框架也需要支持在线动态增加容错降级策略,它的工作流程与屏蔽降级类似。

而在实际项目中,利用容错降级做业务放通是主要的应用场景。

3 业务层降级

实际业务开发过程中,可能会存在比较复杂的业务放通场景,例如“调用 A 服务 + 执行本地方法调用”组合成一个流程,针对这个流程的执行结果做放通,这种场景由于本地方法调用并不经过分布式服务框架,因此需要业务自己做放通处理。

服务降级并不能 100% 满足所有业务放通场景,需要业务层开发自己的降级框架。

4 个人总结

在服务化之前,业务往往需要自己实现放通逻辑或者框架,不同的业务模块,甚至不同的开发者都自己实现了一套私有的放通流程,这对项目的开发和运维都会造成很多麻烦。

更为严重的是由于没有统一的服务降级策略和框架,无法在服务治理 Portal 上进行统一线上降级,在应对业务高峰时,运维人员会力不从心,往往需要一大群开发在背后支撑,运维效率非常低下。
基于分布式服务框架的服务降级功能,有效提升线上的服务治理效率,保证服务的 SLA,尽管服务降级更多是为了提升服务线上运行质量,但是它反向对服务的设计和开发也有约束。它要求服务在设计之初就要做如下识别:

  1. 哪些服务是核心服务、哪些是非核心服务?
  2. 哪些服务支持降级,降级策略是什么?
  3. 除了服务降级之外,是否还存在更为复杂的业务放通场景,它的策略是什么?

系统的高效、健康运行仅仅依赖线上服务治理和运维是解决不了的,需要通过分布式服务框架的特性反向映射到设计和开发态,从设计阶段就开始考虑未来如何高效运维,才能在根本上提升服务和产品的质量,这也是矛盾对立和统一的一个具体体现。

当资源成为瓶颈时,服务框架需要对消费者做限流,启动流控保护机制。

1 静态流控

主要针对客户端访问速率进行控制,它通常根据服务质量等级协定(SLA)中约定的 QPS做全局流量控制,例如订单服务的静态流控阈值为 100QPS,则无论集群有多少个订单服务实例,它们总的处理速率之和不能超过 100QPS。

1.1 传统静态流控设计方案

在软件安装时,根据集群服务节点个数和静态流控阈值,计算每个服务及诶单分摊的 QPS阈值,系统运行时,各个服务节点按照自己分配的阈值进行流控,对于超出流控阈值的请求则拒绝访问。

服务框架启动时,将本节点的静态流控阈值加载到内存中,服务框架通过 Handler拦截器咋服务调用前做拦截计数,当计数器在指定周期 T到达 QPS上限时,启动流控,拒绝信的请求消息接入。注意:

  1. 服务实例通常由多线程执行,因此计数时需要考虑线程并发安全,可以使用 Atomic原子类进行原子操作。
  2. 达到流控阈值之后拒绝新的请求消息接入,不能拒绝后续的应答消息,否则这会导致客户端超时或者触发 FailOver,增加服务端的负载。

1.2 传统方案的缺点

  1. 云端服务的弹性伸缩性使服务节点数处于动态变化过程中,预分配方案行不通。
  2. 服务节点宕机,或者有新的服务节点动态加入,导致服务节点数发生变化,静态分配的 QPS需要实时动态调整,否则会导致流控不准。

当应用和服务迁移到云上之后, PaaS 平台的一个重要功能就是支持应用和服务的弹性伸缩,在云上,资源都是动态分配和调整的,静态分配阈值方案无法适应服务迁移到云上。

1.3 动态配额分配制

原理:由服务注册中心以流控周期 T为单位,动态推送每个节点分配的流控阈值 QPS。当服务节点发生变更时,会触发服务注册中心重新计算每个节点的配额,然后进行推送,这样无论是新增还是减少服务节点数,都能够在下一个流控周期内被识别和处理。

而在生产环境中,每台机器/VM 的配置可能不同,如果每个服务节点采用流控总阈值/服务节点数这种平均主义,可能会发生性能高、处理快的节点配额很快用完,但是性能差的节点配额有剩余的情况,这会导致总的配额没用完,但是系统却发生了静态流控的问题。
解决方案一:根据各个服务节点的性能 KPI数据(例如服务调用平均时延)做加权。
解决方案二:配额指标返还和重新申请,每个服务节点根据自身分配的指标值、处理速率做预测,如果计算结果表明指标会有剩余,则把多余的返还给服务注册中心;对于配额已经使用完的服务节点,重新主动去服务注册中心申请配额,如果连续 N次都申请不到新的配额指标,则对于新接入的请求消息做流控。

结合负载均衡进行静态流控,才能够实现更精确的调度和控制。消费者根据各服务节点的负载情况做加权路由,性能差的节点路由到的消息更少,这样保证了系统的负载均衡和配额的合理分配。

1.4 动态配额申请制

尽管动态配额分配制可以解决节点变化引起的流控不准问题,也能改善平均主义配额分配缺点如下:

  1. 如果流控周期 T比较大,各服务节点的负载情况变化比较快,服务节点的负载反馈到注册中心,统一计算后再做配额均衡,误差会比较大。
  2. 如果流控周期 T比较小,服务注册中心需要实时获取各服务节点的性能 KPI数据并计算负载情况,经过性能数据采集、上报、汇总和计算之后会有一定的时延,这会导致流控滞后产生误差。
  3. 如果采用配额返还和重新申请方式,则会增加交互次数,同时也会存在时序误差。
  4. 扩展性差,负载的汇总、计算和配额分配、下发都由服务注册中心完成,如果服务注册中心管理的节点数非常多,则服务注册中心的计算压力就非常大,随着服务节点数的增加服务注册中心配额分配效率会急速下降、系统不具备平滑扩展能力。

而动态配额申请制,工作原理如下:

  1. 系统部署的时候,根据服务节点数和静态流控 QPS阈值,拿出一定比例的配额做初始分配,剩余的配额放在配额资源池中。
  2. 哪个服务节点使用完了配额,就主动向服务注册中心申请配额。配额的申请策略是,如果流控周期为 T,则将周期 T分成更小的周期 T/N(N为经验值,默认值为 10),当前的服务节点数为 M个,则申请的配额为(总 QPS配额-已经分配的 QPS)/ M * T / N。
  3. 总的配额如果被申请完,则返回 0 配额给各个申请配额的服务节点,服务节点对新接入的请求消息进行流控。

动态配额申请制的优点:

  1. 各个服务节点最清楚自己的负载情况,性能 KPI数据在本地内存中计算获得,实时性高。
  2. 由各个服务节点根据自身负载情况去申请配额,保证性能高的节点有更高的配额,性能差的自然配额就少,实现合理资源,流控的精确性。

2 动态流控

动态流控的最终目标是为了保命,并不是对流量或者访问速度做精确控制。
触发动态流控的因子是资源,资源又分为系统资源和应用资源两大类,根据不同的资源负载情况,动态流控又分为多个级别,每个级别流控系数都不同,也就是被拒绝掉的消息比例不同。每个级别都有相应的流控阈值,这个阈值通常支持在线动态调整。

2.1 动态流控因子

动态流控因子包括系统资源和应用资源两大类,常见的系统资源包括:

  1. 应用进程所在主机/VM 的 CPU使用率。
  2. 应用进程所在主机/VM 的 内存使用率。

使用 java.lang.Process 执行 top、sar 等外部命令获取系统资源使用情况。
常用的应用资源:

  1. JVM 堆内存使用率
  2. 消息队列积压率
  3. 会话积压率

具体实现策略是系统启动时拉起一个管理线程,定时采集应用资源的使用率,并刷新动态流控的应用资源阈值。

2.2 分级流控

不同级别拒掉的消息比例不同,例如一级流控拒绝掉 1/8 的消息;发生二级流控时,拒绝掉 1/4 消息。
为了防止系统波动导致的偶发性流控,无论是进入流控状态还是从流控状态恢复,都需要连续采集 N次并计算平均值,如果连续 N次平均值大于流控阈值,则进入流控状态。
而在一个流控周期内,不会发生流控级别的跳变。

3 并发控制

并发控制针对线程的并发执行数进行控制,它的本质是限制对某个服务或者服务的方法过度消息,耗用过多的资源而影响其它的服务的正常运行。有两种形式:

  1. 针对服务提供者的全局控制。
  2. 针对服务消费者的局部控制。

4 连接控制

通常分布式服务框架服务提供者和消费者之间采用长连接私有协议,为了防止因为消费者连接数过多导致服务端负载压力过大,系统需要针对连接数进行流控。

5 并发和连接控制算法

并发连接的控制算法原理如下图:

基于服务调用 Pipeline 机制,可以对请求消息接收和发送、应答消息接收和发送、异常消息等做切面拦截(类似 Spring 的 AOP 机制,但是没采用反射机制,性能更高),利用 Pipeline 拦截切面接口,对请求消息做服务调用前的拦截和计数,根据计数器做流控,服务端的算法如下:

  1. 获取流控阈值。
  2. 从全局 RPC上下文中获取当前的并发执行数,与流控阈值对比,如果小于流控阈值,则对当前的计数器做原子自增。
  3. 如果等于或者大于流控阈值,则抛出 RPC流控异常给客户端。
  4. 服务调用执行完成之后,获取 RPC上下文中的并发执行数,做原子自减。

客户端的算法如下:

  1. 获取流控阈值。
  2. 从全局 RPC上下文中获取当前的并发执行数,与流控阈值对比,如果小于流控阈值,则对当前的计数器做原子自增。
  3. 如果等于或大于流控阈值,则当前线程进入 wait状态, wait超时时间为服务调用的超时时间。
  4. 如果有其它线程服务调用完成,调用计数器自减,则并发执行数小于阈值,线程被 notify,退出 wait,继续执行。

6 个人总结

流量控制是保证服务 SLA(Sevice-Level Agreement)的重要措施,也是业务高峰期故障预防和恢复的有效手段,分布式服务框架需要支持流控阈值、策略的在线调整,不需要重启应用即可生效。

服务上线之后,由于功能变更、BUG修复,以及服务升级,需要对服务采用多版本管理。

1 服务多版本管理设计

管理的对象包括服务提供者和消费者:

  1. 服务提供者:发布服务的时候,支持指定服务的版本号。
  2. 服务消费者:消费服务的时候,支持指定引用的服务版本号或者版本范围。

1.1 服务版本号管理

服务的版本号是有序的,在服务名相同的情况下,两个相同服务名的不同服务版本的版本号可以比较大小。完整的版本号由“主版本号(Major)+副版本号(Minor)+微版本号(Micro)”构成:

  1. 主版本号:表示重大特性或者功能变更,接口或功能可能会不兼容。
  2. 副版本号:发生了少部分功能变更,或者新增了一些功能。
  3. 微版本号:主要用于 BUG修改,对应于常见的 SP补丁包。

1.2 服务提供者

服务开发完成之后,需要将一个或者多个服务打包成一个 jar/war 包,为了便于对服务进行物理管理,打包后的名称中会包含服务的版本号信息,例如 com.huawei.orderService_1.0.1.jar。
在微服务架构中,微服务独立开发、打包、部署和升级,因此微服务的版本和软件包的版本可以一一映射。但是在实际开发中,尤其是大规模企业应用开发,单独为每个服务打包和部署目前尚未成为主流,它会增加服务软件包的管理和线上治理成本,因此目前的主流模式仍然是多个服务提供者合一个大的 jar/war 包,这就会存在一个问题:项目开发后期,有些服务进行了版本升级,有些服务没有,这样当它们被打包成同一个软件包时,就会导致版本号不一致。

每个服务都指定一个版本号,对开发而言也比较麻烦。一个比较好的实践就是微服务+全局版本模式。对于经常发生功能变更、需要独立升级的服务,将其独立拆分出来进行微服务化,实现单个微服务级的打包和部署。

对于其它服务,服务框架提供全局版本功能,在 Maven组件工程开发时,只需要为整个工程配置一个版本号,该组件工程包含的所有服务都共用该版本号。如果组件工程包含的某个服务发生了版本变更,就统一升级全局版本号,其它未发生功能变更但是打包在一起的服务做级联升级。这样做的一个原因是服务被打包在一起后,无论其它服务是否需要升级,只要软件包中的一个服务发生了版本升级,其它合设的服务也必须与其一起打包升级,它们之间存在物理上的耦合,这也是为什么微服务架构提倡微服务独立打包、部署和升级的原因。

1.3 服务消费者

与服务提供者不同,服务消费者往往不需要指定具体依赖的服务版本,而是一版本范围,例如:version=“[1.0.1, 2.0.8]”。

  1. 消费者关心的是某个新特性从哪个服务版本中开始提供,它并不关系服务提供者的版本演进以及具体的版本号。
  2. 消费者想使用当前环境中服务的最新版本,但不清楚具体的版本号,希望自动适配最新的服务版本。

当然需要指定一个默认的服务提供者版本号。

1.4 基于版本号的服务路由

服务提供者将服务注册到服务注册中心时,将服务名+服务版本号+服务分组作为路由关键信息存放到注册中心,服务消费者在发起服务调用时,除了携带服务名、方法名、参数列表之外,还需要携带要消费的服务版本信息,由路由接口负责服务版本过滤,如下图:

1.5 服务热升级

在业务不中断的情况下,实现系统的平滑升级,考虑到版本升级的风险,往往需要做多次滚动升级,最终根据升级之后新版本服务的运行状况决定是继续升级还是回退。这就意味着在同一时刻,整个集群环境中会同时存在服务的多个版本咋线运行,这就是热升级相比于传统 AB Test等升级方式的差异,如下图:

核心点如下:

  1. 升级的节点需要重启,由于自动发现机制,停机升级的节点自动被隔离,停机并不会中断业务。
  2. 服务路由规则的定制:如果是滚动式的灰度发布,在相当长的一段时间(例如一周)内线上都会存在服务的多个版本。哪些用户或者业务需要路由到新版本上,需要通过路由策略和规则进行制定,服务框架应该支持用户配置自定义的路由规则来支持灵活的路由策略。
  3. 滚动升级和回退机制:为了降低服务热升级对业务的影响,同时考虑到可靠性,在实际工作中往往采用滚动升级的方式,分批次进行服务的热升级,实现敏捷的特性交付,滚动升级如下图:

2 与 OSGI 的对比

OSGI,成立于 1999年,全名原为:Open Services Gateway initiative,但现在这个全名已经废弃。
致力于家用设备、汽车、手机、桌面、其它环境指定下一代网络服务标准的领导者,推出了 OSGI 服务平台规范,用于提供开放和通用的架构,使得服务提供商、开发人员、软件提供商、网关操作者和设备提供商以统一的方式开发、部署和管理服务。

目前最广泛和应用是 OSGI规范5(Release 5),共由核心规范、标准服务(Standard Services)、框架服务(Framework Services)、系统服务(System Services)、协议服务(Protocol Services)、混合服务(Miscellaneous Services)等几部分共同组成。
核心规范通过一个分层的框架,实现了 OSGI最为成功的动态插件机制,它主要提供了:

  1. OSGI Bundle 的运行环境。
  2. OSGI Bundle 间的依赖管理。
  3. OSGI Bundle 的生命周期管理。
  4. OSGI 服务的动态交互模型。

OSGI 两个最核心的特性就是模块化和热插拔机制,分布式服务框架的服务多版本管理和热升级是否可以基于 OSGI来实现?下面围绕着模块化和插件热插拔这两个特性进行详细分析。

2.1 模块化开发

在 OSGI中,我们以模块化的方式去开发一个系统,每个模块被称为 Bundle,OSGI 提供了对 Bundle的整个生命周期管理,包括打包、部署、运行、升级、停止等。
模块化的核心并不是简单地把系统拆分成不同的模块,如果仅仅是拆分,原生的 Jar包+Eclipse工程就能够解决问题。更为重要的是要考虑到模块中接口的导出、隐藏、依赖、版本管理、打包、部署、运行和升级等全生命周期管理,这些对于原生的 Jar包而言是不支持的。
传统开发的模块划分通常由两种方式:

  1. 使用 package来进行隔离。
  2. 定义多个子工程,工程之间通过工程引用的方式进行依赖管理。
    存在的问题:无法实现资源的精细划分和对依赖做统一管理。以 Jar包依赖为例,依赖一个 Jar包就意味着这个 Jar包中所有 public的资源都可能被引用,但事实上也许只需要依赖该 Jar包中的某几个 public接口。无法对资源做细粒度、精确的管控,不知道 public的接口都被哪些模块依赖和使用,消费者是谁,更为复杂的场景是如果消费者需要依赖不同的接口版本,那该肿么办?

OSGI 很好地解决了这个问题,每个 OSGI工程是一个标准的插件工程,实际就是一个 Bundle。实现了 package级的管理依赖关系,而 Maven则是 Jar包级的管理依赖。
而分布式服务:

  1. 服务提供者通过 service export将某个服务接口发布出去,供消费者使用。
  2. 服务消费者通过 service import导入某个服务接口,它不关心服务提供者的具体位置,也不关心服务的具体实现细节。
    这样就比 OSGI的 package导入导出功能粒度更细。
    利用 Maven的模块化管理 + 分布式服务框架自身的服务接口导入导出功能,解决了模块化开发和精细化依赖管理难题,完成可以替代 OSGI的相关功能,

2.2 插件热部署和热升级

OSGI 另外一个非常酷的特性就是动态性,即插件的热部署和热升级,它可以在不重启 JVM的情况下安装部署一个插件,实现升级不中断业务。
OSGI 的插件热部署和热升级原理就是基于自身的网状类加载机制实现的,下面我们分析在分布式服务框架中,如何实现服务热部署和热升级:

  1. 服务是分布式集群部署的,通常也是无状态的,停掉其中某一个服务节点,并不会影响系统整体的运行质量。
  2. 服务自动发现和隔离机制,当有新的服务节点加入时,服务注册中心会向消费者集群推送新的服务地址信息;当有服务节点宕机或重启时,服务注册中心会发送服务下线通知消息给消费者集群,消费者会将下线服务自动隔离。
  3. 优雅停机功能,在进程退出之前,处理完消息队列积压的消息,不再接受新的消息,最大限度保障丢失消息。
  4. 集群容错功能,如果服务提供者正在等待应答消息时系统推出了,消费者会发生服务调用超时,集群容错功能会根据策略重试其它正常的服务节点,保证流程不会因为某个服务实例宕机而中断。
  5. 服务多版本管理,支持集群中同一个服务的多个版本同时运行,支持路由规则定制,不同的消费者可以消费不同的服务版本。

相比于 OSGI在 JVM内部通过定制类加载机制实现插件的多版本运行和升级,使用分布式服务框架自身的分布式集群特性实现服务的热部署和热升级,更加简单、灵活和可控。

3 个人总结

服务多版本在实际项目中非常实用,用于实现服务的热部署和热升级,同时支持按照消费者做差异化路由,同时也方便演进到微服务架构,来迁移到服务的独立打包、部署、运行和运维。