BIGO 网络架构后台一面:内存碎片、哈希冲突与 TCP 粘包拆包
- 轮次
- 一面
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
- 自我介绍
- 项目鞭打(实现和相关优化)
- 内存碎片的概念与分类
- 哈希表的底层实现
- 哈希冲突的解决方法
- C++11 对哈希冲突的解决方式
- 手撕:大数相加
- poll 和 epoll 的区别
- epoll 的触发模式,哪种是其特有的
- TCP 两次握手的弊端
- TCP 存在长期占用 / 高频请求的客户端连接怎么处理
- TCP 粘包和拆包的解决方案?HTTP 协议使用的是哪一种?为什么请求体不能同样使用分隔符法?
- HTTP/2.0 的优化升级?如果继续使用 HTTP/1.1,怎么避免多开连接的问题?
- 反问:后台存在多个模块,主要是哪些小组,是否存在轮岗?
《参考解析》
内存碎片分内部碎片和外部碎片。 内部碎片是分配器给出的内存块比请求的大、多出来的部分被浪费——例如为对齐或按固定规格(buddy/slab 的 size class)分配时,请求 100 字节实际给了 128 字节;外部碎片是空闲内存总量够但不连续,导致较大的分配请求拿不到空间。堆上长期运行的服务最容易积累外部碎片,典型表现是 RSS 持续上涨但实际活对象并不多。缓解手段包括使用对象池/内存池按尺寸分类管理、用 slab/tcmalloc/jemalloc 这类分级分配器、减少不同尺寸的混合分配、以及定期重启(务实但粗糙)。面试里如果能补一句「碎片是分配策略与访问模式共同造成的,不是某个函数的问题」,会显得理解到位。
哈希表的底层实现与冲突解决。 标准做法是桶数组加链表或开放寻址:index = hash(key) % bucket_count。冲突解决主要有两类:链地址法(每个桶挂链表,Java 的 HashMap 在链表长度超过 8 且容量 ≥ 64 时转红黑树,把最坏查找从 O(n) 降到 O(log n))和开放寻址法(线性探测、二次探测、双重散列,C++ 的 std::unordered_map 多数实现用链地址法,部分实现如 absl 的 flat_hash_map 用开放寻址 + SIMD,缓存友好)。另外必须提扩容(rehash):负载因子超过阈值就翻倍重建,JDK 8 利用「扩容后元素要么留在原位、要么移动到原位 + 旧容量」的规律避免重新计算哈希,Redis 用渐进式 rehash 把搬迁分摊到每次操作里避免长时间阻塞。题目问「C++11 对哈希冲突的解决方式」,标准答案其实就是 std::unordered_map 采用链地址法(bucket 挂单向链表),负载因子可调、扩容时全部 rehash——真正值得说的是C++ 标准并没有规定实现,各家 STL 可以不同,答题时把「标准没规定、libstdc++ 用链地址法」讲清楚比硬背更有说服力。
poll 与 epoll 的区别,本质是「怎么找就绪的 fd」。 两者都解决了 select 的 1024 fd 上限问题,但 poll 仍然是每次调用把整个 fd 数组拷贝进内核并线性扫描,就绪数少而监听的 fd 多时全是无效遍历,复杂度 O(n)。epoll 用三个接口拆开了这件事:epoll_create 建实例,epoll_ctl 维护红黑树(增删改 fd 只做一次,之后不再需要每次全量传入),epoll_wait 直接返回内核就绪链表里的 fd,复杂度只与就绪数相关 O(k)。触发模式上:水平触发(LT) 是 fd 仍可读写就持续通知,是默认模式、编程简单;边缘触发(ET) 只在状态变化时通知一次,是 epoll 特有的(select/poll 没有这个概念),必须配非阻塞 socket 并循环读写到 EAGAIN,否则剩下的数据会永远留在缓冲区里。另外还有 EPOLLONESHOT(一次性触发,适合多线程下保证同一 fd 只被一个线程处理)和 EPOLLEXCLUSIVE(缓解惊群)。
TCP 两次握手的弊端,核心是「无法确认对端的接收能力,也无法防止历史连接」。 两次握手时服务端一收到 SYN 就建立连接并分配资源,如果客户端的 SYN 是网络里滞留的旧报文(延迟到达),服务端会凭空建立一个客户端并不认账的连接,白白占用资源;另外两次握手无法让双方对初始序列号达成一致确认,服务端不知道客户端是否收到了自己的 SYN。三次握手把这两件事都解决了:第三次 ACK 让服务端确认「对方既能发也能收」。顺带可以提 SYN flood——攻击者只发 SYN 不完成握手,服务端半连接队列被占满,对策是 SYN Cookie、调大队列和缩短超时。
长期占用 / 高频请求的连接治理。 长期空闲连接会占着 fd 和内核缓冲区,要靠应用层心跳 + 空闲超时回收(心跳间隔必须小于中间设备(NAT/防火墙/负载均衡)的空闲断链时间,否则会出现「连接在服务端还在、实际已断」的死连接);频繁创建销毁短连接的开销主要在手握手的 RTT 和 TIME_WAIT 堆积,对策是连接池与长连接复用。服务端还要防慢连接攻击(限制单连接读写缓冲、限制并发连接数、对慢速读写设超时)。这些问题在 RPC 框架里一般都有对应配置,面试时能点名「空闲超时、心跳、连接池上限、背压」四个旋钮就够。
粘包拆包与 HTTP 用的定界方式。 TCP 是字节流,不保留应用层消息边界,一次 send 可能被拆成多次到达、多次 send 也可能合并被一次 recv 读出,所以定界必须由应用层做:固定长度(简单但浪费)、分隔符(如按 \n,实现简单但正文出现分隔符要转义或编码)、长度前缀(先读固定长度头部拿长度再读正文,最通用)、以及 TLV 这类二进制协议。HTTP 用的是「头部 + Content-Length」或「chunked 分块传输」:有 Content-Length 时按长度读;未知长度时用 Transfer-Encoding: chunked,每块前缀十六进制长度、以 0 长度块结束。请求体不能沿用分隔符法的原因是——body 是任意二进制(上传图片、文件、序列化数据),任何分隔符都可能合法地出现在数据里,无法可靠判断结束;而头部是纯文本且结构简单。这也是为什么 HTTP/1.1 要求不得同时使用 Content-Length 和 chunked(会造成请求走私),处理多段数据则用 multipart 配合显式 boundary。
HTTP/2 的升级点,以及 HTTP/1.1 时代的规避手段。 HTTP/2 的核心改进是二进制分帧 + 多路复用(多个 Stream 复用一条 TCP 连接,彻底解决 HTTP/1.1 的应用层队头阻塞)、头部压缩 HPACK(静态表 + 动态表 + 哈夫曼编码,避免重复发送 Cookie 这类头部)、服务端推送(HTTP/3 中已被弃用)、Stream 优先级,以及依赖 TCP 可靠有序带来的「丢包会阻塞所有 Stream」这一遗留问题(HTTP/3 用 QUIC 解决)。在 HTTP/1.1 下避免多开连接的手段有:域名分片(用多个子域绕过浏览器单域并发上限,但和 HTTP/2 的复用思路相反,属于过渡手段)、连接复用(keep-alive)、资源合并与内联(雪碧图、把小 JS/CSS 内联进 HTML,代价是缓存粒度变粗)、以及预连接/preconnect 与 CDN 就近接入来降低每次握手成本。