首页 / 文章

超越P95:衡量用户实际体验的延迟情况

一个性能良好的P95指标产品可以与性能较慢的产品并列。队列时间和数据分发机制如何从监控面板中隐藏,以及逐步骤的可观测性如何避免延迟问题的相互指责。

发布者:REACTAPP.TOP阅读约 17 分钟3974 词

来源: Your P95 Latency Is Lying to You.

有客户反映产品运行速度很慢。有人打开延迟监控面板后发现所有指标均为正常:P95值在目标范围内,错误率保持稳定,CPU负载较低,数据库连接数远低于预设上限。人们很容易得出结论:该投诉肯定是错误的,而这种倾向于相信图表而非等待中的用户的直觉,才是真正的问题。

以下内容将解释为何良好的P95值与缓慢的产品表现可以同时存在。您将会了解延迟是如何在服务级指标(如队列长度、重试次数、分支数量、阻塞决策等)中隐藏起来的,为何数据尾部的影响比其规模所显示的更为重要,以及如何通过为请求的每一步指定计时方式,将模糊的投诉转化为可验证的证据。读完之后,您应该能够更有针对性地质疑“P95值正常”这一说法,而不会简单地接受或忽视它。

P95能告诉你的与不能告诉你的信息

在批评这一指标之前,先弄清楚其含义是必要的。P95指的是95%的请求在该数值或更低的时间内完成。这确实是一个非常有用的数据,相比平均延迟而言有了显著提升。平均值往往会让问题被忽视:如果99个请求在50毫秒内完成,而有一个请求需要5秒钟,那么平均值大约为100毫秒,这样的数字没人会去关注。而百分位数至少能将人们的注意力引向数据分布的边缘情况。

因此,百分位数本身并非问题所在。问题在于团队在看到它之后的解读方式。“P95值没问题”往往被当作一种无过错的证明,从而终止讨论。这种做法悄悄忽视了一个基本事实:使用你产品的人并不会接触到百分位数,他们经历的是等待。他们按下按钮后不确定操作是否已被记录,看到看似已加载的页面却得不到响应,看着加载指示器,而其背后的多个服务其实都报出了完全正常的数值。

一个API的P95值可能非常优秀,但基于它的产品却显得反应迟缓。同时理解这两个事实是后续所有分析的起点。

每次请求都会运行三个时钟

大多数关于延迟的讨论都假设存在一个时钟,但实际上至少有三个时钟,它们衡量的是不同的指标。

  • 服务器时钟。请求到达你的服务,响应发出后计时器停止。大多数后端控制台显示的都是这个时间。
  • 依赖项时钟。在请求处理过程中,时间会用于身份验证、等待数据库连接、内部HTTP调用、缓存未命中处理、重试操作、队列处理、特性标志查询、第三方API调用,以及所有“通常很快”的组件处理。
  • 用户感知时钟。用户点击后等待,直到屏幕可以正常使用。这是用户唯一在意的间隔时间。

各团队往往先优化首次响应时间,然后便宣称产品速度很快。下图将三者并列展示:服务端的响应时间为180毫秒,而依赖链中却存在600毫秒的重试时间,此时操作人员仍在询问页面为何迟迟无法加载。

SERVER CLOCK

request in  ------------------------------> response out
              "API P95: 180ms"

DEPENDENCY CLOCK
request in  ---> auth ---> db ---> service A ---> retry ---> service B ---> response out
                 40ms    70ms      120ms        600ms       90ms

HUMAN CLOCK
click  ----------------------------------------------------------------> usable screen
       "Why is this still loading?"

服务器端的计时数据本身并无错误,只是不够完整。仅凭这些数据来判断产品速度,就好比测量某人的体温后就宣称其所有器官都健康:有时这个数值确实能反映关键问题,但有时也会遗漏真正的故障所在。

端点如同门,用户旅程则像一座建筑

一个有用的思维模型是:后端端点就如同单扇门,而用户旅程则相当于在整座建筑中穿行。控制面板大多关注的是这些“门”——请求进入,响应传出,图表随之更新,大家也就放心了。

然而,用户并非为了欣赏门而使用产品。他们希望达成某个目标:打开主屏幕、发送付款、下订单、下载对账单,或是完成某项实际任务,而不希望感觉产品正在拖延他们。这些任务很少仅对应一次后台调用。在典型的单页应用中,打开控制面板的过程更像是下图所示的那样——在出现任何有用内容之前,应用会先请求用户资料、权限、账户信息、优惠内容、通知以及使用限制等数据。

User Clicks "Open Dashboard"
        |
        v
+----------------------+
| Frontend Shell       |
+----------------------+
        |
        +------> GET /profile
        |
        +------> GET /permissions
        |
        +------> GET /accounts
        |
        +------> GET /offers
        |
        +------> GET /notifications
        |
        +------> GET /limits
        |
        v
Screen Finally Becomes Useful

那六个端点中的每一个单独来看似乎都正常,但整体流程却存在问题。某个服务即便达到了服务等级协议的要求,仍可能让整个产品运行缓慢;一个团队即使完成了自己的目标,也可能导致用户体验不佳。当请求依次发出时,它们的延迟会叠加;而当它们并行执行时,最慢的那个请求就会决定整体速度,此外浏览器的单源连接限制或请求之间的依赖关系也可能使本应同时进行的操作变为串行处理。

正因如此,“我们的API很快”只是调查的起点,而非答案。后续需要探讨的问题虽然棘手却必不可少:与什么相比快?对谁来说快?又从哪个时间点来衡量速度?是从服务内部来看,还是从用户点击的那一刻开始计算?

剩余的5%并非四舍五入误差

“P95”这个名称让人误以为问题并不严重。5%的数值在大规模运营中似乎属于可接受的成本,是那些冷静的决策层能够承受的范围。

但实际上,当处理量真正增大时情况就不同了。一个每天要处理一百万次请求且P95阈值为该值的系统,意味着每天会有五万次请求超出这个阈值。每一次这样的请求都可能让用户选择再次点击、刷新页面,或者放弃操作并将责任归咎于产品。

即便如此,这种描述仍然过于简化了,因为慢请求并不会在所有用户之间均匀分布:

  • 某个不幸的用户可能会反复遇到慢响应的情况。
  • 某一家企业用户可能始终被分配到负载过重的服务节点上。
  • 某个地区的服务性能可能会下降,而整体全球表现仍保持良好。
  • 某些高价值的工作流程可能会变慢,但由于大量低成本的频繁请求,百分比指标仍会显得良好。

控制面板显示的是数据分布情况,而受影响的用户则体验到个人层面的问题。从系统角度看,只有一小部分请求速度较慢;但从用户视角来看,该产品显然浪费了他们的时间。

正因如此,用户投诉应当被视作数据。人们不会用百分比来描述问题,而是通过具体事例表达:“我每次点击提交后它都会卡住。”“页面能显示出来,但数据迟迟不出现。”“我得重新加载两次。”“它能正常运行,但速度很慢。”这些描述都十分具体,并非模糊不清。每一条都是用通俗语言写成的性能报告,而常见的错误在于将其转化为错误的指标,从而得出“没有问题”的结论。

两种服务,同一个P95指标,却存在截然不同的风险

单一的百分位数无法反映其周围数据分布的形态。以两个P95值相近的服务为例:第一个服务表现平平——大多数请求的处理速度处于正常水平,较慢的请求也只是稍慢一点,并不存在性能骤降的情况;而第二个服务大多数时候响应迅速,但偶尔也会出现数秒级的延迟。

SERVICE A

Most Requests        120ms - 260ms
Slow Requests        300ms - 380ms
Disaster Requests    Rare

SERVICE B

Most Requests        40ms - 80ms
Slow Requests        300ms
Disaster Requests    8s - 12s

这两种服务报告的P95值可能大致相同。只有服务B存在隐藏的问题,其性能问题完全位于第95百分位之外,常规的百分位数统计无法察觉。只有通过查看P99、P99.9以及最高延迟值,或者更直观地使用延迟直方图或热力图,才能发现这种差异。

百分位数也掩盖了问题集中的位置。它无法告诉你那些缓慢的请求是来自某个租户、某个端点、某个数据库分区、某个下游依赖、某个区域、某条重试路径、某个已饱和的线程池,还是仅在重要演示时才会被使用的功能。只有按照这些维度来分析延迟,才能将一个数字转化为有意义的诊断结果。

实际原则是:百分位数仪表板应当开启对话而非终结对话。问题不应该是“我们的P95值是多少?”,而应是“这个数值背后隐藏着怎样的问题?”

最严重的延迟存在于团队之间

有些延迟问题因为原因显而易见,所以解决起来几乎毫不费力。查询速度慢的话就加上索引;缓存缺失则谨慎地补充;数据量过大的话就进行压缩;效率低下的循环则重新编写。这些道理大家都明白。

而那些棘手的问题则存在于团队之间的缝隙之中。后端称API运行正常,前端则表示正在等待后端响应;平台方面说网关没有问题,数据库团队也认为数据库状态良好,但产品团队却反馈用户仍然不满意。各方说的都是实话,而这恰恰让情况变得更加令人沮丧。

在这些情况下,系统并非在某个明显的环节出现延迟,而是在那些衔接处出现问题:

  • 服务之间的数据传递环节;
  • 导致调用时间翻倍的重试机制;
  • 本不该发出的第二次请求。
  • 在昂贵的操作已经开始之后才进行的权限检查中;
  • 由于接口响应时间仍低于预警阈值而未被察觉的缓存未命中情况中;
  • 在各方都称之为“外部依赖”的第三方服务中,这样说法比承认其属于用户体验的一部分要显得更自然;
  • 在队列中,甚至在代码执行路径开始之前;
  • 在业务逻辑处理完成后进行的序列化过程中;
  • 在前端,因为API返回的数据是为数据库设计的而非为屏幕显示设计的,客户端必须重新组装这些数据。
  • P95值并非因虚假而误导任何人,而是因其孤立存在而造成误导。这一单独的指标让每个团队都能宣称自己没有问题,而用户却只能持续等待。

    在队列中消耗的时间依然属于延迟

    还有一个更为微妙的测量问题:应用层的计时器往往启动得过晚。当处理程序或控制器开始执行时,请求可能已经在负载均衡器处等待、等待建立连接、等待工作进程或线程响应,或者因为系统虽处于运行状态但实际上已饱和而排队等待其他任务。在 Node.js 中,同样的现象表现为事件循环延迟:繁忙的事件循环意味着包括请求处理程序在内的回调函数会在套接字收到数据之后才开始执行。

    随后计时器开始计时,代码快速运行,指标系统也会将此记录为快速请求。用户并不关心你的计时器起始点在哪里,计时器启动前的等待时间同样会被计入。通过重试进行的等待、等待浏览器发出下一次请求,以及等待被禁用的按钮恢复可点击状态,这些都属于需要计入的等待时间。

    这就是测量边界的陷阱。团队只衡量其服务能够观测到的内容,进而默认将视野之外的所有事物视为不存在。那些看不见的延迟并非凭空想象;它们只是无人负责处理的延迟,而这种无主延迟往往最为昂贵,因为没人有责任去降低它。负载均衡器请求时间、连接池等待指标、事件循环延迟以及浏览器端的计时,都是让这些时间间隔重新可见的常用方法。

    为请求的每个步骤命名

    通常能解决这些争议的并非另一个控制面板、新的供应商或更高的可观测性预算,而是通过为请求的每个环节赋予明确的名称,让那些缓慢的部分无法再被隐藏。

    下面的代码片段是 Java 中的 Spring Boot 处理程序,但这种模式同样适用于 Express、Fastify 或 NestJS。当调用 GET /home 时,会启动一个 LatencyTrace 对象,并将其与传入的 X-Request-Id 请求头关联起来。从加载用户、获取权限、调用账户及相关服务,到映射响应结果,每一个重要步骤都会被包裹在带有描述性标签的 trace.time(...) 中。在 finally 块中,只有当整个请求的处理时间超过 700 毫秒时,trace.logIfTotalCrosses(Duration.ofMillis(700)) 才会输出详细的时间分解信息,这样正常流量时日志不会显示内容,而慢速流量时则会显示详细信息。具体的实现方式并非关键,重要的是它所培养的这种习惯。

    @GetMapping("/home")
    public HomeResponse home(@RequestHeader("X-Request-Id") String requestId) {
        LatencyTrace trace = LatencyTrace.start("GET /home", requestId);
    try {
            User user = trace.time("db.user", () ->
                userRepository.findActiveUser()
            );
            Permissions permissions = trace.time("auth.permissions", () ->
                permissionClient.fetch(user.id())
            );
            List<Account> accounts = trace.time("accounts.http", () ->
                accountClient.fetchAccounts(user.id())
            );
            List<Offer> offers = trace.time("offers.http", () ->
                offerClient.fetchEligibleOffers(user.id())
            );
            return trace.time("response.mapping", () ->
                HomeResponse.from(user, permissions, accounts, offers)
            );
        } finally {
            trace.logIfTotalCrosses(Duration.ofMillis(700));
        }
    }
    

    有两点值得注意。首先,finally 确保即使某个步骤抛出异常也会记录故障信息,而这正是最需要它的时刻。其次,这里的各个步骤是依次执行的,因此总耗时大致等于各步骤耗时的总和;如果你对账户查询和优惠查询这类独立操作进行并行处理,仍需为每个操作计时,这样才能知道是哪个操作拖慢了整体进度。

    对比一下普通延迟日志与这种记录方式的区别。普通版本只有一行,显示总耗时:

    GET /home took 1180ms
    

    虽然数据准确,但几乎毫无用处,因为它没有说明1,180毫秒的耗时去向了哪里。而按步骤划分的版本则能立即给出答案:

    GET /home took 1180ms
    
    db.user              42ms
    auth.permissions     81ms
    accounts.http        156ms
    offers.http          823ms
    response.mapping     28ms
    

    当这样的输出呈现在团队面前时,讨论的基调就会改变。大家无需再猜测或为自己的服务辩护,“在我这边的环境可以运行”这类说法也不再有实际意义。在所有请求中,涉及账户相关的需求约占70%,因此讨论的重点从观点转向了证据。这正是优秀监控工具的真正价值:不是更多的图表,而是更少的争论。

    在 Node.js 代码库中,你可以在每个待处理的调用周围使用 performance.now() 结合结构化的日志记录工具来达到相同的效果;或者更系统地使用 OpenTelemetry spans,它能在不同服务之间保持相同的步骤名称,这样即使慢速处理步骤位于其他服务中,也能清晰地查看性能分解情况。关于如何结合日志、指标和追踪的更详细内容,请参阅 构建可观测的 Node.js API。

    为何干净的基准测试能理清混乱的系统

    通常在性能分析报告中,这里会呈现一个基准测试表格。但这次我们不这么做是有原因的:只有真实的基准测试才有价值,而为这个问题编造的基准测试只不过是披着正式外衣的又一个误导性数字罢了。

    问题不在于工程师无法进行受控测试,而在于这类测试往往恰恰会排除那些会给用户带来困扰的复杂因素:

    • 基准测试只针对一个接口点;而真实用户的操作会触发多个接口点。
    • 基准测试在已预热的内存缓存环境下运行;而用户是在系统部署、数据清除以及代码路径处于冷状态之后才使用系统。
    • 基准测试衡量的是成功响应;而用户则要面对部分失败、重试、超时以及无法使用的界面。
    • 基准测试假设依赖项始终稳定;但生产环境中的依赖项往往在某个时刻突然变得不稳定。
    • 基准测试只测试单个服务;而用户实际使用的系统是由多个服务、浏览器、网络、队列、API协议,以及可能已不再协作的团队所共同构成的复杂整体。

    基准测试衡量的是受控路径的执行速度,而生产环境测试则反映整个系统的稳定性。这是两种不同的衡量标准,彼此无法替代。

    快速的代码并不能保证快速的产品

    许多后端工程师认为本地优化能带来极大的满足感。将某个函数的执行时间从20毫秒缩短到8毫秒,消除一次内存分配,重构循环结构,优化查询语句,这些改动看起来都很完美。然而产品仍然需要依赖那个每次处理第十个请求时就要耗时900毫秒的远程服务。界面仍然需要很长时间才能正常使用,按钮依然会卡住,客户反馈的问题也依然是“速度太慢”。

    这就好比在地面开始塌陷时还在擦拭黄铜。本地处理方式很有吸引力,因为它完全由你掌控。而端到端的延迟问题则更为复杂,因为要改善它需要其他团队的协作、旧的合同条款、产品选择以及没人愿意重新审视的架构。然而,用户并不会因为本地处理的优雅性而给予奖励,他们看重的是任务是否完成。函数运行速度快固然不错,接口响应快更好,但真正重要的是整个流程的快速完成。在优化任何单个组件之前,先确认它是否处于某个重要流程的关键路径上会很有帮助。

    当最慢的环节其实是决策时

    并非所有的延迟问题都是技术层面的问题。有些则是已经固化在架构中的决策:

    • 前端调用了六个API,而其实一个聚合响应就足够了。
  • 每个界面都会再次向权限服务请求许可,而非复用之前的结果。
  • 重试操作在面向用户的请求路径内同步执行。
  • 广告组件会阻碍整个主界面的显示。
  • 在允许用户继续操作之前,必须先触发分析事件。
  • API返回类似数据库结构的对象,由前端负责解析其含义。
  • 某下游服务被标记为“关键”,是因为没人愿意承担其功能可能延迟加载的风险。
  • 系统中最慢的部分往往源于从未经过评估的产品选择、所有权缺失,或是很久以前召开的会议所带来的永久性延迟。对于这些问题,有许多众所周知的解决方案,比如使用后端服务来整合前端请求、对非必要的组件采用延迟加载方式、将重试和分析操作移出关键路径,以及根据屏幕尺寸设计响应格式。不过,每一种解决方案都需要有人拥有修改决策的权限。

    正因如此,一旦简单的修复措施都已完成,性能优化工作就会变得充满政治因素。剩余的延迟问题便转化成了责任归属的问题:谁有资格宣布某个依赖项是必须的?谁对整个流程负责?谁有权取消某次请求?当所有服务状态正常但用户体验依然不佳时,谁该承担责任?P95无法回答这些问题,也从来就不是用来回答的。

    绿色仪表板会降低团队的好奇心

    一个不太乐观的现象是:绿色仪表板可能会让团队停止提问。虽然并非总是如此,但频率之高已足以产生影响。当图表变为红色时,所有人都会去调查原因;而当图表显示为绿色但用户仍提出投诉时,讨论往往转向为该投诉找借口。也许是他们的网络不佳,也许是浏览器速度太慢,又或许只是偶发问题,或是客服夸大了情况,又或者是前端出现了异常,又或是客户过早点击了按钮,又或者该投诉属于情感层面而非技术问题。

    团队就是这样失去信任的——并非因为缺乏数据,而是因为将数据当作拒绝倾听的理由。用户拥有的是你的产品,而非仪表板;如果他们觉得产品运行缓慢,那这就是证据。虽然这并非决定性的完美证据,但终究是证据。

    健康的工程文化会将图表视为一种证据,用户视为另一种证据,而生产环境则是需要调查的现场。那种将图表当作真理、把用户视为噪音的文化,只会推出速度缓慢的产品,却还自以为它们很快。

    从用户想要完成的任务出发

    关于性能的讨论通常围绕各个组件展开:是哪个服务、哪个接口、哪条查询速度慢。这些都是好的问题,但很少是最适合首先提出的问题。更好的切入点应该是:用户试图完成什么任务?一旦回答了这个问题,正确的衡量标准自然就会随之出现:

    • 对于打开仪表板,应测量从开始操作到仪表板真正可用所需的时间。
    • 对于提交表单,应测量从提交到看到确认信息所需的时间。
    • 对于下载报告,应测量从点击操作到文件可下载所需的时间。
  • 对于付款而言,应从开始状态计算到最终结算状态为止。
  • 这听起来很显然,但许多系统并未以这种方式进行监控。人们通常沿着团队边界来衡量性能,因为这样便于实施监控并生成整洁的仪表板。而整个使用流程则更难衡量,得出的结果也不那么理想,但正是这些不理想的结果才为产品改进提供了方向。在客户端,诸如 Performance 和 User Timing 接口之类的浏览器 API 允许我们明确标记“用户点击”和“屏幕可用”等状态,将这些标记与请求 ID 相关联,就能将人类的时间概念与服务器日志联系起来。如果性能问题出在渲染而非网络层面,关于在 API 响应时间之外诊断 React 性能问题的文章会介绍相关解决方案。

    以审慎的态度看待各项指标

    以上内容并非主张从仪表板中移除P95指标。它理应出现在那里,也确实很重要,只是不应单独被信任。一个合理的百分位数应当引发你的好奇心,引导你去关注那些处于阈值之外的请求:

    • 究竟是谁属于那5%性能较慢的请求,而且是否每天都是同一批人?
    • 这些请求对应哪些用户操作流程?
    • 是否有某个依赖项导致了大部分性能问题?
    • 在整体情况看似正常时,是否有某个地区出现性能下降?
    • 是否有某个特定租户始终面临最糟糕的性能状况?
    • 重试机制是否在悄悄将故障转化为长时间等待?
    • 在应用程序计时器开始工作之前,请求需要在队列中等待多久?
  • 在后台已经响应之后,是否仍有某些前端状态导致界面无法正常显示?
  • 某个服务在理论上看似正常,但实际上却在损害产品体验?
  • 对待指标的正确态度既不是盲目崇拜,也不是全盘否定,而应是审慎的怀疑。指标只是见证者,而见证者可能会遗漏一些重要信息。

    关键要点

    最危险的并非明显错误的指标——那种很容易被发现。真正危险的是那些过于自信地宣称某个小事实为真的指标,以至于大家不再去寻找更重要的真相。下次再听到有人说P95指标没问题时,不要与数字本身争论,而应询问它排除了哪些因素。它是否包含了排队时间、重试次数、前端等待时间、第三方接口调用时间、用户因未见变化而再次点击的时间,以及客户最终能够使用界面完成任务的那一刻?

    • P95值虽高于平均值,但无法显示数据分布形态、尾部严重程度以及受影响的具体对象;应结合更高分位数、直方图以及按租户或区域划分的统计数据进行分析。
    • 需区分服务器时钟、依赖项相关时钟与人工设置的时钟,并确保每一种时钟都有专人负责管理。
    • 用户操作流程通常会涉及多次调用,应全面衡量整个流程而非仅关注最终节点。
    • 应用程序计时器往往无法捕捉到排队等待、连接建立延迟以及事件循环延迟等情况,需对这些环节进行单独测量。
    • 对请求的每个步骤进行计时,并在超过阈值时记录详细数据,这样就能用证据而非指责来解决问题。
    • 部分延迟源于产品设计及责任划分方面的决策,要解决这些问题就需要有权限调整这些决策的人。
    • 应将用户投诉视为性能报告,而仪表板则只是众多参考依据之一。