TT Lab
开始
学习 学习路径 课程

慢的不是一个请求,而是全部

亲手做一把测量事件循环停顿的尺子

在 TT Lab 中继续学习

目标

亲手做出一把测量事件循环停顿了多久的尺子,并用数字确认一次同步任务如何让该进程的所有请求一起变慢。

为什么重要

收到“API 很慢”的反馈时,通常会先看这个 API 的代码。但在 Node 里,元凶常常在别处。执行 JavaScript 的道只有一条,所以只要某一个请求把这条道占住 300ms,在这段时间里到达的所有请求都会一起晚 300ms。变慢的那个 API 的代码并没有任何问题。

所以本课程的第一个工具不是分析器,而是尺子。只要看按固定间隔设置的定时器晚了多久才响,就能得出在此期间事件循环被别人的事占住的时间。平均值几乎不动,只有长尾在动,这就是这种分布的关键,也是只看平均值的仪表盘抓不住这起事故的原因。

步骤

  1. 在 /root/work/loop/lag.mjs 中编写 pct(samples, p)。
  2. 在同一个文件中编写 sample({durationMs, intervalMs})。
  3. 什么都不让它做,直接测量,写入 /root/work/loop/report.json 的 runs.idle。
  4. 编写 blockFor(ms),在测量过程中阻塞一次,写入 runs.blocked。
  5. 把超过 100ms 的样本个数和比例,以及平均值,补充到 runs.blocked 中。
  6. 用 queue({n, serviceMs, blockAt, blockMs}) 一次处理多个请求,写入 runs.queue。
  7. 用 verdict(run, budgetMs) 做出统计超出预算的样本的规则。

参考

先把百分位规定下来

在 /root/work/loop/lag.mjs 中导出 pct(samples, p)。使用最近秩规则,并且不能对传进来的数组排序而改变它。

排序要在副本上进行。请以 [...samples].sort((a, b) => a - b) 开头。

秩是 Math.ceil(p / 100 * n) - 1,小于 0 或超过 n 时取两端。出现 p99 与最大值相同的小样本是正常的。

定时器晚了多少,就是事件循环停顿了多久

在同一个文件中导出 sample({durationMs, intervalMs})。返回一个包含 {intervalMs, durationMs, samples} 的 Promise,samples 中以 ms 放入扣除间隔之后的延迟时间。

用 setInterval 每隔 intervalMs 醒来一次,用这次与上次醒来时刻之差减去 intervalMs。负数取 0。

过了 durationMs 之后,clearInterval 并返回结果。评分器会拿着这把尺子自己去阻塞事件循环,所以必须测量真实的时刻。

什么事都没有时的基线

什么都不让它做,测量 1 秒多,把 node 和 runs.idle 写入 /root/work/loop/report.json。runs.idle 中包含 intervalMs、durationMs、samples、p50、p99、max。

没有基线,就无法说之后测得的数字是大还是小。

p50、p99、max 要用自己写的 pct 来计算并写入。评分器会从 samples 重新计算并对照,所以手工改动会不及格。

阻塞一次再测量

导出 blockFor(ms)(在那段时间里必须真的占住),并在测量过程中只插入一次 blockFor(300),写入 runs.blocked。同时放入 blockMs。

像 setTimeout(() => blockFor(300), 300) 这样,在开始测量之后只阻塞一次。

await new Promise(r => setTimeout(r, ms)) 不是阻塞——在等待期间事件循环是自由的。这里需要的是占着 CPU 的一种。

为什么平均值什么都不说

把 over100(超过 100ms 的样本数)、over100Ratio(保留到小数点后四位)、mean(平均值)补充到 runs.blocked 中。

数一数阻塞 300ms 一次时,超过 100ms 的样本有几个。

因为这一个样本,某个请求整个停住了,平均值却几乎不变。只看平均值的仪表盘抓不住这起事故的原因,就在这两个数字里。

变慢的不是一个请求

导出 queue({n, serviceMs, blockAt, blockMs})。同时启动 n 个请求,各自等待 serviceMs,只让第 blockAt 个用 blockFor(blockMs) 阻塞,然后返回各请求延迟(ms)的数组。用 n=10、serviceMs=20、blockAt=2、blockMs=300 测量,把 latencies、p50、p99、victims 写入 runs.queue。

victims 是除去被阻塞的那个请求之外,延迟达到 blockMs * 0.8 以上的请求数。

同时启动,指的是先把 Promise 全部创建好,再用 Promise.all 等待。如果在 for 里面 await,就成了一个一个依次处理,那样的话,本实验想展示的东西就消失了。

把测量结果变成判断

导出 verdict(run, budgetMs)。统计 run.samples 中超出预算的样本(相等不算超出),返回 {ok, worst, breaches}。没有样本时 worst 为 0。

如果不规定边界值归到哪一边,每个人会给出不同的答案。这里只有 > budgetMs 才算违规。

有了这个函数,就能不再说“延迟有一点”,而是说“预算 100ms 超出了 3 次,最差是 310ms”。告警的条件就是从这样的句子里来的。