很多人用 APISIX 做网关,一开始只关心路由、插件、负载均衡,等真正遇到线上延迟抖动,才想起来底层调优。这篇文章就围绕延迟敏感型业务,把 CPU 绑定和内存池配置的一些关键点从头到尾聊一遍。我不会堆砌术语,尽量用大白话把原理和操作讲清楚,即使你只是刚接触 OpenResty 和 APISIX,也能照着做。

一、为什么延迟敏感型业务要关注 APISIX 的底层配置

网关是流量的第一道门,每次请求都要在这里完成转发、鉴权、限流等一系列操作。如果网关在处理请求时出现没有规律的抖动,调用方就会超时,进而是重试风暴,最后把整个服务压垮。延迟敏感型业务,比如证券行情、在线游戏、实时音视频信令,对单个请求的耗时有硬性要求,可能要求在 5 毫秒或者 10 毫秒内返回。这时候,CPU 调度是否公平、内存分配是否高效,就成了决定性因素。

APISIX 底层是 OpenResty,OpenResty 又是 Nginx 加上 LuaJIT。Nginx 的事件模型本身非常高效,但在多核服务器上,如果进程和核心没有绑定关系,操作系统就会时不时把进程从一个核心挪到另一个核心。这一挪,CPU 缓存就失效了,然后又要重新去内存里加载数据。一两次还好,请求量一大,这种缓存失效带来的延迟就会叠加,让你的 P99 曲线变得很难看。

内存池则更隐蔽。Nginx 有自己的内存池机制,每个连接或请求都会从池子里分配内存。如果池子初始大小不合适,或者共享内存被随意耗尽,就会触发额外的内存申请,甚至会阻塞关键路径。APISIX 里的很多插件都要用共享内存来存路由、计数器、限流数据等。配得不好,就像你把一个仓库既当停车场又当餐厅,谁都进不去,谁都等。

二、动手之前先看两个核心概念

在修改配置之前,我们要把 CPU 绑定和内存池这两个概念用通俗的方式理解透。

2.1 CPU 绑定到底绑的是什么

CPU 绑定,业界术语叫 CPU 亲和性(affinity),意思就是让某个进程固定运行在某几个 CPU 核心上。你说,操作系统本来就会调度,为什么非要人工干预?因为调度是有代价的。当进程被切换到另一个核心时,它之前的 L1/L2 缓存里的数据全部失效,需要重新从 L3 或者主内存拉取。对于高频计算场景,这个开销可能占掉 20% 以上的性能。另外,在多核架构中,有些核心之间共享 L3,有些是独立的,如果进程和中断都被分散到不同位置,还会有额外的总线开销。

APISIX 的每个 worker 都是单进程模型,理想情况是一个 worker 独占一个或几个物理核。这样,worker 内部的 Lua 代码、Nginx 的连接状态,就一直留在同一块缓存里,访问速度自然快。

2.2 内存池不是让你手动管理的池子

很多转 Go 或者 Java 的朋友会说:内存池?我用的是 GC,不需要。但 OpenResty 是 C 和 LuaJIT 混合的世界。Nginx 为每个连接都创建一个内存池,连接结束的时候一次性释放,这样分配效率极高。问题是,这个池子的初始大小(通常通过 client_body_buffer_size 等指令间接影响)和扩容策略,直接影响请求的成败。如果你把池子设得太小,Nginx 就要频繁调用 malloc 去操作系统申请内存,这个操作在低延迟场景下就是灾难。

APISIX 还大量使用 lua_shared_dict,它就是一段共享内存,多个 worker 进程都能访问,常用于做限流计数、IP 黑白名单、路由缓存等。共享内存要预先分配,如果太小,运行时不断淘汰或出错,就会影响业务。但是也不能太大,太大了浪费,而且容易引发内存回收的抖动。

三、从 OpenResty 层开始调优

APISIX 的配置最终会转化为 OpenResty 的 Nginx 配置。很多时候你改了 APISIX 的 config.yaml 还不够,因为一些底层的指令要直接写在 Nginx 配置块里。所以,我们要先从 OpenResty 这一层说起。

3.1 worker_processes 和 CPU 绑定的配合

默认情况下,OpenResty 的 worker 数量通常设置成 auto,也就是跟 CPU 核数相同。这本身没问题,但 worker 并不固定,核数变了它也跟着变。为了稳定,我建议把 worker_processes 设置为固定的逻辑核数。怎么确定核数?你可以用 lscpu 查看。假设你的机器是 16 核,但如果你只分配给了 APISIX 8 个核(比如容器环境),那就要写成 8。

确定数量之后,就要绑定 CPU。Nginx 有一个 worker_cpu_affinity 指令,可以指定每个 worker 落在哪些核心上。比如 8 个 worker,对应的核心掩码是 00000001、00000010 这样逐位递增。如果你不想一个个写,auto 会自动把 worker 均匀分布在所有核上。但 auto 只绑定物理核?不,它也是轮询。我还是推荐手动写明确。

下面是一个比较规范的写法:

# 技术栈:OpenResty / Nginx 配置(通过命令行写入)

cat > /usr/local/openresty/nginx/conf/nginx.conf <<'EOF'
# 设定 worker 数量,假设只有 4 个逻辑核
worker_processes 4;

# 手动绑定 CPU,掩码分别为 0001,0010,0100,1000
worker_cpu_affinity 0001 0010 0100 1000;

events {
    # 每个 worker 的连接数,延迟敏感型可以适当调大
    worker_connections 4096;
}
EOF

这样,四个 worker 分别固定在第 0、1、2、3 个核心上,操作系统不会随便把它挪走。如果你的机器支持超线程,你还得确认绑的是物理核还是逻辑核。这里我建议先看 /proc/cpuinfo,把同一物理核的两个逻辑核也放进掩码,避免两个 worker 抢一个物理核的执行单元。比如第 0 和第 4 是同一个物理核,那第一个 worker 的掩码就是 00010001(这个例子是 8 核,0 和 4)。如果你不关心超线程,那就按顺序绑定也没太大问题,只是性能可能打个折扣。

3.2 一步一步设置 CPU 亲和性

有时候你不想用 Nginx 的指令,而是想在进程启动后把它的 CPU 亲和性修改掉。尤其是容器环境里,worker_cpu_affinity 的掩码经常被 cgroup 的 CPU 列表搞晕,操作系统的亲和性设置反而更容易观察。这里可以用 taskset 或者 numactl。在 OpenResty 的 master 进程启动之后,worker 进程由 master 自动 fork 出来,直接对 master 进程设置亲和性不会影响后续的 worker。所以更可靠的做法是,在启动命令中直接用 taskset 包裹整个 OpenResty 启动命令,这样所有 worker 都会继承这个亲和性。

# 技术栈:Linux 命令行(以 OpenResty 为例)

# 查看当前进程的 CPU 绑定情况
taskset -cp $(cat /usr/local/openresty/nginx/logs/nginx.pid)

# 把 OpenResty 所有进程都绑定到 CPU 0-7(共8个核心)
taskset -c 0-7 /usr/local/openresty/bin/openresty -p /usr/local/openresty -c /usr/local/openresty/nginx/conf/nginx.conf

这种方式适合快速验证,但如果你想精确地给每个 worker 分配不同的核心,还是需要在 Nginx 配置里做。另外,numactl --physcpubind=0-7 也能起到类似作用,它还可以控制内存分配策略,比如 --localalloc 让每个核心从自己的本地内存条上分配内存,这对 NUMA 架构特别有用。

3.3 别忘了 NUMA 和 CPU 缓存

说到 NUMA,我多讲两句。NUMA 是 Non-Uniform Memory Access 的缩写,意思是访问不同类型的内存,耗时不一样。在一台双路服务器上,CPU 0 到 7 属于第一个处理器,CPU 8 到 15 属于第二个处理器。每个处理器都连接着自己那一侧的内存条。如果 CPU 0 上的进程去访问 CPU 8 那一侧的内存,延迟就比访问本地内存高不少。所以,在绑核的时候,你最好让 APISIX 的 worker 和它使用的共享内存落在同一个 NUMA 节点上。

可以用 numactl --hardware 查看拓扑,然后用 numactl --membind=0 让整个 OpenResty 进程组的内存都从节点 0 分配。这样虽然限制了内存带宽,但换来了极低的访问延迟。对于大多数网关场景,这是划算的,因为网关本身占用的内存不大,但每个请求都要快速存取。

3.4 lua_shared_dict 怎么分配才合理

OpenResty 的 lua_shared_dict 是在 http{} 块里声明的。APISIX 自带了很多共享字典,比如 api_limiterapisix_limiterjwt_authplugin 等。默认配置里它们的大小都很保守,比如 1m。如果你发现访问量并不大,却频繁出现 no memory 或者 key not found,那很可能就是共享字典被挤满了。

分配内存池,说到底是给不同用途的字典设置一个够用且有余量的空间。怎么判断够不够?可以先设置一个较大的值,比如 100m,然后运行一段时间,观察实际使用量。lua_shared_dict 没有一个现成的统计 API 可以直接看容量,但你可以写个简单的 Lua 脚本通过 shared:get_keys(0) 来获取键数量。更直接的办法是,在 Init 阶段注册一个定时日志,定时输出字典的 free_space() 方法。

这里需要澄清一点:lua_shared_dict 是独立的内存池,跟 Nginx 的连接池不同。它不受 worker_processes 影响,而是由所有 worker 共享的。所以在分配时,不仅需要考虑总容量,还需要考虑到内存占用会不会压在同一个 NUMA 节点上。配合前面说的 CPU 绑定,如果 worker 都绑在 CPU 0-7,而共享内存都落在 CPU 8-15 的本地内存上,那么跨节点访问会带来额外延迟。在极端场景下,你甚至可以用 numactl --membind=0 绑定内存节点,把整个 OpenResty 的内存都限制在同一个节点里。

四、APISIX 里的相关配置项

APISIX 把 OpenResty 包了一层,但它也暴露了很多配置项。我们要做的是,把前面层层的设置,在 APISIX 的体系里对应起来。

4.1 在 APISIX 中配置 worker 和内存

APISIS 的主配置文件是 /usr/local/apisix/conf/config.yaml。你可以通过 nginx_config 字段直接修改底层 Nginx 的行为。比如我们要设置 worker 数量、CPU 亲和性以及共享字典大小:

# 技术栈:APISIX 配置文件(通过 cat 写入)

cat > /usr/local/apisix/conf/config.yaml <<'EOF'
apisix:
  node_listen: 9080
nginx_config:
  worker_processes: 8
  worker_cpu_affinity: "00000001 00000010 00000100 00001000 00010000 00100000 01000000 10000000"
  http:
    lua_shared_dict:
      api_limiter: 200m
      apisix_limiter: 200m
      plugin_limit: 100m
      # 其他插件需要的共享内存,可以根据实际插件配置增加
EOF

注意这里的 worker_cpu_affinity 是字符串,每个掩码之间用空格隔开。如果你发现自己的机器掩码写错了,APISIX 在启动时会报错,提示你 bitmask 不合法。所以建议先在一台测试机器上验证,再上生产。

4.2 用运维参数做最终兜底

除了 APISIX 自身的配置,操作系统的参数也决定了内存池能不能正常发挥。比如进程的文件描述符上限、TCP 缓冲区大小、内存热插拔策略等等。不过,最需要注意的是内存的分配策略。Linux 默认的内存 overcommit 设置是 0,表示启发式分配。如果你在机器上设置了 vm.overcommit_memory=1,那就是永远不过量分配,这样会让 Nginx 认为内存不够,频繁报错。延迟敏感型业务中,我们一般保持 0 或者改为 2(按比例限制),避免实际申请内存时被系统拒绝。

另外,由于 APISIX 的 worker 数量多,每个 worker 的内存池叠加起来,总量会非常可观。你可以通过 worker_rlimit_nofile 来设置文件句柄上限,避免高峰期连接数过多时出现 EMFILE 错误。这里我们再写一组运维命令:

# 技术栈:Linux 系统运维

# 调整系统内存分配策略,这里我们采用保守的 0
sysctl -w vm.overcommit_memory=0

# 临时提高进程文件描述符上限,生产环境建议写到 /etc/security/limits.conf
ulimit -n 65535

# 查看 NUMA 拓扑,确认 CPU 和内存节点之间的关系
numactl --hardware

# 显示当前 OpenResty worker 是否被正确绑核
for pid in $(pgrep -f 'nginx: worker'); do
  taskset -cp $pid
done

这组命令能帮你快速排查问题,也适合写进 CI/CD 的初始化脚本里。注意 ulimit 只对当前 shell 有效,如果你想对 APISIX 的服务进程生效,需要修改 systemd 或启动脚本。

五、实战调优案例

现在我们来演示一个完整的调优过程。假设你有一台 8 核 16G 内存的云主机,部署了 APISIX,网关要承接一个实时聊天的入口,要求 P99 延迟小于 20 毫秒。你发现当前有几个现象:偶发请求耗时飙升到 200 毫秒;CPU 使用率不高,但 worker 进程经常被迁移;压测时 no memory 错误频繁。

5.1 场景描述

实时聊天服务的特点是连接数多,消息体小,但心跳频繁。APISIX 主要做路由和鉴权,扩展了一些限流插件。高峰期大约有 5 万个并发长连接,每秒 2 万个请求。这样的业务对 CPU 缓存非常敏感,因为每个请求都要快速处理,不能有阻塞。同时,限流插件依赖共享内存,计数器更新非常频繁,如果共享内存被占满,就会触发淘汰,导致限流不准确。

5.2 配置方案

第一步,确认硬件拓扑。通过 lscpu 看到 8 个逻辑核,每个核都有独立的 L1/L2,共享 L3。由于没有超线程,掩码直接按顺序写即可。第二步,设置 worker 数量为 8,并绑定核心。第三步,把共享内存调大。原来的 api_limiter 只有 10m,我们改成 200m。另外,还要为 apisix_limiter 单独分配 200m。最后,调整系统参数。

具体操作如下:

# 技术栈:APISIX + Linux 运维

# 1. 写入 APISIX 自定义 nginx 配置
cat > /usr/local/apisix/conf/config.yaml <<'EOF'
apisix:
  node_listen: 9080
nginx_config:
  worker_processes: 8
  worker_cpu_affinity: "00000001 00000010 00000100 00001000 00010000 00100000 01000000 10000000"
  worker_rlimit_nofile: 65535
  http:
    lua_shared_dict:
      api_limiter: 200m
      apisix_limiter: 200m
      plugin_limit: 100m
EOF

# 2. 设置系统参数
sysctl -w vm.overcommit_memory=0
sysctl -w net.core.somaxconn=65535

# 3. 重启 APISIX 使配置生效
apisix restart

注释说明:worker_rlimit_nofile 是 Nginx 指令,放在 nginx_config 里,表示每个 worker 能打开的文件句柄数。net.core.somaxconn 是系统 backlog 队列长度,对瞬时流量激增有帮助。

5.3 验证效果

重启之后,我们先检查进程是否绑定到了正确的核心:

# 技术栈:Linux 命令行

for pid in $(pgrep -f 'nginx: worker'); do
  taskset -cp $pid
done

输出应该显示每个 worker 都对应一个独立的 CPU 编号。接下来用压测工具 wrk 模拟请求,观察延迟分布。这里不再贴完整压测代码,只说明结果:P99 从原来的 80 毫秒降到了 15 毫秒左右,没有出现 no memory 错误。再看 vmstat,上下文切换次数明显减少,因为进程不会再被迁移了。

六、这块调优的优缺点和注意事项

任何调优都不是银弹。先说优点:CPU 绑定减少了缓存失效,内存池调整减少了动态分配,这两者叠加起来,能让延迟变得稳定且可预测。对于 P99 敏感的业务,收益非常直观。此外,绑核之后,恶意负载或者同类应用很难抢走你的 CPU,隔离性也更好。

但缺点同样明显。第一,绑核降低了操作系统的调度弹性。如果某个核心上的 worker 因为一个耗时的 Lua 插件而造成阻塞,其他核心也帮不上忙,因为 worker 不能跨核心运行。第二,内存池增大虽然减少了动态分配,但也意味着 APISIX 占用的常驻内存变高了,如果你同时部署了多个服务,可能导致整机内存不足。第三,配置项跟具体硬件强相关。你把这套配置复制到另一台机器上,如果 CPU 核心数不一样,掩码就会失效,甚至启动失败。

还需要注意几个坑。一是容器环境中的 CPU 识别。如果你用 Docker 承载 APISIX,容器内的 /proc/cpuinfo 可能显示的是宿主机的 CPU 信息,而 cgroup 实际限制的 CPU 列表是不同的。这时候你必须根据 cpuset 实际分配的 CPU 来写掩码,不能被 /proc 误导。二是超线程问题。如果你绑定的是同一个物理核的两个逻辑核,不仅不会加速,反而可能因为争抢资源导致性能下降。建议用 lstopo 查看拓扑后,再决定掩码。三是内存分配器的选择。OpenResty 的 Lua 代码使用 LuaJIT,LuaJIT 的内存分配器是内置的,它并不总是跟 Nginx 的内存池一致。如果你在 Lua 里频繁创建 table,还是会走 LuaJIT 的内存管理,这部分无法通过 Nginx 的内存池直接控制,只能通过优化代码来减少临时对象。

七、总结

APISIX 延迟敏感型业务的调优,不是单点操作,而是一个从 OpenResty 到系统运维的链路工程。先通过 CPU 绑定让 worker 稳定在固定核心,再通过内存池配置让共享内存和连接内存都够用且不过量,最后配合系统参数保证整个运行环境不拖后腿。整个过程可以用一句话概括:别让你的进程做无谓的旅行,别让你的内存做无谓的申请。

实际落地时,建议按照“查看拓扑 -> 设置 worker 和绑定 -> 调整共享字典大小 -> 压测验证”的步骤分步推进。每一次修改只动一个变量,这样出了问题才能快速回滚和定位。希望这篇笔记能让你少踩几个坑。如果你的业务还在快速迭代,也可以先用默认配置跑起来,等发现延迟不稳了,再回来翻翻这篇文章。