[Golang]从源码分析hystrix-go的性能隐患

353 阅读5分钟

hystrix 是一个知名的“容错库”,用来提供限制并发数、熔断、超时控制功能。一般用在远程访问外部API或系统时。它的Golang版本是github.com/afex/hystrix-go

但请注意,hystrix-go这个库设计之初并没有考虑太多性能方面的考量,在性能上的损耗比较多。当然,如果只是低频使用,没有任何问题。但如果“请求外部API”本身就是这个系统的核心功能,且被高频使用 (比如类似于 http proxy的系统),那么这个库在性能上的问题才可能会暴露出来,甚至成为瓶颈。

下面我们分别对限流、熔断、超时这三个功能从实现上讨论它为什么性能为差,以及有什么方法来替代。

建议在对hystrix-go​源码比较熟悉的前提下,或同时参考源码来看本文。

限制并发数

hystrix采用的是发token的方式来限制并发数,即MaxConcurrentRequests​配置。

它的实现方式也很粗暴:建立一个长度=MaxConcurrentRequests​ 的 chan ,并向里面提前存入MacConcurrentRequests​个结构体。每次请求从chan里取一个结构体,用完再放回chan里。

这样带来的后果是,当MacConcurrentRequets​比较大时, 一个较大的chan *struct​会对GC产生一定的负担。每次GC时系统都要遍历一次整个chan。

替换方案:可参考golang官方 rate.Limiter​库的实现。

熔断

hystrix的熔断控制方式是“滚动统计10秒内失败率”+探测流量。

这里的关键是实现“滚动统计”。

hystrix采用的方法是如下结构体:

type Number struct {
	Buckets map[int64]*numberBucket
	Mutex   *sync.RWMutex
}

type numberBucket struct {
	Value float64
}


使用如上结构来滚动保存前10秒的统计数据。Buckets的key就是当前的时间戳(秒)。

每次有数据更新时,加锁,遍历整个map,删除时间戳在当前时间10秒之前的数据。

每次需要读数据时,加锁,遍历整个map , 将时间戳在当前时间10秒内的数据进行累加。

简单来说,就是一个时间轮。但这个时间轮的实现很差

大量的加锁+遍历map的操作,在性能上很吃亏。

替换方案:自己使用slice实现无锁的滚动统计。

时间本来就是连续的,完全可以不使用map[int64]*number​, 而是改成 []*number​ 来实现。这样一来,遍历slice性能就已经比遍历map强不少了,还可以通过原子操作实现无锁。 甚至gc上的压力也小了。

可以说性能上的提升空间很大。

(具体怎么使用slice实现无锁的滚动统计,可以留作思考题,也可私信与我交流。或等拙作专门对此话题进行讨论)

超时控制

这一部分的实现倒没什么问题, 使用ticker+go+select的常规实现。

如果是HTTP请求,标准库里已经有了超时控制功能,没必要重复控制。

生命周期控制

hystrix需要能够异步获取每个请求完成的时间和它报错时的错误信息。这个功能的实现同样有明显的性能问题。

其性能问题在于,对于每一次请求,都需要建立一个新的command​结构体。该结构体如下:

type command struct {
	sync.Mutex
	ticket      *struct{}
	start       time.Time
	errChan     chan error
	finished    chan bool
	circuit     *CircuitBreaker
	run         runFuncC
	fallback    fallbackFuncC
	runDuration time.Duration
	events      []string
}

众所周知,像这样结构比较复杂又带嵌套的结构体,对gc的压力还是比较明显的。

尤其是里面还有两个chan。我通过pprof发现,高频地进行新建chan的操作,即调用makechan​, 其实还是比较耗性能的,对GC也不友好。

另外,hystrix同时支持异步的GoC​和同步的DoC​两个函数。而其中DoC​的实现仅是将GoC​使用一个chan阻塞住。所以,如果是用的 DoC​ 而非GoC​, 每次请求还会再多出一个 chan​。

hystrix对于goroutine的使用也比较泛滥,每个请求都需要额外的三个goroutine来处理。

有人会问,这会带来什么问题呢? goroutine不是很廉价的资源吗?没错。但廉价不等于免费。

如果你有研究过net/http​的源码,你会知道,对于每个http请求,golang会创建两个goroutine来处理,分别用来进行数据和读和写。也就是说,对于并发的N个请求,所需的goroutine数会从2N变为5N。这在CPU上带来的也是GC负担变重(需要遍历goroutine并检查它们的stack),而在内存上的表现就是内存的占用也会有所上升(一个goroutine的堆栈约4-5kB)。

改进方法:其实这里改进的方法都已经包含在前面几项里了。因为上述的无论是command​结构体、多余的chan, 还是多余的goroutine, 都是为了实现前面的超时、熔断等功能而出现的。只是本库设计之初在性能上欠考虑,采用了这种耗性能的设计。

只要按前面几项里提到的改进方法分别将各种功能等效替换,本篇里提到的所有性能隐患也都会消失。

总结

再次重申,hystrix在中轻度使用下没有任何问题。但如果你的系统在重度使用,且有真正遇到性能上的问题,才需要考虑换掉它。