☰
Nginx源码解析:ngx_inet_add_addr如何实现地址去重与池化管理
2026/10/2 7:43:07 网站建设 项目流程

如果你翻过 Nginx 源码,尤其是 upstream 和 resolver 这两块,一定见过ngx_inet_add_addr这个函数。它没有ngx_inet_addr那么出名——后者是纯粹的 IP 文本解析,几乎每个网络模块都会用到;而ngx_inet_add_addr做的事,是把已经解析好的 socket 地址收进一个数组,而且是带去重地收。域名多 IP、DNS 轮询、upstream peer 数量这些概念,最后都落在它身上。这篇文章我会把这个函数的实现、调用链、以及我自己写模块时跟它打交道踩过的坑,一次讲透。无论你是打算读源码做二次开发,还是单纯好奇"为什么server后面写一个域名会带出好几个后端节点",都建议往下看。

1. 它解决什么问题:把零散的地址收进一个池化数组

先说数据结构。ngx_inet_add_addr操作的核心对象是ngx_addr_t和ngx_array_t。ngx_addr_t是 Nginx 内部表示"一个已解析出来的网络端点"的标准结构,定义在src/core/ngx_inet.h里:

typedef struct { struct sockaddr *sockaddr; socklen_t socklen; ngx_str_t name; } ngx_addr_t;

三个字段各司其职:sockaddr指向真正的地址内存,socklen记录它的长度,name是一个附加的"名字"。注意,在ngx_inet_add_addr这条路径上,name指向的并不是可读字符串,这点后面会专门讲。

ngx_array_t则是 Nginx 自带的动态数组,思路和 C++ 的vector一致:elts指向元素存储区,nelts是已用元素数,nalloc是容量,size是单个元素字节数。ngx_inet_add_addr每次只往里追加一个ngx_addr_t,追加之前先做去重。

在 Nginx 里,一个sockaddr的来源主要有三种:

  • 配置里写的字面 IP,比如server 10.0.0.1:80;
  • 操作系统getaddrinfo解析域名得到的addrinfo链表
  • DNS resolver 从应答包里解出来的 A/AAAA 记录

这三种来源的生存期完全不同:字面 IP 在配置池里常驻,addrinfo在 libc 堆上需要手动freeaddrinfo,DNS 应答包处理完就丢。如果不做统一收集,upstream 就很难用"一份共用的地址列表"去建立 peer。

ngx_inet_add_addr就是这套流程的公共出口:无论地址从哪来,最终都转成一个sockaddr + socklen交给它,由它在指定的池里拷一份,并保证数组里不会出现两个完全相同的条目。

去重不是洁癖。域名解析结果里出现重复地址比想象中常见:有的权威服务器会同时返回 answer 区的记录和 additional 区的重复项,有的内网 DNS 在多个 CNAME 链上返回同一个 IP,CDN 厂商为了容错也可能在同一响应里塞两份同名记录。如果不去重,upstream 建立 peer 列表时就会把同一个真实节点算成两台 server,权重、max_fails、故障隔离的判断全部失真。举例来说,某个节点宕机时,你会看到 upstream 里两个同样的地址都在失败,排查时还以为有两个独立节点出了问题。

2. 源码拆解:查重、压入、拷贝三段式

ngx_inet_add_addr本身很短,加上前置的去重辅助函数也就四十几行。以 1.24.x 为参考,源码如下(版本之间可能有细微差异):

static ngx_int_t ngx_inet_find_addr(ngx_array_t *addrs, struct sockaddr *sa, socklen_t socklen) { ngx_uint_t i; ngx_addr_t *addr; addr = addrs->elts; for (i = 0; i < addrs->nelts; i++) { if (addr[i].socklen != socklen) { continue; } if (ngx_memcmp(addr[i].sockaddr, sa, socklen) == 0) { return i; } } return -1; } ngx_int_t ngx_inet_add_addr(ngx_pool_t *pool, ngx_array_t *addrs, struct sockaddr *sa, socklen_t socklen) { ngx_addr_t *addr; if (ngx_inet_find_addr(addrs, sa, socklen) != -1) { return NGX_OK; } addr = ngx_array_push(addrs); if (addr == NULL) { return NGX_ERROR; } addr->sockaddr = ngx_palloc(pool, socklen); if (addr->sockaddr == NULL) { return NGX_ERROR; } ngx_memcpy(addr->sockaddr, sa, socklen); addr->socklen = socklen; addr->name.len = socklen; addr->name.data = (u_char *) addr->sockaddr; return NGX_OK; }

逻辑非常直白:先查重,重复就直接返回NGX_OK;不重复则压入一个新元素,从池里分配一块socklen大小的内存,把调用方的sockaddr内容整体拷进去。没有复杂的哈希表,没有状态机,就是一个朴素的三段式。

2.1 查重为什么先比 socklen 再 memcmp

ngx_inet_find_addr的循环里有个容易被忽略的细节:先比较socklen再比较字节内容。这其实是一道快路径过滤。

IPv4 的sockaddr_in固定 16 字节,IPv6 的sockaddr_in6固定 28 字节,长度本身就包含了"地址族 + 端口 + 地址"的全部信息。如果两条记录的socklen都不同,那它们的语义必然不同,直接跳过memcmp就行;只有长度相同时,才有必要逐字节比对。这样既省掉大部分无意义的比较,也让去重规则变得非常简单:长度相同且字节完全相同,才算重复。

由此也能推出一个重要结论:这里是字节级去重,不是语义级去重。127.0.0.1:80和127.0.0.1:8080虽然 IP 一样,但字节内容不同,不会合并;::ffff:127.0.0.1和127.0.0.1也永远不会被视为同一地址。这点放在后面的避坑清单里单独说。

2.2 先 push 后 palloc 的失败语义

很多人第一次看这段代码会问:万一ngx_palloc返回NULL,前面的ngx_array_push已经把nelts加了 1,数组末尾不就留下一个未初始化的槽吗?

答案:是,确实如此。这段源码没有回滚。

原因倒也好理解——池分配失败在 Nginx 里基本等价于 OOM,调用方拿到NGX_ERROR之后大多直接报错退出。与其追求滴水不漏的回滚,不如保持代码简单。但你如果是给自己的模块写类似逻辑,我强烈建议调换顺序:先palloc再push,或者失败时手动把addrs->nelts减回去。

这里还要注意一个返回值陷阱:NGX_OK并不代表"真的插入了"。地址已存在时,同样返回NGX_OK。如果你想统计"新增了几个唯一地址",必须比较调用前后的addrs->nelts,不能只看返回值。

2.3 name 字段的"欺骗性"

这块是我见过最容易误导人的地方。add_addr里有一条很突兀的赋值:

addr->name.len = socklen; addr->name.data = (u_char *) addr->sockaddr;

name不是一个字符串。它把整个sockaddr的原始二进制当成了"名字",长度就是socklen。这和ngx_parse_addr的行为完全不同——后者会把name.data指向用户传入的文本 IP,比如"192.168.1.1",可以直接用%V打印出来。

为什么这里要这么"偷懒"?我理解有两个原因:一是省一次额外分配,反正池里已经有sockaddr的拷贝,顺手借用它的内存;二是后续真要打印可读地址时,Nginx 有专门的ngx_sock_ntop函数可以重新格式化,name在这个场景下只是备用字段。

所以,如果你在自定义模块里图省事,直接ngx_log_error(..., "%V", &addr->name),打出来的就是一堆二进制乱码,甚至可能因为中间有\0而被截断。正确做法是:

u_char text[NGX_SOCKADDR_STRLEN]; (void) ngx_sock_ntop(addr->sockaddr, text, NGX_SOCKADDR_STRLEN, 0); /* 此时 text 里就是 "192.168.1.1" 这样的可读字符串 */

3. 调用链全景:从 server 指令到 upstream peer

ngx_inet_add_addr不是一个孤立的工具函数,它是"地址收集"这个环节的必经之路。我把两条主要调用链拆开讲。

3.1 静态 upstream 配置的解析链路

最常见的场景是配置文件里的:

upstream backend { server example.com:8080; }

这条指令在解析阶段会经历下面这条链路:

server example.com:8080; └→ upstream 模块解析 server 参数 └→ ngx_parse_url(pool, &us->host) ├→ 字面 IP?是 → ngx_array_create → ngx_inet_add_addr ├→ 字面 IP?否 → ngx_inet_resolve_host() │ ├→ getaddrinfo(host, ...) │ ├→ 遍历 addrinfo → 构造 sockaddr 并写入端口 │ ├→ 每个地址调用 ngx_inet_add_addr() │ └→ freeaddrinfo(aitop) └→ u->addrs 填充完毕

ngx_inet_resolve_host会先判断主机名是不是字面 IP,是的话直接构造一个sockaddr交给ngx_inet_add_addr;不是的话就走getaddrinfo。getaddrinfo返回的是一个单向链表,每个节点代表一个解析结果,Nginx 会遍历这个链表,把每个结果都拷进池里,然后立刻freeaddrinfo。

这里就体现了池分配的核心价值:getaddrinfo返回的内存是 libc 堆上的,必须手动释放;而拷进池后的地址可以安全地随配置池或请求池存活,生命周期完全解耦。后续 peer 建立、重试、日志打印,引用的都是池里的稳定拷贝。

3.2 动态 DNS resolver 路径

如果你用的是动态上游,比如配置了resolver指令,然后proxy_pass http://example.com;,这时候域名不是启动时解析的,而是请求到来后异步查 DNS。

DNS 响应回来后,ngx_resolver_process_a和ngx_resolver_process_aaaa会把 answer 区里的 A/AAAA 记录一条条取出来,构造成sockaddr_in/sockaddr_in6,随后同样调用ngx_inet_add_addr,把结果累积到解析上下文的addrs数组里。

这条路径也是去重的主要受益者。DNS 应答经过 CNAME 链、附加区、甚至是解析器自己拼接的结果,经常出现重复地址。如果不去重,动态上游的 peer 列表就会膨胀,负载均衡的权重分配自然跟着失真。

顺便一提,在这些路径里,端口是调用方在构造sockaddr时自己写进去的(比如配置 URL 里解析出的端口),ngx_inet_add_addr本身不关心端口,它只负责忠实拷贝你给它的字节。

3.3 round-robin peer 如何消费 addrs 数组

upstream 初始化 round-robin peer 时,会遍历addrs数组的每个元素,每个唯一地址对应一个 peer。

这里有个经典现象值得展开:server example.com;如果解析出 5 个 A 记录,就会产生 5 个 peer;如果配置了weight=3,这 5 个 peer 各自weight=3,总权重就是 15。Nginx 官方文档承认这种行为,但很多人第一次发现都以为是 bug。

更实际的影响是,max_fails、backup这些参数也会应用到每个解析出来的地址上。比如你配置server example.com backup;,那么这个域名解析出来的所有地址都会被标记为 backup,而不是"域名整体作为 backup"。理解ngx_inet_add_addr之后,这些行为就都有了解释:配置里的一个server指令,经过地址收集后,本质上会展开成多个独立的 server 项。

4. 去重粒度与内存设计的工程取舍

这个函数看起来简单,背后其实藏着几个值得琢磨的工程决策。

4.1 字节级去重 vs 语义级去重

前面的源码分析已经说明,去重完全是字节级的。用表格概括一下各种场景下的结果:

候选地址比较socklenmemcmp是否去重
同一 IPv4、同一端口16相等去重
同一 IPv4、不同端口16不等不去重
IPv4 与 v4-mapped IPv6 表示同一 IP16 vs 28不等不去重
同一 IPv6、同一 scope_id28相等去重

这里的关键是:sockaddr_in6里除了地址和端口,还有sin6_flowinfo和sin6_scope_id,这两个字段也参与字节比较。所以严格来说,两个 scope_id 不同的 IPv6 地址,即使链路本地地址完全相同,也不会被合并。多数场景下这没问题,但你要是做 IPv6 相关的模块,心里得有这根弦。

4.2 为什么坚持用池分配

Nginx 里几乎所有小内存分配都走池,ngx_inet_add_addr也不例外。这里用ngx_palloc而不是ngx_pnalloc,我理解是为了对齐:sockaddr_in6里的字段需要对齐访问,池分配器可以保证合适的内存对齐。

池分配的好处是立体的:

  • 生命周期绑定:配置池、请求池、解析上下文池,释放时一次性回收,没有手动free的负担
  • 分配速度快:池分配器在大块内存上做游标式分配,比频繁调用malloc的库函数开销小
  • 避免碎片:小对象集中在一个池里,不干扰系统堆
  • 语义统一:数组里所有ngx_addr_t和它们指向的sockaddr都来自同一个池,释放顺序天然安全

4.3 线性查重的复杂度表现

ngx_inet_find_addr是O(n)的,所以一次性构造 N 个地址的数组,总代价是O(N^2)。

对正常 DNS 场景完全无所谓——一个域名能解析出十几条 A/AAAA 记录已经算极端了,这个量级的线性查重也就是几微秒的事。真正需要注意的是别在高频路径上反复构造超大addrs数组。如果有上千地址的极端场景,我建议自己加哈希索引;Nginx 在这里选择简单方案是合理的,因为 peer 建立本来就不是热路径,而且大多数服务器很少需要同时持有几百个上游地址。

5. 给自己模块写代码时的避坑清单

如果你要写一个通过自定义协议解析主机名并构建地址列表的模块,ngx_inet_add_addr是一个很好的范本,但照抄之前有几个坑必须先避开。

5.1 sockaddr 必须先清零再填字段

这是我在实际项目里踩过最隐蔽的坑。sockaddr_in这类结构体里存在填充字节,如果你在栈上声明一个局部变量,只给sin_family、sin_addr、sin_port赋值,剩下的填充字节是未定义的。ngx_inet_find_addr做的是memcmp全字节比较,一旦填充字节不同,两个明明语义相同的地址就会被认为不同。

更麻烦的是,这种问题具有随机性:栈上残留数据有时候是 0,有时候不是,于是同一个地址有时能去重成功,有时去重失败,非常难排查。

正确的姿势是:

struct sockaddr_in sin; ngx_memzero(&sin, sizeof(sin)); sin.sin_family = AF_INET; sin.sin_addr.s_addr = addr; sin.sin_port = htons(port);

Nginx 自己的 resolver 代码就是这么做的,先ngx_memzero再填字段。这不是防御性编程,而是保证字节比较结果可预期的必要条件。

5.2 push 与 palloc 的顺序问题

如前面所说,原生实现是先ngx_array_push再ngx_palloc,失败时留下一个未初始化槽。这个选择在 Nginx 内部没问题,但你自己写模块时最好反过来:

struct sockaddr *sa_copy = ngx_palloc(pool, socklen); if (sa_copy == NULL) { return NGX_ERROR; } addr = ngx_array_push(addrs); if (addr == NULL) { return NGX_ERROR; } ngx_memcpy(sa_copy, sa, socklen); addr->sockaddr = sa_copy;

这样即使push失败,也不会污染数组状态。

5.3 name.data 不是字符串

这条我在前面强调过,但值得再提一次:从ngx_inet_add_addr出来的ngx_addr_t,它的name字段指向sockaddr的原始二进制,不是文本 IP。别把它直接丢给%V。需要日志输出时,老老实实调ngx_sock_ntop格式化。

5.4 别混池、别指向临时内存

ngx_inet_add_addr会拷贝传入的sockaddr,所以调用方传一个栈上临时变量是安全的。但数组本身和数组内部各元素引用的内存,必须来自同一个池或生命周期可控的池。

如果你写异步模块,尤其要注意:解析上下文的池可能在回调返回后被释放,addrs数组和里面的sockaddr也会一起失效。不要在池释放之后再保存ngx_addr_t指针并指望它还能用。

5.5 端口不同不会去重

如果你在配置里同时写server 10.0.0.1:80;和server 10.0.0.1:8080;,这是两个完全不同的sockaddr,不会被合并。这是正确行为,别指望字节级去重帮你做端口维度之外的合并。

6. 我实际调试中遇到的三个案例

最后分享三个我在真实场景里碰到、最终都回到ngx_inet_add_addr才能解释的问题。

第一个是"同一地址出现两次"的灵异事件。有次线上 upstream 的 peer 列表里出现了一个重复 IPv4 地址,排查半天发现,DNS 的 AAAA 记录里返回了::ffff:x.x.x.x这种 v4-mapped IPv6 地址,而 A 记录里又有对应的纯 IPv4 地址。字节级去重拿它们毫无办法,两条记录长度不同,直接被判为两个地址。解决方案很直接:要么关掉该域名的 AAAA 记录,要么在上游侧过滤掉 v4-mapped 地址。这不是 Nginx 的 bug,是字节级去重的必然结果。

第二个是 weight 翻倍的疑问。有人配置server example.com weight=2;,解析出 3 个 IP,压测发现某个节点的流量占比明显异常偏高。结合第 3.3 节的逻辑解释一下就通了:实际生效的是 3 个节点各weight=2,而不是"域名整体 weight=2"。想精确控制权重,就拆成多行server指令写死 IP。

第三个是关于动态上游的。用 resolver 做动态解析时,DNS 应答如果包含重复地址,peer 列表原本会膨胀;正是因为有ngx_inet_add_addr这道去重闸门,最终列表才保持精简。这也是为什么我建议所有自己写的解析模块都复用它,而不是自己另搞一套收地址的逻辑——统一走这一个入口,行为和 Nginx 其他部分保持一致。

读这类底层函数,我的习惯是不要只看单点,而是把它放到数据流里看:谁来调用、地址往哪去、消费方怎么用。ngx_inet_add_addr就是一个非常好的样本,函数本身不到三十行,却把池分配、动态数组、去重策略、生命周期设计全部串了起来。自己写模块时照着这个模式做,出错概率会低很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询