运行时行为盲区:APISIX AI 网关CPU打满故障的AI辅助事后复盘

如何借助AI高效复盘?

起因

最近在使用 API7 的时候反复遇到 CPU 打满的问题:

  1. 下游客户端取消请求,但大模型还在持续输出导致 CPU 打满
  2. 表达式post_arg.*匹配多次decode
  3. 问题修复后,又出现了 CPU 毛刺,现在怀疑
    • 是当上游 LLM 快速返回大量小 chunk,极少的并发甚至单个请求即可将 worker 的 CPU 打满 100%。比如下面这里:local chunk, err = body_reader()
    • 单次 AI 请求对请求体执行了 3 次 JSON decode 和 1 次 encode,造成不必要的 CPU 开销 💡

除了decode问题,其他问题都是出在 apisix/plugins/ai-providers/base.luaparse_streaming_response 函数。

💡 PR 为作者发现问题并提出修复方案的。

我现在想要复盘明确:

  1. 为什么这个问题在阅读代码的时候没有看出来?
  2. 针对这两个问题需要补充哪些知识?

🤖 借助 AI 进行系统性复盘

我将上述 CPU 问题提交给 Claude Opus 4.6 / 4.7,让它帮助我:

  • 🔍 分析”为什么阅读代码时未能发现这些问题”
  • 📚 梳理需要补充的 OpenResty / 协作式调度知识体系
  • ✅ 提供可操作的运行时代码审查 checklist

以下是 AI 协助完成的深度分析成果:


一、为什么阅读代码时没看出来

这两个问题有共同特征:代码逻辑是正确的,bug 藏在运行时行为中。这说明读代码时使用的是”逻辑正确性”视角,缺少”运行时行为”视角。

1.1 问题 1(下游客户端取消但模型继续输出)

读代码看到的是:

1
2
3
4
5
local ok, flush_err = plugin.lua_response_filter(ctx, res.headers, chunk, true)
if not ok then
abort_on_disconnect(flush_err)
return
end

逻辑没问题:flush 失败就退出循环。但心智模型默认了”客户端断开 → flush 立即失败”。实际情况是:

  • 客户端发送 RST 后,nginx 不会立刻知道
  • nginx 必须等到下次 write 操作才能感知客户端已断开
  • 在感知到之前,body_reader() 仍在持续读上游数据、做 CPU 密集处理

认知盲区:默认了”理想错误传播路径”,没考虑错误检测的延迟。

1.2 问题 2(上游快速返回打满 CPU)

读代码看到的是:

1
2
3
local chunk, err = body_reader()  -- 看起来是 IO,应该会 yield
...
ngx.sleep(0) -- 让出 CPU

心智模型是:”cosocket 是 IO 操作,自然会 yield”。这在大多数场景下是对的,但有一个关键认知盲区:

当 socket 接收 buffer 里已经有数据时,body_reader() 不会 yield,行为等同于 memcpy。

源码注释里 APISIX 开发者已经明确指出了这一点:

1
2
3
body_reader() and ngx.flush() do not yield when the upstream socket
already has data buffered or the downstream client drains immediately,
so under bursty SSE upstreams this loop can monopolize the worker CPU.

这不是逻辑错误,是对 OpenResty 协作式调度模型和 socket buffer 行为的认知盲区。

1.3 根本原因

读代码时只问了”逻辑正确性”问题:

  • 数据流对不对?
  • 错误处理覆盖全吗?
  • 边界条件处理了吗?

但没问”运行时行为”问题:

  • 这个循环在最坏情况下会不会不 yield?
  • 这个 IO 调用在什么条件下变成纯 CPU 操作?
  • 这个协程会不会饿死同 worker 上的其他协程?

二、需要补充的知识

2.1 OpenResty 协作式调度模型(最核心)

需要理解

  • Nginx worker 是单线程多协程,所有请求通过协程并发
  • 协程是协作式调度——不主动 yield,其他协程就饿死
  • yield 点只有:cosocket IO 等待、ngx.sleep()ngx.flush(true) 在 buffer 满时
  • 关键:cosocket IO 调用如果数据已就绪,不会 yield

推荐资料

2.2 TCP/Socket Buffer 行为

需要理解

  • 内核 SO_RCVBUF:上游数据先到内核接收 buffer
  • 当 buffer 有数据时,recv() 系统调用立即返回,不阻塞
  • 上游发送速度 > 应用消费速度 → buffer 持续有数据 → body_reader() 永远不 yield
  • 这就是源码注释里说的:”body_reader() and ngx.flush() do not yield when the upstream socket already has data buffered”

推荐资料

2.3 协作式调度下的”饥饿”问题

需要理解

  • 一个协程持续不 yield,会饿死同 worker 上的所有其他协程
  • ngx.sleep(0) 只让出一次调度机会,不保证下次什么时候被唤醒
  • 如果让出后 socket buffer 又有数据,会立即被唤醒,等于没让
  • 真正的公平调度需要按时间片让出,不是按操作次数

推荐资料

2.4 性能分析与火焰图

学会用工具发现”代码看不出来的问题”:

  • Lua-land CPU 火焰图:定位 CPU 热点函数
  • Off-CPU 火焰图:定位阻塞调用
  • Yield latency/count:定位协程调度异常

推荐资料

2.5 lua-resty-http 源码

直接读 cosocket 客户端的实现,理解什么时候 yield、什么时候不 yield:

推荐资料

2.6 补充:操作系统 / 网络基础

以上知识的底层依赖:

  • 进程/线程/协程的区别:抢占式调度(OS 线程)vs 协作式调度(协程),为什么协作式中一个死循环能卡死所有并发
  • Socket Buffer 机制:内核 SO_RCVBUF / SO_SNDBUFrecv() 在 buffer 有数据时立即返回
  • IO 多路复用(epoll/kqueue):事件驱动模型,”就绪”的含义
  • 客户端断开检测:TCP RST/FIN 的传播路径,为什么”读”操作感知不到对端断开,必须靠”写”

推荐资料

知识点与 Bug 的对应关系

优先级 知识点 直接解释哪个 bug
P0 协作式调度 + yield 语义 两个都解释
P0 cosocket buffer 行为 问题 2(快速返回打满 CPU)
P1 TCP 断开检测机制 问题 1(客户端取消未感知)
P1 Socket buffer 机制 问题 2 的底层原因
P2 Nginx 事件循环 理解为什么单个协程能影响整个 worker
P2 Streaming backpressure 理解正确的修复方向

核心就一句话:协作式调度 + IO 操作在 buffer 就绪时不 yield = 潜在的 CPU 独占风险

建议阅读顺序

  1. 先看 cosocket 核心机制(30 分钟)— 立刻理解 yield 行为
  2. 再看 OpenResty 最佳实践 的 sleep 和 cosocket 章节(1 小时)
  3. 然后看 lua-resty-http 的 body_reader 源码(30 分钟)— 对应你遇到的具体代码
  4. 最后看 Beej’s Guide 的 recv/send 部分(1 小时)— 补 socket buffer 底层认知

总共 3-4 小时,足够建立起识别此类问题的知识框架。


三、读代码的方法论升级

针对协作式调度的代码(OpenResty、Node.js event loop 等),每看到一个循环都要问:

检查点 问题
退出条件 循环什么时候结束?依赖外部事件吗?外部事件不来会怎样?
yield 点 循环体里哪些调用会 yield?在什么条件下不 yield?
极端场景 假设所有 IO 都不阻塞(buffer 满),循环每秒能跑多少次?每次开销多少?
饿死风险 这个协程占用 CPU 时,同 worker 上的其他协程会怎样?
CPU 占比 计算 IO 时间和 CPU 时间的比例,CPU 占比 > 50% 就要警惕

应用到 parse_streaming_response

具体到这段代码,应该能回答:

问题 答案
body_reader() 什么时候 yield? socket buffer 为空时,等内核通知
body_reader() 什么时候不 yield? buffer 有数据时,直接 memcpy 返回
ngx.flush(true) 什么时候 yield? 下游 send buffer 满时
ngx.flush(true) 什么时候不 yield? buffer 未满时,立即返回
ngx.sleep(0) 能解决问题吗? 不能,让出后 buffer 有数据会立即被唤醒
最坏情况 CPU 占用? 100%,单个请求即可打满 worker

四、总结

这两个问题不是”代码没看仔细”,而是缺少一个分析维度:

在协作式调度环境中,任何”看起来是 IO”的操作都可能在特定条件下变成纯 CPU 操作,从而饿死整个 worker。

读 OpenResty 代码时,每看到一个循环,都要问”这个循环在最坏情况下会不会不 yield”。这是一个需要刻意练习的习惯。

补充上述知识后,再回头读这段代码,立刻能看到 while true 循环里的所有 yield 点都不可靠,自然会想到要加时间片让出或并发控制机制。