P95陷阱

想象这个场景:

一位测试同事走到你的座位旁,说:“接到客户(上游系统)反馈,说我们系统有个Rest接口响应慢。我看了监控上的P95响应时间,都一秒多了,帮忙看看吧。”

又来活了。

你喜欢解决问题带来的成就感,但你更享受这个过程,从现象开始一步步抽丝剥茧,直到找出问题根因并解决它,就像是一个侦探游戏。

侦探需要亲自在犯罪现场搜集证据。你打开监控页面,查看网关统计的P95。你调整采样周期查询多次,发现问题接口的P95都基本保持在1秒多。可见问题描述无误。

网关监控数据最多保留一个月,可能是一个月前改出来的?你简单阅读了代码,并查看对应的修改历史,发现最近几个月都没有动过,于是否定了这个猜想。也许这个接口慢问题存在很久了。

但为什么客户现在才提出来?你联系上游系统,对方告知,最近他们有些接口准备提供给外部客户使用,正在逐个梳理,以确定SLA。在此过程中,对方发现了他们调用我们这个接口慢的问题,联系了我方测试。

问题起源基本澄清了,接下来看代码。经过阅读,你发现这个接口还算简单,它不是批量处理接口,也不涉及复杂计算,主要耗时估计在一次数据库查询和三次串行的外部系统接口调用上面:

入口--->数据库查询--->调外部系统A接口--->调外部系统B接口--->调外部系统C接口--->出口

分析数据库查询,发现SQL语句比较简单,数据表不大且查询条件有索引,返回的数据量很少。暂时先排除。

也许我们是被这些外部系统给拖累了?但我们没有对发出的请求进行统计,该怎么证明?

你想到了APM,公司最近统一在生产环境部署了skywalking,虽然采样率只有10%,但是在这种“统计类”问题上还是能起到作用。

你打开APM性能监控网站,进入跟踪查询页面,输入对应接口地址,选择时间段,并将查询结果按响应时长倒序排列。

一共得到了600页的结果,乘以 5%就是30。跳转到第30页,可以看到,该页及其相邻页中列出采样的接口响应时间,与网关监控给出的P95时间基本差不多。

进入其中一条跟踪日志,发现大部分时间都耗费在等待系统A接口的响应上。换一条,发现时间浪费在等待系统B。再换一条,发现又是系统C拖了后腿......

就这样快速浏览了附近的几十上百条跟踪,并简单记录之后,心里基本有底了:这些慢响应是下游系统造成的。

于是你分别给系统A、B、C的同事发消息,内容都差不多:“你们系统的XX接口有点慢,影响了我们给上游的响应时间,看看啥时能帮忙给解决。” 并附上了从APM平台记录下的一些响应时间,都是数百毫秒级别的。

你在座椅上伸个懒腰。问题已定位,接下来跟踪就行。

不一会,你收到了系统A同事回复:“哥们,我们接口没那么慢的,看看你们自己有没有别的问题吧。”他还附上了权威的网关监控截图,系统A接口的P95竟然只有几十毫秒。很快系统B和系统C同事也回复了类似的消息和截图,他们给出的P95也都是几十毫秒。

怎么回事,难道定位有误?

你重新挑选了一个时间段,再次对其中超过95%的上百个慢响应进行了统计,发现耗时仍然在下游系统。你请系统A、B、C的同事选择该时间段的网关统计数据再截图发给你,但你收到的P95都还只是几十毫秒。

三个系统的P95都是几十毫秒,加起来都不到200,离1000差得远。是哪里错了?

你陷入了沉思。

这好像是个机率问题。假如每个系统95%的响应都很快,而剩余的5%响应都非常慢。调用这三个独立系统时,有时AB快但C慢,而有时AC快但B慢...,正如前面在APM平台上看到的。所以我们错在以“每个系统同时响应很快,同时响应很慢”为前提,得到“本接口P95为三个下游接口P95之和”这个错误结论。实际上,三个独立系统都“快速响应”的概率是95%的三次方,即85.7%,而剩下的14.3%都会慢。

14.3%是5%的将近三倍,可见本系统的慢响应区间被放大很多!

可以写个程序来简单模拟一下

import java.util.List;
import java.util.ArrayList;
import java.util.Collections;

public enum Sys {

        A(10, 100),
        B(20, 200),
        C(40, 400),
        ;

        int p95;
        int p99;
        List<Integer> rsList;

        Sys(int p95, int p99) {
            this.p95 = p95;
            this.p99 = p99;
            rsList = new ArrayList<>(1000);
            //粗略假设95%的响应都一样快,剩下的5%响应都一样慢
            for (int i = 0; i < 1000; i++) {
                if (i < 950) {
                    rsList.add(p95);
                } else {
                    rsList.add(p99);
                }
            }
            System.out.printf("%s p95 = %d, p99 = %d\n", 
                this, rsList.get(950-1), rsList.get(990-1));
        }


    public static void main(String[] args) {
        //打乱
        for (Sys sys : Sys.values()) {
            Collections.shuffle(sys.rsList);
        }

        List<Integer> totalRs = new ArrayList<>(1000);
        for (int i = 0; i < 1000; ++i) {
            int rs = 0;
            for (Sys sys : Sys.values()) {
                rs += sys.rsList.get(i);
            }
            totalRs.add(rs);
        }
        Collections.sort(totalRs);
        System.out.printf("new P95 = %d\n", totalRs.get(950-1));
        System.out.printf("new P85 = %d\n", totalRs.get(850-1));
    }

}

得到

c:\>java Sys
A p95 = 10, p99 = 100
B p95 = 20, p99 = 200
C p95 = 40, p99 = 400
new P95 = 340
new P85 = 70

用图表来简单表示(D表示按顺序简单相加的错误结果,E为响应时间随机打乱后的正确结果),可以看到在这个非常简陋的模型下,P95比三个系统P95之和都高很多,P99则比三者之和低不少。

总之,原因还是在于调下游系统接口。但是要怎么解决呢?

1、要求下游系统提升性能。

如果他们的P99时间能减少到现在的P95时间,那么我们的P95就肯定不会超过三个系统现在的P95之和(99%的三次方约等于97%)。不过这个要求实在不合理,下游系统不可能接受,现在的P95指标已经不错了,继续提升的成本太高,没人会买单。

2、改成并行调用。

这样我们接口的P95就约等于最慢系统的P95,也在几十毫秒内。。。

不对不对!差点又再次掉入思维陷阱。不管是串行并行,5%的响应慢区间还是会扩散为14.3%,差别只是从三者的和变成了三者的最大值而已。而且不存在所谓的最慢系统,除非某个系统的P95都远远大于其他系统的P99。

可以用代码来模拟。把前面的

            for (Sys sys : Sys.values()) {
                rs += sys.rsList.get(i);
            }

改成

            for (Sys sys : Sys.values()) {
                if (rs < sys.rsList.get(i)) {
                    rs = sys.rsList.get(i);
                }
            }

可以得到

c:\>java Sys
A p95 = 10, p99 = 100
B p95 = 20, p99 = 200
C p95 = 40, p99 = 400
new P95 = 200
new P85 = 40

在这个非常简陋的模型下,新的P95响应时间和各系统的P99响应时间在一个数量级,并没有太显著的提高。

除了并行调用增加的复杂度,根本的问题是这些接口在业务上存在逻辑关系,只能先后调用,所以这个方案看起来不可行。

3、减少接口超时时间。

既然下游能保证95%的接口在几十毫秒内返回,那我们就把超时时间缩短成 (P95 + x毫秒),而不是现在的500毫秒甚至1秒。如果响应时长不幸进入了5%的慢区间,则我们代码快速失败,并重新发起一次调用。两次进入慢区间的概率是0.25%,已经非常低了。把三个系统一起算,得到的P95大概是......(此时一个恶魔在你耳边低语:你数学不行,放弃吧)

你接受了恶魔的建议,放弃了数学推导,改用代码来做实验。将最早的

            for (Sys sys : Sys.values()) {
                rs += sys.rsList.get(i);
            }

改为

            for (Sys sys : Sys.values()) {
                if (sys.rsList.get(i) > sys.p95) {
                    rs += sys.p95 * 2; 
                } else {
                    rs += sys.rsList.get(i);
                }
            }

得到

c:\>java Sys
A p95 = 10, p99 = 100
B p95 = 20, p99 = 200
C p95 = 40, p99 = 400
new P95 = 100
new P85 = 70

这100看来比最初的340有相对不错的提升(当然是在这种简陋模型下)。

这个方案还有其他问题,比如下游接口的幂等性,对自己和下游系统及网络负荷的影响,超时时长的选择等等。如果某个时间段正好出现一次网络拥塞,而太短的超时接口可能导致该时间段所有请求都重试,反而又加重了网络的恶化。

三个方案都不够好,你再次陷入了沉思。这个慢响应区间放大实在太恶心,它应该是业界都会遇到的问题,会不会有成熟的解决方案?

放大,放大......你隐约记得在哪里看到过类似说法。对了,在神书DDIA看到过,还有参考文献:

Even if only a small percentage of backend calls are slow, the chance of getting a slow call increases if an end-user request requires multiple backend calls, and so a higher proportion of end-user requests end up being slow (an effect known as tail latency amplification [24]).

[24]Jeffrey Dean and Luiz André Barroso: “The Tail at Scale,” Communications of the ACM, volume 56, number 2, pages 74–80, February 2013. doi: 10.1145/2408776.240879

阅读优秀的论文使眼界开阔,但感觉 The Tail at Scale 里面的有些方案太复杂,不适合在系统应用层实现,而属于底层提供的能力(gRPC库好像就有对冲策略)。

在实现复杂度和收益之间找到一个平衡点,做出合适的方案,会是你接下来的工作。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值