并发性能优化复盘
好吧理所当然的他来找我。第一时间“男人的第六感”告诉我是后端服务处理时间太长了导致客户端在超时时间内未处理完成。要不把 timeout 改大点在一想咱是干开发的不能治标不治本。于是开始接口优化之路。这个接口的大致情况是这样服务治理系统调用/environments/list获取平台的所有环境假设每个环境对应一个 kubernetes 集群。我们的平台大概有 30 个环境。服务治理系统调用/environments/:name/resouce获取对应环境下资源。后端environments/:name/resource会并发起多个协程访问代理系统的 api获取到结果并处理后返回给调用方。服务治理系统的使用场景是每天晚上调用environments/list获取平台的所有环境然后遍历调用/environments/:name/resource获取所有环境下的资源。服务治理系统只调用一次不会重复/并发调用。我们首先排查/environments/listapipostman调用发现第一次调用 30ms第二次调用 4ms。前后调用差这么大是因为我们用了缓存client-go第一次获取资源会缓存资源到内存第二次调用直接从内存中拿资源。这是合理的在开发初期也和调用方说过了。看起来这个 api 不需要怎么优化就算是 30ms 也是可以接受的。继续排查/environments/:name/resourceapipostman调用花费一分多钟第二次调用花费 50 多秒。这还得了1 个环境花费一分钟三十个环境要 30 分钟。叔可以忍婶都不能忍。确定了问题 api我们继续深入看逻辑。看到日志打印里有很多 error:tenant xxx can not access to tenant xxxx为什么租户 xxx 不能访问 xxxx 呢配置信息都是客户配好的不太会配置这么多错的啊。虽然心里有点疑问但是还是相信代码没有问题。接着看逻辑突然好像发现了一个并发竞态问题。问题是这样为了加速 api 的响应后端创建 20 个协程并发访问代理系统接口获取信息并处理。并发协程会创建自己的代理系统客户端然后拿着这个客户端请求代理系统 api。并发协程的代理系统客户端有一部分配置是公用的一部分是协程独有的。为了降低客户端的创建减少内存。我们用sync.Once包创建通用客户端然后协程定制通用客户端。示例如下type proxyClient struct { proxyAdress string // 通用配置 tenant string // 协程独有配置 } func (c *proxyClient) Request() { // 请求代理系统获取响应 } // 并发调用 New 创建代理系统客户端 func New(adress string, tenant string) *proxyClient { var c new(proxyClient) once.Do( c proxyClient{proxyAdress: adress} } ) c.tenant tenant // 1 return c }这里问题在于 1 这里并发访问客户端的共享资源tenant会导致竞态。既然找到了问题解决就不难了我们把客户端拆成 base 客户端和协程客户端。base 客户端是共用的而协程客户端是协程专用的。接着在请求发现日志tenant xxx can not access to tenant xxxx报错消失了。没想到性能优化的第一站是修复 bug 现在业务逻辑看起来没问题了。继续优化性能。我们发现client-go在缓存资源时大概需要 20ms 左右整个 api 用时 1m 左右。那么第一个优化思路来了可以把缓存逻辑提前吗我们在程序启动时预热缓存这样调用 api 直接从缓存中拿数据就行。说干就干优化之后在此请求大概在 40s 左右单个 api 表现不错别忘了我们还有 30个环境呢。在问了调用方他们的超时时间设置了 3分钟我们的 40s 1 个环境处理速度极大概率还是超时还得优化。我们发现后端有两个系统代理 api 请求是无关联的但是在协程内是串形执行的我们统计发现两个 api 调用时间差不多都是 50ms 左右。如果串形执行这两个 api 需要 100ms 左右。如果并行执行会不会到 50ms 左右这样多个并发请求时间省下来应该也挺可观的。能不能优化这里让两个 api 并发执行呢我们开始并发这两个 api红色框示意图如下接着调用 api结果让我们大跌眼镜。不仅时间没减少反而变多了请求花了 1m30s 左右脑子里蹦出来一句国骂ri 了 dog 了...好吧既然结果是这样只能接受了。平复好心情我们在分析整体的接口是 IO 阻塞型的大部分时间协程都是在等待代理系统的响应。代理系统是我们不想碰的我们只想优化我们的平台系统接口。那能不能继续加大并发多个协程并发等 IO 请求。虽然单个请求时间没变但是并发处理的请求多啊。有了这个想法我们把并发请求从 20 提升到 100。继续调用 api“奇迹出现了” 我们的接口花了 20s 左右意味着我们砍掉了一半的时间太棒了从全局来看服务治理系统每个环境调用一次 api每次调用需要预热缓存然后创建 100 个并发协程。这样也会有问题每次调用都需要预热缓存实际只需要预热一次就够了。每次调用创建 100 个协程go 运行时需要管理这 100 个协程的创建/销毁/复用/调度增加了运行时的负担。我们可以为系统提供一个新的 api 以解决上述问题新的 api 对所有环境只需要预热一次缓存并且所有环境只创建 100 个协程处理不用每次调用创建 100 个协程减轻了运行时压力。我们用这个新 api 做测试调用花费了 2m30s 左右满足客户端调用 3m 超时的限制后来才知道客户端其实设置的是 5m 的超时。太棒了现在问题解决了但是还有一个疑问没解决为什么并发那两个协程请求时间不降反升呢笔者想可能是理论上并发实际是 go 运行时的压力大了本来串形调用虽然是阻塞 IO 但是阻塞时间并不长只有 50ms 左右。现在不仅要阻塞还要调度协程算下来时间反而超过了串形执行时间。一句话就是 go 运行时压力增加了由于内网开发环境限制笔者没有用 pprof 证实是不是运行时调度花了时间。后来在 《Concurrency in Go 中文笔记》一书中看到作者说的几种情况非常贴近我们的实践扇出Fan-out是一个术语用于描述启动多个goroutines以处理来自管道的输入的过程并且扇入fan-in是描述将多个结果组合到一个通道中的过程的术语。 那么在什么情况下适用于这种模式呢如果出现以下两种情况你就可以考虑这么干了 - 不依赖模块之前的计算结果。 - 运行需要很长时间。我们这里要并发扇出那两个协程但是运行时间很短不满足这里的运行需要很长时间情况导致请求时间不降反增。对于 IO 阻塞型可增大并发量来降低时间