Skip to content

blog: add APISIX throughput regression investigation (en + zh) - #2115

Open
Yilialinn wants to merge 8 commits into
apache:masterfrom
Yilialinn:codex/add-apisix-throughput-regression-blog
Open

blog: add APISIX throughput regression investigation (en + zh)#2115
Yilialinn wants to merge 8 commits into
apache:masterfrom
Yilialinn:codex/add-apisix-throughput-regression-blog

Conversation

@Yilialinn

@Yilialinn Yilialinn commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add the Simplified Chinese APISIX throughput-regression investigation and its English counterpart
  • adapt the English article from the current Chinese source for natural developer-facing English while preserving the same technical facts, data, code, tables, images, limitations, and four references
  • use the shorter English title “APISIX Throughput Regression: Beyond the Flame Graph” and remove literal or editor-sounding phrasing from the English article
  • address all eight English review comments, including the opening, CPU prerequisite, LuaJIT wording, summary table, conclusion language, and canonical English reference titles
  • keep technical corrections outside the source article’s scope; do not add PR #13779, release/version notes, or separate LuaJIT corrections

Review focus

  • treat blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md as the technical source for this PR
  • review blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md for natural developer-facing flow and semantic fidelity, rather than sentence-by-sentence literal parity
  • both locales contain the same 6 H2 sections, 3 H3 sections, 5 tables, 3 Lua code blocks, 2 images, and 4 references
  • the latest Chinese wording and italic-caption edits already on the PR branch are preserved unchanged by the English review-fix commit

Validation

  • yarn lint:frontmatter
  • targeted yarn remark: English has no issues; Chinese retains two no-emphasis-as-heading warnings on the existing italic captions
  • CI-version markdownlint-cli@0.25.0 on both articles
  • bilingual checks for structure, URLs, executable code, and shared technical data; raw token/number totals intentionally differ where the latest Chinese captions omit the English Figure 1/2 labels and one caption-level run_global_rules mention
  • git diff --check
  • independent final review after fixes: no remaining P0-P2 findings
  • full site build not run locally: 44 GiB free is below this Mac's 45 GiB safety threshold; remote CI will provide the build result

@Yilialinn
Yilialinn requested a balanced review from Copilot August 28, 2026 07:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Yilialinn
Yilialinn requested a balanced review from Copilot August 28, 2026 07:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Yilialinn
Yilialinn marked this pull request as ready for review August 28, 2026 09:14
Comment thread blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md Outdated
Comment thread blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md Outdated
Comment thread blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md Outdated
Comment thread blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md Outdated
Comment thread blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md Outdated
Comment thread blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md Outdated
Comment thread blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md Outdated
Comment thread blog/en/blog/2026/08/28/debugging-apisix-throughput-regression.md Outdated

## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图

火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

有资格 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?").

Suggested change
火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退
火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才能解释性能回退


两个放大器都在这里:

1. 单次过滤成本随已加载插件数量的增长。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

随…的增长 leaves the sentence without a predicate.

Suggested change
1. 单次过滤成本随已加载插件数量的增长
1. 单次过滤的成本随已加载插件数量增长

1. 单次过滤成本随已加载插件数量的增长。
2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。

本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The passive 被同一个请求重复了 9 次 is awkward here; the request is the actor.

Suggested change
本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。
本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,同一个请求重复做了 9 次。


## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件

第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

改变调用方之后以解释器还是机器码运行 is missing 是, which makes the clause hard to parse.

Suggested change
第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行
第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后是以解释器还是以机器码运行


开启 `jit.v` 后,一次短时压测输出中看到 417 次 trace 编译成功、493 次 abort。按位置聚合后,一批路径的 abort 次数整齐停在 11。

但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

唯一函数数 is a literal rendering of "unique function count" and doesn't read as Chinese.

Suggested change
但这是编译事件数,不是唯一函数数、覆盖率或 CPU 时间。`jit.v` 有几个边界:
但这是编译事件数,不是去重后的函数个数、覆盖率或 CPU 时间。`jit.v` 有几个边界:


- abort 行显示的是 trace 中断位置,而惩罚记在 trace 起点,两者可能不是同一行。
- 某函数从未成为 trace 起点,不代表它没有被编译,函数体可能已被内联进父 trace。
- 文本日志很难对功能开启/关闭两组结果做稳定的集合差。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

做稳定的集合差 is a literal rendering of "stable set difference".

Suggested change
- 文本日志很难对功能开启/关闭两组结果做稳定的集合差
- 文本日志很难在功能开启和关闭两组结果之间做稳定的集合比较

end, "trace")
```

实际探针还要用 `jit.util.funcinfo` 解析源位置,用 `jit.vmdef.traceerr` 还原 abort 原因。这样就能比较开启/关闭自定义组件时:哪些起点进入 compiled 集合,哪些反复 abort,以及是否发生 trace flush。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

比较开启/关闭自定义组件时: breaks off before the object of 比较.

Suggested change
实际探针还要用 `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 次/请求。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

本次探针注入晚时 and 之前后 in the same sentence are hard to read.

Suggested change
还有一个容易让结论失真的陷阱:探针必须装在被观测模块加载之前。如果模块在 `require` 时保存了函数引用,之后替换原函数,计数器只能看到少量没被快照走的调用。本次探针注入晚时测到 1 次/请求,前移到 `require("apisix")` 之前后,才确认真实值是 5 次/请求。
还有一个容易让结论失真的陷阱:探针必须装在被观测模块加载之前。如果模块在 `require` 时保存了函数引用,之后替换原函数,计数器只能看到少量没被快照走的调用。本次探针注入较晚时测到 1 次/请求;把它前移到 `require("apisix")` 之前,才确认真实值是 5 次/请求。


| 步骤 | 核心问题 | 证据 |
|---|---|---|
| 1. 确认 CPU 前提 | 火焰图是否有资格解释回退? | worker 饱和、绑核、上下游余量、稳定复现 |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same 有资格 as L34.

Suggested change
| 1. 确认 CPU 前提 | 火焰图是否有资格解释回退| worker 饱和、绑核、上下游余量、稳定复现 |
| 1. 确认 CPU 前提 | 火焰图能否解释这次回退| worker 饱和、绑核、上下游余量、稳定复现 |

Comment on lines +225 to +227
火焰图没有撒谎。横向宽度告诉我们 CPU 样本聚集在哪,纵向调用栈告诉我们成本如何被公共路径放大。但当成本进入解释器、JIT trace、分配器和调用方后,火焰图看到的就不再是完整归属。

这时最有效的动作不是继续猜哪行 Lua 应该更快,而是让 LuaJIT 交出编译事件,再用每请求调用次数和配对 A/B 把量级钉死。最终值得优化的,往往不是最宽的柱子,而是那段经过证据证明、根本不需要反复发生的工作。

@AlinsRan AlinsRan Aug 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
火焰图没有撒谎。横向宽度告诉我们 CPU 样本聚集在哪,纵向调用栈告诉我们成本如何被公共路径放大。但当成本进入解释器、JIT trace、分配器和调用方后,火焰图看到的就不再是完整归属
这时最有效的动作不是继续猜哪行 Lua 应该更快,而是让 LuaJIT 交出编译事件,再用每请求调用次数和配对 A/B 把量级钉死。最终值得优化的,往往不是最宽的柱子,而是那段经过证据证明、根本不需要反复发生的工作
火焰图没有撒谎。宽度告诉我们 CPU 样本聚集在哪,调用栈告诉我们这些成本如何被公共路径放大。但成本一旦被记到解释器符号、JIT trace、内存分配器或者调用方头上,火焰图给出的归属就不再完整
这时候最有效的做法不是继续猜哪行 Lua 应该更快,而是让 LuaJIT 交出编译事件,再用每请求调用次数和配对 A/B 实验把这条路径的真实开销测出来。最终值得优化的,往往不是最宽的柱子,而是那些有证据表明根本不需要反复执行的工作

@@ -0,0 +1,234 @@
---
title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The object of 告诉我们 is incomplete — 告诉我们瓶颈 leaves out 在哪. And 吞吐回退定位 turns a verb into a noun that doesn't read naturally in Chinese.

Suggested change
title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位"
title: "火焰图没撒谎,但没直接指出瓶颈在哪:一次 APISIX 吞吐回退排查"


一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。

这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
这次排查最有价值的不是某个补丁,而是一条证据链:先确认 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 起点已被放弃继续编译。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
在本次 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 收口:实验回答“值多少钱”

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

收口 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.

Suggested change
## 5. 配对 A/B 收口:实验回答“值多少钱”
## 5. 用配对 A/B 定量:这条路径到底值多少


火焰图给位置,调用栈给放大链,LuaJIT 事件解释错位成本。最终还要靠配对实验定量。

以 Global Rule 路径为例,我们保留相同的调度和配置,只让 Prometheus 业务函数进入后立即返回,用来区分“插件业务逻辑慢”和“进入插件前的公共路径慢”。为避免把定制环境绝对 RPS 当成 APISIX OSS benchmark,关闭 Prometheus 的吞吐被归一化为 100:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

关闭 Prometheus 的吞吐被归一化为 100 is genuinely ambiguous — it parses as 关闭[Prometheus 的吞吐]. The passive also has no actor. (Also 定制环境绝对 RPS is missing 的.)

Suggested change
以 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 层成本分布,放大链才真正闭合。

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

放大链才真正闭合 — 闭合 is a coined term here, and 放大链 itself is never defined. Plain wording carries the same meaning.

Suggested change
即使插件业务代码被短路,差距仍然显著。这个实验不能把成本定位到某一行,但足以证明损耗不只来自指标计算,进入插件前的公共路径本身就值得追查。再结合每请求 9 次过滤、纵向调用栈和 C 层成本分布,放大链才真正闭合
即使插件业务代码被短路,差距仍然显著。这个实验不能把成本定位到某一行,但足以证明损耗不只来自指标计算,进入插件前的公共路径本身就值得追查。再结合每请求 9 次过滤、纵向调用栈和 C 层成本分布,这条放大链才算说得通

| 2. 横向看宽度 | 样本主要落在哪里? | 全局火焰图与候选排序 |
| 3. 纵向沿栈看 | 小成本为何被放大? | 调用阶段、公共函数、每请求调用次数 |
| 4. 检查 LuaJIT | 成本为何错位或消失? | `start`、`stop`、`abort`、`flush` 与 `jit.dump` |
| 5. 配对 A/B 收口 | 真实量级与因果是什么? | 吞吐、延迟、调用次数、错误率与响应一致性 |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same 收口 as the section 5 heading.

Suggested change
| 5. 配对 A/B 收口 | 真实量级与因果是什么? | 吞吐、延迟、调用次数、错误率与响应一致性 |
| 5. 配对 A/B 定量 | 真实量级与因果是什么? | 吞吐、延迟、调用次数、错误率与响应一致性 |

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants