运行时行为盲区:API7 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 进行系统性复盘

我将上述我要复盘的问题提交给 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 操作(即 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
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 行为的认知盲区。

“看起来” 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_SNDBUFTCP_NODELAYTCP_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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
时间 ──────────────────────────────────────────────────────────────→

✅ 正常情况(sleep 有效):上游发送慢,buffer 经常为空
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
协程A body_reader() sleep(0) ═══════ 其他协程执行 ══════ body_reader()
(你的请求) [buffer空→yield] [让出] ←── 真正拿到了 CPU 时间 ──→ [buffer空→yield]

这段时间其他协程
正常处理请求

❌ 问题场景(sleep 失效):上游发送快,buffer 始终有数据
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
协程A body_reader() sleep(0) ║ body_reader() sleep(0) ║ body_reader()
(你的请求) [buffer满→返回] [让出] ║ [buffer满→返回] [让出] ║ [buffer满→返回]
║ ↑ ↑ ║
║ └── 几乎瞬间被唤醒 ────┘ ║
║ (buffer 又满了) ║
║ ║
其他协程:║═════════════════════════════════║
(饿死) ║ 拿不到任何 CPU 时间! ║
╙━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
→ CPU 100% 🚨

一句话总结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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
-- lua-resty-http 简化后的核心逻辑
function new_response(socket)
local self = {
socket = socket,
buffer = "", -- ← 这就是"应用层 buffer"
-- ...其他字段
}

-- body_reader 是一个闭包,能访问 self.buffer
local function body_reader()
-- 第1步:先检查自己的应用层 buffer
if self.buffer ~= "" then
local data = self.buffer
self.buffer = "" -- 取出后清空
return data, nil -- ← 直接返回!不调用 cosocket!
end

-- 第2步:应用层 buffer 为空,才走 cosocket
local data, err, part = self.socket:receive("*a")
if not data then
return part, err
end

-- 如果读到的数据比需要的多,多余的存到 buffer
if #data > remaining then
self.buffer = data:sub(remaining + 1) -- ← 多余的存起来
data = data:sub(1, remaining)
end

return data, nil
end

return { body_reader = body_reader }
end

关键洞察:注意第 1 步——body_reader 自身也有一个应用层 buffer。即使底层 cosocket yield 了回来,数据也可能先缓存在这个 buffer 里。下次调用时直接从这里返回,连 cosocket 都不会走到

三次调用的执行路径演示(上游一次发了 3 个 chunk 的量):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
第 1 次 body_reader():
① self.buffer = "" → 跳过
② socket:receive() → 读到 [chunk1][chunk2][chunk3]
③ #data > remaining? → YES! 返回 chunk1,[chunk2][chunk3] 存入 buffer
──────────────────────────────────────────────────────────→ 返回 chunk1 ✅

第 2 次 body_reader():
① self.buffer = "[chunk2][chunk3]" → 不为空!
→ 直接返回 chunk2,清空 buffer = "[chunk3]"
──────────────────────────────────────────────────────────→ 返回 chunk2 ⚠️ 没走 cosocket!

第 3 次 body_reader():
① self.buffer = "[chunk3]" → 不为空!
→ 直接返回 chunk3,清空 buffer = ""
──────────────────────────────────────────────────────────→ 返回 chunk3 ⚠️ 没走 cosocket!

结果:3 次调用中只有第 1 次可能走到 cosocket(还不一定 yield),
第 2、3 次连 cosocket 都没碰到 → 绝对不可能 yield

两层 Buffer 完整结构

“应用层 buffer”指的是 lua-resty-http 库内部维护的一个 Lua 字符串变量 self.buffer,不是内核 socket buffer,也不是 nginx buffer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
┌─────────────────────────────────────────────────────────────┐
│ APISIX Worker 进程 │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Lua 代码 (你的代码) │ │
│ │ │ │
│ │ while true do │ │
│ │ local chunk, err = body_reader() │ │
│ │ → 调用 lua-resty-http 的 body_reader 闭包 │ │
│ │ end │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ┌──────────────────────▼──────────────────────────────┐ │
│ │ lua-resty-http (库代码) │ │
│ │ │ │
│ │ body_reader(): │ │
│ │ │ │
│ │ ① self.buffer 有数据? │ │
│ │ ├── YES → 直接返回(纯 memcpy) │ │
│ │ │ ↑ │ │
│ │ │ 【应用层 buffer】 │ │
│ │ │ (Lua 字符串变量,无系统调用) │ │
│ │ │ │ │
│ │ └── NO ↓ │ │
│ │ ② 调用 self.socket:receive() │ │
│ │ → 进入 OpenResty cosocket 层 │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ┌──────────────────────▼──────────────────────────────┐ │
│ │ OpenResty / Nginx (C 层) │ │
│ │ │ │
│ │ socket:receive(): │ │
│ │ ③ 内核 recv buffer 有数据? │ │
│ │ ├── YES → memcpy 到 Lua → 返回(不 yield) │ │
│ │ │ ↑ │ │
│ │ │ 【内核 SO_RCVBUF】 │ │
│ │ │ │ │
│ │ └── NO → 注册 epoll 事件 → yield 协程 ✅ │ │
│ └──────────────────────┬──────────────────────────────┘ │
│ │ │
│ ┌──────────────────────▼──────────────────────────────┐ │
│ │ OS 内核 │ │
│ │ │ │
│ │ SO_RCVBUF ← 上游 LLM 发送的数据存在这里 │ │
│ └─────────────────────────────────────────────────────┘ │

“不 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
场景:某个执行单元进入 while true 死循环

═══ 抢占式调度(OS 线程 / Go goroutine / Erlang process)═══

时间 ────────────────────────────────────────────────→

CPU ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐
│T1│ │T2│ │T1│ │T3│ │T1│ │T2│ ...
│ │ │ │ │██│ │ │ │██│ │ │
└──┘ └──┘ └──┘ └──┘ └──┘ └──┘
↑ ↑ ↑
正常运行 🔴死循环中 ⚡时间片到!内核强制打断

结果:其他线程/协程 **正常工作** ✅ 死循环只浪费自己的时间片

═══ 协作式调度(OpenResty Lua / Node.js / Python asyncio)═══

时间 ───────────────────────────────────────────────→

CPU ┌──────────────────────────────────────────────┐
│ 协程A: while true do │
│ body_reader() │
│ ngx.flush() │
│ ← 没有任何 yield! │
│ ← 调度器永远不会介入! │
│ ← 其他协程永远等不到! │
│ ████████████████████████████████████████████│
└──────────────────────────────────────────────┘
(协程B、C、D... 😵 😵 😵 全部饿死)

结果:整个 worker **完全卡死** ❌ 单个请求即可打满 100% CPU

一句话记住:抢占式 = “保镖”,你犯错它也把你拉开;协作式 = “君子协定”,你不让座别人永远没机会坐。

Socket Buffer 数据流示意

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
                  上游 LLM 服务端
┌─────────────────┐
│ │
│ send(data) │
└────────┬────────┘
│ 数据经过网络传输

┌──────────────────────────────────────────────────┐
│ OS 内核 (APISIX 所在机器) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ SO_RCVBUF │ │ SO_SNDBUF │ │
│ │ (接收 buffer) │ │ (发送 buffer) │ │
│ │ │ │ │ │
│ │ [chunk1][ch2] │ │ │ │
│ │ [chunk3][...] │ │ │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ recv() │ │ send() │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ 用户空间 (OpenResty / Lua) │ │
│ │ │ │
│ │ socket:receive() → memcpy ← 从 RCVBUF │ │
│ │ ngx.flush() → memcpy → 到 SNDBUF │ │
│ └─────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘


下游客户端 (浏览器)

关键行为

操作 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
                  ┌──────────────────────────────┐
│ OS 内核 (epoll) │
│ │
注册阶段 │ epoll 实例 │
────────── │ ┌─────────────────────┐ │
│ fd=3 (上游) ──→│ │ 红黑树 (interest) │ │
│ fd=5 (下游) ──→│ │ ├─ fd3: EPOLLIN │ │
│ fd=7 (定时器) ──│ │ ├─ fd5: EPOLLOUT │ │
└ epoll_ctl() │ │ └─ fd7: EPOLLIN │ │
│ └────────┬────────────┘ │
│ │ │
等待阶段 │ ▼ │
────────── │ ┌─────────────────────┐ │
│ epoll_wait() ──→│ │ 就绪队列 (ready) │ │
│ (阻塞/超时) │ │ │ │
└ 返回就绪 fd │ │ [fd3] ← 数据到了! │ │
│ │ [fd5] ← 可写了! │ │
│ └─────────────────────┘ │
└──────────────┬────────────────┘

用户空间处理 ▼
┌──────────────────────────────────┐
│ OpenResty 事件循环 │
│ │
│ while true do │
│ ready_fds = epoll_wait() │
│ for _, fd in ipairs(ready_fds) do
│ if fd == fd3 then │
│ -- 上游数据到达 │
│ resume(等待fd3的协程) ✅ │
│ end │
│ if fd == fd5 then │
│ -- 下游可写 │
│ resume(等待fd5的协程) ✅ │
│ end │
│ end │
│ end │
└──────────────────────────────────┘

关键点:
┌─────────────────────────────────────────────────────┐
│ yield = 把当前协程从 CPU 上拿下来,挂到 epoll 等待队列 │
│ resume = epoll 说"你等的 fd 就绪了",把协程放回调度队列 │
│ │
│ 如果 fd **始终就绪**(buffer 有数据)→ epoll_wait 立即返回 │
│ → 协程 resume 后继续跑 → 但下次 recv 又立即返回 │
│ → 永远不会真正"等" → busy loop ❌ │
└─────────────────────────────────────────────────────┘

一句话记住:epoll 是 OpenResty 调度的基础设施——协程的 yield/resume 本质上就是”从 epoll 队列中摘除 / 挂回”。但如果数据永远在 buffer 里,epoll 会每次都告诉你”就绪”,协程就永远不会真正休息。

TCP 断开检测为何延迟?—— 事件链追踪

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[客户端下游]                    [APISIX nginx worker]
│ │
├── 用户关闭浏览器 / 取消请求 │
▼ │
├── 发送 TCP RST 包 ──────────────→ │ ⚠️ RST 已到达 OS 内核
│ (nginx 事件循环尚未处理)
✕ 客户端已断开 │

═════════════════════════════════════════╡ T2: body_reader()
│ ├─ socket:receive(上游) → OK ✅
│ └─ ❌ 感知不到!读的是上游连接,跟下游无关
│ │
═════════════════════════════════════════╡ T3: ngx.flush(true)
│ ├─ 写数据到下游 → buffer 未满 → OK ✅
│ └─ ❌ 感知不到!数据还在 nginx 内部 buffer,没真正 write
│ │
═════════════════════════════════════════╡ T4: ngx.flush(true)
│ ├─ 写数据到下游 → buffer 满了 → 调用 write()
│ └─ 💥 ERR! "connection reset" ✅ 终于检测到!
│ 内核在 write() 时把之前收到的 RST 抛出

关键结论

操作 方向 能否感知 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_SNDBUFTCP_NODELAY/TCP_CORKTCP_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 共同的根因。

建议阅读顺序

  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”的调用都有不 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌─────────────────────────────────────────────────────────────┐
│ OpenResty 进程 │
│ │
│ ┌─── Worker 1 ───┐ ┌─── Worker 2 ───┐ ┌─── Worker N ──┐ │
│ │ Core 0 │ │ Core 1 │ │ Core N-1 │ │
│ │ │ │ │ │ │ │
│ │ [协程A] │ │ [协程X] │ │ [协程M] │ │
│ │ [协程B] │ │ [协程Y] │ │ [协程K] │ │
│ │ [协程C] │ │ [协程Z] │ │ │ │
│ │ ↓ │ │ ↓ │ │ │ │
│ │ epoll_wait() │ │ epoll_wait() │ │ │ │
│ └────────────────┘ └────────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
↑ ↑ ↑
事件驱动,非阻塞IO 各自独立 并行处理

关键点:

  • 单个 Worker 内部:协程协作式调度,共享一个 CPU 核心 → 所以 while 死循环会打满这一个核心 100%
  • 多个 Worker 之间:每个 Worker 占一个核心 → 所以 CPU 饱和现象是 100% × N cores

这解释了为什么”单 Worker 单核心”架构本身没问题

特性 说明
无锁竞争 每个 Worker 独立内存空间,不需要锁
CPU 缓存友好 单线程 = L1/L2 缓存命中率高
无上下文切换开销 协程切换 ≈ 函数调用(纳秒级)vs OS 线程切换(微秒级)
天然负载均衡 OS 调度器自动把 Worker 分配到不同核心

这个架构的设计假设:你的代码是 IO 密集型的(网络请求、数据库查询),而不是 CPU 密集型的。 用对地方是神器(QPS 可达 100K+),用错地方(CPU 死循环)就是自毁武器——这正是本次故障复盘的核心教训。