一次升级引出两个独立缺陷:API7 AI 网关流式 500 与低流量 CPU 打满排查
生产 AI 网关从 API7 3.9.14 升级到 3.9.19-patch.1 后,陆续出现两个问题:流式请求偶发 500,客户端收不到任何内容;56 节点的多个 OpenResty worker 反复接近 100% CPU,流量却没有明显增长。一次升级踩中两个互相独立的软件缺陷,异常最明显的节点还叠加了调频策略差异。
下面先给结论,再回溯排查过程。中间被推翻的猜想我都标了出来,免得和最终结论混在一起。
结论摘要
问题一:流式请求偶发 500
- 根因:SSE 消息分片暂存期间,旧逻辑仍标记“需要刷新”;后台定时器触发后发送接口返回
nothing to flush(只表示当前没有内容可刷新),代码却把它当成客户端断连,主动停止读取上游。错误发生在响应内容发出之前,所以客户端一个字都收不到。- 触发条件:取决于分块到达与定时刷新的先后顺序,与响应长短无关,因此时好时坏。
- 处理:临时把
streaming_flush_interval_ms配为0,绕开后台刷新路径;patch.3 修复状态判断后撤销临时配置。问题二:低流量下多 worker 高 CPU
- 根因:
nginx-lua-prometheus-api7升到 1.0.0-1 带来的索引同步回归。过期指标重新注册会让全局delete_count变化,其他 worker 下次同步就做一次 O(key_count) 的全量槽位扫描,扫描里的get、ttl反复争抢 shared dict 的全局锁,烧掉的是 user CPU。key_count只增不减,CPU 消耗跟着历史累计槽位走,不跟流量走,所以低 QPS 也能打满。- 触发条件:高基数标签加
expire: 1800的指标不断生成、过期、重新注册;worker 越多,竞争越明显。- 处理:重建 Pod 清零累计索引只是止血;最终把
nginx-lua-prometheus-api7回退到 3.9.14 所带的 0.20250302-1,随新 patch 交付,网关保持 3.9.19。- 影响:除问题一的流式 500 外,CPU 异常时段没有观察到 5xx 增长;可感知的主要是响应时间略有增加,幅度没有单独量化。
一句话教训:重启只清状态,不修 bug。软件 bug 也解释不了“只有一台机器告警”,那是另一层要查的基础设施差异。
背景:链路与环境
链路很短:
1 | 客户端(Codex、curl、业务程序) → API7 网关(3 个节点) → Azure(GPT 系列模型) |
三个节点是 10.200.76.54、55、56,各 16 核。两个问题都出现在升级后,下面分别排查,最后看节点差异。
先说几个后文会反复出现的词
熟悉 OpenResty 的读者可以跳过这段。
- shared dict 和它的锁:
ngx.shared.DICT是所有 worker 共享的一块内存字典,为保证并发一致,每次读写都要过同一把互斥锁(ngx_shmtx_lock),纯读操作也一样。多个 worker 高频访问同一块字典时,抢锁和自旋退避直接消耗 user CPU,不体现为 iowait。 KeyIndex与三个计数:KeyIndex是nginx-lua-prometheus-api7里的槽位下标缓存,每个 worker 本地一份,用来避免每次从头遍历字典。它牵涉三个数:key_count:历史累计分配过的槽位数,只增不减;delete_count:全局删除计数,指标槽位过期回收后又被重新注册就 +1;self.deleted:worker 本地记下的delete_count快照,和全局值对不上就触发全量重扫。
expire与remove_expired_keys_interval:前者是单个条目活多久(本文配的 1800 秒),后者是清理任务跑多勤(库内默认 3600 秒)。- cpufreq governor:内核的调频策略,
performance尽量保持高频,powersave倾向降频省电(intel_pstate下行为不同,后文会说到)。同样的 user CPU 工作,频率越低耗时越长,也越容易撞上告警阈值。
问题一:流式请求返回 500
先说结论:patch.1 改造了公共流式路径,消息拼接与定时刷新的配合又有缺陷,两者叠加,把“没有内容可刷新”误判成了客户端断连,从而中止上游读取;错误发生在响应发出之前,所以表现为客户端收不到内容、日志记 500。
现象与三组对照
把 API7 代理的 GPT 系列模型配到 Codex 后,用着用着就报错,客户端什么都收不到。翻网关访问日志,对应请求记的是 500。最初做了三组对照:
| 对照方式 | 结果 | 当时能缩小的范围 |
|---|---|---|
| 同样的请求体直连 Azure | 正常 | 降低上游异常和请求格式问题的嫌疑,优先检查网关 |
原请求去掉 tools,仍经过网关 |
正常 | 说明请求形态影响触发条件,但不能据此认定 tools 不合法 |
保留原请求,将 stream 改为 false |
正常 | 优先检查流式处理路径 |
这里我绕了个弯:第二组让我怀疑了半天,以为是 tools 里某个非标准参数的问题,后来才知道它只是绕开了触发条件:请求形态一变,分片到达与定时刷新的先后顺序也跟着变,是否踩中纯看时序,tools 本身并不非法。对照实验能缩小范围,根因还得靠复现和代码确认。
复现
最初换上游测试没有复现。拿到完整 curl、网关日志、路由配置和 Azure 流式响应,再放进相同版本镜像(3.9.19-patch.1),问题随即出现。关键是保留原始请求与环境,缺一样都可能复现不出来。
根因:新改动踩中旧缺陷
原因分两块。
一块是 patch.1 为了修内容审核,改了公共流式路径。有些模型的流式回答不带 usage,可能导致完整回答没被审核,于是网关改成先收齐一条完整的 SSE 消息,再交给后面的插件处理和发送。没开内容审核的请求,也会经过这段逻辑。
另一块是消息拼接与定时刷新配合出了问题。先补一点背景:SSE 一条消息可能被拆成多个 TCP 分块到达网关,不完整的内容先暂存、等拼齐;但旧逻辑仍然标记“需要刷新”。后台定时器此时触发,发送接口返回 nothing to flush。这个返回值只表示当前没有内容可刷新,不足以判断网络已经断开;旧代码却把它按客户端断连处理,主动停止读取 Azure。
完整的错误链如下图:分片暂存期间触发后台刷新,nothing to flush 被误判为断连,链路在响应内容发出之前就断了。
是否触发,取决于分块到达与定时刷新的先后顺序,并不以长响应为前提,所以表现为时好时坏。
还有一个放大排查成本的坑:误判断连的记录是 INFO 级别,生产却配了 WARN,所以看不到对应日志;最后能定位,靠的是复现结果与流式路径的代码对照,而不是日志。没有错误日志,不等于错误路径没有发生。
缓解与修复
临时缓解是将受影响路由的 streaming_flush_interval_ms 配为 0,生产验证有效,不需要删 tools 或关闭流式。
这个参数属于 ai-proxy 插件,ai-proxy-multi 也暴露了它。官方文档中的默认值是 10,取值大于等于 0;这次涉及的两条刷新路径是:
- 大于 0:后台定时器每 N 毫秒调用
ngx.flush(false),将已准备好的输出攒批刷新。 - 等于 0:不启用后台刷新定时器,改走
ngx.flush(true)的同步刷新路径。
配 0 绕开了出问题的后台路径,但放弃攒批也可能增加发送开销,需要观察实际 CPU 变化。ngx.flush(true) 等待时挂起的是当前请求协程,不是阻塞整个 Nginx worker;慢客户端仍可能推迟当前请求的后续上游读取,参见 OpenResty 的流式响应说明。
上线时只改受影响路由,观察 5xx、499、客户端首字延迟、worker CPU 和上游耗时。包含代码修复的 patch.3 验证通过后,撤掉临时配置,恢复默认的 10 毫秒。
代码修复有两处:只在内容交给下游响应处理后才安排刷新;返回“没有内容可刷新”时继续等待,不再据此判断断连。SSE 拼接逻辑仍然保留,修复的关键是区分“暂时无内容可刷”和“客户端断开”。
问题二:多个 worker 跑满,流量却不高
问题二的根因是 nginx-lua-prometheus-api7 1.0.0-1 的索引同步回归,让 delete_count 变化触发了 O(key_count) 的全量槽位扫描,扫描中的 get、ttl 争抢 shared dict 全局锁,烧掉 user CPU。key_count 是历史累计值,生产上一度达到 57 万,所以与当前流量无关。
每小时的 flush_expired() 是另一条路径。监控里的反例(见下文)说明回收量大小与 CPU 无关,它更像触发小时级短尖峰的扳机,而不是 CPU 本身。重建 Pod 清零索引状态算止血,根治靠回退该依赖。
现象与排除
升级 patch.1 后,56 节点开始频繁出现每小时一两分钟的 CPU 尖峰,后来又出现多 worker 反复满载的密集尖峰。现场 9 个 OpenResty worker 都在 99% 以上,第 10 个约 38%,合计消耗约 9.3 核——不是单个 worker 的短暂毛刺,而是 10 个 worker 里 9 个同时跑满,总量几乎顶到 license 允许的 10 核上限。

最先想到的是流量洪峰。但峰值时三个节点的 QPS 分别是 77.5、74.6、52.3,56 的流量最低,CPU 却冲到约 9 核(上图红线)、逼近 license 允许的 10 核上限,另外两台只有 1.1 到 1.4 核。ES 日志里的请求数、流式请求数、进出字节和 Token 也都没有洪峰。
网络、内存和磁盘没有对应异常:软中断很低,没有丢包和队列溢出,内存充足、没有 swap,iowait 接近 0。异常时段 CPU 消耗中约 97%~98% 在 user mode,排查因此转向 OpenResty 的用户态执行路径。
业务侧倒是没有跟着出事:除问题一的流式 500 外,CPU 尖峰时段没有观察到 5xx 增长,客户端可感知的影响主要是响应时间略有增加,幅度没有单独量化。症状落在延迟上、不落在错误率上,也正因为不报错,发现这类问题基本只能靠 CPU 告警和延迟曲线。
到这里只能说,当前请求量不足以解释节点间的 CPU 差异,但还不能据此断言“与业务负载完全无关”。
监控反例:回收量大小和 CPU 对不上
最直观的嫌疑就是那个每小时一次的清理 timer。先检验它:CPU 飙高到底是不是 flush_expired() 造成的?
把两天的监控按分钟聚合,CPU 曲线其实有两种形态:
| 对比项 | 形态①:每小时固定时刻的短尖峰 | 形态②:密集尖峰群 |
|---|---|---|
| 形状 | 每小时固定时刻上的短尖峰,每次一两分钟 | 不是持续不降的高原,而是多 worker 高 CPU 尖峰密集连发,单次几秒到几十秒,中间回落 |
| 出现时刻 | 和每小时一次的清理同一时刻;细看曲线,尖峰在清理后 30~60 秒达到峰值,约 90 秒落回 | 一波接一波,成片时段的起止落在两次 timer 之间 |
| 定时清理说得清吗 | 只说得清“为什么在这个时刻”,说不清幅度 | 说不清 |
清理时刻为什么固定?每个 worker 从进程启动那一刻起,每 3600 秒跑一次清理,于是每小时都固定在同一秒触发,比如 25 分 30 秒。CPU 尖峰只有同样出现在这个时刻上,才能说和清理相关;那片密集尖峰明显对不上。
再把回收量和 CPU 对照起来看,两个方向都对不上。回收量取共享字典占用空间的单次下降量,CPU 取回收时刻前后十分钟的节点峰值(rate 取 1 分钟窗口、30 秒步长,用更细的步长重算结果相同,不是平滑抹掉了尖峰):
| 时刻(56 节点) | 回收量 | 节点 CPU 峰值 | 同期单 worker 最高 |
|---|---|---|---|
| 09-16 14:18(清理时刻) | 8.63 MiB | 4.74 核 | 1.00 核,4 个 worker 同时接近满载 |
| 09-16 23:22(清理时刻) | 12.53 MiB | 0.78 核 | 0.14 核 |
| 09-20 12:25(清理时刻) | 17.07 MiB | 1.35 核 | 0.18 核 |
| 09-20 14:45(不在清理时刻) | 5.30 MiB | 7.35 核 | 0.76 核,10 个 worker 同时在 0.5 核以上 |
前两行的数据来自 5 worker 时代;9 月 17 日晚上每个节点的 license 从 5 核调整为 10 核,worker 也增到 10 个,后两行都在 10 worker 时代。
大量回收可以几乎不消耗 CPU。12:25:30 的小时清理如期执行,共享字典占用空间掉了一个台阶,单次回收 17.07 MiB,是白天最大的一次定时清理回收,节点 CPU 峰值却只有 1.35 核,全部 worker 都在 0.2 核以下。同一天 12:22、12:29,另外两台节点各自回收 19.75、18.82 MiB,CPU 峰值也只有 0.96、0.72 核。小量回收反而可以很贵:14:45:00 只回收 5.30 MiB,节点 CPU 却冲到 7.35 核。
把 9 月 16 日白天“每小时 5.5~21.8 MiB 的回收伴随 2.4~4.7 核”当成规律,是把同一时刻上的共现当了因果:同一天夜里 22:22、23:22 两次 8.14、12.53 MiB 的同类回收,CPU 只有 0.75、0.78 核。
光看表格容易觉得是挑出来的个例,那就把 9 月 20 日 56 节点一整天的 CPU 和回收画到同一条时间线上:上面是节点 CPU,下面是每次回收的时间和大小。回收从 1 MiB 到 18 MiB 什么量级都有,除凌晨 3 点到 9 点的空档外,几乎每小时都有;但密集尖峰群只出现在上午 10 点后和下午 1 点后那两段,中午 12:25 那次 17.07 MiB 的大回收正好落在平静段里,晚上的 8~18 MiB 回收也都没掀起尖峰。图里橙色的回收发生在清理时刻之外,反倒全部压在尖峰群下面。再把 9/14~9/22 三个节点全部 522 次 ≥1 MiB 的回收事件放到一起算,回收量与回收前后 10 分钟 CPU 峰值的相关系数只有 0.18。

顺带把来源也分开:监控里的“空间回收”并非来自同一条路径。和清理落在同一时刻的,是 flush_expired() 的批量清理;不在这个时刻上的回收(如 14:45:00 那次)更可能来自写路径的 LRU 淘汰,它和密集尖峰是同一股 churn 的两个侧面。混在一个口径里比较,才会出现“回收量相同、CPU 完全不同”的怪相。
所以“回收了多少”不是自变量:定时清理最多解释短尖峰的时刻,解释不了它的幅度,更解释不了那片密集连发的尖峰。还得找第二条触发路径。
依赖 diff:两个提交,两条路径
接下来把升级的变更面完整 diff 一遍,间接依赖也算上:
1 | 3.9.14 nginx-lua-prometheus-api7 0.20250302-1 |
线索落在该依赖两个版本之间的两个提交:
1 | 845084f 2026-06-23 过期指标重新出现时 KeyIndex:add() 增加全局 delete_count |
第一条是计数变化后的全量扫描。KeyIndex:sync() 的关键分支如下(简化摘录,只保留与故障相关的分支;完整实现见上游 prometheus_keys.lua 的 KeyIndex,API7 的 fork 发布为 LuaRocks 包 nginx-lua-prometheus-api7):
1 | -- N 为当前已知的槽位上界,随 key_count 增长 |
触发链是:配置了 expire: 1800 的指标槽位过期并被物理回收,但某个 worker 的本地索引还保留着它;后续请求尝试续期时,dict:expire() 返回 not found,于是走 add() 重新注册,全局 delete_count 加一。其他 worker 下次 sync() 发现本地 self.deleted 对不上,就执行 sync_range(0, N):
1 | for i = 0, N do |
每个历史槽至少需要一次 shared dict get,仍有值的槽还要查一次 ttl。循环里没有让出执行权的操作,同一 worker 上的其他请求,包括正在输出的流式响应,都得等待这段扫描结束。
这次的坑在于:key_count 是历史累计分配过的槽位上界,在这版实现中只增不减,扫描成本取决于历史槽位,而不是当前 /metrics 的 samples 数。低流量也能让 worker 跑满,就是这个原因。
第二条是每小时的无上限清理:
1 | function KeyIndex:remove_expired_keys() |
该调用持有 shared dict 的 mutex 遍历 LRU 队列。每个 worker 各建一份清理 timer,间隔取库内 remove_expired_keys_interval 的默认值 3600 秒,这个版本的 APISIX 没有覆盖。因此它有能力把多个 worker 在每小时的同一时刻附近一起牵扯进来。
两条路径放在一起对比:
| 对比项 | 路径 A:delete_count 变化后的全量扫描 |
路径 B:每小时 flush_expired() |
|---|---|---|
| 触发时机 | 任何请求触发过期指标重新注册之后 | 每 3600 秒的清理 timer |
| CPU 形态 | 形态②:密集尖峰群 | 形态①落在它的时间表上,但归因见下 |
| perf 证据 | 23 份中 17 份 shared-dict 锁热点 | 23 份都没采到 flush_expired |
| 监控证据 | 受控复现的 worker 签名与两种形态一致 | 同量级回收多次全程平缓(17.07 MiB → 1.35 核、12.53 MiB → 0.78 核),清理自身几乎不耗 CPU |
| 定位 | 本批尖峰的主因 | 扳机与放大器,独立 CPU 来源证据不足 |
形态① 的归因还得再核一遍。它虽然和清理落在同一时刻,但有三个细节对不上“清理自身烧 CPU”:尖峰在清理后 30~60 秒才到峰值、约 90 秒落回;幅度与回收量无关;worker 分布是 4 个接近单核满载、其余偏低,这正是后文受控复现里路径 A 的签名(签名指各 worker CPU 占用的特征分布,可用来反推故障机制)。
更一致的读法是:timer 批量回收槽位只是扣下扳机,CPU 实际花在随后的“过期指标重新注册 → delete_count 变化 → 其他 worker 全量扫描”上。白天有请求持续访问过期指标,清理一结束就有重新注册跟上;夜里没有请求,同样量级的回收就几乎不消耗 CPU。
边界也要说清:perf 的 23 份报告都取自单 worker 持续 ≥80% 的密集尖峰段,形态① 没有独立 perf 实证,这层归因是时序与签名的推断。
生产配置让问题更容易出现:http_status、http_latency、bandwidth 都配置了 expire: 1800,部分指标带有 app、project、request_host 等高基数标签;histogram 又会把每组标签展开成多个 bucket。大量时序不断生成、过期和重新注册。排障期间新增的 pid 只用于一个指标,额外时序有限,不是主要因素。
worker 从 5 个增至 10 个,还可能放大并发竞争。
perf 实证:热点在共享字典锁上
现场脚本每秒采集 worker CPU,发现持续 ≥80% 后,自动记录系统快照并运行 perf record,最终拿到 23 份报告。
其中 17 份呈现 shared-dict 锁热点:ngx_shmtx_lock 的 Self CPU 中位数 66.47%,ngx_meta_lua_ffi_shdict_get 的 Children CPU 中位数 73.99%,get_ttl 占比明显更低。Self 和 Children 口径不同,不能相加。
23 份报告都没采到 flush_expired 符号。未采到不等于从未执行,但结合尖峰成片出现、与 timer 的固定时刻对不上,它作为这批尖峰主因的可能性明显降低;每小时清理的风险仍保留。
perf 里的 ngx_meta_lua_ffi_shdict_get、ngx_meta_lua_shdict_lookup、ngx_meta_lua_ffi_shdict_get_ttl 分别对应 get、字典查找和 ttl。get、ttl 各自需要竞争同一把锁,获取失败时的自旋退避会消耗 CPU。这里不是一个 worker 持锁扫完整张索引,而是多个 worker 各自扫描,在反复访问共享字典时竞争同一把锁。这与 flush_expired() 在一次清理中持锁遍历,是两种不同的竞争形态。
get 高、ttl 低,与“大量历史槽已失效”的模型一致,但还不能据此反推槽位数量。高 CPU 尖峰成片出现时,9 到 10 个 worker 会反复同时落入热点。
内部计数与受控复现
key_count 和 delete_count 当时没有暴露成指标,我通过仅限运维来源访问的临时诊断入口读取。读取时需要区分共享计数与 worker 本地缓存,并将读数与各 worker CPU、共享字典剩余空间对齐到同一时间线。这类入口应在排查结束后删除。
56 节点重建前的 key_count 已经达到 57 万,实例重建后降到 1 万多。前者意味着一次全量同步要扫数十万个历史槽位。key_count 表示扫描规模,delete_count 的变化则提示后续同步可能走全量路径。
受控实验使用同版本镜像,起 5 个 worker,先构造出已回收的槽位,再触发一次计数变化。结果复现了生产中的形态:发起变更的 worker 保持低 CPU,其余 4 个同时接近单核满载(发起方为什么低,没有继续拆解)。去掉外部指标抓取后仍能复现,说明外部抓取不是必要条件。
浓缩成最小复现步骤(描述的是必要条件与顺序,具体命令和配置随版本略有差异):
- 用同版本镜像(
nginx-lua-prometheus-api7 1.0.0-1)起实例,5 个 worker; - 配置带
expire: 1800的指标,最好带高基数标签,制造持续的槽位分配与过期; - 等待槽位被物理回收,但各 worker 本地
KeyIndex仍保留旧下标; - 触发一次“过期指标重新注册”,使全局
delete_count变化; - 观察:发起变更的 worker CPU 保持低位,其余 worker 同时接近单核满载,持续几秒到几十秒;perf 中可见
ngx_shmtx_lock热点; - 去掉外部 Prometheus 抓取后仍可复现。
依赖变更给出触发机制,perf 指向锁竞争,内部计数解释扫描规模,受控实验复现多 worker 高 CPU。厂商(API7)的最终报告也确认了根因,几组证据到这里才真正对上。
止血、配置缓解与代码修复
先止血:读取三个节点的计数后,逐个删除并重建 gateway Pod。普通 reload 会保留 shared dict,这次通过重建实例清掉了累计索引状态,CPU 飙升暂时没有再出现。但这只清零状态,不修复 bug。
如果暂时不能交付代码修复,配置层面可以减少触发和放大条件:
- 收敛高基数标签:优先检查带过期时间的指标,移除非必要的
app、project、request_host维度及临时排障标签。 - 评估过期策略:延长过期时间可以减少短周期回收与重新注册;关闭过期则要先评估时序数量和共享字典容量,不能把状态增长问题换成容量问题。
- 区分两条路径:调大
remove_expired_keys_interval只能降低定时清理频率,不能修复delete_count变化后的全量扫描;是否能调整还取决于对应版本有没有暴露配置入口。 - 滚动重建实例:清掉已经膨胀的历史状态,但不能代替算法修复。
修复选择:为什么是整体回退
摆在桌面上的有三条路:在 nginx-lua-prometheus-api7 1.0.0-1 上继续修这两个缺陷、只撤销那两个相关提交、把该依赖整体回退到 3.9.14 所带的 0.20250302-1。最终选的是第三条——网关保持 3.9.19,保留其他功能与修复,只把 Prometheus 依赖回退到 3.9.14 的版本,随新 patch 交付,并非整套网关降级。
选回退,主要是图它的确定性:回退目标是 3.9.14 长期生产验证过的行为,属于回到已知状态;在 1.0.0 上继续修补,等于在新版本上叠加补丁,回归验证成本更高。改动面也是个考虑,两个缺陷都在 LuaRocks 依赖内部,一个在索引同步、一个在清理逻辑,只撤销单个提交能否干净回退,要看后续提交是否依赖前面的改动。代价同样清楚:放弃 1.0.0 相对 0.20250302-1 的其他改动,将来若要重新升级,这两个提交的问题必须先解决,验收也要覆盖索引状态重新积累的过程。
说白了,取舍的不是哪个方案更优雅,是哪个能最快回到已验证的行为。
节点差异:为什么总是 56 这台
查 CPU 问题时还有一件事:同样的代码、没有明显流量洪峰,告警却几乎都落在 56 节点。软件缺陷解释不了这种严重程度差异;除了当前 QPS,还得比较进程运行时长、key_count、delete_count 变化速率和机器配置。
机器侧确实发现了差异:三台跑的都是 Ubuntu 内核 5.4.0,但补丁级别不同——56 是 5.4.0-135-generic(2022 年 11 月构建),54/55 都是 5.4.0-90-generic(2021 年 10 月构建),56 的内核反而新了约一年。CPU 型号三台相同,都是 Xeon Silver 4110,但 56 在 lscpu 中显示的 CPU MHz 更低。再看调频策略:
1 | cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor |
不同驱动和模式下,powersave 行为不同,不能据此断言 CPU 被固定在低频,参见 intel_pstate 文档。一次 lscpu 读数也不足以代表整个故障窗口。
统一 governor 之后效果比较明显:对比前后两天的相同时段,56 的 CPU 总消耗曲线明显下移,节点间的差距也随之缩小。不过 56 仍略高于另外两台。还要注意,这次对比没有单独控制历史索引状态和业务负载,只能算生产前后对照,够不上受控实验。
所以调频差异的定位是:生产对照中下降明显,是确有影响的放大因素,但它的独立贡献没有量化,剩余差异还得从内核小版本、累计索引状态和负载分布继续找。已确认的 CPU 根因仍是 Prometheus 索引同步缺陷。
处理结果与验证边界
几个动作解决的层次不同,不能统一概括成“升级后好了”:
| 问题 | 已有处理与观察 | 结论边界 |
|---|---|---|
| 流式 500 | 配 0 的临时方案在生产有效;patch.3 验证通过并升级后,撤销临时配置 | 规避后台路径与修复状态判断,是两种不同措施 |
| 多 worker 高 CPU | 重建后累计计数下降、CPU 飙升暂未再出现;Prometheus 依赖回退(nginx-lua-prometheus-api7 回到 0.20250302-1)通过新 patch 交付 |
重建清状态属于止血;依赖回退针对回归路径 |
| 节点差异 | governor 统一后 CPU 明显下移,节点间差距缩小,但 56 仍略高 | 生产前后对照,不能单独量化调频贡献;剩余差异的原因未定位 |
踩坑与经验
这里把最容易栽跟头的几个坑,和几次值得保留的判断转向,放在一起说。
- 没有错误日志不等于错误路径没发生。 误判断连的记录是 INFO 级,生产配 WARN 就完全看不到。
nothing to flush不是断连信号,它只表示暂时无内容可刷。拿它推断网络状态,就会误杀上游读取。- 重启、重建实例只是清零状态,不修 bug。 shared dict 里的累计索引要重建实例才清得掉,但回归还在,状态重新积累后问题会回来。
- CPU 消耗取决于历史累计槽位,不取决于当前流量或活跃指标数。
key_count只增不减,57 万历史槽位足以让低 QPS 场景跑满 CPU。 - shared dict 的读操作也要抢锁。 循环里的大量
get、ttl一样能烧 user CPU,表现是 CPU 高而不是 iowait。 - “只有一台机器告警”指向另一层问题。 软件 bug 解释不了节点间严重程度差异,要往下查内核版本、调频、电源策略;但找到一个配置差异,不等于解释了差异,不能就此停止比较其他变量。
- 资源打满不等于业务报错。 CPU 尖峰密集出现期间没有 5xx,只有响应时间略有增加;只盯错误率会漏掉这类故障,资源告警和延迟曲线同样要看。
- 把间接依赖纳入变更面。 只 diff 网关代码,会漏掉这次的 Prometheus 回归。
- 让反例改变假设,也复查对照的口径。 回收 17.07 MiB 只有 1.35 核、5.30 MiB 却伴随 7.35 核,说明“回收量”从来不是 CPU 的自变量,9 月 16 日白天看到的只是共现不是因果。perf 和受控复现再把范围收窄。
下次要更早做两件事:多 worker 高 CPU 尖峰密集出现时立刻抓 perf;复现时一次备齐完整请求、原始响应和相同版本环境。
AI 助手主要帮忙统计 perf、聚合监控和整理源码线索,本文的整理与润色由 mimo-v2.6-pro 完成;判断仍要回到现场证据,不能让一个看似合理的解释盖过反例。工具怎样参与分析,见《运行时行为盲区:API7 AI 网关 CPU 饱和故障的 AI 辅助复盘》。
尚未验证与遗留问题
这几项结论边界要留着,避免把“有嫌疑”读成“已证实”:
- powersave 的独立贡献未量化:只确认 56 是 powersave、频率更低;统一 governor 后 CPU 下降明显、节点差距缩小,但 56 仍略高,剩余差异未定位,也没有做受控对比。要验证需先控制版本、worker 数、索引状态和负载,再在同一节点切换 governor 对比频率、CPU 与请求延迟。目前的处置是把内核版本、调频与电源策略加入节点基线巡检,同时保留对应用累计状态的检查。
- 长期效果未量化:修复 CPU 问题的补丁上线后效果如何,本文拿不出量化证据——既没有记录补丁的完整构建号和观察时长,也没有修复前后的错误率、延迟对比。因此只能说修复已交付、短期未复发,不对长期效果下结论。
- 验收未闭环:后续验收还需覆盖正常业务与指标抓取、小时级周期和索引状态重新积累,不能只看重建后的短暂平稳。