blog: add APISIX throughput regression investigation (en + zh) - #2115
blog: add APISIX throughput regression investigation (en + zh)#2115Yilialinn wants to merge 8 commits into
Conversation
|
|
||
| ## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图 | ||
|
|
||
| 火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。 |
There was a problem hiding this comment.
有资格 reads as if the flame graph were a person. Matches the wording already used in the en table ("Can the flame graph explain this regression?").
| 火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。 | |
| 火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才能解释性能回退。 |
|
|
||
| 两个放大器都在这里: | ||
|
|
||
| 1. 单次过滤成本随已加载插件数量的增长。 |
There was a problem hiding this comment.
随…的增长 leaves the sentence without a predicate.
| 1. 单次过滤成本随已加载插件数量的增长。 | |
| 1. 单次过滤的成本随已加载插件数量增长。 |
| 1. 单次过滤成本随已加载插件数量的增长。 | ||
| 2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。 | ||
|
|
||
| 本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。 |
There was a problem hiding this comment.
The passive 被同一个请求重复了 9 次 is awkward here; the request is the actor.
| 本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。 | |
| 本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,同一个请求重复做了 9 次。 |
|
|
||
| ## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件 | ||
|
|
||
| 第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。 |
There was a problem hiding this comment.
改变调用方之后以解释器还是机器码运行 is missing 是, which makes the clause hard to parse.
| 第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。 | |
| 第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后是以解释器还是以机器码运行。 |
|
|
||
| 开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。 | ||
|
|
||
| 但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界: |
There was a problem hiding this comment.
唯一函数数 is a literal rendering of "unique function count" and doesn't read as Chinese.
| 但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界: | |
| 但这是编译事件数,不是去重后的函数个数、覆盖率或 CPU 时间。`jit.v` 有几个边界: |
|
|
||
| - abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。 | ||
| - 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。 | ||
| - 文本日志很难对功能开启/关闭两组结果做稳定的集合差。 |
There was a problem hiding this comment.
做稳定的集合差 is a literal rendering of "stable set difference".
| - 文本日志很难对功能开启/关闭两组结果做稳定的集合差。 | |
| - 文本日志很难在功能开启和关闭两组结果之间做稳定的集合比较。 |
| end, "trace") | ||
| ``` | ||
|
|
||
| 实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启/关闭自定义组件时:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。 |
There was a problem hiding this comment.
比较开启/关闭自定义组件时: breaks off before the object of 比较.
| 实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启/关闭自定义组件时:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。 | |
| 实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启和关闭自定义组件时的差异:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。 |
|
|
||
| JIT 数据在这里承担两个职责:发现火焰图无法正确归属的成本,验证功能开关确实改变了高频路径的执行状态。但它不能直接告诉我们吞吐损失多大;真正的方向也不是“强迫所有函数编译”,而是让关闭状态下本来不该发生的工作根本不执行。 | ||
|
|
||
| 还有一个容易让结论失真的陷阱:探针必须装在被观测模块加载之前。如果模块在 `require` 时保存了函数引用,之后替换原函数,计数器只能看到少量没被快照走的调用。本次探针注入晚时测到 1 次/请求,前移到 `require("apisix")` 之前后,才确认真实值是 5 次/请求。 |
There was a problem hiding this comment.
本次探针注入晚时 and 之前后 in the same sentence are hard to read.
| 还有一个容易让结论失真的陷阱:探针必须装在被观测模块加载之前。如果模块在 `require` 时保存了函数引用,之后替换原函数,计数器只能看到少量没被快照走的调用。本次探针注入晚时测到 1 次/请求,前移到 `require("apisix")` 之前后,才确认真实值是 5 次/请求。 | |
| 还有一个容易让结论失真的陷阱:探针必须装在被观测模块加载之前。如果模块在 `require` 时保存了函数引用,之后替换原函数,计数器只能看到少量没被快照走的调用。本次探针注入较晚时测到 1 次/请求;把它前移到 `require("apisix")` 之前,才确认真实值是 5 次/请求。 |
|
|
||
| | 步骤 | 核心问题 | 证据 | | ||
| |---|---|---| | ||
| | 1. 确认 CPU 前提 | 火焰图是否有资格解释回退? | worker 饱和、绑核、上下游余量、稳定复现 | |
There was a problem hiding this comment.
Same 有资格 as L34.
| | 1. 确认 CPU 前提 | 火焰图是否有资格解释回退? | worker 饱和、绑核、上下游余量、稳定复现 | | |
| | 1. 确认 CPU 前提 | 火焰图能否解释这次回退? | worker 饱和、绑核、上下游余量、稳定复现 | |
| 火焰图没有撒谎。横向宽度告诉我们 CPU 样本聚集在哪,纵向调用栈告诉我们成本如何被公共路径放大。但当成本进入解释器、JIT trace、分配器和调用方后,火焰图看到的就不再是完整归属。 | ||
|
|
||
| 这时最有效的动作不是继续猜哪行 Lua 应该更快,而是让 LuaJIT 交出编译事件,再用每请求调用次数和配对 A/B 把量级钉死。最终值得优化的,往往不是最宽的柱子,而是那段经过证据证明、根本不需要反复发生的工作。 |
There was a problem hiding this comment.
| 火焰图没有撒谎。横向宽度告诉我们 CPU 样本聚集在哪,纵向调用栈告诉我们成本如何被公共路径放大。但当成本进入解释器、JIT trace、分配器和调用方后,火焰图看到的就不再是完整归属。 | |
| 这时最有效的动作不是继续猜哪行 Lua 应该更快,而是让 LuaJIT 交出编译事件,再用每请求调用次数和配对 A/B 把量级钉死。最终值得优化的,往往不是最宽的柱子,而是那段经过证据证明、根本不需要反复发生的工作。 | |
| 火焰图没有撒谎。宽度告诉我们 CPU 样本聚集在哪,调用栈告诉我们这些成本如何被公共路径放大。但成本一旦被记到解释器符号、JIT trace、内存分配器或者调用方头上,火焰图给出的归属就不再完整。 | |
| 这时候最有效的做法不是继续猜哪行 Lua 应该更快,而是让 LuaJIT 交出编译事件,再用每请求调用次数和配对 A/B 实验把这条路径的真实开销测出来。最终值得优化的,往往不是最宽的柱子,而是那些有证据表明根本不需要反复执行的工作。 |
| @@ -0,0 +1,234 @@ | |||
| --- | |||
| title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位" | |||
There was a problem hiding this comment.
The object of 告诉我们 is incomplete — 告诉我们瓶颈 leaves out 在哪. And 吞吐回退定位 turns a verb into a noun that doesn't read naturally in Chinese.
| title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位" | |
| title: "火焰图没撒谎,但没直接指出瓶颈在哪:一次 APISIX 吞吐回退排查" |
|
|
||
| 一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。 | ||
|
|
||
| 这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。 |
There was a problem hiding this comment.
Five steps separated by four semicolons in one sentence — the reader has to hold all of them before reaching the body. Split after the second step. 建立候选 is also not a natural collocation; 圈定 is.
| 这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。 | |
| 这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈,再横向看火焰图宽度,圈定候选位置。然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。 |
|
|
||
| 实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启/关闭自定义组件时:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。 | ||
|
|
||
| 在本次 LuaJIT 构建中,失败惩罚从 72 开始逐次翻倍:第 10 次是 36,864,第 11 次达到 73,728,超过 60,000 上限。多个起点恰好 abort ×11 后不再增长;它们没有进入 compiled 集合,采集期间也没有 trace flush。两组存活 trace 数均低于缓存上限,因此排除了 trace 缓存被挤爆的可能。这些证据共同说明:对应的 trace 起点已被放弃继续编译。 |
There was a problem hiding this comment.
Two things make this hard to read. 多个起点恰好 abort ×11 后不再增长 shifts subject mid-clause — what stops growing is the abort count, not the starting points, so it first reads as "the number of starting points stopped increasing". And 对应的 trace 起点已被放弃继续编译 is a passive wrapped around a pivot construction with no actor; LuaJIT is the one that gave up.
| 在本次 LuaJIT 构建中,失败惩罚从 72 开始逐次翻倍:第 10 次是 36,864,第 11 次达到 73,728,超过 60,000 上限。多个起点恰好 abort ×11 后不再增长;它们没有进入 compiled 集合,采集期间也没有 trace flush。两组存活 trace 数均低于缓存上限,因此排除了 trace 缓存被挤爆的可能。这些证据共同说明:对应的 trace 起点已被放弃继续编译。 | |
| 在本次 LuaJIT 构建中,失败惩罚从 72 开始逐次翻倍:第 10 次是 36,864,第 11 次达到 73,728,超过 60,000 上限。多个起点的 abort 次数恰好停在 11 次,之后不再增加;它们没有进入 compiled 集合,采集期间也没有 trace flush。两组存活 trace 数均低于缓存上限,因此排除了 trace 缓存被挤爆的可能。这些证据共同说明:LuaJIT 已经放弃编译这些 trace 起点。 |
|
|
||
| 还有一个容易让结论失真的陷阱:探针必须装在被观测模块加载之前。如果模块在 `require` 时保存了函数引用,之后替换原函数,计数器只能看到少量没被快照走的调用。本次探针注入晚时测到 1 次/请求,前移到 `require("apisix")` 之前后,才确认真实值是 5 次/请求。 | ||
|
|
||
| ## 5. 配对 A/B 收口:实验回答“值多少钱” |
There was a problem hiding this comment.
收口 is jargon and is never explained in the article; putting it in a section heading makes it worse. Spelling out what the section does is clearer, and it keeps the 值多少钱 idea.
| ## 5. 配对 A/B 收口:实验回答“值多少钱” | |
| ## 5. 用配对 A/B 定量:这条路径到底值多少 |
|
|
||
| 火焰图给位置,调用栈给放大链,LuaJIT 事件解释错位成本。最终还要靠配对实验定量。 | ||
|
|
||
| 以 Global Rule 路径为例,我们保留相同的调度和配置,只让 Prometheus 业务函数进入后立即返回,用来区分“插件业务逻辑慢”和“进入插件前的公共路径慢”。为避免把定制环境绝对 RPS 当成 APISIX OSS benchmark,关闭 Prometheus 的吞吐被归一化为 100: |
There was a problem hiding this comment.
关闭 Prometheus 的吞吐被归一化为 100 is genuinely ambiguous — it parses as 关闭[Prometheus 的吞吐]. The passive also has no actor. (Also 定制环境绝对 RPS is missing 的.)
| 以 Global Rule 路径为例,我们保留相同的调度和配置,只让 Prometheus 业务函数进入后立即返回,用来区分“插件业务逻辑慢”和“进入插件前的公共路径慢”。为避免把定制环境绝对 RPS 当成 APISIX OSS benchmark,关闭 Prometheus 的吞吐被归一化为 100: | |
| 以 Global Rule 路径为例,我们保留相同的调度和配置,只让 Prometheus 业务函数进入后立即返回,用来区分“插件业务逻辑慢”和“进入插件前的公共路径慢”。为避免把定制环境的绝对 RPS 当成 APISIX OSS benchmark,我们把关闭 Prometheus 时的吞吐归一化为 100: |
| | 保留插件与调度,业务函数立即返回 | 77.2 | -22.8% | | ||
| | 完整 Prometheus Global Rule | 56.9 | -43.1% | | ||
|
|
||
| 即使插件业务代码被短路,差距仍然显著。这个实验不能把成本定位到某一行,但足以证明损耗不只来自指标计算,进入插件前的公共路径本身就值得追查。再结合每请求 9 次过滤、纵向调用栈和 C 层成本分布,放大链才真正闭合。 |
There was a problem hiding this comment.
放大链才真正闭合 — 闭合 is a coined term here, and 放大链 itself is never defined. Plain wording carries the same meaning.
| 即使插件业务代码被短路,差距仍然显著。这个实验不能把成本定位到某一行,但足以证明损耗不只来自指标计算,进入插件前的公共路径本身就值得追查。再结合每请求 9 次过滤、纵向调用栈和 C 层成本分布,放大链才真正闭合。 | |
| 即使插件业务代码被短路,差距仍然显著。这个实验不能把成本定位到某一行,但足以证明损耗不只来自指标计算,进入插件前的公共路径本身就值得追查。再结合每请求 9 次过滤、纵向调用栈和 C 层成本分布,这条放大链才算说得通。 |
| | 2. 横向看宽度 | 样本主要落在哪里? | 全局火焰图与候选排序 | | ||
| | 3. 纵向沿栈看 | 小成本为何被放大? | 调用阶段、公共函数、每请求调用次数 | | ||
| | 4. 检查 LuaJIT | 成本为何错位或消失? | `start`、`stop`、`abort`、`flush` 与 `jit.dump` | | ||
| | 5. 配对 A/B 收口 | 真实量级与因果是什么? | 吞吐、延迟、调用次数、错误率与响应一致性 | |
There was a problem hiding this comment.
Same 收口 as the section 5 heading.
| | 5. 配对 A/B 收口 | 真实量级与因果是什么? | 吞吐、延迟、调用次数、错误率与响应一致性 | | |
| | 5. 配对 A/B 定量 | 真实量级与因果是什么? | 吞吐、延迟、调用次数、错误率与响应一致性 | |
Summary
Review focus
blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.mdas the technical source for this PRblog/en/blog/2026/08/28/debugging-apisix-throughput-regression.mdfor natural developer-facing flow and semantic fidelity, rather than sentence-by-sentence literal parityValidation
yarn lint:frontmatteryarn remark: English has no issues; Chinese retains twono-emphasis-as-headingwarnings on the existing italic captionsmarkdownlint-cli@0.25.0on both articlesrun_global_rulesmentiongit diff --check