高可用04:高可用系统设计之「服务高可用」


高可用系列目录


服务高可用——弹力设计

image-20220313162619060

在上面这个图上,我们可以看到,有三大块的东西——

服务冗余:通过冗余服务的副本数可以消除单点故障。这需要服务发现【zookeeper、consul】,负载均衡【Nginx、HAProxy】,动态路由和健康检查四个功能或组件。

服务解耦:通过解耦可以做到把业务隔离开来,不让服务间受影响,这样就可以有更好的稳定性。在水平层面上,需要把业务或用户分片分区(业分做隔离,用户做多租户)。在垂直层面上,需要异步通讯机制。因为应用被分解成了一个一个的服务,所以在服务的编排和聚合上,需要有工作流(像 Spring 的 Stream 或 Akka 的 flow 或是 AWS 的 Simple Workflow)来把服务给串联起来。而一致性的问题又需要业务补偿机制来做反向交易。

服务容错:服务容错方面,需要有重试机制,重试机制会带来幂等操作,对于服务保护来说,熔断限流降级都是为了保护整个系统的稳定性,并在可用性和一致性方面在出错的情况下做一部分的妥协。

初看之下,我们可能对这些概念有些陌生,但其实都是我们日常开发和设计中“触手可及”的设计,下面我们来一一简单学习下这些设计思想:

(1)服务隔离(Bulkheads)

水密舱壁是中国造船史上的一项重要发明,其原理是用隔舱板将船舱分成若干个互不相通的独立船舱,当船舶发生触礁、碰撞等造成船壳破损时,即使某一船舱破损进水,也不致于波及其它船舱,从而提高船舶的抗沉性。其原理如下图所示:

960a304e251f95cad1c8e606975b683e6709c93df465

我们的软件设计当然也可能会“漏水”,所以为了不让“故障”蔓延开来,需要使用“隔板”技术,来将架构分隔成多个“船舱”来隔离故障。

在分布式软件架构中,我们同样需要使用类似的技术来让我们的故障得到隔离。这就需要我们对系统进行分离。一般来说,对于系统的分离有两种方式,一种是以服务的种类来做分离,一种是以用户来做分离。

  • 按照服务的种类——典型的例子:微服务

    image-20220313162919658

  • 按照用户的请求——典型的例子:多租户

    image-20220313162929855

(2)异步通讯(Asynchronous)

在服务解耦的操作完成后,我们肯定要面临的一大问题就是这些服务直接如何通讯。

通讯一般来说分同步异步两种。同步通讯就像打电话,需要实时响应,而异步通讯就像发邮件,不需要马上回复。各有千秋,我们很难说谁比谁好。但是在面对超高吐吞量的场景下,异步处理就比同步处理有比较大的优势了,这就好像一个人不可能同时接打很多电话,但是他可以同时接收很多的电子邮件一样。

异步通讯通常有三种方式:

①请求响应式

这种方式的通讯是:发送方(sender)会直接请求接收方(receiver),被请求方接收到请求后,直接返回结果,比如“正在处理”。对于返回结果,有两种方法,一种是发送方时不时地去轮询一下,问一下干没干完。另一种方式是发送方注册一个回调方法,也就是接收方处理完后回调请求方。这种架构模型在以前的网上支付中比较常见,页面先从商家跳转到支付宝或银行,商家会把回调的 URL 传给支付页面,支付完后,再跳转回商家的 URL。很明显,这种情况下还是有一定耦合的。是发送方依赖于接收方,并且要把自己的回调发送给接收方,处理完后回调。

②直接订阅

在这种方式下,接收方(receiver)会来订阅发送方(sender)的消息,发送方会把相关的消息或数据放到接收方所订阅的队列中,而接收方会从队列中获取数据。这种方式下,发送方并不关心订阅方的处理结果,它只是告诉订阅方有事要干,收完消息后给个 ACK 就好了,你干成啥样我不关心。

举个例子,在购物过程下订单的时候,一旦用户支付完成了,就需要把这个事件通知给订单处理以及物流,订单处理变更状态,物流服务需要从仓库服务分配相应的库存并准备配送,后续这些处理的结果无需告诉支付服务。商家这边只需要订阅一个支付完成的事件,这个事件带一个订单号,而不需要让支付方知道自己的回调 URL。

但是,在这种方式下,接收方需要向发送方订阅事件,所以是接收方依赖于发送方。这种方式还是有一定的耦合。

③中间订阅(Broker)

所谓 Broker,就是一个中间人,发送方(sender)和接收方(receiver)都互相看不到对方,它们看得到的是一个 Broker,发送方向 Broker 发送消息,接收方向 Broker 订阅消息。如下图所示。

image-20220313162946453

这是完全的解耦。所有的服务都不需要相互依赖,而是依赖于一个中间件 Broker。这个 Broker 是一个像数据总线一样的东西,所有的服务要接收数据和发送数据都发到这个总线上,这个总线就像协议一样,让服务间的通讯变得标准和可控。

在 Broker 这种模式下,发送方的服务和接收方的服务最大程度地解耦。但是所有人都依赖于一个总线,所以这个总线就需要有如下的特性:

  • 必须是高可用的,因为它成了整个系统的关键;
  • 必须是高性能而且是可以水平扩展的;
  • 必须是可以持久化不丢数据的。

要做到这三条还是比较难的。当然,好在现在开源软件或云平台上 Broker 的软件是非常成熟的,所以节省了我们很多的精力。

事件驱动架构(EDA – Event Driven Architecture)

image-20220313162956535

(3)幂等性(Idempotency)

编程中的“幂等性”是指任意多次执行所产生的影响,与一次执行的影响相同。一个拥有幂等性设计的接口,保证无论一次或多次来调用接口,都能够得到相同的结果。接口的幂等性设计在某些场景下是必需的,例如用户下单的场景。

我们知道,服务间的调用可能会有三个状态,一个是成功(Success),一个是失败(Failed),一个是超时(Timeout)。前两者都是明确的状态,而超时则是完全不知道是什么状态。比如,超时原因是网络传输丢包的问题,可能是请求时就没有请求到,也有可能是请求到了,返回结果时没有正常返回等等情况。于是我们完全不知道下游系统是否收到了请求,而收到了请求是否处理了,成功 / 失败的状态在返回时是否遇到了网络问题。总之,请求方完全不知道是怎么回事。

比如说,对于用户下单的场景的超时重试我们考虑以下问题:

  • 是否会导致最终创建了两条一样的订单?
  • 是否会扣除两遍库存?
  • 是否会重复扣除用户的钱?

因为系统超时,而调用户方重试一下,会给我们的系统带来不一致的副作用。

在这种情况下,一般有两种处理方式。

一种是需要下游系统提供相应的查询接口。上游系统在 timeout 后去查询一下。如果查到了,就表明已经做了,成功了就不用做了,失败了就走失败流程。

另一种是通过幂等性的方式。也就是说,把这个查询操作交给下游系统,我上游系统只管重试,下游系统保证一次和多次的请求结果是一样的。

对于第一种方式,需要对方提供一个查询接口来做配合。而第二种方式则需要下游的系统提供支持幂等性的交易接口。

全局ID

要做到幂等性的交易接口,需要有一个唯一的标识,来标志交易是同一笔交易。而这个交易 ID 由谁来分配是一件比较头疼的事。因为这个标识要能做到全局唯一。

这里介绍一下雪花片算法,这是一个 Twitter 的开源项目 Snowflake。它是一个分布式 ID 的生成算法。其核心思想是,产生一个 long 型的 ID,其中:

  • 41bits 作为毫秒数。大概可以用 69.7 年。
  • 10bits 作为机器编号(5bits 是数据中心,5bits 的机器 ID),支持 1024 个实例。
  • 12bits 作为毫秒内的序列号。一毫秒可以生成 4096 个序号。

image-20220313163012791

当然,雪花片算法提供的只是一个思想,根据实际的具体情况我们可以适当调整每一部分的比例,比如在接处警的系统中,我们采用了改造过的雪花片算法——保证总长度在53位,并且加上区号的限制,以此来保证全国的接处警系统都能生成唯一的ID。

设计幂等性接口

  • HTTP GET 方法用于获取资源,不应有副作用,所以是幂等的。

  • HTTP HEAD 和 GET 本质是一样的,区别在于 HEAD 不含有呈现数据,而仅仅是 HTTP 头信息,不应用有副作用,也是幂等的。

  • HTTP OPTIONS 主要用于获取当前 URL 所支持的方法,所以也是幂等的。

  • HTTP DELETE方法用于删除资源,有副作用,但它应该满足幂等性。

  • HTTP POST 方法用于创建资源,所对应的 URI 并非创建的资源本身,而是去执行创建动作的操作者,有副作用,不满足幂等性

  • HTTP PUT 方法用于创建或更新操作,所对应的 URI 是要创建或更新的资源本身,有副作用,它应该满足幂等性。

所以,对于 POST 的方式,很可能会出现多次提交的问题,就好比,我们在论坛中发贴时,有时候因为网络有问题,可能会对同一篇贴子出现多次提交的情况。对此的一般的幂等性的设计如下:

  1. 首先,在表单中需要隐藏一个 token,这个 token 可以是前端生成的一个唯一的 ID。用于防止用户多次点击了表单提交按钮,而导致后端收到了多次请求,却不能分辨是否是重复的提交。这个 token 是表单的唯一标识。(这种情况其实是通过前端生成 ID 把 POST 变成了 PUT。)
  2. 然后,当用户点击提交后,后端会把用户提交的数据和这个 token 保存在数据库中。如果有重复提交,那么数据库中的 token 会做排它限制,从而做到幂等性。
  3. 当然,更为稳妥的做法是,后端成功后向前端返回 302 跳转,把用户的前端页跳转到 GET 请求,把刚刚 POST 的数据给展示出来。如果是 Web 上的最好还把之前的表单设置成过期,这样用户不能通过浏览器后退按钮来重新提交。这个模式又叫做 PRG 模式(Post/Redirect/Get)。

(4)超时重试(Retry)

当我们把单体应用服务化,尤其是微服务化,本来在一个进程内的函数调用就成了远程调用,这样就会涉及到网络上的问题。

网络上有很多的各式各样的组件,如 DNS 服务、网卡、交换机、路由器、负载均衡等设备,这些设备都不一定是稳定的。在数据传输的整个过程中,只要任何一个环节出了问题,最后都会影响系统的稳定性。

所以我们需要一个重试的机制。

但是,我们需要明白的是,“重试”的语义是我们认为这个故障是暂时的,而不是永久的,所以,我们会去重试。

在设计重试时,我们需要定义出什么情况下需要重试,例如,调用超时、被调用端返回了某种可以重试的错误(如繁忙中、流控中、维护中、资源不足等)。

而对于一些别的错误,则最好不要重试,比如:业务级的错误(如没有权限、或是非法数据等错误),技术上的错误(如:HTTP 的 503 等,这种原因可能是触发了代码的 bug,重试下去没有意义)。

重试的策略

关于重试的设计,一般来说,都需要有个重试的最大值,经过一段时间不断的重试后,就没有必要再重试了,应该报故障了。

在重试过程中,每一次重试失败时都应该休息一会儿再重试,这样可以避免因为重试过快而导致网络上的负担加重。在重试的设计中,我们一般都会引入,Exponential Backoff 的策略,也就是所谓的 " 指数级退避 "。在这种情况下,每一次重试所需要的休息时间都会成倍增加。这种机制主要是用来让被调用方能够有更多的时间来从容处理我们的请求。

下面是一些伪代码:

 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

public static void doOperationAndWaitForResult() {
    
    // Do some asynchronous operation.
long token = asyncOperation();

    int retries = 0;
    boolean retry = false;

    do {
        // Get the result of the asynchronous operation.
        Results result = getAsyncOperationResult(token);

        if (Results.SUCCESS == result) {
            retry = false;
        } else if ( (Results.NOT_READY == result) ||
                      (Results.TOO_BUSY == result) ||
                      (Results.NO_RESOURCE == result) ||
                      (Results.SERVER_ERROR == result) ) {
            retry = true;
        } else {
            retry = false;
        }
        if (retry) {
            long waitTime = Math.min(getWaitTimeExp(retries), MAX_WAIT_INTERVAL);
            // Wait for the next Retry.
            Thread.sleep(waitTime);
        }
    } while (retry && (retries++ < MAX_RETRIES));
}

当然,我们可以在项目中直接使用Spring的重试策略,可以直接通过注解(Annotation)的方式使用。下面给出一个例子:

1
2
3
4
5
6
7
8
9
@Service
@EnableRetry
public interface MyService {
    @Retryable(value = Exception.class, 
               maxAttempts = 4, 
               backoff = @Backoff(delay = 500L, multiplier = 1.5))
    void retryService(String sql) throws SQLException;
    ...
}

当然了,这里的重试机制和退避机制提供了很多,细节就不展开了,具体可以查看相应的文档

(5)熔断机制(Circuit Breaker)

熔断机制这个词来源于我们电闸上的“保险丝”,当电压有问题时(比如短路),自动跳闸,此时电路就会断开,我们的电器就会受到保护。不然,会导致电器被烧坏,如果人没在家或是人在熟睡中,还会导致火灾。所以,在电路世界通常都会有这样的自我保护装置。

同样,在我们的分布式系统设计中,也应该有这样的方式。前面说过重试机制,如果错误太多,或是在短时间内得不到修复,那么我们重试也没有意义了,此时应该开启我们的熔断操作,尤其是后端太忙的时候,使用熔断设计可以保护后端不会过载。

假设一个这样的场景:A 服务的 X 功能依赖 B 服务的某个接口,当 B 服务的接口响应很慢的时候,A 服务的 X 功能响应肯定也会被拖慢,进一步导致 A 服务的线程都被卡在 X 功能处理上,此时 A 服务的其他功能都会被卡住或者响应非常慢。这时就需要熔断机制了,即:A 服务不再请求 B 服务的这个接口,A 服务内部只要发现是请求 B 服务的这个接口就立即返回错误,从而避免 A 服务整个被拖慢甚至拖死。

这时候我们首先会想到,如果我们可以设计几条规则并且在系统中自动执行就好了,比如说:

  • 1 分钟内 30% 的请求响应时间超过 1 秒就熔断
  • 如果该请求连续5次调用失败,那么在接下来的20分钟内,所有调用该请求的服务都直接返回异常
  • ...

这里我们也会引出几个问题:

  • 问题1:什么样的情况,可以理解为服务提供方出现了问题?
  • 问题2:触发熔断会怎么样?
  • 问题3:熔断打开以后,如何关闭?

下面我们来看一下熔断机制的实现方法——熔断状态机。

熔断状态机

image-20220313163041303

首先在熔断器内部维护者一个调用失败的计数器,如果调用服务方的接口失败,则使失败次数加 1。

然后在熔断器内部有3种状态,分别是:

  1. 熔断器关闭(Closed ):客户端正常访问服务提供方

  2. 熔断器打开( Open):阻断客户端对服务提供方的访问

  3. 熔断器半开( Half Open):熔断器开始重新判断是否需要继续熔断

结合实际情况,我们来看下这些状态之间的转换情况:

①关闭 -> 断开

当服务提供方出现异常时自动断开,这里的异常可以指:

  1. 连续发生threshold个错误,立即熔断;
  2. 单位时间请求数达到minSamples个,错误率达到rate,即熔断;
  3. 单位时间发生threshold个错误,立即熔断。

这里的错误完全是由业务系统来定义,可能是:

  1. 后端接口的响应严重超时;
  2. 后端服务返回异常的错误(如HTTP协议 5xx);
  3. RPC返回有异常的错误码。

当熔断器处于Open状态,客户端服务提供方的访问被阻断了。我们该如何响应客户端的请求?通常而言,可以有这么几种做法:

  1. 直接返回给客户端失败信息;
  2. 将返回降级后的结果,比如针对读请求,可以返回固定值,或者cache中的历史数据。

② 断开 -> 半断开

在熔断一段时间后,服务提供方的服务可能已经恢复了。那么怎么感知到服务提供方的服务已经恢复了呢?一种通用的的做法是使用定时器,每当定时器的设定的时间到达,熔断器的状态从自动从Open 切换到 Half Open

③半断开 -> 关闭/半断开 -> 断开

Half Open状态,熔断器重新探测(计算)服务提供方的健康情况进行检测。

如果服务提供方的服务已经恢复,则熔断器切换到Closed状态,否则切换到Open

【推荐阅读】

从状态机看熔断器 – 萌叔 (vearne.cc)

(6)限流设计(Throttle)

我们在一些系统中都可以看到像限流这样的设计,比如,我们的数据库访问的连接池,还有我们的线程池,还有 Nginx 下的用于限制瞬时并发连接数的 limit_conn 模块,限制每秒平均速率的 limit_req 模块,还有限制 MQ 的生产速,等等。

限流的分类

限流一般都是系统内实现的,常见的限流方式可以分为两类:基于请求限流和基于资源限流

  • 基于请求限流。

    基于请求限流指从外部访问的请求角度考虑限流,常见的方式有:限制总量、限制时间量。

  • 基于资源限流。

    基于资源限流是从系统内部考虑的,即:找到系统内部影响性能的关键资源,对其使用上限进行限制。常见的内部资源有:连接数、文件句柄、线程数、请求队列等。

触发限流后的策略

限流的目的是通过对并发访问进行限速,相关的策略一般是,一旦达到限制的速率,那么就会触发相应的限流行为。一般来说,触发的限流行为如下。

  • 拒绝服务。

    把多出来的请求拒绝掉。一般来说,好的限流系统在受到流量暴增时,会统计当前哪个客户端来的请求最多,直接拒掉这个客户端,这种行为可以把一些不正常的或者是带有恶意的高并发访问挡在门外。

  • 服务降级。

    关闭或是把后端服务做降级处理。这样可以让服务有足够的资源来处理更多的请求。降级有很多方式,一种是把一些不重要的服务给停掉,把 CPU、内存或是数据的资源让给更重要的功能;一种是不再返回全量数据,只返回部分数据。因为全量数据需要做 SQL Join 操作,部分的数据则不需要,所以可以让 SQL 执行更快,还有最快的一种是直接返回预设的缓存,以牺牲一致性的方式来获得更大的性能吞吐。

  • 特权请求。

    所谓特权请求的意思是,资源不够了,我只能把有限的资源分给重要的用户,比如:分给权利更高的 VIP 用户。在多租户系统下,限流的时候应该保大客户的,所以大客户有特权可以优先处理,而其它的非特权用户就得让路了。

  • 延时处理。

    在这种情况下,一般会有一个队列来缓冲大量的请求,这个队列如果满了,那么就只能拒绝用户了,如果这个队列中的任务超时了,也要返回系统繁忙的错误了。使用缓冲队列只是为了减缓压力,一般用于应对短暂的峰刺请求。

  • 弹性伸缩。

    动用自动化运维的方式对相应的服务做自动化的伸缩。这个需要一个应用性能的监控系统,能够感知到目前最繁忙的 TOP 5 的服务是哪几个。然后去伸缩它们,还需要一个自动化的发布、部署和服务注册的运维系统,而且还要快,越快越好。否则,系统会被压死掉了。当然,如果是数据库的压力过大,弹性伸缩应用是没什么用的,这个时候还是应该限流。

限流的实现方式

那么如何实现限流呢?通常业界有下面几种方法来实现限流:

  • 计数器(Counter)
  • 队列算法(Queue)
  • 漏斗算法(Leaky Bucket)
  • 令牌桶算法(Token Bucket)

【推荐阅读】

高并发下的限流分析

(7)服务降级(Degradation)

熔断和降级是两个比较容易混淆的概念,因为单纯从名字上看好像都有禁止某个功能的意思,但其实内在含义是不同的,原因在于降级的目的是应对系统自身的故障,而熔断的目的是应对依赖的外部系统故障的情况。

所谓的降级设计,本质就是为了解决资源不足和访问量过大的问题。当资源和访问量出现矛盾的时候,在有限的资源下,为了能够扛住大量的请求,我们就需要对系统进行降级操作。也就是说,暂时牺牲掉一些东西,以保障整个系统的平稳运行。例如,论坛可以降级为只能看帖子,不能发帖子;也可以降级为只能看帖子和评论,不能发评论;而 App 的日志上传接口,可以完全停掉一段时间,这段时间内 App 都不能上传日志。

降级的核心思想就是丢车保帅,优先保证核心业务。例如,对于论坛来说,90% 的流量是看帖子,那我们就优先保证看帖的功能;对于一个 App 来说,日志上传接口只是一个辅助的功能,故障时完全可以停掉。

为了降级我们一般会优先牺牲掉的东西有:

  • 数据的强一致性,比如简化流程、同步改成异步、数据命中使用缓存等
  • 停止次要功能
  • 简化功能

而常见的实现降级的方式有:

  • 系统后门降级

    简单来说,就是系统预留了后门用于降级操作。例如,系统提供一个降级 URL,当访问这个 URL 时,就相当于执行降级指令,具体的降级指令通过 URL 的参数传入即可。这种方案有一定的安全隐患,所以也会在 URL 中加入密码这类安全措施。

    系统后门降级的方式实现成本低,但主要缺点是如果服务器数量多,需要一台一台去操作,效率比较低,这在故障处理争分夺秒的场景下是比较浪费时间的。

  • 独立降级系统

    为了解决系统后门降级方式的缺点,将降级操作独立到一个单独的系统中,可以实现复杂的权限管理、批量操作等功能。其基本架构如下:

    image-20220313163104940

(8)排队

排队实际上是限流的一个变种,限流是直接拒绝用户,排队是让用户等待一段时间,全世界最有名的排队当属 12306 网站排队了。排队虽然没有直接拒绝用户,但用户等了很长时间后进入系统,体验并不一定比限流好。

由于排队需要临时缓存大量的业务请求,单个系统内部无法缓存这么多数据,一般情况下,排队需要用独立的系统去实现,例如使用 Kafka 这类消息队列来缓存用户请求。

下面是 1 号店的“双 11”秒杀排队系统架构:

image-20220313163140536

其基本实现摘录如下:

【排队模块】负责接收用户的抢购请求,将请求以先入先出的方式保存下来。每一个参加秒杀活动的商品保存一个队列,队列的大小可以根据参与秒杀的商品数量(或加点余量)自行定义。

【调度模块】负责排队模块到服务模块的动态调度,不断检查服务模块,一旦处理能力有空闲,就从排队队列头上把用户访问请求调入服务模块,并负责向服务模块分发请求。这里调度模块扮演一个中介的角色,但不只是传递请求而已,它还担负着调节系统处理能力的重任。我们可以根据服务模块的实际处理能力,动态调节向排队系统拉取请求的速度。

【服务模块】负责调用真正业务来处理服务,并返回处理结果,调用排队模块的接口回写业务处理结果。

服务高可用小结

首先,我们的服务不能是单点,所以,我们需要在架构中冗余服务,也就是说有多个服务的副本。这需要使用到的具体技术有:

  • 负载均衡 + 服务健康检查–可以使用像 Nginx 或 HAProxy 这样的技术;

  • 服务发现 + 动态路由 + 服务健康检查,比如 Consul 或 ZooKeeper;

  • 自动化运维,Kubernetes 服务调度、伸缩和故障迁移。

然后,我们需要隔离我们的业务,要隔离我们的服务我们就需要对服务进行解耦和拆分,这需要使用到以前的相关技术。

  • bulkheads 模式:业务分片 、用户分片、数据库拆分。

  • 自包含系统:所谓自包含的系统是从单体到微服务的中间状态,其把一组密切相关的微服务给拆分出来,只需要做到没有外部依赖就行。

  • 异步通讯:服务发现、事件驱动、消息队列、业务工作流。

  • 自动化运维:需要一个服务调用链和性能监控的监控系统。

然后,接下来,我们就要进行和能让整个架构接受失败的相关处理设计,也就是所谓的容错设计。这会用到下面的这些技术。

  • 错误方面:调用重试 + 熔断 + 服务的幂等性设计。

  • 一致性方面:强一致性使用两阶段提交、最终一致性使用异步通讯方式。

  • 流控方面:使用限流 + 降级技术。

  • 自动化运维方面:网关流量调度,服务监控。

updatedupdated2023-06-032023-06-03
加载评论