亲手做一把测量事件循环停顿的尺子
目标
亲手做出一把测量事件循环停顿了多久的尺子,并用数字确认一次同步任务如何让该进程的所有请求一起变慢。
为什么重要
收到“API 很慢”的反馈时,通常会先看这个 API 的代码。但在 Node 里,元凶常常在别处。执行 JavaScript 的道只有一条,所以只要某一个请求把这条道占住 300ms,在这段时间里到达的所有请求都会一起晚 300ms。变慢的那个 API 的代码并没有任何问题。
所以本课程的第一个工具不是分析器,而是尺子。只要看按固定间隔设置的定时器晚了多久才响,就能得出在此期间事件循环被别人的事占住的时间。平均值几乎不动,只有长尾在动,这就是这种分布的关键,也是只看平均值的仪表盘抓不住这起事故的原因。
步骤
- 在
/root/work/loop/lag.mjs中编写pct(samples, p)。 - 在同一个文件中编写
sample({durationMs, intervalMs})。 - 什么都不让它做,直接测量,写入
/root/work/loop/report.json的runs.idle。 - 编写
blockFor(ms),在测量过程中阻塞一次,写入runs.blocked。 - 把超过 100ms 的样本个数和比例,以及平均值,补充到
runs.blocked中。 - 用
queue({n, serviceMs, blockAt, blockMs})一次处理多个请求,写入runs.queue。 - 用
verdict(run, budgetMs)做出统计超出预算的样本的规则。
参考
- 百分位按最近秩(nearest-rank)方法计算。按升序排序后,取第
ceil(p/100 * n) - 1个值,超出范围时取两端。评分器会按同一规则重新计算并对照。 - 延迟是扣除所测间隔后的余数。如果直接放进经过的时间,即使在安静状态下,中位数也会等于间隔。
- 在
report.json中,用node字段写入这台 Pod 的版本(process.version)。 - 常见错误:以为阻塞期间样本也会不断积累。事件循环停住时,定时器也会停住,所以那一段会以一个很大的样本值的形式出现。
先把百分位规定下来
在 /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”。告警的条件就是从这样的句子里来的。