工作总结_应用可用性

故障隔离

隔离是为了在发生故障时,将传播和影响范围尽可能限定在一个较小的范围内,避免造成滚雪球效应,导致整个系统的不可用

进程内隔离

在公司的发展过程中,不可避免的会形成一些大而全的应用,为了避免某些功能的某些模块出现异常导致应用的所有功能的不可用,需要在应用中不同功能模块间做隔离。

优点:模块间通信方便,开发简单,功能间无需拆分单独部署

缺点:CPU,内存等资源无法做到隔离,如果某模块出现故障耗尽CPU及内存,故障仍然会扩散至整个进程中

案例1:某模块代码bug,出现死循环,将CPU耗尽,整个服务处理能力大幅下降

案例2:某模块本地内存缓存巨量膨胀,将内存耗尽,频繁Full GC,整个服务处理能力大幅下降

进程内隔离方式

线程池隔离

不同模块设置不同的线程池进行执行,当某个模块代码出现异常,不会将故障扩散到其他模块的线程池

例:某模块耗时突然大幅上升,线程池中所有线程均阻塞在该模块的执行上,导致该线程池处理能力大幅下降。如果多个模块与故障模块共享一个线程池,故障将会在这几个模块中传播。

缺点:线程数过多,上下文切换开销大

信号量隔离

通过信号量,限制某个模块并发执行的线程数。同一条执行路径上的多个模块在同一个线程内执行,不存在线程上下文切换所带来的性能开销

缺点:不支持异步超时,如果阻塞,只能在模块内部设置超时才能返回(如:socket超时)

两种隔离方式的总结

隔离方式 超时 异步同步 资源消耗
线程池隔离 支持 异步或同步 线程的上下文切换
信号量隔离 不支持 同步 计数器

适用场景

  • 线程池:调用外部依赖,长耗时,性能不敏感
  • 信号量:执行内部逻辑,短耗时,性能敏感

进程间隔离

对大而全的应用进行拆分为多个子系统单独部署,做到物理上的隔离

需要考虑:部署成本,跨应用功能开发成本,子应用间通信成本

流量限流

避免流量超过系统负载的上限,需要对流量的进入进行限制

限流主要起到两种效果

  • 流量整形:削峰去谷,使流量匀速进入
  • 流量控制:控制一段时间内进入的流量大小

限流算法

  • 令牌桶算法
  • 漏桶算法

令牌桶算法: 可以看做一个桶,按照固定速率向桶中放入令牌,令牌桶放满则不在放入令牌。每一个请求流量从桶中拿走指定数量的令牌,桶中令牌数不足则拒绝请求

漏桶算法: 可以看做一个桶,请求流量不断的流入桶中且流入速率随意,桶的底部,流量请求按照固定速率不断流出。如果流入速率大于流出速率则请求在桶中堆积,达到上限后,新的请求将会被拒绝。

令牌桶相对于漏桶,允许一定程度的突发流量,而漏桶则保证流量速率是均匀的

案例

机票运价缓存生成job,以90秒为一个周期,访问数据库,生成一万条航线的运价缓存,每条航线请求数据库一次。为了使请求均匀分布在90秒的周期内,避免数据库负载尖刺,通过漏桶算法对数据库读流量进行整形。

熔断与降级

人工降级

当出现异常时,比如说CPU使用率变高高,耗时上升,代码抛出异常等情况。通过配置开关,选择性的将一些功能关闭,释放这些功能占用的资源,或者避免异常导致的脏数据输出,达到丢车保帅的目的。

业务降级

通过对业务功能进行划分,通过配置开关控制,必要时关闭一些业务。比如一个机票售卖系统,同时售卖自有运价和供应商运价,如果某一个业务模块出现异常,可以通过开关控制只售卖自有运价或者供应商运价。

案例1:由于供应商操作失误,导致供应商运价数量剧增,应用处理耗时上升严重。临时关闭供应商运价售卖,通知供应商修正后重新开放。

案例2:由于代码bug,导致输出的供应商运价价格不对,临时关闭供应商运价售卖,紧急修复后重新开放

外部数据依赖降级

外部依赖的数据服务挂了,通常降级方案可以有:

  • 使用默认值 (获取某个航班用户评价的服务挂了,默认没有评价)
  • 使用之前请求成功的缓存值(需要将之前的值缓存起来)

自动降级

  • 超时降级:调用外部依赖超时后的降级处理
  • 异常降级:catch异常后的降级处理
  • 限流降级:因为流量限制被拒绝后的降级处理
  • 其他:种种原因无法达到预期目的后的降级处理

自动熔断

熔断指的是受熔断器保护的代码块执行失败率超过一定阈值时,熔断器打开,在打开的时间段中,任何进入该代码块的尝试将会被拒绝。通常熔断与降级配套的,尝试执行受保护的代码块被拒绝后,转而执行降级代码,从而实现自动降级

通常的熔断器支持失败率和慢调用率的阈值触发,即

  • 当失败的百分比大于或等于配置的阈值时,熔断器开启,例如,当超过50%的调用失败时,熔断器开启
  • 当慢调用的百分比大于等于配置的阈值时,熔断器开启,例如,当超过50%的调用响应时间超过5秒时,熔断器开启

通常来说,熔断器用在对外部依赖的调用上,当外部依赖出现异常时(比如耗时上升),使得调用能够快速的失败,保护调用方避免被外部服务的异常拖垮

以Resilience4j中的熔断器为例,代码如下所示

    // 尝试进入受保护的代码块
    if(breaker.tryAcquirePermission()) {
        long start = System.nanoTime();
        try {
            // 受保护的代码块 
            // begin .....


            // end

            // 记录成功以及执行时间
            long durationInNanos = System.nanoTime() - start;
            breaker.onSuccess(durationInNanos, TimeUnit.NANOSECONDS);
        } catch (Exception exception) {
            // 记录失败以及执行时间
            long durationInNanos = System.nanoTime() - start;
            breaker.onError(durationInNanos, TimeUnit.NANOSECONDS, exception);
            throw exception;
        }
    } else {
        // 执行被拒绝
        // 执行熔断后的降级操作
    }

案例:机票售卖系统,存储航班舒适度信息(比如座椅宽度等信息)的Redis出现故障,响应耗时大幅上升,触发熔断,应用自动执行降级操作,不输出舒适度信息(因为对于机票售卖,航班舒适度信息并不是必要的)

其他

降级逻辑的实现值得仔细思考,使得服务有损程度降到最小。同时,降级也不是银弹,大部分关键数据的获取,关键代码的执行是无法降级的。

超时与重试

通常对外部依赖需要设置一个超时时间,调用的外部依赖超过指定时间仍未返回,则抛出超时异常

重试机制,允许在调用超时后,再重试调用几次。一般来说外部依赖偶发的超时,重试一般都能成功。重试必须保证调用的服务是幂等的,否则可能会有问题(比如重复下单)

超时时间和重试次数,需要仔细考虑。一般 (重试次数 + 1) * 超时时间 = 最长可接受的阻塞时间

超时时间设置过长,一旦外部依赖失去响应,将会导致调用阻塞时间过长,拖慢调用方的服务响应时间。超时时间设置过短,将会导致调用方对外部依赖的响应时间非常敏感,一个小的波动将会导致大量超时。

曾经有同事将一个关键外部服务的超时时间设置的过短,外部服务响应时间因为一些原因有小幅度上升,导致大量请求超时,服务不可用。实际上外部服务响应时间上升还是在可接受的范围内。