AlinsRan commented on code in PR #2115: URL: https://github.com/apache/apisix-website/pull/2115#discussion_r3879714159
########## blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md: ########## @@ -0,0 +1,234 @@ +--- +title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位" +authors: + - name: "Xin Rong" + title: "Author" + url: "https://github.com/AlinsRan" + image_url: "https://github.com/AlinsRan.png" + - name: "Yilia Lin" + title: "Technical Writer" + url: "https://github.com/Yilialinn" + image_url: "https://github.com/Yilialinn.png" +keywords: + - Apache APISIX + - 火焰图 + - LuaJIT + - 性能优化 + - 吞吐回退 +description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。" +tags: [Ecosystem] +--- + +火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。 + +<!--truncate--> + +一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。 + +这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。 + +> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。 + +## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图 + +火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。 + +我们在采样前固定了这些条件: + +- APISIX 单 worker 并绑定独占物理核; +- 上游服务和压测端使用其他核,避免 CPU 争抢; +- 请求模型、配置、响应内容保持一致; +- 吞吐回退可稳定复现,错误率和响应结果没有偏移; +- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。 + +如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。 + +## 2. 横向看宽度,而不是排名 + +我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。 + + + +*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。* + +横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序: Review Comment: This opening block is the main source of the machine-written tone. `火焰图没有撒谎` appears in `description`, in the lead, and again in the conclusion; `证据链` and the 横向/纵向 framing are methodology vocabulary rather than a description of what happened. Rewritten as a plain account. This also turns the Figure 1 caption into a real caption instead of an all-italic paragraph. ````suggestion description: "一次 APISIX 吞吐回退的排查记录。火焰图里最宽的两根柱子都不是原因,真正的问题在 Lua 采样里只占 3.0% 和 3.9%,得靠调用栈、LuaJIT 编译事件和 A/B 实验才挖得出来。" tags: [Ecosystem] --- 一次 APISIX 吞吐回退的排查记录。火焰图里最宽的两根柱子都不是原因,真正的问题在 Lua 采样里只占 3.0% 和 3.9%,得靠调用栈、LuaJIT 编译事件和 A/B 实验才挖得出来。 <!--truncate--> 按热点大小排队,这两处根本进不了第一轮优化名单。 这次排查留下来的东西不是某个补丁,而是一套翻查顺序:先确认瓶颈真的在 CPU 上;再看火焰图的宽度挑候选;然后顺着调用栈往上,看这点开销是怎么被公共路径放大的;等火焰图解释不了端到端的差距了,就去查 LuaJIT 的编译和中断事件;最后用每请求调用次数和配对 A/B 把量级定下来。 > 数据说明:下文的数据来自同一个受控环境里的一个 APISIX 内部定制构建,加载了 100 多个插件。吞吐做了归一化,只用来说明排查方法和因果关系,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的表现。 ## 1. 先确认瓶颈真在 CPU 上 火焰图只能告诉你采样期间 CPU 在跑什么。如果 worker 根本没跑满,吞吐卡在别的地方,那再宽的柱子也解释不了这次回退。 所以采样之前,我们把环境固定成这样: - APISIX 单 worker,绑一个独占的物理核; - 上游服务和压测端跑在别的核上,避免 CPU 争抢; - 请求模型、配置、响应内容三样都不变; - 吞吐回退能稳定复现,错误率和响应结果没有偏移; - 目标 worker 一直接近饱和,而上游、网络、压测端都还有余量。 这几条只要有一条不满足,就该先去查连接、网络、上游或者压测端,而不是抓一张 on-CPU 火焰图慢慢看。 ## 2. 看宽度,不是看排名 我们用 eBPF 以 500 Hz 同时采 C 和 Lua 的调用栈。下面是这次排查里真实的 Lua on-CPU 火焰图。  图 1:火焰图全局视图。搜索 `run_global_rules` 命中 3 处,但在整张图里都不显眼。原始采样共 2,446 个样本,截图去掉了进程 PID。 火焰图的 x 轴没有先后关系,有意义的只是宽度:一条路径越宽,说明落在它上面的采样越多。按 Lua self time 排下来,第一轮候选是这样的: ```` ########## blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md: ########## @@ -0,0 +1,234 @@ +--- +title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位" +authors: + - name: "Xin Rong" + title: "Author" + url: "https://github.com/AlinsRan" + image_url: "https://github.com/AlinsRan.png" + - name: "Yilia Lin" + title: "Technical Writer" + url: "https://github.com/Yilialinn" + image_url: "https://github.com/Yilialinn.png" +keywords: + - Apache APISIX + - 火焰图 + - LuaJIT + - 性能优化 + - 吞吐回退 +description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。" +tags: [Ecosystem] +--- + +火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。 + +<!--truncate--> + +一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。 + +这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。 + +> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。 + +## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图 + +火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。 + +我们在采样前固定了这些条件: + +- APISIX 单 worker 并绑定独占物理核; +- 上游服务和压测端使用其他核,避免 CPU 争抢; +- 请求模型、配置、响应内容保持一致; +- 吞吐回退可稳定复现,错误率和响应结果没有偏移; +- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。 + +如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。 + +## 2. 横向看宽度,而不是排名 + +我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。 + + + +*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。* + +横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序: + +| 候选位置 | Lua self time | 初判 | +|---|---:|---| +| Prometheus exporter | 25.9% | 最值得先看 | +| `ctx.var` 元方法 | 15.1% | 第二候选 | +| 自定义日志旁路 | 3.9% | 很容易被忽略 | +| `run_global_rules` | 3.0% | 很容易被忽略 | + +这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。 + +这个排名有四个盲区: + +1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。 +2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。 +3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。 +4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。 + +`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。 + +## 3. 纵向沿栈看,3% 如何被公共路径放大 + +选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。 + + + +*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。* + +纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。 + +修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置: Review Comment: Same pattern: the heading and the closing line are built as matched couplets (横向/纵向, `单处不宽,不等于合起来不贵`). Reworded, and the Figure 2 caption made plain. ````suggestion 这里的 self time 指的是:在能拿到 Lua 上下文的样本里,直接落在这个位置上的比例,不是它占总 CPU 的比例。这次采样有 19.6% 的样本没有 Lua 上下文,所以这几个数字只够用来挑候选,不能拿去推算优化能换回多少吞吐。 排在前面也不代表就该先动它,这个排名有四个盲区: 1. LuaJIT 解释执行的时候,不同的 Lua 代码可能折叠进同一批 `lj_BC_*`、`lj_vm_*` 符号里。 2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,所以看得见的 JIT 样本只是个下界。 3. 表查找、内存分配、GC 这些开销记在 C 符号上,而不是触发它们的那行 Lua 上。 4. 一次调用可能改变调用方的 JIT 状态,于是开销看起来落在了调用方函数的头上。 `run_global_rules` 还有个当时没在意的特征:它没有形成一根显眼的柱子,而是散在好几个请求阶段的栈里。每一处都不宽,加起来是多少就不好说了。 ## 3. 顺着调用栈往上:3% 是怎么被放大的 点开其中一个 `run_global_rules` 方块,把这条栈单独放大看:底下是请求阶段的入口,往上依次是 `common_phase`、Global Rule 的插件筛选、调度,最后才轮到具体插件。  图 2:选中一条 `run_global_rules` 栈之后的放大视图。所选栈会被重新铺满画布,宽度已经归一化,不能再当成它占总 CPU 的比例。点击图片可查看大图。 栈的高度本身跟耗时没关系。它的用处是看清楚这段开销从哪儿进来、被谁调用、为什么会反复出现。 修复之前,`run_global_rules()` 会在请求的好几个阶段各调一次 `_M.filter()`。而 `_M.filter()` 的做法是遍历所有已加载的插件,挨个判断这个插件有没有被 Global Rule 配置过: ```` ########## blog/zh/blog/2026/08/28/debugging-apisix-throughput-regression.md: ########## @@ -0,0 +1,234 @@ +--- +title: "火焰图没撒谎,但没直接告诉我们瓶颈:一次 APISIX 吞吐回退定位" +authors: + - name: "Xin Rong" + title: "Author" + url: "https://github.com/AlinsRan" + image_url: "https://github.com/AlinsRan.png" + - name: "Yilia Lin" + title: "Technical Writer" + url: "https://github.com/Yilialinn" + image_url: "https://github.com/Yilialinn.png" +keywords: + - Apache APISIX + - 火焰图 + - LuaJIT + - 性能优化 + - 吞吐回退 +description: "火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。" +tags: [Ecosystem] +--- + +火焰图没有撒谎,却也没有直接指出 APISIX 吞吐回退的瓶颈。本文沿 CPU、调用栈和 LuaJIT 证据链逐步排查,并通过每请求调用次数和配对 A/B 实验,解释为什么最宽的热点未必最值得优化。 + +<!--truncate--> + +一次吞吐回退中,火焰图最宽的路径并不是最终瓶颈。真正值得追查的两个位置,在 Lua 采样里只有 3.0% 和 3.9%。如果按热点排序,它们根本进不了第一轮优化名单。 + +这次排查最有价值的不是某个补丁,而是一条证据链:先确认 CPU 确实是瓶颈;再横向看火焰图宽度,建立候选;然后沿调用栈纵向追,看重复调用和公共路径如何放大成本;当火焰图解释不了端到端差距时,继续查 LuaJIT 编译与中断事件;最后用每请求调用次数和配对 A/B 实验定量。 + +> 数据范围:下文数据来自同一受控环境中的一个 APISIX 内部定制构建,其中加载了 100 余个插件。吞吐统一归一化,只用于说明定位方法与因果链,不代表 Apache APISIX OSS 在其他硬件、配置或负载下的通用表现。 + +## 1. 先确认 CPU 瓶颈,而不是上来就看火焰图 + +火焰图显示的是采样期间 CPU 在执行什么。只有目标 worker 的 CPU 接近饱和、吞吐受这个核限制时,火焰图的宽度才有资格解释性能回退。 + +我们在采样前固定了这些条件: + +- APISIX 单 worker 并绑定独占物理核; +- 上游服务和压测端使用其他核,避免 CPU 争抢; +- 请求模型、配置、响应内容保持一致; +- 吞吐回退可稳定复现,错误率和响应结果没有偏移; +- 目标 worker 持续接近饱和,上游、网络、压测端仍有余量。 + +如果不满足这些前提,首先应该查连接、网络、上游或压测端,而不是在 on-CPU 火焰图里找答案。 + +## 2. 横向看宽度,而不是排名 + +我们使用 eBPF 以 500 Hz 同时采集 C 和 Lua 调用栈。下面是排查中的真实 Lua on-CPU 火焰图。 + + + +*搜索 `run_global_rules` 后命中 3 处,但这些位置在整张图里并不醒目。原始采样共 2,446 个样本;截图已移除进程 PID。* + +横向看火焰图,看的是宽度,不是 x 轴顺序。宽度越大,采样落在该路径上的次数越多。初步候选位置按 Lua self time 排序: + +| 候选位置 | Lua self time | 初判 | +|---|---:|---| +| Prometheus exporter | 25.9% | 最值得先看 | +| `ctx.var` 元方法 | 15.1% | 第二候选 | +| 自定义日志旁路 | 3.9% | 很容易被忽略 | +| `run_global_rules` | 3.0% | 很容易被忽略 | + +这里的 self time 指:在带 Lua 上下文的样本中,直接落在这个位置上的比例,不是总 CPU 占比。本次采样约 19.6% 的样本缺失 Lua 上下文,所以这些百分比只用于选候选,不能直接预测吞吐收益。 + +这个排名有四个盲区: + +1. LuaJIT 解释执行时,不同的 Lua 代码可能折叠到共享的 `lj_BC_*`、`lj_vm_*` 符号里。 +2. 已编译的 JIT trace 不一定能被常规栈回溯完整展开,因此可观察到的 JIT 样本只是下界。 +3. 表查找、内存分配、GC 等成本会记在 C 符号上,而不是触发的 Lua 行上。 +4. 一个调用可能改变调用方的 JIT 状态,使成本看起来落在调用方函数的头上。 + +`run_global_rules` 还有一个特征:它没有形成一根大柱子,而是散在多个请求阶段的调用栈里。单处不宽,不等于合起来不贵。 + +## 3. 纵向沿栈看,3% 如何被公共路径放大 + +选中某个 `run_global_rules` 方块后,纵向看调用栈:底部是请求阶段入口,向上经过 `common_phase`、Global Rule 插件筛选、调度,再到具体插件执行。 + + + +*所选栈会被重新铺满画布,因此图中的宽度已经归一化,不能再当作它占总 CPU 的比例。* + +纵向高度本身不代表耗时。它的作用是看清成本从哪里进入、被谁调用、为什么反复出现。 + +修复前,`run_global_rules()` 会在请求的多个阶段调用 `_M.filter()`。`_M.filter()` 遍历所有已加载插件,再逐个判断该插件是否被 Global Rule 配置: + +```lua +-- 简化示意,非 APISIX 完整实现 +for _, plugin_obj in ipairs(local_plugins) do + local name = plugin_obj.name + local plugin_conf = user_plugin_conf[name] + if type(plugin_conf) ~= "table" then + goto continue + end + -- 处理已配置插件 + ::continue:: +end +``` + +两个放大器都在这里: + +1. 单次过滤成本随已加载插件数量的增长。 +2. 相同过滤结果跨请求阶段重复生成;`body_filter` 与 `delayed_body_filter` 还可能按响应块多次进入。 + +本次测试配置中,一个请求实际触发了 **9 次过滤**。也就是说,“从 100+ 插件中找出已配置的少量插件”这件事,被同一个请求重复了 9 次。 + +这解释了为什么 Lua 层 3.0% 会低估整条路径成本: + +| 实际工作 | 火焰图常见归属 | +|---|---| +| 循环本身 | `plugin.lua` 对应位置 | +| 大量表查找 | `lj_BC_TGETS` | +| 临时表分配 | `lj_alloc_malloc` | +| 临时对象回收 | `gc_sweep` | +| 未编译解释器派发 | `lj_vm_*` / `lj_BC_*` | + +横向看,它只是 3% 的候选;纵向看,才会发现它位于一条被反复进入的公共路径。优化方向也很直接:同一请求内,如果 Global Rule 和匹配路由没有变化,就复用已筛选的插件集合,而不是每个阶段重新生成。 + +## 4. 火焰图解释不了差距时,查 LuaJIT 编译事件 + +第二条路径来自自定义观测组件。它和日志旁路不属于 APISIX OSS,但揭示的问题对 APISIX 插件和 OpenResty 扩展有参考价值:一次看似很轻的调用,既可能产生直接成本,也可能改变调用方之后以解释器还是机器码运行。 + +### 4.1 解释器派发异常活跃 + +把 C 层样本按运行时类型重新分类,最值得注意的不是某行 Lua,而是解释器与 JIT 的关系: Review Comment: The 横向看…纵向看 couplet again, plus a section heading that spells out the whole thesis. Shortened. ````suggestion | 未编译的解释器派发 | `lj_vm_*` / `lj_BC_*` | 只看宽度它就是个 3% 的小候选,顺着栈看才发现它蹲在一条被反复进入的公共路径上。改法也就摆在那儿了:同一个请求里,只要 Global Rule 和匹配到的路由没变,就把筛过一次的插件集合留下来复用,别每个阶段重算一遍。 ## 4. 火焰图对不上账的时候,去查 LuaJIT 第二条线索来自一个自定义的观测组件。这个组件和它的日志旁路都不属于 APISIX OSS,但它踩的坑对写 APISIX 插件和 OpenResty 扩展的人都有参考价值:一次看上去很轻的调用,除了自己那点开销,还可能改变调用方后面是跑解释器还是跑机器码。 ### 4.1 解释器派发活跃得反常 把 C 层的样本按运行时类型重新归类之后,最扎眼的不是哪一行 Lua,而是解释器和 JIT 的比例: ```` -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
