运行时行为盲区:APISIX AI 网关CPU打满故障的AI辅助事后复盘
如何借助AI高效复盘?
起因
最近在使用 API7 的时候反复遇到 CPU 打满的问题:
- 下游客户端取消请求,但大模型还在持续输出导致 CPU 打满
- 表达式post_arg.*匹配多次decode
- 问题修复后,又出现了 CPU 毛刺,现在怀疑
- 是当上游 LLM 快速返回大量小 chunk,极少的并发甚至单个请求即可将 worker 的 CPU 打满 100%。比如下面这里:local chunk, err = body_reader()
- 单次 AI 请求对请求体执行了 3 次 JSON decode 和 1 次 encode,造成不必要的 CPU 开销 💡
除了decode问题,其他问题都是出在 apisix/plugins/ai-providers/base.lua 的 parse_streaming_response 函数。
💡 PR 为作者发现问题并提出修复方案的。
我现在想要复盘明确:
- 为什么这个问题在阅读代码的时候没有看出来?
- 针对这两个问题需要补充哪些知识?
🤖 借助 AI 进行系统性复盘:
我将上述 CPU 问题提交给 Claude Opus 4.6 / 4.7,让它帮助我:
- 🔍 分析”为什么阅读代码时未能发现这些问题”
- 📚 梳理需要补充的 OpenResty / 协作式调度知识体系
- ✅ 提供可操作的运行时代码审查 checklist
以下是 AI 协助完成的深度分析成果:
一、为什么阅读代码时没看出来
这两个问题有共同特征:代码逻辑是正确的,bug 藏在运行时行为中。这说明读代码时使用的是”逻辑正确性”视角,缺少”运行时行为”视角。
1.1 问题 1(下游客户端取消但模型继续输出)
读代码看到的是:
1 | local ok, flush_err = plugin.lua_response_filter(ctx, res.headers, chunk, true) |
逻辑没问题:flush 失败就退出循环。但心智模型默认了”客户端断开 → flush 立即失败”。实际情况是:
- 客户端发送 RST 后,nginx 不会立刻知道
- nginx 必须等到下次 write 操作才能感知客户端已断开
- 在感知到之前,
body_reader()仍在持续读上游数据、做 CPU 密集处理
认知盲区:默认了”理想错误传播路径”,没考虑错误检测的延迟。
1.2 问题 2(上游快速返回打满 CPU)
读代码看到的是:
1 | local chunk, err = body_reader() -- 看起来是 IO,应该会 yield |
心智模型是:”cosocket 是 IO 操作,自然会 yield”。这在大多数场景下是对的,但有一个关键认知盲区:
当 socket 接收 buffer 里已经有数据时,
body_reader()不会 yield,行为等同于 memcpy。
源码注释里 APISIX 开发者已经明确指出了这一点:
1 | body_reader() and ngx.flush() do not yield when the upstream socket |
这不是逻辑错误,是对 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
推荐资料:
- OpenResty 官方:cosocket 核心机制 — 解释 yield/resume 的触发条件
- Stack Overflow: What is the concurrency model in OpenResty? — 并发模型概览
- NGINX 官方博客:Lua 与 NGINX 的复杂之舞 — 陷阱和性能挑战
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”
推荐资料:
- Beej’s Guide to Network Programming: send() and recv() — TCP socket 基础
- Linux man page: tcp(7) —
SO_RCVBUF、TCP_NODELAY等 socket 选项
2.3 协作式调度下的”饥饿”问题
需要理解:
- 一个协程持续不 yield,会饿死同 worker 上的所有其他协程
ngx.sleep(0)只让出一次调度机会,不保证下次什么时候被唤醒- 如果让出后 socket buffer 又有数据,会立即被唤醒,等于没让
- 真正的公平调度需要按时间片让出,不是按操作次数
推荐资料:
- OpenResty 官方博客:When Lua IPC Pipes Block OpenResty/Nginx Event Loops — 经典案例:阻塞调用导致 worker CPU 利用率低、QPS 仅 130
- GitHub Issue: OpenResty Workers Infinite Loop (100% CPU) — busy loop 打满 CPU 的真实案例
- OpenResty 最佳实践:sleep —
ngx.sleep的正确用法
2.4 性能分析与火焰图
学会用工具发现”代码看不出来的问题”:
- Lua-land CPU 火焰图:定位 CPU 热点函数
- Off-CPU 火焰图:定位阻塞调用
- Yield latency/count:定位协程调度异常
推荐资料:
- OpenResty 官方博客:Introduction to Lua-Land CPU Flame Graphs — 火焰图入门
- OpenResty 官方博客:Pinpointing hottest Lua code paths — 用火焰图定位 CPU 100% 问题
- OpenResty 官方博客:Analyzing the Most CPU-Consuming Requests — 找出最耗 CPU 的请求
- Brendan Gregg: Flame Graphs — 火焰图发明者的权威介绍
2.5 lua-resty-http 源码
直接读 cosocket 客户端的实现,理解什么时候 yield、什么时候不 yield:
推荐资料:
- lua-resty-http 源码 — 看
body_reader的实现 - openresty/lua-nginx-module 文档 — 官方 API 文档,关注
ngx.socket.tcp相关章节 - OpenResty Reference — API 速查
2.6 补充:操作系统 / 网络基础
以上知识的底层依赖:
- 进程/线程/协程的区别:抢占式调度(OS 线程)vs 协作式调度(协程),为什么协作式中一个死循环能卡死所有并发
- Socket Buffer 机制:内核
SO_RCVBUF/SO_SNDBUF,recv()在 buffer 有数据时立即返回 - IO 多路复用(epoll/kqueue):事件驱动模型,”就绪”的含义
- 客户端断开检测:TCP RST/FIN 的传播路径,为什么”读”操作感知不到对端断开,必须靠”写”
推荐资料:
- 《UNIX Network Programming, Volume 1》W. Richard Stevens — 第 2 章 TCP、第 7 章 Socket Options、第 6 章 IO 多路复用
- Beej’s Guide to Network Programming — 免费、简洁,快速理解 socket send/recv 的阻塞/非阻塞行为
- Linux man: tcp(7) — socket buffer 相关内核参数速查
知识点与 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 独占风险。
建议阅读顺序
- 先看 cosocket 核心机制(30 分钟)— 立刻理解 yield 行为
- 再看 OpenResty 最佳实践 的 sleep 和 cosocket 章节(1 小时)
- 然后看 lua-resty-http 的 body_reader 源码(30 分钟)— 对应你遇到的具体代码
- 最后看 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 点都不可靠,自然会想到要加时间片让出或并发控制机制。