Feature 服务作为特征服务,产出特征数据供上游业务使用。服务压力:高峰期 API 模块 10wQPS,计算模块 20wQPS。服务本地缓存机制:
Feature 服务模块图
服务 API 侧存在较严重的 P99 耗时毛刺问题(固定出现在每分钟第 0-10s),导致上游服务的访问错误率达到 1‰ 以上,影响到业务指标;目标:解决耗时毛刺问题,将 P99 耗时整体优化至 15ms 以下;
API 模块返回上游 P99 耗时图
偶然的一次上线变动中,发现对 Feature 服务来说 CPU 的使用率的高低会较大程度上影响到服务耗时,因此从提高服务 CPU Idle 角度入手,对服务耗时毛刺问题展开优化。
通过对 Pprof profile 图的观察发现 JSON 反序列化操作占用了较大比例(50% 以上),因此通过减少反序列化操作、更换 JSON 序列化库(json-iterator)两种方式进行了优化。
收益:CPU idle 提升 5%,P99 耗时毛刺从 30ms 降低至 20 ms 以下。
优化后的耗时曲线(红色与绿色线)
为什么 CPU Idle 提升耗时会下降
json-iterator 库为什么快标准库 json 库使用 reflect.Value 进行取值与赋值,但 reflect.Value 不是一个可复用的反射对象,每次都需要按照变量生成 reflect.Value 结构体,因此性能很差。json-iterator 实现原理是用 reflect.Type 得出的类型信息通过「对象指针地址+字段偏移」的方式直接进行取值与赋值,而不依赖于 reflect.Value,reflect.Type 是一个可复用的对象,同一类型的 reflect.Type 是相等的,因此可按照类型对 reflect.Type 进行 cache 复用。总的来说其作用是减少内存分配和反射调用次数,进而减少了内存分配带来的系统调用、锁和 GC 等代价,以及使用反射带来的开销。
详情可见:https://cloud.tencent.com/developer/article/1064753
API 模块访问计算模块 P99 与 P95 耗时曲线
计算模块返回 API P99 耗时曲线(未聚合)
计算模块返回 API P99 耗时曲线(均值聚合)
对冲请求:把对下游的一次请求拆成两个,先发第一个,n毫秒超时后,发出第二个,两个请求哪个先返回用哪个;Hedged requests. A simple way to curb latency variability is to issue the same request to multiple replicas and use the results from whichever replica responds first. We term such requests “hedged requests” because a client first sends one request to the replica be- lieved to be the most appropriate, but then falls back on sending a secondary request after some brief delay. The cli- ent cancels remaining outstanding re- quests once the first result is received. Although naive implementations of this technique typically add unaccept- able additional load, many variations exist that give most of the latency-re- duction effects while increasing load only modestly. One such approach is to defer send- ing a secondary request until the first request has been outstanding for more than the 95th-percentile expected la- tency for this class of requests. This approach limits the additional load to approximately 5% while substantially shortening the latency tail. The tech- nique works because the source of la- tency is often not inherent in the par- ticular request but rather due to other forms of interference. 摘自:论文《The Tail at Scale》
package backuprequest
import (
"sync/atomic"
"time"
"golang.org/x/net/context"
)
var inflight int64
// call represents an active RPC.
type call struct {
Name string
Reply interface{} // The reply from the function (*struct).
Error error // After completion, the error status.
Done chan *call // Strobes when call is complete.
}
func (call *call) done() {
select {
case call.Done <- call:
default:
logger.Debug("rpc: discarding Call reply due to insufficient Done chan capacity")
}
}
func BackupRequest(backupTimeout time.Duration, fn func() (interface{}, error)) (interface{}, error) {
ctx, cancelFn := context.WithCancel(context.Background())
defer cancelFn()
callCh := make(chan *call, 2)
call1 := &call{Done: callCh, Name: "first"}
call2 := &call{Done: callCh, Name: "second"}
go func(c *call) {
defer helpers.PanicRecover()
c.Reply, c.Error = fn()
c.done()
}(call1)
t := time.NewTimer(backupTimeout)
select {
case <-ctx.Done(): // cancel by context
return nil, ctx.Err()
case c := <-callCh:
t.Stop()
return c.Reply, c.Error
case <-t.C:
go func(c *call) {
defer helpers.PanicRecover()
defer atomic.AddInt64(&inflight, -1)
if atomic.AddInt64(&inflight, 1) > BackupLimit {
metric.Counter("backup", map[string]string{"mark": "limited"})
return
}
metric.Counter("backup", map[string]string{"mark": "trigger"})
c.Reply, c.Error = fn()
c.done()
}(call2)
}
select {
case <-ctx.Done(): // cancel by context
return nil, ctx.Err()
case c := <-callCh:
metric.Counter("backup_back", map[string]string{"call": c.Name})
return c.Reply, c.Error
}
}
收益:P99 耗时整体从 20-60ms 降低至 6ms,毛刺全部干掉;(backupTimeout=5ms)
API 模块返回上游服务耗时统计图
括号中内容为个人解读为什么存在变异性?(高尾部延迟的响应时间)
减少组件的可变性
关于消除变异源
对冲请求典型场景
名称来源 backup request 好像是 BRPC 落地时候起的名字,论文原文里叫 Hedged requests,简单翻译过来是对冲请求,GRPC 也使用的这个名字。
对冲请求超时时间并非动态调整,而是人为设定,因此极端情况下会有雪崩风险;
摘自《Google SRE》 如果不加限制确实会有雪崩风险,有如下解法
在引入对冲请求机制进行优化后,在耗时方面取得了突破性的进展,但为从根本上解决耗时毛刺,优化服务内部问题,达到标本兼治的目的,着手对服务的耗时毛刺问题进行最后的优化;
第一步:观察现象,初步定位原因对 Feature 服务早高峰毛刺时的 Trace 图进行耗时分析后发现,在毛刺期间程序 GC pause 时间(GC 周期与任务生命周期重叠的总和)长达近 50+ms(见左图),绝大多数 goroutine 在 GC 时进行了长时间的辅助标记(mark assist,见右图中浅绿色部分),GC 问题严重,因此怀疑耗时毛刺问题是由 GC 导致;
第二步:从原因出发,进行针对性分析
关于 GC 辅助标记 mark assist 为了保证在Marking过程中,其它G分配堆内存太快,导致Mark跟不上Allocate的速度,还需要其它G配合做一部分标记的工作,这部分工作叫辅助标记(mutator assists)。在Marking期间,每次G分配内存都会更新它的”负债指数”(gcAssistBytes),分配得越快,gcAssistBytes越大,这个指数乘以全局的”负载汇率”(assistWorkPerByte),就得到这个G需要帮忙Marking的内存大小(这个计算过程叫revise),也就是它在本次分配的mutator assists工作量(gcAssistAlloc)。引用自:https://wudaijun.com/2020/01/go-gc-keypoint-and-monitor/
第三步:按照分析结论,设计优化操作从减少对象分配数角度出发,对 Pprof heap 图进行观察
但无法确定哪一个是真正使分配内存增大的因素,因此着手对这两点进行分开优化;
通过对业界开源的 json 和 cache 库调研后(调研报告:https://segmentfault.com/a/1190000041591284),采用性能较好、低分配的 GJSON 和 0GC 的 BigCache 对原有库进行替换;
计算模块耗时统计图(浅色部分:GJSON,深色部分:BigCache)
API 模块返回上游耗时统计图
在通俗意义上常认为,GO GC 触发时机为堆大小增长为上次 GC 两倍时。但在 GO GC 实际实践中会按照 Pacer 调频算法根据堆增长速度、对象标记速度等因素进行预计算,使堆大小在达到两倍大小前提前发起 GC,最佳情况下会只占用 25% CPU 且在堆大小增长为两倍时,刚好完成 GC。
关于 Pacer 调频算法:https://golang.design/under-the-hood/zh-cn/part2runtime/ch08gc/pacing/
但 Pacer 只能在稳态情况下控制 CPU 占用为 25%,一旦服务内部有瞬态情况,例如定时任务、缓存失效等等,Pacer 基于稳态的预判失效,导致 GC 标记速度小于分配速度,为达到 GC 回收目标(在堆大小到达两倍之前完成 GC),会导致大量 Goroutine 被招募去执行 Mark Assist 操作以协助回收工作,从而阻碍到 Goroutine 正常的工作执行。因此目前 GO GC 的 Marking 阶段对耗时影响时最为严重的。
关于 gc pacer 调频器
引用自:https://go.googlesource.com/proposal/+/a216b56e743c5b6b300b3ef1673ee62684b5b63b/design/44167-gc-pacer-redesign.md
API 模块 P99 耗时从 20-50ms 降低至 6ms,访问错误率从 1‰ 降低到 1‱。
API 返回上游服务耗时统计图