APISIX 多进程架构:优势、缺点与适用边界
APISIX 用 Nginx 多进程处理连接,在 worker 内运行 Lua 协程,通过本地缓存和共享内存管理状态。这套组合支持动态配置和插件扩展,但也带来了副本、同步和调度成本。
下面从两张架构图看起。这些成本有些属于同类架构的共性,有些来自 OpenResty 的具体机制,并不都是 APISIX 独有的问题,也不意味着一定会发生故障。
1. 先看架构:哪些东西共享,哪些不共享
master 管进程,worker 处理请求
APISIX 构建在 Nginx/OpenResty 之上。master 负责启动和管理 worker、处理信号等工作,业务请求不是先进入 master,再由它转发给 worker。
多个 worker 是多个独立进程。每个 worker 有自己的地址空间、LuaJIT 运行时、模块变量和本地缓存。普通请求处理主要运行在各自的单线程事件循环中,多个 worker 可以在不同 CPU 核上并行工作。
图中省略了辅助进程和具体配置同步组件。需要注意三点:
- worker 之间不共享普通 Lua 对象。 一个 worker 修改模块变量,不会自动改变其他 worker 的变量。
- 共享字典是另一个存储空间。 多个 worker 映射同一共享区,通过相应 API 访问;它不是一个所有进程都能直接操作的 Lua table。
- 进程隔离不等于资源完全隔离。 worker 仍共用机器的 CPU、内存和网络,也可能访问同一共享字典。
lua_shared_dict 是常用的跨 worker 共享机制,不同字典有各自的共享区和锁。此外也可以使用 IPC 或外部服务通信。
worker 内部:协程共享一个主要执行线程
请求进入 worker 后,路由匹配与插件逻辑在相应阶段执行。Lua 协程配合 Nginx 的事件机制,在等待网络等可让出的操作处挂起,让其他请求继续处理。
协程并发与 CPU 并行是两回事。 一个 worker 可以同时维持很多进行中的请求,但普通 Lua 请求代码并不是在这个 worker 内同时使用多个核执行。
Lua timer 回调也在 worker 中执行,不是独立后台线程。FFI 不会自动把调用异步化。OpenResty 虽有辅助线程等能力,普通 Lua 插件并不会自动使用它们。
请求、配置与状态是不同路径
常规 HTTP 代理依赖 Nginx 的上游和输出机制,APISIX 在各阶段执行路由与插件。部分插件使用 cosocket 自行读取和转换响应,后文会单独讨论这条路径。
配置更新不需要跟着每条请求访问 etcd。在常见的 etcd 部署方式下,配置经过同步形成本地对象,供数据面使用;具体同步路径取决于版本和部署模式。
| 状态范围 | 典型内容 | 谁能直接使用 |
|---|---|---|
| 请求内 | ngx.ctx、解析结果 |
当前请求的处理逻辑 |
| worker 内 | 模块变量、lrucache、配置对象 | 同一个 worker 内的请求与任务 |
| 共享内存 | 部分计数、限流状态、指标条目 | 映射该共享区的多个 worker |
| 外部系统 | etcd 配置、外部缓存 | 通过同步或网络访问取得数据 |
2. 这套架构的优势
成熟的网络底座。 Nginx 的事件驱动模型适合大量并发连接,OpenResty 提供 Lua 执行与非阻塞网络 API,LuaJIT 降低许多 Lua 计算的开销。
本地处理。 常用配置和缓存留在 worker 内,减少了路由匹配与对象读取的共享访问;多个 worker 则可以利用多核。
动态配置。 路由、上游和常用插件配置可以在运行中更新,不必每次 reload Nginx。路由多、变更频繁时,这比每次修改都重载进程更方便。
扩展效率。 Lua 插件可以复用网关阶段和已有能力,cosocket 允许用接近同步代码的方式组织非阻塞 I/O。很多业务策略不必下沉到 C 模块。
3. 多 worker 的缺点:副本成本与共享协调
本地状态不能免费复用
回到第一张图,每个 worker 都有本地缓存。相同的配置派生对象和热点缓存,可能在多个 worker 中分别构建;cosocket 连接池通常也是 worker 级的,一个 worker 的空闲连接不能直接供另一个 worker 使用。
增加 worker 数,可能同时增加缓存副本、空闲连接和初始化工作。流量分布不均匀时,各 worker 的缓存命中率和连接复用效率也可能不同。
这并不意味着所有内存都按 worker 数线性增长:共享页、共享字典和独占对象要分开看,也不能把各 worker 的 RSS 简单相加。连接池容量则主要约束池内保留的空闲连接,不能直接当作活跃连接总数上限。
一旦需要全局状态,就要付同步成本
限流、计数和共享缓存不能总是各算各的。把状态放入 shared dict,可以避免每次都访问外部服务,但常见的 get、set、incr 等操作需要获取字典级共享互斥锁。读取也不是无锁操作:同一字典内,读读、读写和写写都可能竞争,即使访问的是不同 key。 不同字典则有各自的锁。
当共享访问较少时,这笔成本通常可控;当多个 worker 高频访问同一字典,或者成批遍历共享条目时,增加 worker 也会增加竞争者。扩展性此时取决于共享访问比例,而不只是 CPU 核数。
这种锁等待不会像 cosocket 等待网络那样挂起 Lua 协程。竞争严重时,调用所在的 worker 无法及时处理其他请求。get_keys(0) 会在持锁期间遍历整个字典,不宜在请求路径或高频 timer 中对大字典反复执行。
这是 OpenResty shared dict 的具体机制,并非 APISIX 独有。其他共享存储也有协调成本,但锁粒度、分片方式和读写并发能力可能不同。
本地副本减少了共享读取,却需要处理失效、版本变化和同步。增量更新可以摊薄工作量,全量重建则可能产生较大的单次开销。
本地参考 Counter 实现先在 worker 内累计增量,再由 timer 批量同步到共享字典。这样减少了共享更新次数,但需要处理同步延迟和失败。
单次操作原子,不等于多步逻辑原子
incr、add 等单次操作提供原子语义,不代表“读取状态 → 修改指标值 → 注册索引”整体成为一个事务。多个 worker 仍可能在操作之间交错执行。
例如,用 get 后接 set(value + 1) 更新计数器,两个 worker 可能都读到 10、都写入 11,丢失一次增量。单 key 计数应使用 incr;多 key 或跨字典更新仍需额外协调。
同一 worker 内也有类似问题:模块变量虽然没有被多个普通 Lua 线程同时执行,协程 yield 后,其他请求仍可能修改它。局部计算中的临时值,不能随意变成跨请求共享变量。
4. 协作式调度的缺点:重任务会拖慢同一 worker
第二张图中,多个请求最终使用同一个主事件循环。一段长时间不让出执行权的 Lua 代码,会延迟这个 worker 上其他请求的处理,即使那些请求与它没有业务关系。
大 JSON 解析、复杂匹配、长循环、共享字典扫描,都可能产生这种影响。LuaJIT 可以让许多计算更快,但不会把它们变成自动可抢占的请求任务。
循环里有 cosocket 读取,也不保证每轮都会让出。 数据已经在缓冲区时,读取可能立即返回,随后连续解析、拼接、统计,仍可能长时间占用 worker。
加入 ngx.sleep(0) 可以增加其他协程获得执行机会,但不减少总计算量,也不是严格的公平保证。更根本的处理是减少重复工作、限制单次批量规模,必要时把重计算移出普通请求执行路径。
如果每个 worker 都创建同一个 timer,任务还可能重复执行。需要从组件实现确认是否做了单例协调,以及单次处理有没有批量上限。
这项缺点对尾延迟尤其重要:平均吞吐可能正常,少量重任务却已经让同 worker 的轻请求变慢。
长计算阻碍事件循环是同类模型的共性。独立执行池可以缩小影响,但仍需要限制任务数量和排队长度。
5. 共享内存的缺点:容量和生命周期需要单独管理
shared dict 无需网络往返,但容量由配置确定,条目不由 Lua GC 回收。
它可以存储字符串、数字、布尔值等支持的值类型,不能直接共享任意 Lua table。复杂对象放入共享字典通常需要编码与解码;留在 lrucache 中则可以直接使用,但会回到前面的副本问题。
TTL 解决有效性,不保证立即释放空间
TTL 到期后,条目不再按普通有效值读取;物理空间何时回收,还受访问、分配路径和显式清理影响。
组件维护的索引、版本记录或本地引用还需要单独清理。经历反复生成、过期、重建后,应检查内存和维护工作量是否持续增长。
因此,容量规划不只看当前活着多少条目,还要看序列更替速度、键长度、分配开销和内部索引。经典直方图有 B 个有限桶时,每组标签最多展开 B+3 条序列;本地报告中 15 个有限桶对应 18 条。长标签又会增加每条完整键的成本,在参考实现中还可能被值条目和索引分别保存。
空间不足时,成功写入也可能有代价
普通 set 在必要时可能强制淘汰尚未过期的条目,并返回 forcible=true;safe_set 避免这种强制淘汰,空间不足时可能失败。
写入成本也取决于条目是否需要创建。不断写入新 key,会涉及内存分配和内部索引维护;容量紧张时还可能触发回收或淘汰,增加持锁时间。同样的写入频率,更新少量已有计数器与持续创建、过期、重建条目,开销可能明显不同。
指标、限流和缓存对丢失的容忍度不同,不能只看写入是否成功。free_space() 反映空闲页空间,也不能直接当成精确的剩余可写容量。
共享区大小不像路由配置那样可以随意动态调整。其调整需要考虑目标版本的 reload 和重建约束。扩容可以缓解容量压力,却不能修复一个不收敛的索引或回收算法。
6. 插件扩展的缺点:功能边界不是资源边界
鉴权、请求转换、流式解析和部分指标维护,可能共用一个 worker 的执行资源。即使指标使用独立字典,其维护任务仍可能占用业务请求所在的事件循环。
进程隔离保护的是 worker 之间的地址空间,普通 Lua 插件没有因此获得独立的 CPU 配额或内存预算。不同租户的请求也一样:租户 A 长时间计算时,租户 B 的轻请求可能等待。入口限流控制进入的请求数量,无法保证每个请求的计算成本相同。
共用执行资源造成的租户相互影响,并非 APISIX 独有。 多线程或可抢占调度可以改变影响方式,但不会自动提供租户 CPU 配额。要求强隔离时,可以按租户或负载拆分实例,并配置资源配额,代价是额外的资源和运维工作。
7. 动态配置的边界:能热更新,不等于全局瞬时切换
新配置需要同步到数据面、更新本地对象,并使旧缓存失效。多个 worker、多个实例应用更新存在传播过程,其间可能短暂使用不同版本;集中重建缓存也有开销。要求跨路由、跨实例同时切换的业务,需要验证目标部署是否提供相应保证。
同样,路由配置、Lua 代码、底层 Nginx 配置和共享内存容量,不属于同一种更新机制。长连接场景中,涉及进程更替的升级还需要规划连接排空和旧 worker 的退出,而不只是确认新实例启动成功。
8. AI 负载如何改变架构的收益与成本
等待模型首字和后续输出,适合事件驱动模型;如果网关还要持续解析和修改内容,就需要评估额外的 CPU 与缓冲开销。
优势:等待多、逻辑轻时,这套组合很合适
等待上游时可以处理其他请求。 大量进行中的模型请求不必对应大量请求线程,但连接仍占用文件描述符、缓冲区和请求状态。
模型路由调整方便。 切换供应商地址、修改流量权重等常用操作,可以通过动态配置完成,无需重载网关进程。但自动故障切换、额度管理等能力,仍取决于具体插件和策略。
简单接口适配容易扩展。 不同供应商的鉴权方式、模型名称和请求字段存在差异,可以通过 Lua 插件进行改写,无需修改底层网络代码。
缺点:长连接放大资源占用,内容加工放大执行成本
| AI 负载特征 | 对应的架构边界 | 需要关注的影响 |
|---|---|---|
| 长上下文、大型 tools JSON | 解析与转换占用 worker 执行时间 | 重复 decode、分配和 GC 压力 |
| 密集小片段输出 | 读取、解析循环增加 | 单次连续工作量、其他请求的尾延迟 |
| 长时间流式连接 | 已建立的连接通常持续由原 worker 处理 | worker 间负载差异;新开 worker 不会直接迁移正在处理的流 |
| 慢客户端、中途取消 | 自定义流式路径需要背压和清理 | 缓冲上限、上游是否及时停止、资源何时释放 |
| 模型、路由、消费者等指标维度 | 共享状态有容量和维护成本 | 实际标签组合、桶数、序列更替和同步工作量 |
| 多租户混合负载 | 请求共用 worker 的执行资源 | 重请求对其他租户的影响,见第六节 |
连接时间长本身不会让 CPU 持续繁忙。等待、计算、缓冲和共享状态维护,需要分别测量。
转发流与加工流要分开评估
直接代理 SSE,与读取 SSE、逐事件解析、修改内容、统计用量、再重新输出,不是同一种负载。
前者较多复用 Nginx 的代理和输出机制,需要正确配置超时与缓冲。后者除了转换内容,还要控制单轮解析量和消息暂存上限,在下游变慢时放慢读取,取消后停止上游活动,并区分“暂时无完整消息”和“连接失败”。这些都需要插件实现与测试。
普通代理的吞吐不能直接代表流式加工的性能。测试应使用实际启用的插件与处理路径。
AI 容量评估不能只看 QPS
在稳定状态下,在途请求数大致等于请求到达率乘以平均持续时间。每秒进入 20 个请求、平均持续 60 秒,对应约 1,200 个在途请求。
除 QPS 外,还应观察在途流数量、每个 worker 的 CPU、请求体大小、分片处理频率和缓冲占用。首字延迟与输出间隔受模型上游影响,应对照直连上游或基线代理,辨别网关增加的开销。再加入慢客户端、取消和轻请求混跑,检查缓冲是否受控、上游是否及时停止、轻请求是否被拖慢。
9. 从架构判断适用范围
如果主要工作是等待上游响应、本地配置读取和简单改写,APISIX 比较合适。大量共享更新、重计算和持续流式加工,则需要额外评估同步、调度与隔离。
这些问题也应带到其他网关的选型中:
| 通用问题 | APISIX/OpenResty 中的具体表现 |
|---|---|
| 本地副本与共享协调 | worker 级 Lua 状态、cosocket 池,跨进程使用共享字典等机制 |
| 长计算影响事件循环 | Lua 协作式调度、worker 内的 timer 回调 |
| 有限容量与状态回收 | shared dict 的锁、TTL、淘汰与索引维护 |
| 插件和租户共用执行资源 | 普通 Lua 插件运行在 worker 内,强隔离需另行设计 |
| 配置传播与升级 | 本地对象更新、缓存失效、旧 worker 与长连接退出 |
上线前比较不同 worker 数下的吞吐、尾延迟、独占内存和连接复用,并让状态测试覆盖多轮过期、清理和重建。短时压测看不到历史状态积累的成本。
换架构可能减少某项成本,也可能把它转移到同步、排队或部署上。判断依据应是目标实现和自己的负载,而不是语言名称、线程数量,或某一次组件故障。
故障旁证与参考
我们的生产案例全部来自 API7 AI 网关,涉及的企业版实现不能直接等同于所有开源 APISIX 版本。这里只保留它们与架构的关系,不展开排查过程:
- 9 月升级后的索引同步回归:特定指标库版本全量扫描约 57 万历史槽,说明共享访问与连续执行可以放大组件算法的代价;直接缺陷在该版本,最终回退的是依赖。
- 调度、取消与重复解析问题:用于理解复杂插件的执行和资源边界。流式 500 的暂态误判是另一个独立实现错误,不是协程模型必然产生的结果。
- 11 月共享字典满:存活序列约 3.9 万至 4.6 万,剩余空间仍减少,说明当前序列数不足以描述状态成本。约 240 MB 未解释占用是估算,不能直接认定为过期索引;Counter 回落也不能单独证明过期重建。
- 15 秒 504 来自该环境的内核 SYN 重传策略,不属于本文的架构缺点。
机制参考:温铭《OpenResty 从入门到实战》第 18、19、20、31、39、41 讲;《Apache APISIX 架构分析》;本地 APISIX 的 lrucache 封装及 Prometheus Counter、KeyIndex 参考实现。教材和本地开源代码用于解释机制,不替代生产版本取证。本文两张架构图为新绘,刻意省略非主线组件。