ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Nginx源码解析:ngx_inet_add_addr如何实现地址去重与池化管理

Nginx源码解析:ngx_inet_add_addr如何实现地址去重与池化管理 如果你翻过 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 堆上需要手动freeaddrinfoDNS 应答包处理完就丢。如果不做统一收集upstream 就很难用一份共用的地址列表去建立 peer。ngx_inet_add_addr就是这套流程的公共出口无论地址从哪来最终都转成一个sockaddr socklen交给它由它在指定的池里拷一份并保证数组里不会出现两个完全相同的条目。去重不是洁癖。域名解析结果里出现重复地址比想象中常见有的权威服务器会同时返回 answer 区的记录和 additional 区的重复项有的内网 DNS 在多个 CNAME 链上返回同一个 IPCDN 厂商为了容错也可能在同一响应里塞两份同名记录。如果不去重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 再 memcmpngx_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 peerngx_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如果配置了weight3这 5 个 peer 各自weight3总权重就是 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 weight2;解析出 3 个 IP压测发现某个节点的流量占比明显异常偏高。结合第 3.3 节的逻辑解释一下就通了实际生效的是 3 个节点各weight2而不是域名整体 weight2。想精确控制权重就拆成多行server指令写死 IP。第三个是关于动态上游的。用 resolver 做动态解析时DNS 应答如果包含重复地址peer 列表原本会膨胀正是因为有ngx_inet_add_addr这道去重闸门最终列表才保持精简。这也是为什么我建议所有自己写的解析模块都复用它而不是自己另搞一套收地址的逻辑——统一走这一个入口行为和 Nginx 其他部分保持一致。读这类底层函数我的习惯是不要只看单点而是把它放到数据流里看谁来调用、地址往哪去、消费方怎么用。ngx_inet_add_addr就是一个非常好的样本函数本身不到三十行却把池分配、动态数组、去重策略、生命周期设计全部串了起来。自己写模块时照着这个模式做出错概率会低很多。
返回列表