微服务架构熔断设计分析

服务雪崩效应

微服务系统有多个系统组成,但由于某些原因(到达性能极限、未知bug、网络分区等)导致访问上游很慢,这时如果没有服务的熔断与降级那么调用者服务会因为上游异常而积累过多请求导致产生大量等待请求,进而调用者服务也会引发访问慢或中止服务的问题,从而引发其他系统问题,如此一来,由上游本身的问题而引发依赖服务的整个链路都出问题,这就是典型的服务雪崩效应。如下图

问题

我们如何防止雪崩问题或者缓解雪崩的问题?

思路

为了解决微服务的雪蹦效应,提出来使用熔断机制为微服务链路提供保护机制。

当链路中的某个微服务不可用或者响应的时间太长时,会进行服务的降级,进而熔断该节点微服务的调用,快速返回错误的响应信息,当检测到该节点微服务调用响应正常后,恢复调用链路。

涉及到将服务接入熔断机制,我们会涉及到熔断库的选型问题。本
文我们就介绍两个开源熔断框架:Hystrix-go和 Sentinel-go

Hystrix-go

例子:

hystrix.ConfigureCommand("my_command", hystrix.CommandConfig{
	Timeout:               1000,
	MaxConcurrentRequests: 100,
	ErrorPercentThreshold: 25,
})

err := hystrix.Do("my_command", func() error {
	// 业务逻辑
	return nil
}, func(err error) error {
	// 降级逻辑
	return nil
})

首先使用 hystrix.ConfigureCommand 配置熔断策略,然后调用 hystrix.Do 方法即可。

hystrix.Do 方法有三个参数:

  • 第一个是我们配置的命令,即调用 hystrix.ConfigureCommand 时指定的命令,不能为空;
  • 第二个参数时我们的正常业务逻辑函数;不能为空;
  • 第三个参数为 fallback 参数,当发生熔断会调用第三个参数。如果不希望有降级逻辑,那么第三个参数置为 nil 即可;

配置

  • Timeout:定义执行command的超时时间,时间单位是ms,默认时间是1000ms;
  • MaxConcurrnetRequests:定义command的最大并发量,默认值是10并发量;
  • SleepWindow:熔断器被打开后使用,在熔断器被打开后,根据
  • SleepWindow设置的时间控制多久后尝试服务是否可用,默认时间为5000ms;
  • RequestVolumeThreshold:判断熔断开关的条件之一,统计10s(代码中写死了)内请求数量,达到这个请求数量后再根据错误率判断是否要开启熔断;
  • ErrorPercentThreshold:判断熔断开关的条件之一,统计错误百分比,请求数量大于等于* RequestVolumeThreshold并且错误率到达这个百分比后就会启动熔断 默认值是50;

如何判断需要熔断

  • 根据并发数是否达到限制请求 (MaxConcurrnetRequests )
  • 根据错误数是否达到错误比率进行限制请求 (ErrorPercentThreshold)

如何判断恢复

  • 在熔断开启之后,没隔SleepWindow时间会将一个请求放到后端,如果请求成功,则会认为服务已经恢复。
IsOpen

IsOpen 这个函数会先判断是否强制开启了熔断,然后再检查当前的请求数是否达到了最小的请求数, 错误率是否达到阈值

func (circuit *CircuitBreaker) IsOpen() bool {
	circuit.mutex.RLock()
    // 是否强制打开
	o := circuit.forceOpen || circuit.open
	circuit.mutex.RUnlock()

	if o {
		return true
	}
    // 当前的请求数是否达到了最小的请求数
	if uint64(circuit.metrics.Requests().Sum(time.Now())) < getSettings(circuit.Name).RequestVolumeThreshold {
		return false
	}
    // 错误率是否达到阈值
	if !circuit.metrics.IsHealthy(time.Now()) {
		// too many failures, open the circuit
		circuit.setOpen()
		return true
	}

	return false
}

// 计算错误率
func (m *metricExchange) IsHealthy(now time.Time) bool {
	return m.ErrorPercent(now) < getSettings(m.Name).ErrorPercentThreshold
}
allowSingleTest

allowSingleTest 让熔断器 在SleepWindow时间后进行尝试

func (circuit *CircuitBreaker) allowSingleTest() bool {
	circuit.mutex.RLock()
	defer circuit.mutex.RUnlock()

	now := time.Now().UnixNano()
	openedOrLastTestedTime := atomic.LoadInt64(&circuit.openedOrLastTestedTime)
    // 当前是打开状态而且当前时间已经超过了SleepWindow时间
    // 则会将这个请求作为探测请求过去
	if circuit.open && now > openedOrLastTestedTime+getSettings(circuit.Name).SleepWindow.Nanoseconds() {
		swapped := atomic.CompareAndSwapInt64(&circuit.openedOrLastTestedTime, openedOrLastTestedTime, now)
		if swapped {
			log.Printf("hystrix-go: allowing single test to possibly close circuit %v", circuit.Name)
		}
		return swapped
	}

	return false
}
数据统计是如何做的
数据上报

circuit 的数据上报是通过chan 来进行的,circuit 将数据写入到 Updates 中,metricExchange 会有 一个 Monitor 方法一直读取 Updates chan 中的数据,写入到数据收集器中(默认:DefaultMetricCollector)

// ReportEvent records command metrics for tracking recent error rates and exposing data to the dashboard.
func (circuit *CircuitBreaker) ReportEvent(eventTypes []string, start time.Time, runDuration time.Duration) error {
	if len(eventTypes) == 0 {
		return fmt.Errorf("no event types sent for metrics")
	}

	circuit.mutex.RLock()
	o := circuit.open
	circuit.mutex.RUnlock()
  // 上报的状态事件是success 并且当前熔断器是开启状态,则说明下游服务正常了,可以关闭熔断器了
    if eventTypes[0] == "success" && o {
		circuit.setClose()
	}

	var concurrencyInUse float64
	if circuit.executorPool.Max > 0 {
		concurrencyInUse = float64(circuit.executorPool.ActiveCount()) / float64(circuit.executorPool.Max)
	}

	select {
    // 上报状态指标,与下文的monitor呼应
    case circuit.metrics.Updates <- &commandExecution{
		Types:            eventTypes,
		Start:            start,
		RunDuration:      runDuration,
		ConcurrencyInUse: concurrencyInUse,
	}:
	default:
		return CircuitError{Message: fmt.Sprintf("metrics channel (%v) is at capacity", circuit.Name)}
	}

	return nil
}

// 指标上报到数据收集器中
func (m *metricExchange) Monitor() {
	for update := range m.Updates {
		// we only grab a read lock to make sure Reset() isn't changing the numbers.
		m.Mutex.RLock()

		totalDuration := time.Since(update.Start)
		wg := &sync.WaitGroup{}
		for _, collector := range m.metricCollectors {
			wg.Add(1)
			go m.IncrementMetrics(wg, collector, update, totalDuration)
		}
		wg.Wait()

		m.Mutex.RUnlock()
	}
}
数据计算

hystrix-go为每一个Command设置了一个默认统计控制器,用来保存熔断器的所有状态,包括调用次数、失败次数、被拒绝次数等,存储指标结构,使用rolling.Number结构保存状态指标,使用rolling.Timing保存时间指标。

rolling.Number 底层是 Number 数据结构,这个是数据统计的核心结构,Number 提供了 Sum() 方法计算在统计周期内的总和

type DefaultMetricCollector struct {
	mutex *sync.RWMutex

	numRequests *rolling.Number
	errors      *rolling.Number

	successes               *rolling.Number
	failures                *rolling.Number
	rejects                 *rolling.Number
	shortCircuits           *rolling.Number
	timeouts                *rolling.Number
	contextCanceled         *rolling.Number
	contextDeadlineExceeded *rolling.Number

	fallbackSuccesses *rolling.Number
	fallbackFailures  *rolling.Number
	totalDuration     *rolling.Timing
	runDuration       *rolling.Timing
}


// Number tracks a numberBucket over a bounded number of
// time buckets. Currently the buckets are one second long and only the last 10 seconds are kept.
type Number struct {
	Buckets map[int64]*numberBucket
	Mutex   *sync.RWMutex
}

// Sum sums the values over the buckets in the last 10 seconds.
func (r *Number) Sum(now time.Time) float64 {
	sum := float64(0)

	r.Mutex.RLock()
	defer r.Mutex.RUnlock()

	for timestamp, bucket := range r.Buckets {
		// TODO: configurable rolling window
		if timestamp >= now.Unix()-10 {
			sum += bucket.Value
		}
	}

	return sum
}

Hystrix-go 处理流程

在这里插入图片描述

  • 请求流程:获取断路器–>条件判断–>业务函数执行–>上报执行状态信息
状态变更
  • Close,Open

在这里插入图片描述

存在的风险

当熔断开启之后,SleepWindow时间一个请求过去了,成功了,熔断就会关闭。这个时候服务正在恢复,会有少量请求成功。而熔断关闭了,这个时候流量过去了,会不会服务又没有办法恢复。

Sentinel-go

例子:

package main

import (
	"errors"
	"fmt"
	"log"
	"math/rand"
	"time"

	sentinel "github.com/alibaba/sentinel-golang/api"
	"github.com/alibaba/sentinel-golang/core/circuitbreaker"
	"github.com/alibaba/sentinel-golang/core/config"
	"github.com/alibaba/sentinel-golang/logging"
	"github.com/alibaba/sentinel-golang/util"
)

type stateChangeTestListener struct {
}

func (s *stateChangeTestListener) OnTransformToClosed(prev circuitbreaker.State, rule circuitbreaker.Rule) {
	fmt.Printf("rule.steategy: %+v, From %s to Closed, time: %d\n", rule.Strategy, prev.String(), util.CurrentTimeMillis())
}

func (s *stateChangeTestListener) OnTransformToOpen(prev circuitbreaker.State, rule circuitbreaker.Rule, snapshot interface{}) {
	fmt.Printf("rule.steategy: %+v, From %s to Open, snapshot: %.2f, time: %d\n", rule.Strategy, prev.String(), snapshot, util.CurrentTimeMillis())
}

func (s *stateChangeTestListener) OnTransformToHalfOpen(prev circuitbreaker.State, rule circuitbreaker.Rule) {
	fmt.Printf("rule.steategy: %+v, From %s to Half-Open, time: %d\n", rule.Strategy, prev.String(), util.CurrentTimeMillis())
}

func main() {
	 m := map[int]bool{}
	conf := config.NewDefaultConfig()
	// for testing, logging output to console
	conf.Sentinel.Log.Logger = logging.NewConsoleLogger()
	err := sentinel.InitWithConfig(conf)
	if err != nil {
		log.Fatal(err)
	}
	ch := make(chan struct{})
	// Register a state change listener so that we could observer the state change of the internal circuit breaker.
	circuitbreaker.RegisterStateChangeListeners(&stateChangeTestListener{})

	_, err = circuitbreaker.LoadRules([]*circuitbreaker.Rule{
		// Statistic time span=5s, recoveryTimeout=3s, maxErrorRatio=40%
		{
			Resource:                     "abc",
			Strategy:                     circuitbreaker.ErrorRatio,
			RetryTimeoutMs:               3000,
			MinRequestAmount:             10,
			StatIntervalMs:               5000,
			StatSlidingWindowBucketCount: 10,
			Threshold:                    0.4,
		},
	})
	if err != nil {
		log.Fatal(err)
	}

	logging.Info("[CircuitBreaker ErrorRatio] Sentinel Go circuit breaking demo is running. You may see the pass/block metric in the metric log.")
	go func() {
		for {
			e, b := sentinel.Entry("abc")
			if b != nil {
				// g1 blocked
				time.Sleep(time.Duration(rand.Uint64()%20) * time.Millisecond)
			} else {
				if rand.Uint64()%20 > 6 {
					// Record current invocation as error.
					sentinel.TraceError(e, errors.New("biz error"))
				}
				// g1 passed
				time.Sleep(time.Duration(rand.Uint64()%80+20) * time.Millisecond)
				e.Exit()
			}
		}
	}()
	go func() {
		for {
			e, b := sentinel.Entry("abc")
			if b != nil {
				// g2 blocked
				time.Sleep(time.Duration(rand.Uint64()%20) * time.Millisecond)
			} else {
				// g2 passed
				time.Sleep(time.Duration(rand.Uint64()%80+40) * time.Millisecond)
				e.Exit()
			}
		}
	}()
	<-ch
}

配置

  • Id: 表示 Sentinel 规则的全局唯一ID,可选项。

  • Resource: 熔断器规则生效的埋点资源的名称;

  • Strategy: 熔断策略,目前支持SlowRequestRatio、ErrorRatio、ErrorCount三种;选择以慢调用比例 (SlowRequestRatio) 作为阈值,需要设置允许的最大响应时间(MaxAllowedRtMs),请求的响应时间大于该值则统计为慢调用。通过 Threshold 字段设置触发熔断的慢调用比例,取值范围为 [0.0, 1.0]。规则配置后,在单位统计时长内请求数目大于设置的最小请求数目,并且慢调用的比例大于阈值,则接下来的熔断时长内请求会自动被熔断。经过熔断时长后熔断器会进入探测恢复状态,若接下来的一个请求响应时间小于设置的最大 RT 则结束熔断,若大于设置的最大 RT 则会再次被熔断。
    选择以错误比例 (ErrorRatio) 作为阈值,需要设置触发熔断的异常比例(Threshold),取值范围为 [0.0, 1.0]。规则配置后,在单位统计时长内请求数目大于设置的最小请求数目,并且异常的比例大于阈值,则接下来的熔断时长内请求会自动被熔断。经过熔断时长后熔断器会进入探测恢复状态,若接下来的一个请求没有错误则结束熔断,否则会再次被熔断。代码中可以通过 api.TraceError(entry, err) 函数来记录 error。

  • RetryTimeoutMs: 即熔断触发后持续的时间(单位为 ms)。资源进入熔断状态后,在配置的熔断时长内,请求都会快速失败。熔断结束后进入探测恢复模式(HALF-OPEN)。

  • MinRequestAmount: 静默数量,如果当前统计周期内对资源的访问数量小于静默数量,那么熔断器就处于静默期。换言之,也就是触发熔断的最小请求数目,若当前统计周期内的请求数小于此值,即使达到熔断条件规则也不会触发。

  • StatIntervalMs: 统计的时间窗口长度(单位为 ms)。

  • MaxAllowedRtMs: 仅对慢调用熔断策略生效,MaxAllowedRtMs 是判断请求是否是慢调用的临界值,也就是如果请求的response time小于或等于MaxAllowedRtMs,那么就不是慢调用;如果response time大于MaxAllowedRtMs,那么当前请求就属于慢调用。

  • Threshold: 对于慢调用熔断策略, Threshold表示是慢调用比例的阈值(小数表示,比如0.1表示10%),也就是如果当前资源的慢调用比例如果高于Threshold,那么熔断器就会断开;否则保持闭合状态。 对于错误比例策略,Threshold表示的是错误比例的阈值(小数表示,比如0.1表示10%)。对于错误数策略,Threshold是错误计数的阈值。

  • ProbeNum: 熔断器半开时所需的探针数。当设置了探针数量并且熔断器处于半开状态时。如果探测过程中发生错误,则立即打开熔断器。否则,只有达到探测次数后熔断器才会闭合

  • 一些补充说明:

    • Resource、Strategy、RetryTimeoutMs、MinRequestAmount、StatIntervalMs、Threshold 每个规则都必设的字段,MaxAllowedRtMs是慢调用比例熔断规则必设的字段。

    • MaxAllowedRtMs 字段仅仅对慢调用比例 (SlowRequestRatio) 策略有效,对其余策略均属于无效字段。

    • StatIntervalMs 表示熔断器的统计周期,单位是毫秒,这个值我们不建议设置的太大或则太小,一般情况下设置10秒左右都OK,当然也要根据实际情况来适当调整。

    • RetryTimeoutMs 的设置需要根据实际情况设置探测周期,一般情况下设置10秒左右都OK,当然也要根据实际情况来适当调整。

如何判断需要熔断

  • 根据配置的QPS 或者错误数比例 或者错误计数

如何判断恢复

  • 在半开状态,该状态下除了探测流量,其余对资源的访问也会被切断。探测流量指熔断器处于半开状态时,会周期性的允许一定数目的探测请求通过,
    • 如果探测请求能够正常的返回(需要有满足的配置探测请求个数),代表探测成功,此时断器会重置状态到 Closed 状态,结束熔断;
    • 如果探测失败,则回滚到 Open 状态。

数据统计是如何做的

数据上报

在一个请求结束的时候,会调用到每个熔断器的 OnRequestComplete 方法,这个方法会将请求的状态记录到 metricStat 数据统计中去

func (b *errorRatioCircuitBreaker) OnRequestComplete(_ uint64, err error) {
	metricStat := b.stat
    // 获取到当前的数据统计收集器
	counter, curErr := metricStat.currentCounter()
	if curErr != nil {
		logging.Error(curErr, "Fail to get current counter in errorRatioCircuitBreaker#OnRequestComplete().",
			"rule", b.rule)
		return
	}
	if err != nil {
		atomic.AddUint64(&counter.errorCount, 1)
	}
	atomic.AddUint64(&counter.totalCount, 1)

	errorCount := uint64(0)
	totalCount := uint64(0)
    // 获取当前统计周期内的统计数据
	counters := metricStat.allCounter()
    // 计算在统计数据之合
	for _, c := range counters {
		errorCount += atomic.LoadUint64(&c.errorCount)
		totalCount += atomic.LoadUint64(&c.totalCount)
	}
    // 计算错误率
	errorRatio := float64(errorCount) / float64(totalCount)
}
数据计算

数据收集最底层的的数据结构是 errorCounterLeapArray 和 errorCounter。errorCounterLeapArray 是一个环型的数组,里面存放了每一个Bucket的数据:errorCounter(错误率使用的结构,不同的策略这个结构体不一样)

可以参考文档:Sentinel-Go 源码系列(三)滑动时间窗口算法的工程实现sentinel golang 滑动窗口源码解析

// 统计周期内的数据
type errorCounterLeapArray struct {
	data *sbase.LeapArray
}

// 单个 bucket 数据
type BucketWrap struct {
	// The start timestamp of this statistic bucket wrapper.
	BucketStart uint64
	// The actual data structure to record the metrics (e.g. MetricBucket).
	Value atomic.Value // *errorCounter
}


func (s *errorCounterLeapArray) NewEmptyBucket() interface{} {
	return &errorCounter{
		errorCount: 0,
		totalCount: 0,
	}
}

状态变更

熔断器有三种状态:

  • Closed 状态:也是初始状态,该状态下,熔断器会保持闭合,对资源的访问直接通过熔断器的检查。
  • Open 状态:断开状态,熔断器处于开启状态,对资源的访问会被切断。
  • Half-Open 状态:半开状态,该状态下除了探测流量,其余对资源的访问也会被切断。探测流量指熔断器处于半开状态时,会周期性的允许一定数目的探测请求通过,如果探测请求能够正常的返回,代表探测成功,此时熔断器会重置状态到 Closed 状态,结束熔断;如果探测失败,则回滚到 Open 状态。
    在这里插入图片描述

这三种状态之间的转换关系这里做一个更加清晰的解释:

  • 初始状态下,熔断器处于 Closed 状态。如果基于熔断器的统计数据表明当前资源触发了设定的阈值,那么熔断器会切换状态到 Open 状态;
  • Open 状态即代表熔断状态,所有请求都会直接被拒绝。熔断器规则中会配置一个熔断超时重试的时间,经过熔断超时重试时长后熔断器会将状态置为 Half-Open 状态,从而进行探测机制;
  • 处于 Half-Open 状态的熔断器会周期性去做探测。

总结

Hystrix-goSentinel-go备注
分布式-
半打开状态-
自定义统计时间Hystrix-go 为固定周期10s
指标统计收集收集在统计周期内的数据(请求数,失败数等),Hystrix-go 如果自定义收集需要完全全部自己实现
熔断器状态通知Sentinel-go 提供接口通知熔断器状态变更
支持熔断方式错误比例/并发数错误比例/慢请求/错误数-

从使用上来看,Hystrix-go 简单比较简单,比较容易使用,Sentinel-go 配置项比较多,但是可以支持比较多的熔断模式,对于状态的变更,也提供了自定义接口。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

CoLiuRs

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值