运行时行为盲区:API7 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 进行系统性复盘:
我将上述我要复盘的问题提交给 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 操作(即
ngx.flush()调用时)才能感知客户端已断开 - 在感知到之前,
body_reader()仍在持续读上游数据、做 CPU 密集处理
“下次 write 操作”具体指什么?
就是当前代码中的
lua_response_filter()内部调用ngx.flush()的那一刻。完整时间线如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 T0: 客户端发送 RST (关闭浏览器/Ctrl+C/取消请求)
│
│ ⚠️ 此时 nginx worker 完全不知道客户端已断开!
│ (TCP RST 包还在网络传输中,或已被 OS 内核缓存)
│
T1: body_reader() 从上游读取 chunk N
│ → 这是 read 操作,不会触发断开检测 ❌
│
T2: lua_response_filter() 处理数据
│
T3: ngx.flush(chunk) ← 💡 这就是"下次 write 操作"!
│
├── 如果是 ngx.flush() (异步):
│ → 数据写入 socket send buffer 就立即返回 ✅
→ 但不检查客户端是否还在!❌
│
└── 如果是 ngx.flush(true) (同步):
→ 等待数据真正发送到客户端
→ 发送时发现客户端已断开 → 返回错误 ✅核心区别:
操作类型 能否检测到客户端断开 原因 read ( body_reader())❌ 不能 读 socket 只检查 recv buffer 有无数据 write ( ngx.flush()) 异步❌ 不能 数据丢进 send buffer 就返回,不等发送结果 write ( ngx.flush(true)) 同步✅ 能 等待内核确认发送,此时会收到 RST 错误 所以 PR #13254 的修复就是将
ngx.flush()改为ngx.flush(true),让每次 flush 都能及时检测到客户端断开。
认知盲区:默认了”理想错误传播路径”,没考虑错误检测的延迟。
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 行为的认知盲区。
“看起来” vs “实际上”:
| 层面 | 心智模型(看起来) | 实际运行时行为 |
|---|---|---|
| 操作类型 | IO 读取(从 socket 读数据) | 可能是纯内存拷贝 |
| 预期行为 | 没数据时阻塞等待 → yield ✅ | buffer 有数据时立即返回 → 不 yield ❌ |
| CPU 影响 | 低(大部分时间在等 IO) | 高(持续占用 CPU) |
两种场景对比:
- 场景 A(正常情况,会 yield)✅:上游 LLM 发送慢 → socket recv buffer 为空 →
body_reader()向内核注册等待 → yield 让出 CPU - 场景 B(问题场景,不 yield)❌:上游 LLM 发送快 → recv buffer 始终有数据 →
body_reader()直接 memcpy 返回 → 不 yield →ngx.sleep(0)让出后 buffer 又满 → 立即被唤醒 → while true 循环变成 busy loop → CPU 100% 🚨
1.3 根本原因:从”逻辑正确性”到”运行时行为”的思维升级
读代码时只问了 ❌ “逻辑正确性”问题:
| 审查维度 | 典型问题 |
|---|---|
| 数据流 | 数据流对不对? |
| 错误处理 | 错误处理覆盖全吗? |
| 边界条件 | 边界条件处理了吗? |
但完全忽略了 ✅ “运行时行为”问题:
| 审查维度 | 典型问题 | 对应本文 Bug |
|---|---|---|
| yield 分析 | 这个循环在最坏情况下会不会不 yield? | 问题 2:buffer 满时 body_reader() 不 yield |
| IO 语义 | 这个 IO 调用在什么条件下变成纯 CPU 操作? | 问题 2:memcpy 式读取导致 busy loop |
| 协程公平性 | 这个协程会不会饿死同 worker 上的其他协程? | 问题 1 & 2:单个请求打满 CPU |
核心教训:在 OpenResty 等协作式调度环境中,代码审查必须从静态的”逻辑是否正确”升级为动态的”运行时行为是否安全”。任何”看起来是 IO”的操作都可能在特定条件下变成纯 CPU 操作,从而饿死整个 worker。
二、需要补充的知识
2.1 OpenResty 协作式调度模型(最核心 ⭐)
核心概念速查表:
| 知识点 | 内容 | 解释了哪个 Bug |
|---|---|---|
| 单线程多协程 | Nginx worker 内所有请求通过协程并发执行 | 为什么单个请求能打满整个 worker CPU |
| 协作式调度 | 协程必须主动 yield,否则其他协程饿死 | 问题 1 & 2:不 yield → 同 worker 其他请求卡死 |
| yield 点 | 仅限 cosocket IO 等待、ngx.sleep()、ngx.flush(true)(buffer 满时) |
明确知道哪些操作是”安全”的让出点 |
| buffer 就绪不 yield | cosocket IO 如果数据已在 buffer 中,直接 memcpy 返回 | 问题 2:上游发送快 → buffer 始终有数据 → busy loop |
一句话总结:协作式调度 + IO 操作在 buffer 就绪时不 yield = 潜在的 CPU 独占风险
推荐资料(按阅读顺序):
| 资料链接 | 核心收获 | 阅读时间 |
|---|---|---|
| OpenResty 官方:cosocket 核心机制 | yield/resume 的触发条件,什么时候会 yield、什么时候不会 | 30 min ⭐ 必读 |
| Stack Overflow: OpenResty 并发模型 | 协作式调度的宏观理解,与 OS 线程模型的对比 | 10 min |
| NGINX 官方博客:Lua 与 NGINX 的复杂之舞 | 生产环境中的真实陷阱和性能挑战案例 | 20 min |
2.2 TCP/Socket Buffer 行为
核心概念速查表:
| 知识点 | 内容 | 解释了哪个 Bug |
|---|---|---|
内核 SO_RCVBUF |
上游发送的数据先进入 OS 内核的接收 buffer,应用层再从中读取 | 理解”数据就绪”的物理位置 |
recv() 非阻塞返回 |
当 buffer 中已有数据时,recv() 系统调用立即 memcpy 返回,不阻塞等待 |
问题 2:body_reader() 为什么看起来像 IO 却不 yield |
| 速度差导致 buffer 堆积 | 上游发送速度 > 应用消费速度 → buffer 持续有数据 → 每次读取都立即返回 → busy loop | 问题 2 的直接原因:上游 LLM 快速返回小 chunk |
| 源码注释印证 | APISIX 源码已明确标注:“body_reader() and ngx.flush() do not yield when the upstream socket already has data buffered” | 官方确认了这个行为 |
一句话总结:socket buffer 是上游与应用之间的”中转站”,当中转站始终有货时,取货操作(recv)永远不会”等货”(yield)
推荐资料(按阅读顺序):
| 资料链接 | 核心收获 | 阅读时间 |
|---|---|---|
| Beej’s Guide: send() and recv() | TCP socket 收发基础、阻塞/非阻塞行为、buffer 机制入门 | 30 min ⭐ 必读 |
| Linux man page: tcp(7) | SO_RCVBUF/SO_SNDBUF、TCP_NODELAY、TCP_CORK 等 socket 选项详解 |
15 min(速查用) |
2.3 协作式调度下的”饥饿”问题
核心概念速查表:
| 知识点 | 内容 | 解释了哪个 Bug |
|---|---|---|
| 协程饥饿 | 一个协程持续不 yield,同 worker 上所有其他协程无法执行 | 为什么单个请求能让整个 worker “卡死” |
ngx.sleep(0) 的局限 |
只让出一次调度机会,不保证下次何时被唤醒 | 问题 2 中加了 sleep(0) 却仍然 CPU 100% 的原因 |
| “假让出”现象 | 让出后 socket buffer 又有数据 → 立即被唤醒 → 等于没让 | 上游发送快时 sleep(0) 完全无效 |
| 时间片 vs 操作次数 | 公平调度需要按时间片让出(如每 10ms),而非按操作次数 | 正确的修复方向参考 |
ngx.sleep(0) 为何失效?—— 时序对比
1 | 时间 ──────────────────────────────────────────────────────────────→ |
一句话总结:
ngx.sleep(0)是”礼貌性让座”,但如果让座后座位立刻又被自己占回(buffer 就绪唤醒),那其他协程永远坐不下。
推荐资料(按阅读顺序):
| 资料链接 | 核心收获 | 阅读时间 |
|---|---|---|
| OpenResty 官方博客:Lua IPC Pipes 阻塞事件循环 | 经典案例:阻塞调用导致 worker CPU 利用率异常、QPS 仅 130 的排查过程 | 20 min ⭐ 必读 |
| GitHub Issue: OpenResty Workers Infinite Loop (100% CPU) | busy loop 打满 CPU 的真实 issue 讨论和解决方案 | 10 min |
| OpenResty 最佳实践:ngx.sleep | ngx.sleep 的正确用法、常见误区、与 ngx.timer 的区别 |
15 min |
2.4 性能分析与火焰图
核心工具速查表:
| 工具 | 用途 | 对本文 Bug 的价值 |
|---|---|---|
| Lua-land CPU 火焰图 | 定位 CPU 热点函数,看到哪些 Lua 代码占用了最多 CPU 时间 | 直接定位 parse_streaming_response / body_reader 的 CPU 占比 |
| Off-CPU 火焰图 | 定位阻塞调用,看到协程在哪些地方被阻塞、阻塞了多久 | 验证 body_reader() 是否真的在 yield(还是伪 IO) |
| Yield latency/count | 统计每个协程的 yield 次数和间隔,发现调度异常 | 发现某个请求的 yield 间隔趋近于 0(busy loop 特征) |
| Per-request CPU 分析 | 找出最耗 CPU 的单个请求,关联到具体 URL/参数 | 锁定触发 Bug 的具体请求模式 |
一句话总结:静态代码审查只能发现”逻辑错误”,火焰图等动态分析工具才能发现”运行时行为异常”——这正是两个 Bug 在代码审查中漏掉的原因。
推荐资料(按阅读顺序):
| 资料链接 | 核心收获 | 阅读时间 |
|---|---|---|
| OpenResty: Introduction to Lua-Land CPU Flame Graphs | 火焰图入门:如何生成、如何解读、典型案例 | 20 min ⭐ 必读 |
| OpenResty: Pinpointing hottest Lua code paths | 用 XRay + 火焰图定位 CPU 100% 问题的完整流程 | 15 min |
| OpenResty: Analyzing the Most CPU-Consuming Requests | 按 request 维度分析 CPU 开销,找到”元凶请求” | 15 min |
| Brendan Gregg: Flame Graphs | 火焰图发明者的权威介绍,理解原理与最佳实践 | 30 min(进阶) |
2.5 lua-resty-http 源码(⭐ 关键:理解 body_reader 的 yield 行为)
为什么必须读源码?
文档只会告诉你 API 怎么用,但不会告诉你:
body_reader()内部什么条件下调用socket:receive()会 yield、什么时候直接返回- 返回的 chunk 是从 buffer 拷贝的还是新分配的
- 多次调用之间是否有状态依赖
核心代码路径速查:
| 源码位置 | 关键行为 | 与本文 Bug 的关系 |
|---|---|---|
body_reader 闭包实现 |
调用 self.socket:receive(*args) 读取数据 |
这是 parse_streaming_response 中循环调用的函数 |
socket:receive() (cosocket) |
当 recv buffer 有数据 → memcpy + 立即返回;buffer 空 → 注册事件 → yield |
解释了问题 2 中”看起来是 IO 却不 yield”的根因 |
request() 方法 |
发送 HTTP 请求后返回 response + body_reader | 理解 streaming 响应的完整生命周期 |
set_keepalive() / close() |
连接归还连接池或关闭 | 问题 1 修复中 abort_on_disconnect 需要正确关闭上游连接 |
读源码时重点关注的 yield 判断逻辑:
1 | -- lua-resty-http 简化后的核心逻辑 |
关键洞察:注意第 1 步——
body_reader自身也有一个应用层 buffer。即使底层 cosocket yield 了回来,数据也可能先缓存在这个 buffer 里。下次调用时直接从这里返回,连 cosocket 都不会走到。
三次调用的执行路径演示(上游一次发了 3 个 chunk 的量):
1 | 第 1 次 body_reader(): |
两层 Buffer 完整结构
“应用层 buffer”指的是 lua-resty-http 库内部维护的一个 Lua 字符串变量 self.buffer,不是内核 socket buffer,也不是 nginx buffer:
1 | ┌─────────────────────────────────────────────────────────────┐ |
“不 yield”有两层原因:
| 层级 | Buffer | 不 yield 的原因 |
|---|---|---|
| 第 1 层:应用层 | self.buffer (Lua 字符串) |
连 socket:receive() 都不调用,纯内存操作 |
| 第 2 层:Cosocket | 内核 SO_RCVBUF |
调用了 receive 但数据已就绪,不需要等 |
最坏情况链路:上游一次发送多个 chunk → cosocket 一次性读到大量数据 → 多余部分存入 self.buffer → 后续多次 body_reader() 全部从应用层 buffer返回 → 连 cosocket 那一层都碰不到 → 绝对不可能 yield → 即使加了 ngx.sleep(0) 也无效
推荐资料(按阅读顺序):
| 资料链接 | 核心收获 | 阅读时间 |
|---|---|---|
| lua-resty-http 源码 (ledgetech) | 重点看 body_reader 闭包和 socket:receive 调用链 |
30 min ⭐ 必读 |
| openresty/lua-nginx-module 文档 | 官方 cosocket API 文档,关注 tcp:receive 的行为说明 |
20 min |
| OpenResty Reference: Lua Nginx API | API 速查手册,快速查阅 socket 相关接口 | 10 min(常备) |
2.6 补充:操作系统 / 网络基础
以上所有知识的底层依赖:
核心概念速查表:
| 知识点 | 内容 | 解释了哪个 Bug |
|---|---|---|
| 抢占式 vs 协作式调度 | 抢占式(OS 线程):内核按时间片强制切换,死循环也能被抢走 CPU;协作式(协程):必须主动 yield 才切换,不 yield 就永远占用 | OpenResty 单个 worker 内是纯协作式——一个 Lua 协程死循环 = 整个 worker 卡死。如果是 OS 多线程环境,内核的抢占调度会强制切走失控线程 |
| Socket Buffer 机制 | 内核为每个 TCP 连接维护 SO_RCVBUF(接收)和 SO_SNDBUF(发送),recv()/send() 实际操作的是这些 buffer |
问题 2 的最底层原因:buffer 有数据 → 不阻塞 |
| IO 多路复用(epoll/kqueue) | 事件驱动模型:注册 fd + 关注事件 → 内核通知”就绪” → 用户处理。OpenResty 的事件循环基于此构建 | 理解 “yield = 把协程挂到 epoll 等待队列上” |
| TCP 断开检测 | RST(硬断开)/ FIN(优雅关闭)通过 TCP 协议传播;read 方向感知对端写关闭(FIN),但 RST 需要下次 write/read 时才暴露 | 问题 1:客户端发 RST 后,nginx 必须 write 才能检测到 |
抢占式 vs 协作式:死循环的不同命运
1 | 场景:某个执行单元进入 while true 死循环 |
一句话记住:抢占式 = “保镖”,你犯错它也把你拉开;协作式 = “君子协定”,你不让座别人永远没机会坐。
Socket Buffer 数据流示意
1 | 上游 LLM 服务端 |
关键行为:
| 操作 | Buffer 状态 | 行为 | 是否阻塞/yield |
|---|---|---|---|
recv() |
SO_RCVBUF 有数据 | 直接 memcpy 返回 | ❌ 不阻塞 |
recv() |
SO_RCVBUF 为空 | 挂起等待数据到达 | ✅ 阻塞(协程 yield) |
send() |
SO_SNDBUF 未满 | 直接 memcpy 写入 | ❌ 不阻塞 |
send() |
SO_SNDBUF 已满 | 挂起等待空间释放 | ✅ 阻塞(协程 yield) |
本文问题 2 的触发条件:上游 LLM 发送速度快 → SO_RCVBUF 始终有数据 → 每次 recv() 都立即返回 → cosocket 层不 yield → busy loop
IO 多路复用(epoll/kqueue):事件驱动模型
1 | ┌──────────────────────────────┐ |
一句话记住:epoll 是 OpenResty 调度的基础设施——协程的 yield/resume 本质上就是”从 epoll 队列中摘除 / 挂回”。但如果数据永远在 buffer 里,epoll 会每次都告诉你”就绪”,协程就永远不会真正休息。
TCP 断开检测为何延迟?—— 事件链追踪
1 | [客户端下游] [APISIX nginx worker] |
关键结论:
| 操作 | 方向 | 能否感知 RST | 原因 |
|---|---|---|---|
body_reader() |
读上游 | ❌ 不能 | 读的是上游连接,跟下游无关 |
ngx.flush() (buffer 未满) |
写下游但没真正发 | ❌ 不能 | 数据还在 nginx 内部 buffer,没调 write |
ngx.flush() (buffer 满或 forced) |
写下游真正调用 write | ✅ 能 | 内核在 write 时检查连接状态,返回 ECONNRESET |
一句话总结:TCP 断开检测不是实时的——RST 包到达后需要等到下一次对该连接的 I/O 操作才会被应用层感知。这就是问题 1 中”客户端断了但代码不知道”的根本原因。
推荐资料(按阅读顺序):
| 资料链接 | 核心收获 | 阅读时间 |
|---|---|---|
| Beej’s Guide to Network Programming | 免费、简洁,快速理解 socket send/recv 的阻塞/非阻塞行为、TCP 状态机 | 1 h ⭐ 必读 |
| Linux man: tcp(7) | SO_RCVBUF/SO_SNDBUF、TCP_NODELAY/TCP_CORK、TCP_KEEPALIVE 等参数速查 |
15 min(速查用) |
| 《UNIX Network Programming Vol.1》Stevens — 第 2 章 TCP、第 7 章 Socket Options、第 6 章 IO 多路复用 | 网络编程”圣经”,系统性地建立完整的知识体系 | 数周(长期参考书) |
知识点与 Bug 的对应关系
先回顾本文复盘的两个核心 Bug:
- 问题 1(客户端取消未感知):客户端断开连接后,
parse_streaming_response循环继续读上游、解析 JSON,白白消耗 CPU- 问题 2(CPU busy loop):上游 LLM 快速返回小 chunk 时,
body_reader()+ngx.flush()都不 yield,循环空转打满 CPU
| 优先级 | 知识点 | 解释了什么 | 对应 Bug |
|---|---|---|---|
| P0 | 协作式调度 + yield 语义 | 为什么”看起来是 IO 的代码”可能不 yield → 导致整个 worker 被独占 | 问题 1 + 问题 2 |
| P0 | cosocket / 应用层 buffer 行为 | body_reader() 在 buffer 有数据时直接返回(不经过 cosocket),连 yield 的机会都没有 |
问题 2 的直接原因 |
| P1 | TCP 断开检测机制 | RST 包到达后必须等到下一次 write 才能被应用层感知 → 读操作检测不到下游断开 | 问题 1 的直接原因 |
| P1 | Socket buffer (SO_RCVBUF) | 内核 buffer 有数据时 recv() 立即返回不阻塞 → cosocket 层也不会 yield | 问题 2 的底层原因 |
| P2 | Nginx 单进程事件循环 | 一个 worker 内所有协程共享同一个线程 → 一个协程死循环 = 整个 worker 死锁 | 两个 Bug 的放大器 |
| P2 | Streaming backpressure | 正确做法是根据消费速度控制读取速度,而非无脑 while true 循环 | 正确的修复方向 |
一句话总结:协作式调度下,IO 操作在 buffer 就绪时不 yield = 潜在的 CPU 独占风险。这是两个 Bug 共同的根因。
建议阅读顺序
- 先看 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”的调用都有不 yield 的分支路径 |
| 极端场景 | 假设所有 IO 都不阻塞(buffer 满),循环每秒能跑多少次?每次开销多少? | 每次循环包含 JSON decode/encode 等纯 CPU 操作,且频率无上限 |
| 饿死风险 | 这个协程占用 CPU 时,同 worker 上的其他协程会怎样? | 没有 ngx.sleep() 或其他显式让出机制 |
| CPU 占比 | 计算 IO 时间和 CPU 时间的比例,CPU 占比 > 50% 就要警惕 | 无法估算上界——循环速度完全取决于上游发送速度 |
应用到 parse_streaming_response
把上面的检查清单具体到这段代码:
| 问题 | 答案 | 详见 |
|---|---|---|
body_reader() 什么时候 yield? |
socket buffer 为空时 → 注册 epoll 事件 → 等内核通知 | 2.2 TCP/Socket Buffer 行为 |
body_reader() 什么时候不 yield? |
两层都可能不 yield:① 应用层 buffer 有数据(直接返回);② 内核 SO_RCVBUF 有数据(cosocket 不 yield) | 2.5 lua-resty-http 源码 |
ngx.flush(true) 什么时候 yield? |
下游 send buffer 满时 → 调用 write → 可能阻塞 → yield | 2.1 OpenResty 协作式调度模型 |
ngx.flush(true) 什么时候不 yield? |
buffer 未满时 → 数据存入 nginx 内部 buffer → 立即返回 | 同上 |
ngx.sleep(0) 能解决问题吗? |
❌ 不能!让出后如果 buffer 又有数据(上游持续发送),会被立即唤醒,等于没让 | 2.3 协作式调度下的”饥饿”问题 |
| 最坏情况 CPU 占用? | 100%,单个请求即可打满整个 worker | — |
诊断结论:这段代码的
while true循环中,body_reader()和ngx.flush(true)在上游快速返回场景下全部退化成纯 CPU 操作,没有任何可靠的 yield 点。加上每次循环还有 JSON 解析开销,形成完美的 CPU 风暴。
四、总结
这两个问题的本质
这两个 Bug 不是”代码没看仔细”,而是缺少一个分析维度:
在协作式调度环境中,任何”看起来是 IO”的操作都可能在特定条件下变成纯 CPU 操作,从而饿死整个 worker。
静态代码审查只能发现逻辑错误(变量未定义、条件判断错误等),但无法发现运行时行为异常。本文的两个 Bug 都属于后者——代码逻辑正确,但在特定运行时条件下表现出灾难性的性能行为。
行动 Checklist
下次写 / review OpenResty 协作式调度代码时,逐项确认:
- 每个
while/for循环都有明确的、可控的退出条件 - 循环体内的每个”IO 操作”都已确认:最坏情况下是否还会 yield
- 如果有不 yield 的风险,是否加了时间片让出(如累计 CPU 时间超过 Nms 后强制 sleep)
- 是否处理了客户端断开场景(不能只依赖 write 报错来检测)
- 上游发送速度快于消费速度时,是否有backpressure 机制
- 用火焰图或 CPU profiling 验证过最坏情况的 CPU 占比
一句话收尾
读 OpenResty 代码时,每看到一个循环,都要问:“这个循环在最坏情况下会不会不 yield?” 这不是直觉,是需要刻意练习的习惯——也是本文两个 Bug 用 CPU 100% 买来的教训。
知识迁移:其他语言的协程有这个问题吗?
有的有,有的没有。关键区别在于调度器是否支持”抢占”。
| 语言 | 调度模式 | 有这个问题吗? | 说明 |
|---|---|---|---|
| OpenResty / Lua | 纯协作式 | ✅ 有 | 必须显式 yield,否则永远不切换 |
| Node.js | 纯协作式(单线程事件循环) | ✅ 有 | 同步 while 循环 / 大量 CPU 计算会阻塞事件循环 |
| Python asyncio | 纯协作式 | ✅ 有 | 没有 await 的循环会卡死事件循环 |
| C++20 协程 | 纯协作式 | ✅ 有 | 不 co_await 就永远不会让出 |
| Go goroutine | 协作 + 抢占混合 | ⚠️ 基本没有 | Go 1.14+ 引入基于信号的异步抢占,长时间运行会被强制切换 |
| Erlang / Elixir | 抢占式 | ❌ 没有 | 每次函数调用后都检查抢占,每个进程有最大执行时间限制(约 1ms) |
| Java 虚拟线程 (JDK 21+) | 协作式 + 监控 | ⚠️ 大部分情况没有 | JVM 监控运行时间,长时间运行的虚拟线程可被抢占(除非被 Pin 到平台线程) |
核心规律:纯协作式调度(OpenResty、Node.js、Python)为了零开销切换,牺牲了安全性——程序员必须自己保证正确性。抢占式或混合式调度(Go、Erlang)用少量性能代价换来了”单个协程失控不会拖垮整个系统”的保证。
但即使使用 Go/Erlang,理解协作式调度的 yield 行为仍然有价值——它帮助你写出更高效的代码(减少不必要的上下文切换),也让你在需要极致性能时能做出正确的架构选择。
知识延伸:一个线程/协程能用多个 CPU 核心吗?
不能。 这是理解 CPU 打满问题的关键前提。
| 类型 | 能否使用多核 | 原因 |
|---|---|---|
| 操作系统线程 | ❌ 同一时刻只能在一个核心上 | OS 调度器会把线程在不同时间段分配到不同核心(时间片轮转),但任意时刻仍是单核 |
| 协程(Go/Lua/Python) | ❌ 不能 | 协程是用户态轻量级线程,最终必须绑定到 OS 线程执行 |
| 进程 | ✅ 可以 | 多线程的进程可以利用多核并行 |
OpenResty 的 Worker 模型如何利用多核?
1 | ┌─────────────────────────────────────────────────────────────┐ |
关键点:
- 单个 Worker 内部:协程协作式调度,共享一个 CPU 核心 → 所以
while死循环会打满这一个核心 100% - 多个 Worker 之间:每个 Worker 占一个核心 → 所以 CPU 饱和现象是 100% × N cores
这解释了为什么”单 Worker 单核心”架构本身没问题
| 特性 | 说明 |
|---|---|
| 无锁竞争 | 每个 Worker 独立内存空间,不需要锁 |
| CPU 缓存友好 | 单线程 = L1/L2 缓存命中率高 |
| 无上下文切换开销 | 协程切换 ≈ 函数调用(纳秒级)vs OS 线程切换(微秒级) |
| 天然负载均衡 | OS 调度器自动把 Worker 分配到不同核心 |
这个架构的设计假设:你的代码是 IO 密集型的(网络请求、数据库查询),而不是 CPU 密集型的。 用对地方是神器(QPS 可达 100K+),用错地方(CPU 死循环)就是自毁武器——这正是本次故障复盘的核心教训。