微服务系统有多个系统组成,但由于某些原因(到达性能极限、未知bug、网络分区等)导致访问上游很慢,这时如果没有服务的熔断与降级那么调用者服务会因为上游异常而积累过多请求导致产生大量等待请求,进而调用者服务也会引发访问慢或中止服务的问题,从而引发其他系统问题,如此一来,由上游本身的问题而引发依赖服务的整个链路都出问题,这就是典型的服务雪崩效应。如下图
我们如何防止雪崩问题或者缓解雪崩的问题?
为了解决微服务的雪蹦效应,提出来使用熔断机制为微服务链路提供保护机制。
当链路中的某个微服务不可用或者响应的时间太长时,会进行服务的降级,进而熔断该节点微服务的调用,快速返回错误的响应信息,当检测到该节点微服务调用响应正常后,恢复调用链路。
涉及到将服务接入熔断机制,我们会涉及到熔断库的选型问题。本
文我们就介绍两个开源熔断框架:Hystrix-go和 Sentinel-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 方法有三个参数:
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 让熔断器 在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
}
当熔断开启之后,SleepWindow时间一个请求过去了,成功了,熔断就会关闭。这个时候服务正在恢复,会有少量请求成功。而熔断关闭了,这个时候流量过去了,会不会服务又没有办法恢复。
例子:
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,当然也要根据实际情况来适当调整。
在一个请求结束的时候,会调用到每个熔断器的 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,
}
}
熔断器有三种状态:
这三种状态之间的转换关系这里做一个更加清晰的解释:
Hystrix-go | Sentinel-go | 备注 | |
---|---|---|---|
分布式 | 否 | 否 | - |
半打开状态 | 否 | 是 | - |
自定义统计时间 | 否 | 是 | Hystrix-go 为固定周期10s |
指标统计收集 | 是 | 否 | 收集在统计周期内的数据(请求数,失败数等),Hystrix-go 如果自定义收集需要完全全部自己实现 |
熔断器状态通知 | 否 | 是 | Sentinel-go 提供接口通知熔断器状态变更 |
支持熔断方式 | 错误比例/并发数 | 错误比例/慢请求/错误数 | - |
从使用上来看,Hystrix-go 简单比较简单,比较容易使用,Sentinel-go 配置项比较多,但是可以支持比较多的熔断模式,对于状态的变更,也提供了自定义接口。