如果你翻过 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 语义级去重
前面的源码分析已经说明,去重完全是字节级的。用表格概括一下各种场景下的结果:
| 候选地址比较 | socklen | memcmp | 是否去重 |
|---|---|---|---|
| 同一 IPv4、同一端口 | 16 | 相等 | 去重 |
| 同一 IPv4、不同端口 | 16 | 不等 | 不去重 |
| IPv4 与 v4-mapped IPv6 表示同一 IP | 16 vs 28 | 不等 | 不去重 |
| 同一 IPv6、同一 scope_id | 28 | 相等 | 去重 |
这里的关键是: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就是一个非常好的样本,函数本身不到三十行,却把池分配、动态数组、去重策略、生命周期设计全部串了起来。自己写模块时照着这个模式做,出错概率会低很多。