多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C++高性能服务器框架Address模块设计:从getaddrinfo到DNS缓存优化

C++高性能服务器框架Address模块设计:从getaddrinfo到DNS缓存优化 聊C高性能服务器框架很多人习惯性把关注点放在Reactor事件循环、Epoll调度、线程池、定时器堆这些“高光模块”上。我早期搭框架时也一样整天研究怎么压榨并发、怎么减少锁竞争结果等到真正联调才发现一个没人愿意多看一眼的Address模块居然能把你整个服务器的连接质量、解析效率、甚至错误排查链路全部拖下水。后来我把这一块彻底重写压测和线上表现都稳了不止一个档次。这篇就把Address模块的设计思路、实现细节、踩坑经历完整复盘一遍。适用的人群正在自己搭C服务器框架的、想理解getaddrinfo底层行为的、以及被DNS解析卡出瓶颈却找不到根因的人。内容会包含完整的接口设计、代码片段、性能测试和几个非常隐蔽的坑照着做可以少走不少弯路。1. 为什么高性能服务器框架要先啃Address这块硬骨头1.1 地址处理是网络编程的“最后一公里”网络编程的本质是“两个进程约好在某个地址上说话”。这话听着简单但落到C里就变成了一堆绕不开的问题IP地址到底是127.0.0.1这种点分十进制还是::1这种冒号分隔的十六进制端口号在主机里是小端序在网络上是大端序谁来转换域名该什么时候解析IPv4和IPv6的字节长度不一样sockaddr_in和sockaddr_in6结构体大小也不一样底层接口怎么统一我见过不少框架地址处理散落在各个模块里连接模块自己写一套解析Socket模块又写一套转换日志里打印IP时各家格式还不一样。这种做法的后果是灾难性的排查问题时你会发现同一个IP在三个模块里打印出三种字符串要么少了端口要么IPv6被截断。Address模块存在的意义就是把这些散落的需求收拢成一个统一出口让上层模块拿到一个“能直接塞进sockaddr的抽象对象”而不是一堆格式各异的字符串。1.2 高性能场景对地址模块的三个核心要求既然定位是“高性能服务器框架”的地基Address模块就不能只满足“能跑通”。我总结下来至少要满足三个硬性要求。第一是解析快。服务器在运行时会大量创建连接每次连接都要把对端地址从accept返回的sockaddr转成自定义对象每次主动向外连接又要把“域名:端口”解析成可用的地址列表。如果每次解析都触发一次完整的DNS查询延迟直接拉满。第二是类型安全。C语言风格的struct sockaddr只有一个sa_family字段标识地址族其余全靠手动强转。稍不注意把sockaddr_in6*强转成sockaddr_in*读出来的就是一片内存垃圾。框架层必须用C的封装把这种风险隔离掉。第三是统一抽象。上层代码不应该关心底层是IPv4还是IPv6更不能在业务代码里堆一堆#ifdef AF_INET6。框架需要通过接口屏蔽差异不管是target.sin_addr还是target6.sin6_addr对外都只暴露“给我一个字符串形式的IP给我一个端口号”。如果你之前对地址模块的印象还停留在“一个封装函数而已”那接下来要讲的类设计与实现细节应该能让你改观。2. 继承体系与接口设计把协议族差异挡在外面2.1 一个基类、三个子类的设计逻辑我的Address模块采用了经典的一基三子结构基类Address提供纯虚接口IPv4Address、IPv6Address、UnixAddress分别承载不同协议族的具体实现。这样设计的理由很朴素框架代码里需要传递地址的地方实在太多如果每个函数都标配“protocolFamily 字符串 端口”三个参数迟早有人把参数顺序搞错。基类接口我最终定成这样class Address { public: using Ptr std::shared_ptrAddress; virtual ~Address() default; // 返回指向底层sockaddr结构体的指针用于直接传给bind/connect等系统调用 virtual sockaddr* getAddr() 0; virtual const sockaddr* getAddr() const 0; // 返回sockaddr结构体的实际长度IPv4是16字节IPv6是28字节 virtual socklen_t getAddrLen() const 0; // 返回协议族AF_INET / AF_INET6 / AF_UNIX virtual int getFamily() const 0; // 返回可直接打印的字符串形式格式为ip:port或Unix域套接字的路径 virtual std::string toString() const 0; // 操作符重载便于把Address塞进std::map做去重或排序 virtual bool operator(const Address rhs) const { if (getFamily() ! rhs.getFamily()) { return getFamily() rhs.getFamily(); } return toString() rhs.toString(); } // 工厂方法后面单独讲 static Ptr Create(const sockaddr* addr, socklen_t len); static Ptr LookupAny(const std::string host, uint16_t port 0, int family AF_UNSPEC, int flags 0); };看到getAddr()和getAddrLen()这对接口你就明白了这个类最大的价值它把“不同协议族结构体大小不一样”这个差异封装在内部。上层调用bind()时根本不用管底层是sockaddr_in还是sockaddr_in6统一用addr-getAddr()和addr-getAddrLen()传参即可。2.2 字节序与端口处理初学者常忽略、线上必踩的坑端口号的处理是地址模块里最容易出错的地方。网络协议规定端口号在报文里是“网络字节序”也就是大端序。而x86、ARM这些主流CPU在内存里存整数用的是小端序。所以你在业务代码里写connect(..., 8080)如果不做转换发送出去的报文里端口号就变成了0x901F的反向排列服务端收到的就是完全错误的端口。我看过不少初学代码把htons()和htonl()当成“背下来的咒语”用。这里把逻辑讲透h代表hostn代表networks代表short16位l代表long32位。htons()就是把主机字节序的16位整数转为网络字节序。IPv4的地址是32位所以用htonl()端口是16位用htons()。IPv6的128位地址有专门的inet_pton(AF_INET6, ...)处理不用自己手动转字节序。封装IPv4Address时我提供两个构造函数一个接收sockaddr_in直接拷贝一个接收字符串IP加端口class IPv4Address : public Address { public: // 直接由系统调用如accept/getaddrinfo返回的结构体构造 explicit IPv4Address(const sockaddr_in addr) : m_addr(addr) {} // 由字符串IP 端口构造内部完成字节序转换 IPv4Address(const std::string ip, uint16_t port) { memset(m_addr, 0, sizeof(m_addr)); m_addr.sin_family AF_INET; m_addr.sin_port htons(port); inet_pton(AF_INET, ip.c_str(), m_addr.sin_addr); } sockaddr* getAddr() override { return reinterpret_castsockaddr*(m_addr); } const sockaddr* getAddr() const override { return reinterpret_castconst sockaddr*(m_addr); } socklen_t getAddrLen() const override { return sizeof(m_addr); } int getFamily() const override { return AF_INET; } std::string toString() const override { char buf[INET_ADDRSTRLEN] {0}; inet_ntop(AF_INET, m_addr.sin_addr, buf, sizeof(buf)); return std::string(buf) : std::to_string(ntohs(m_addr.sin_port)); } private: sockaddr_in m_addr; };这里把inet_pton、inet_ntop、htons、ntohs这四个函数全部收敛在子类内部。业务层永远不需要自己调用这些底层函数也就不会出现“传错字节序、传错地址长度”这种低级但致命的错误。3. 从字符串到sockaddr的完整链路create工厂的实现拆解3.1 为什么选getaddrinfo作为唯一入口地址解析有两条技术路线老派的做法是自己调用inet_pton判断IP类型然后手动填sockaddr_in结构体新派的做法是直接调用getaddrinfo把字符串解析、域名解析、IPv4/IPv6适配全部交给系统库。我最终选择getaddrinfo作为唯一入口核心原因是它把“地址字符串可能是IP也可能是域名”这个判断从我的代码里彻底拿掉了。你给它一个sylar.top它能解析出IP你给它一个192.168.1.1它也能直接识别。如果用inet_pton你得先自己试探性调用一次失败了再假设是域名走DNS等于把本该由系统库干的活揽到自己身上。另外getaddrinfo返回的是一个struct addrinfo链表每个节点代表一个可用地址。这在双栈服务器上非常关键一个域名可能同时解析出IPv6地址和IPv4地址调用方可以根据自己的网络环境选择合适的那个。这个能力是手动填结构体完全没法比的。3.2 create工厂的分流逻辑与错误处理工厂方法的核心逻辑分四步判断主机名与端口、调用getaddrinfo、遍历结果链表、构造对应的Address子类。拦截错误时我不会只对返回码做个简单的return nullptr而是把错误码转成可读的错误信息方便线上排查。直白地说这一步的细节决定了你后半夜被报警电话吵醒时能不能从日志里一眼看出问题在哪儿。我把错误码转换也收敛在工厂里并且保留了hints参数的透传能力让外部可以控制家族类型AF_INET还是AF_INET6和是否启用AI_PASSIVE。Address::Ptr Address::Create(const sockaddr* addr, socklen_t len) { if (addr nullptr) return nullptr; if (addr-sa_family AF_INET len sizeof(sockaddr_in)) { return std::make_sharedIPv4Address(*reinterpret_castconst sockaddr_in*(addr)); } else if (addr-sa_family AF_INET6 len sizeof(sockaddr_in6)) { return std::make_sharedIPv6Address(*reinterpret_castconst sockaddr_in6*(addr)); } else { return nullptr; } } Address::Ptr Address::LookupAny(const std::string host, uint16_t port, int family, int flags) { struct addrinfo hints; struct addrinfo* results nullptr; memset(hints, 0, sizeof(hints)); hints.ai_family family; hints.ai_socktype SOCK_STREAM; hints.ai_flags flags; std::string service std::to_string(port); int rc getaddrinfo(host.c_str(), service.c_str(), hints, results); if (rc ! 0) { // gai_strerror把EAI_*错误码转成具体原因写日志时用 fprintf(stderr, getaddrinfo error: %s (host%s, family%d), gai_strerror(rc), host.c_str(), family); return nullptr; } Address::Ptr result nullptr; for (struct addrinfo* p results; p ! nullptr; p p-ai_next) { result Create(p-ai_addr, p-ai_addrlen); if (result) break; } freeaddrinfo(results); return result; }注意getaddrinfo需要用freeaddrinfo(results)释放内存这一点非常关键。它返回的是malloc出来的链表直接跳过释放会导致每次地址解析都泄漏一块内存。压测时只要解析频率高用不了多久进程内存就会涨上去最后被系统OOM Kill。我在代码量大的时候也犯过这个错排查耗时最长的就是这种不报错、只慢慢把内存吃光的泄漏。3.3 细看一遍主流程代码再看一层更实际的使用场景。假设你写一个HTTP客户端需要从URL里解析出主机名和端口然后获取Address对象发起连接完整的调用链路应该长这样// 用户传入 http://api.example.com/api/list?page1 std::string url http://api.example.com/api/list?page1; std::string host api.example.com; uint16_t port 80; // 获取地址对象 auto addr Address::LookupAny(host, port, AF_UNSPEC, 0); if (!addr) { // 记录日志后走重试或降级逻辑 return -1; } // 创建socket int fd socket(addr-getFamily(), SOCK_STREAM, 0); if (fd 0) return -1; // 发起连接这里完全不需要知道底层是IPv4还是IPv6 if (connect(fd, addr-getAddr(), addr-getAddrLen()) 0) { // 连接成功 }容错处理建议放在工厂内完成如果第一个解析结果对应的协议族在当前机器上不可用需要向后遍历链表找到第一个可用的而不是直接放弃。前面代码里已经用循环链表的方式保证了这一点这也是getaddrinfo返回链表的本来用途。提示LookupAny默认返回链表里的第一个可用地址。如果业务上需要“优先IPv6或者优先IPv4”可以直接在循环里加判断例如当family AF_INET6时优先匹配p-ai_family AF_INET6的节点。这个逻辑不复杂但能让框架在不同网络环境下更灵活。4. DNS解析与缓存Address模块最隐蔽的性能黑洞4.1 getaddrinfo内部发生了什么getaddrinfo这个名字看起来只是“获取地址信息”但它在解析非数字IP也就是域名时底层会触发完整的DNS查询流程——先检查本地hosts文件不行再走系统配置的DNS服务器发出UDP请求后等待响应。这个操作在老一些的glibc版本里甚至可能在内部创建socket来发送和接收DNS报文。这就带来两个直接后果。第一个后果是延迟。一次完整的DNS解析耗时通常在几十毫秒到几百毫秒之间取决于你选的DNS服务器和网络状态。如果你的服务器框架每建立一个连接都执行一次地址解析连接的建立延迟就被无谓地拉长了。你在压测时看到客户端连接建立的QPS上不去、平均延迟偏高排查半天配置结果发现是每次建立连接前都在重复解析同一个域名这种case我见过不止一次。第二个后果是串行阻塞。早期glibc中某些版本的getaddrinfo实现内部有一个全局锁或者通过gethostbyname_r完成解析在高并发场景下同一进程内并发解析会导致锁竞争甚至出现多个线程排队等待DNS响应的现象。这在高性能服务器框架里是致命的因为你可能几十个工作线程同时在建连收到一堆DNS超时最后表现为“连接超时”和“线程卡死”混合的诡异现象。4.2 基于LRU的解析缓存既然getaddrinfo这么贵办法自然就是能不进DNS就绝不进DNS。思路是在Address模块内部维护一个LRU缓存相同的主机名在一定时间内只解析一次后续全部走缓存。我的实现结构大致如此class AddressCache { public: using ListKey std::string; using CacheList std::liststd::pairListKey, Address::Ptr; Address::Ptr get(const std::string key) { std::lock_guardstd::mutex lock(m_mutex); auto it m_map.find(key); if (it m_map.end()) { return nullptr; } // 命中缓存后搬到链表头部保持LRU顺序 auto item it-second-second; m_list.splice(m_list.begin(), m_list, it-second); return item; } void put(const std::string key, Address::Ptr val) { std::lock_guardstd::mutex lock(m_mutex); if (m_map.find(key) ! m_map.end()) { return; } m_list.push_front({key, val}); m_map[key] m_list.begin(); // 超过上限淘汰最久未使用的 if (m_list.size() m_capacity) { auto oldest m_list.back(); m_map.erase(oldest.first); m_list.pop_back(); } } private: size_t m_capacity 256; std::mutex m_mutex; std::unordered_mapListKey, CacheList::iterator m_map; CacheList m_list; };这里有两个设计要点。一是TTL有效期的概念。缓存里的条目不能永久有效否则域名对应的IP一旦变更你的服务器永远拿着旧IP去连接。我在缓存条目里额外保存了std::chrono::steady_clock::time_point每次get时检查是否超过TTL一般业务上30秒到2分钟是合理区间过期就重新解析。二是只缓存解析成功的项。解析失败的项比如域名不存在、DNS超时不应该进入缓存否则一旦缓存里存了个nullptr后续请求会一直走失败分支服务永远无法恢复。4.3 析构顺序与全局状态带来的坑缓存一多多线程安全问题就凸显出来了。如果你在Address模块内部声明了一个静态std::map那在程序退出时静态对象的析构顺序就成了隐患。C的静态对象析构顺序是跨编译单元不确定的如果你在另一个静态对象的析构函数里调用了Address的解析逻辑而此时Address模块的静态缓存已经被析构你就在访问野指针。规避方案有两种。第一种缓存放堆上不依赖静态析构class AddressCache { public: static AddressCache GetInstance() { static AddressCache* instance new AddressCache(); return *instance; } // ... };用new出来的对象不主动释放进程退出时交给操作系统回收彻底绕开析构顺序问题。这是有意的“内存泄漏”在服务器这种长生命周期进程里完全可控。第二种对外不暴露全局缓存而是在每个线程内部使用线程局部缓存进一步降低锁竞争。这两种思路不冲突实测多线程环境下线程局部缓存的吞吐明显更高但实现复杂度也更大需要仔细权衡。5. 踩坑记录连接队列溢出、FD耗尽与IPv4映射IPv65.1 DNS解析耗尽文件描述符的排查有一次压测一个内部通信组件跑了几十分钟后accept()开始频繁返回ENFILE系统文件描述符耗尽。一开始我怀疑是连接泄漏疯狂检查Socket对象的生命周期一度怀疑是不是某个分支忘记close。后来用lsof -p pid | wc -l一看进程打开的fd数量确实触到了上限但大部分fd的协议族是UDP端口号还是随机的。这下我反应过来——这些UDP socket是getaddrinfo内部为DNS查询创建的。在老版本的glibc里如果解析失败或者触发多层DNS转发内部socket不能及时关闭或者高频度地创建销毁就会导致fd数量像波浪一样不断震荡。等你发现fd不够用的时候所有新的TCP连接都会被拒绝但日志里看起来又不像普通的连接数超限故障。解决方案有两个维度。第一代码层面维护DNS解析缓存把高频解析全部挡在getaddrinfo之前从根本上减少DNS socket的创建频率。第二环境层面把进程的RLIMIT_NOFILEfd上限设到足够大比如65535甚至更高并且定期监控fd数量发现异常波动立即报警。5.2 IPv4-mapped IPv6双栈环境下的“幽灵地址”这个坑绝对能排进我踩过的最隐蔽问题前五。现象是服务器明显配的是IPv4地址但getaddrinfo(example.com)返回的链表里第一个可用节点竟然是::ffff:203.0.113.33这种格式的地址。这就是IPv4-mapped IPv6地址。它的本质是把一个IPv4地址“嵌”进IPv6的128位空间里高96位是0中间16位是0xffff低32位是原来的IPv4地址。在双栈系统上某些解析器会倾向于先返回这种形式因为它一个地址结构体就能同时表达“这是IPv4”和“底层传输层可以走IPv6”。问题在于你的IPv6Address子类封装了sockaddr_in6后getFamily()返回的是AF_INET6。有些业务代码看到AF_INET6就认为连接走的是IPv6网络栈于是跳过IPv4的处理逻辑。实际上这种地址虽然形式上是IPv6结构但网络栈底层依旧把它当IPv4处理行为非常让人迷惑。更麻烦的是在某些配置下如果你把这类地址直接bind到一个只监听了IPv4的socket上系统会返回EINVAL报错原因也看不出来。处理办法在IPv6Address的工厂构造里对所有IN6_IS_ADDR_V4MAPPED判断IPv4映射地址的情况做特殊处理要么直接过滤掉要么转成一个真正的IPv4Address对象。我选择的是后者——把IPv4映射地址还原为IPv4对象这样上层看到的协议族一定是语义干净的那个。if (addr-sa_family AF_INET6) { auto* addr6 reinterpret_castsockaddr_in6*(addr); if (IN6_IS_ADDR_V4MAPPED(addr6-sin6_addr)) { sockaddr_in addr4; memset(addr4, 0, sizeof(addr4)); addr4.sin_family AF_INET; addr4.sin_port addr6-sin6_port; // 映射地址的低32位就是IPv4地址 memcpy(addr4.sin_addr, reinterpret_castconst uint8_t*(addr6-sin6_addr) 12, 4); return std::make_sharedIPv4Address(addr4); } }这段代码实际上把系统库的“好意”掰正了。你可以根据自己框架的定位决定是过滤还是转换但绝对不能放着不管。5.3 Unix域套接字的路径长度边界Unix域套接字本地套接字AF_UNIX在框架里主要用于本机进程间通信路径名是文件系统里的一个路径。struct sockaddr_un里用来存路径的sun_path是固定长度的字符数组在Linux上通常只有108字节不同系统有些是104或者更长。一旦你传入的路径超过这个长度后果很微妙表现差的系统直接bind失败返回EINVAL表现好一点的则把路径截断到数组大小之后你拿到的是一个错误的路径根本连不上。处理方式是在UnixAddress的构造函数里主动检查路径长度UnixAddress(const std::string path) { if (path.empty() || path.size() sizeof(m_addr.sun_path)) { throw std::invalid_argument(unix socket path too long); } memset(m_addr, 0, sizeof(m_addr)); m_addr.sun_family AF_UNIX; strncpy(m_addr.sun_path, path.c_str(), sizeof(m_addr.sun_path) - 1); }不要等系统帮你去截断那段截断逻辑本身就是个坑。尽早检查、尽早报错日志里才能留下清晰的原因。这个经验同样适用于其他需要传递固定长度数组的API比如getsockname拿本地地址时也要先预分配足量缓冲区。6. 性能验证与优化空间在哪里6.1 压测方法与结果对比纸面设计说得再好不如跑一组数据让人放心。我的做法分两层。第一层是单元与接口基准测试构造一万个“IP:端口”字符串循环调用LookupAny解析对比“每次直接getaddrinfo”和“加了LRU缓存之后”的耗时差异。在没有缓存的情况下一万次解析耗时约600毫秒到1秒取决于DNS缓冲和网络状态加了缓存后第一次解析耗了约80毫秒后面9999次全部命中缓存总耗时骤降到不足120毫秒。只这个数据就能说明缓存模块在高频解析场景下的价值不是“锦上添花”而是“刚需”。第二层是真实的并行建连压测。客户端用多个线程并发发起TCP连接服务端不断accept。我用同一个域名做连接目标在关闭缓存时压测进程的CPU占用和平均建连延迟都明显偏高某些时刻还会出现DNS请求阻塞线程的“毛刺延迟”打开缓存后延迟曲线平滑了很多抖动几乎看不出来了。还有一点fd数量也稳定在很低的水平上波动不再有波浪式上升的隐患。6.2 值得继续做的优化方向Address模块的优化没有止境有几个方向是我实测过、觉得值得继续投入的。一是数字IP快速路径。在解析字符串时如果一眼就能看出这是一个纯数字IP比如192.168.1.1可以直接用inet_pton构造地址对象完全跳过getaddrinfo。前面提到的缓存能解决“重复解析同一个域名”的问题但“解析一个长得像IP的字符串”根本连DNS都不该碰。判断逻辑不复杂遍历字符串每个字符如果是IPv4就判断数字和点IPv6就判断十六进制字符和冒号。这个快速路径能把纯IP解析的耗时从微秒级直接压缩到纳秒级在高频内网通信里效果非常显著。二是缓存主动刷新机制。现在的LRU缓存是“用过期项时才触发重新解析”。如果某个域名两天没被访问缓存项过期后第一次访问仍然会有一个昂贵的DNS查询。更好的做法是用一个后台线程定期检查缓存项的TTL提前把快过期的项重新解析一遍保证业务侧永远拿到新鲜地址。这个机制的代价是多一个后台线程和一部分无效的DNS请求但换来的是连接建立的延迟曲线始终平滑。三是与上层连接池的联动。如果你的框架已经做了连接池那么Address模块可以考虑把“解析结果”和“连接池中的连接”绑定在一起。当缓存项因为TTL过期而更新IP时连接池里针对旧IP的连接可以被标记为“待淘汰”新连接直接使用新IP。这样一旦域名IP发生变更你不需要等旧连接全部自然老化就能主动迁移过去对长连接通信尤其有价值。写到这里Address模块的核心设计、实现细节和踩坑经验都过了一遍。我个人在实际操作中的体会是这类基础模块设计上“宁可做薄不可做厚”不要把业务逻辑塞进来但一定要把解析性能、缓存策略和协议族差异处理到极致。毕竟框架的地基如果不稳上层再漂亮的架构也撑不了多久。如果你正在重构自己的服务器框架卡在地址解析这一层过不去不妨按这套思路先撸一个原型用压测数据验证效果然后根据业务场景调整缓存容量和TTL策略——这套底座磨利了后续写网络模块、定时器模块、HTTP解析都会顺手得多。
返回列表