当 Prometheus 指标写满共享内存:一次 APISIX CPU 100% 问题的复现与分析
前几天在 APISIX 的 issue 列表里看到一个很有意思的故障(apache/apisix#12275):一套由 38 台网关组成的集群里,有 2 台突然 CPU 飙到 100%,彻底不干活了。运维的第一反应是把这两台的流量切走——奇怪的是,没有新的流量了,CPU 却还是死死钉在 100%,像被什么东西困住了一样。
排查一圈下来,唯一的异常线索是:负责存放监控指标的那块 100MB 共享内存,被海量路由产生的指标数据写得满满当当。提 issue 的人最后附了一个朴素的问题:“我把这块内存调大,是不是就没事了?”
这个问题勾起了我的好奇:内存写满和 CPU 空转,中间到底有什么关联?于是我决定在测试环境把这个故障从头到尾复现一遍,用火焰图把 CPU 到底在忙什么看个明白,顺便回答:调大内存,到底算不算解决了问题。
一、问题背景:一个字典,三方争抢
APISIX 的 prometheus 插件基于 nginx-lua-prometheus 实现,所有指标数据最终都集中在一块 nginx 共享内存字典(lua_shared_dict prometheus-metrics)里。围绕这块共享内存有三类角色在工作:
-
业务请求:每个开启了 prometheus 插件的路由,在 log 阶段记录
http_status、http_latency、bandwidth等指标。新版本的 nginx-lua-prometheus 会先把计数攒在 worker 本地表里,由每秒一次的 sync 定时器批量刷进共享字典; -
指标抓取:Prometheus server 定期访问
/apisix/prometheus/metrics,exporter 要对整个字典做get_keys(0)(一次性拷出全部 key,期间持有锁),然后逐 keyget并格式化成文本输出; -
锁:以上所有共享字典操作都要过同一把
ngx_shmtx互斥锁——先原子自旋,自旋耗尽后通过 POSIX 信号量睡眠等待,这一过程体现在系统调用上就是 futex。
关键在于指标的数量:每一个“路由 × 状态码 × 直方图桶”的组合都是字典里一个独立的 key。路由多、状态码杂,key 的数量是乘法增长的。字典写满后,新指标写入全部失败,error.log 里出现 no memory,但已有的几万个 key 还在,每次抓取仍要在锁内全量遍历一遍。
二、复现设计:把“量变”压缩成“质变”
原始故障要 38 个实例、海量路由才能积累出来,测试环境在此基础上等比例缩小:字典从 100M 缩到 4M,再用可控的手段把基数打上去。
环境:一台 Ubuntu 24.04(x86_64,4 核)服务器,docker 跑 etcd + apache/apisix。配置里只改三处:
nginx_config:
worker_processes: 4
http:
lua_shared_dict:
prometheus-metrics: 4m # 默认 10m,缩小以加速复现
plugins:
- prometheus
- serverless-pre-function
制造高基数有个小技巧:创建 400 条路由,每条挂一个 serverless 函数,按查询参数直接返回不同状态码,不需要真实上游,一条 curl 就能实现一个新的指标组合:
return function(conf, ctx)
local e = tonumber(ngx.var.arg_e) or 0
ngx.exit(200 + (e % 100))
end
然后 400 条路由 × 100 个状态码灌一遍流量(约 4 万个请求、4 万个 label 组合),字典很快就耗尽了。判定字典耗尽的信号有三个:
-
error.log 开始刷
prometheus.lua: Unexpected error adding a key: no memory(十分钟内 4 万多条); -
指标
apisix_nginx_metric_errors_total一路飙升; -
/apisix/prometheus/metrics的输出变短了,4M 字典只装下约 8000 条数据,后来的全部丢失。指标静默不全,往往比报错更危险。
最后加压:多路并发循环抓取 metrics 端点 + 多路业务流量,4 个 worker 的 CPU 全部拉起来。故障条件就绪。
三、采样:一条命令拿到 Lua + native 融合火焰图
复现只是第一步,接下来得看清 CPU 在处理什么——而 OpenResty 的性能剖析恰恰是个老大难的问题:C 栈里看不到 Lua,Lua 栈里看不到 C。传统做法是拿 SystemTap 脚本来做,环境依赖一大堆,GC64 的 LuaJIT 还经常不配合。这次我换了个省事的方法:APISIX 社区新推出的 debug 工具 ngxdig,它可以基于 eBPF 在一次栈回溯里同时拿到 Lua 帧和 native 帧。在做好之前的准备工作之后,一条命令对 nginx 工作进程采样:
sudo ngxdig cpu-on --pid <worker_pid> --duration 90s --kernel stack --format html
90 秒后得到一个自包含的 HTML 报告,火焰图可以直接在浏览器里交互。
四、读图:CPU 到底消耗在哪

火焰图的形状非常清晰,结论几乎是从图里自己跳出来的:
-
62% 的样本落在
[phase:content]→content_by_lua→ngx_http_lua_run_thread——也就是/apisix/prometheus/metrics这个端点本身。注意此时 Prometheus 甚至还没开始抓,是我们的并发抓取在互相踩踏。 -
展开往上看,热点集中在 exporter 的两处 Lua 代码:
exporter.lua:423/460(遍历全部指标)和prometheus.lua的metric_data()(get_keys(0)+ 逐 keyget+ 拼接文本)。字典里的 key 无论有没有写满都在,每次抓取都是一次全量 O(N) 的遍历。 -
LuaJIT 的
lj_str_new、lj_str_hash也排进了前列——几万个 metric key 字符串在每次抓取时反复驻留,这是高基数的隐性问题。

火焰图里的 phase 归属非常清晰,得益于 ngxdig 会给栈补充一个合成的 [phase:content]、[phase:log] 根帧,我们一眼就能把“CPU 花在指标导出”和“CPU 花在业务请求”分开,这正是这次诊断最需要的切分维度。
五、结论
回到最初的问题:“是不是把 prometheus 的共享字典调大就行?”现在我们可以确定:
调大字典是临时方案,不能治本。
这次复现给出的完整因果链是:
指标基数失控 → 字典写满 → 几万 key 的字典在每次抓取时被锁内全量遍历 → 抓取端点吃满 worker CPU,业务请求在同一把锁上排队 → 服务失去响应。
当前这个 bug 已经得到了社区的及时响应,修复代码已经合并进 master 分支。