背景

我的项目背景

Feature 服务作为特色服务,产出特色数据供上游业务应用。
服务压力:高峰期 API 模块 10wQPS,计算模块 20wQPS。
服务本地缓存机制:

  • 计算模块有本地缓存,且命中率较高,最高可达 50% 左右;
  • 计算模块本地缓存在每分钟第 0 秒会全副生效,而在此时流量会全副击穿至上游 Codis;
  • Codis 中 Key 名 = 特色名 + 天文格子 Id + 分钟级工夫串;
                                Feature 服务模块图

面对问题

服务 API 侧存在较重大的 P99 耗时毛刺问题(固定呈现在每分钟第 0-10s),导致上游服务的拜访错误率达到 1‰ 以上,影响到业务指标;
指标:解决耗时毛刺问题,将 P99 耗时整体优化至 15ms 以下;

                            API 模块返回上游 P99 耗时图

解决方案

服务 CPU 优化

背景

偶尔的一次上线变动中,发现对 Feature 服务来说 CPU 的使用率的高下会较大水平上影响到服务耗时,因而从进步服务 CPU Idle 角度动手,对服务耗时毛刺问题开展优化。

优化

通过对 Pprof profile 图的察看发现 JSON 反序列化操作占用了较大比例(50% 以上),因而通过缩小反序列化操作、更换 JSON 序列化库(json-iterator)两种形式进行了优化。

成果

收益:CPU idle 晋升 5%,P99 耗时毛刺从 30ms 升高至 20 ms 以下

                            优化后的耗时曲线(红色与绿色线)

对于 CPU 与耗时

为什么 CPU Idle 晋升耗时会降落

  • 反序列化时的开销缩小,使单个申请中的计算工夫失去了缩小;
  • 单个申请的解决工夫缩小,使同时并发解决的申请数失去了缩小,加重了调度切换、协程/线程排队、资源竞争的开销;

对于 json-iterator 库

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/dev…

调用形式优化 – 对冲申请

背景

                     API 模块拜访计算模块 P99 与 P95 耗时曲线
                     计算模块返回 API P99 耗时曲线(未聚合)
                     计算模块返回 API P99 耗时曲线(均值聚合)

优化

  1. 针对 P99 高于 P95 景象,提出对冲申请计划,对毛刺问题进行优化;

    对冲申请:把对上游的一次申请拆成两个,先发第一个,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》

  2. 调研

    • 浏览论文 Google《The Tail at Scale》;
    • 开源实现:BRPC、RPCX;
    • 工业实际:百度默认开启、Grab LBS 服务(上游纯内存型数据库)成果非常明显、谷歌论文中也有相干的实际成果论述;
  3. 落地实现:批改自 RPCX 开源实现
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 模块返回上游服务耗时统计图

《The Tail at Scale》论文节选及解读

括号中内容为集体解读
为什么存在变异性?(高尾部提早的响应工夫)

  • 导致服务的个别局部呈现高尾部提早的响应工夫的变异性(耗时长尾的起因)可能因为许多起因而产生,包含:
  • 共享的资源。机器可能被不同的应用程序共享,抢夺共享资源(如CPU外围、处理器缓存、内存带宽和网络带宽)(在云上环境中这个问题更甚,如不同容器资源争抢、Sidecar 过程影响);在同一个应用程序中,不同的申请可能抢夺资源。
  • 守护程序。后盾守护程序可能均匀只应用无限的资源,但在安顿时可能产生几毫秒的中断。
  • 全局资源共享。在不同机器上运行的应用程序可能会抢夺寰球资源(如网络交换机和共享文件系统(数据库))。
  • 保护流动。后盾流动(如分布式文件系统中的数据重建,BigTable等存储系统中的定期日志压缩(此处指 LSM Compaction 机制,基于 RocksDB 的数据库皆有此问题),以及垃圾收集语言中的定期垃圾收集(本身和上下游都会有 GC 问题 1. Codis proxy 为 GO 语言所写,也会有 GC 问题;2. 此次 Feature 服务耗时毛刺即时因为服务自身 GC 问题,详情见下文)会导致周期性的提早顶峰;以及排队。两头服务器和网络交换机的多层排队放大了这种变动性。

缩小组件的可变性

  • 后台任务能够产生微小的CPU、磁盘或网络负载;例子是面向日志的存储系统的日志压缩和垃圾收集语言的垃圾收集器流动。
  • 通过节流、将重量级的操作分解成较小的操作(例如 GO、Redis rehash 时渐进式搬迁),并在整体负载较低的时候触发这些操作(例如某数据库将 RocksDB Compaction 操作放在凌晨定时执行),通常可能缩小后盾流动对交互式申请提早的影响。

对于打消变异源

  • 打消大规模零碎中所有的提早变异源是不事实的,特地是在共享环境中。
  • 应用一种相似于容错计算的办法(此处指对冲申请),容尾软件技术从不太可预测的局部中造成一个可预测的整体(对上游耗时曲线进行建模,从概率的角度进行优化)。
  • 一个实在的谷歌服务的测量后果,该服务在逻辑上与这个理想化的场景类似;根服务器通过两头服务器将一个申请散发到大量的叶子服务器。该表显示了大扇出对提早散布的影响。在根服务器上测量的单个随机申请实现的第99个百分点的提早是10ms。然而,所有申请实现的第99百分位数提早是140ms,95%的申请实现的第99百分位数提早是70ms,这意味着期待最慢的5%的申请实现的工夫占总的99%百分位数提早的一半。专一于这些慢速异样值的技术能够使整体服务性能大幅升高。
  • 同样,因为打消所有的变异性起源也是不可行的,因而正在为大规模服务开发尾部容忍技术。只管解决特定的提早变异起源的办法是有用的,但最弱小的尾部容错技术能够从新解决提早问题,而不思考根本原因。这些尾部容忍技术容许设计者持续为一般状况进行优化,同时提供对非一般状况的恢复能力。

对冲申请原理

                                对冲申请典型场景
  • 其原理是从概率角度登程,利用上游服务的耗时模型,在这条耗时曲线上任意取两个点,其中一个小于x的概率,这个概率远远大于任意取一个点小于x的概率,所以能够极大水平升高耗时;
  • 但如果多发的申请太多了,比如说1倍,会导致上游压力剧增,耗时曲线模型产生好转,达不到预期的成果,如果管制比如说在5%以内,上游耗时曲线既不会好转,也能够利用他95分位之前的那个平滑曲线,因而对冲申请超时工夫的抉择也是一个须要关注的点;
  • 当超过95分位耗时的时候,再多发一个申请,这时候这整个申请残余的耗时就取决于在这整个线上任取一点,和在95分位之后的那个线上任取一点,耗时是这两点中小的那个,从概率的角度看,这样95分位之后的耗时曲线,会比之前平滑相当多;
  • 这个取舍相当奇妙,只多发5%的申请,就能基本上间接干掉长尾状况;
  • 局限性

    • 申请须要幂等,否则会造成数据不统一;
    • 总得来说对冲申请是从概率的角度打消偶发因素的影响,从而解决长尾问题,因而须要考量耗时是否为业务侧本身固定因素导致,举例如下:

      • 如同一个 mget 接口查 100 个 key 与查 10000 个 key 耗时肯定差别很大,这种状况下对冲申请时无能为力的,因而须要保障同一个接口申请之间品质是类似的状况下,这样上游的耗时因素就不取决于申请内容自身;
      • 如 Feature 服务计算模块拜访 Codis 缓存击穿导致的耗时毛刺问题,在这种状况下对冲申请也无能为力,甚至肯定状况下会好转耗时;
    • 对冲申请超时工夫并非动静调整而是人为设定,因而极其状况下会有雪崩危险,解决方案见一下大节;

名称起源
backup request 如同是 BRPC 落地时候起的名字,论文原文里叫 Hedged requests,简略翻译过去是对冲申请,GRPC 也应用的这个名字。

对于雪崩危险

对冲申请超时工夫并非动静调整,而是人为设定,因而极其状况下会有雪崩危险;

                                摘自《Google SRE》

如果不加限度的确会有雪崩危险,有如下解法

  • BRPC 实际:对冲申请会耗费一次对上游的重试次数;
  • bilibili 实际:

    • 对 retry 申请上游会阻断级联;
    • 自身要做熔断;
    • 在 middleware 层实现窗口统计,限度重试总申请占比,比方 1.1 倍;
  • 服务本身对上游实现熔断机制,上游服务对上游流量无限流机制,保障不被打垮。从两方面登程保障服务的稳定性;
  • Feature 服务实际:对每个对冲申请在收回和返回时减少 atmoic 自增自减操作,如果大于某个值(申请耗时 ✖️ QPS ✖️ 5%),则不收回对冲申请,从管制并发申请数的角度进行流量限度;

语言 GC 优化

背景

在引入对冲申请机制进行优化后,在耗时方面获得了突破性的停顿,但为从根本上解决耗时毛刺,优化服务外部问题,达到标本兼治的目标,着手对服务的耗时毛刺问题进行最初的优化;

优化

第一步:察看景象,初步定位起因
对 Feature 服务早顶峰毛刺时的 Trace 图进行耗时剖析后发现,在毛刺期间程序 GC pause 工夫(GC 周期与工作生命周期重叠的总和)长达近 50+ms(见左图),绝大多数 goroutine 在 GC 时进行了长时间的辅助标记(mark assist,见右图中浅绿色局部),GC 问题重大,因而狐疑耗时毛刺问题是由 GC 导致;

第二步:从起因登程,进行针对性剖析

  • 依据察看计算模块服务均匀每 10 秒产生 2 次 GC,GC 频率较低,但在每分钟前 10s 第一次与第二次的 GC 压力大小(做 mark assist 的 goroutine 数)呈显著差距,因而狐疑是在每分钟前 10s 进行第一次 GC 时的压力过高导致了耗时毛刺。
  • 依据 Golang GC 原理剖析可知,G 被招募去做辅助标记是因为该 G 调配堆内存太快导致,而 计算模块每分钟缓存生效机制会导致大量的上游拜访,从而引入更多的对象调配,两者联合相互印证了为何在每分钟前 10s 的第一次 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/…

第三步:依照剖析论断,设计优化操作
从缩小对象调配数角度登程,对 Pprof heap 图进行察看

  • 在 inuse_objects 指标下 cache 库占用最大;
  • 在 alloc_objects 指标下 json 序列化占用最大;

但无奈确定哪一个是真正使分配内存增大的因素,因而着手对这两点进行离开优化;


通过对业界开源的 json 和 cache 库调研后(调研报告:https://segmentfault.com/a/11…),采纳性能较好、低调配的 GJSON 和 0GC 的 BigCache 对原有库进行替换;

成果

  • 更换 JSON 序列化库 GJSON 库优化无成果;
  • 更换 Cache 库 BigCache 库成果显著,inuse_objects 由 200-300w 降落到 12w,毛刺根本隐没;
                    计算模块耗时统计图(浅色局部:GJSON,深色局部:BigCache)
                            API 模块返回上游耗时统计图

对于 Golang GC

在艰深意义上常认为,GO GC 触发机会为堆大小增长为上次 GC 两倍时。但在 GO GC 理论实际中会依照 Pacer 调频算法依据堆增长速度、对象标记速度等因素进行预计算,使堆大小在达到两倍大小前提前发动 GC,最佳状况下会只占用 25% CPU 且在堆大小增长为两倍时,刚好实现 GC。

对于 Pacer 调频算法:https://golang.design/under-t…

但 Pacer 只能在稳态状况下管制 CPU 占用为 25%,一旦服务外部有瞬态状况,例如定时工作、缓存生效等等,Pacer 基于稳态的预判生效,导致 GC 标记速度小于调配速度,为达到 GC 回收指标(在堆大小达到两倍之前实现 GC),会导致大量 Goroutine 被招募去执行 Mark Assist 操作以帮助回收工作,从而妨碍到 Goroutine 失常的工作执行。因而目前 GO GC 的 Marking 阶段对耗时影响时最为重大的。

对于 gc pacer 调频器

援用自:https://go.googlesource.com/p…

最终成果

API 模块 P99 耗时从 20-50ms 升高至 6ms,拜访错误率从 1‰ 升高到 1‱。

                            API 返回上游服务耗时统计图

总结

  1. 当剖析耗时问题时,察看监控或日志后,可能会发现趋势齐全匹配的两种指标,误以为是因果关系,但却有可能这两者都是内部体现,独特受到第三变量的影响,相干但不是因果
  2. 绝对于百毫秒耗时服务,低延时服务的耗时会较大水平上受到 CPU 使用率的影响,在做性能优化时切勿漠视这点;(线程排队、调度损耗、资源竞争等)
  3. 对于高并发、低延时服务,耗时方面受到上游的影响可能只是一个方面,服务本身开销如序列化、GC 等都可能会较大水平上影响到服务耗时;
  4. 性能优化因从进步可观测性动手,如链路追踪、标准化的 Metric、go pprof 工具等等,打好排查根底,基于多方牢靠数据进行剖析与猜想,最初着手进行优化、验证,防止盲人摸象似的操作,妄图通过碰运气的形式解决问题;
  5. 理解一些简略的建模常识对耗时优化的剖析与猜想阶段会有不错的帮忙;
  6. 实践结合实际问题进行思考;多看文章、参加分享、进行交换,理解更多技术,扩大视线;每一次的探讨和质疑都是进一步深刻思考的机会,以上多项优化都出自与大佬(特地鸣谢 @李心宇@刘琦@龚勋)的探讨后的实际;
  7. 同为性能优化,耗时优化不同于 CPU、内存等资源优化,更加简单,难度较高,在做资源优化时 Go 语言自带了不便易用的 PProf 工具,能够提供很大的帮忙,但耗时优化尤其是长尾问题的优化十分艰巨,因而在进行优化的过程中肯定要稳住心态、急躁察看,行百里者半九十
  8. 关注申请之间共享资源的争用导致的耗时问题,不仅限于上游服务,服务本身的 CPU、内存(引发 GC)等也是共享资源的一部分;

参考

均为内网文章,略。。