灵犀互娱游戏服务端开发一面面经
- 轮次
- 一面
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
C++ 与语言基础
- 说说 C++ 是怎么实现面向对象三大特性的?
- 详细说说内存对齐?
- C++ 的智能指针有哪几种?分别详细讲述一下。
- 了解哪些常见的查找和排序算法?
- 说下
unordered_map的底层原理?
操作系统
- 进程和线程的区别?
- Linux 中创建子进程的系统调用接口是?怎么判断是子进程还是父进程?
计算机网络
- 说说 TCP 的四次挥手?CLOSE_WAIT 这个状态出现在什么时候?
- 粘包和拆包是什么?怎么解决?你知道相关的系统接口吗?
- 一次
send返回成功代表数据成功发给对方了吗? - 一次
send中,数据从己方用户程序发到对方用户程序,全过程经历了什么? - socket 的发送缓冲区满时,调用一次
send会发生什么? - 说说 poll 和 epoll 的区别?
- 说说 epoll 的两种触发模式?
数据库与项目
- MySQL 和 MongoDB 了解吗?在开发游戏服务端时各自适用场景是?
- 项目中网络通信的序列化用的是什么协议?你数据包的格式是怎么规定的?
- 包头是什么结构?各部分有多少字节?能传递多大的数据包?
- 项目服务端用的是什么架构?登录流程怎么设计的?
- 假如一个数据包太大了,需要做拆分,你该怎么调整数据包的格式来兼顾这种情况?
- 你做这个项目的初衷是?你们团队有几人?你在其中负责客户端还是服务端?
- 你的技术栈好像更偏向游戏客户端,为什么不投游戏客户端?
《参考解析》
1. C++ 三大特性怎么实现:封装靠访问控制(public/protected/private)把数据和对它的操作绑在类里,并用不变式约束外部只能通过接口改状态。继承靠对象内存布局的复用:派生类对象前部嵌入基类子对象,单继承下就是基类成员依次排在前、派生类新增成员排在后。多态靠虚函数表:含有虚函数的类在编译期为它生成一张 vtable,对象里存一个 vptr 指向它;调用虚函数时通过 vptr 找到表项再跳转,因此是运行期绑定(动态绑定)。由此派生出的实践是:基类析构函数必须为虚,否则 delete 基类指针时派生类部分不会析构;构造/析构函数里调用虚函数不会走动态绑定。
2. 内存对齐:CPU 访问未对齐地址时可能需要多次总线访问甚至直接触发异常,同时对缓存行也不友好,所以编译器会让每个成员的起始偏移是它的对齐数的整数倍,结构体整体大小再补齐到最大对齐数的整数倍,这样数组里每个元素都仍然对齐。典型例子是 char c; int i; 结构体占 8 字节而不是 5。控制手段有 #pragma pack 或 alignas;把大对齐成员放前面通常能减少填充。需要注意两处:一是网络包和磁盘格式必须用 packed 或手工序列化,否则跨平台解析会错位;二是伪共享场景下要有意填充到缓存行大小。
3. 智能指针:unique_ptr 独占所有权,不可拷贝只能移动,开销与裸指针相同,是默认选择。shared_ptr 通过控制块做引用计数,计数归零时释放对象,拷贝会带来原子操作开销,且引用计数线程安全但对象本身不是线程安全的;make_shared 能一次分配对象和控制块,效率更高但会延迟内存归还。weak_ptr 不增加计数,用来观察 shared_ptr 管理的对象并打破循环引用(如父子节点互指)。还要提到:自定义删除器可以管理文件句柄、socket 等资源;shared_ptr 从裸指针构造两次会产生两个独立控制块、导致二次释放,所以要么 make_shared 要么只从另一个智能指针构造。
4. 常见查找与排序算法:排序里快排平均 O(n log n)、原地、但不稳定且最坏 O(n²);归并稳定、最坏也是 O(n log n),代价是 O(n) 额外空间,适合链表和外部排序;堆排序 O(n log n)、原地、不稳定,适合只要前 K 个的场景。工程里要能说出「为什么标准库用内省排序」——快排 + 堆排 + 小数组插入排序的组合,兼顾平均性能与最坏情况。查找方面:有序数据二分 O(log n),等值查询用哈希表平均 O(1),范围查询和前缀用有序结构(红黑树、跳表、B+ 树),TopK 用堆或快速选择。
5. unordered_map 的底层原理:它是哈希表,由桶数组和元素节点组成,节点在 libstdc++ 里串成一条单链表、桶指向链表中对应位置(不是每个桶各挂一条独立链表)。插入时对 key 求哈希再映射到桶,冲突用链地址法解决;查找平均 O(1),最坏情况下所有元素挤在一个桶里退化成 O(n)。元素数量超过 max_load_factor × bucket_count(默认 1.0)时触发 rehash,桶数组扩容并重新分布元素,此时迭代器失效但指向元素的引用和指针仍有效。实战注意点:自定义 key 要提供稳定的哈希与 operator==,迭代顺序不保证稳定,需要有序遍历就换 map。
6. 进程和线程的区别:进程是资源分配与隔离的单位,拥有独立虚拟地址空间、页表、fd 表和信号处理;线程是调度单位,同进程内线程共享地址空间、堆、全局变量与 fd,各自持有栈、寄存器与 TLS。因此线程通信只需共享内存加锁,进程通信要走管道、共享内存、消息队列或 socket。切换成本上线程远低于进程(不切页表、TLB 不易失效),并发粒度更细,但隔离性弱,一个线程越界访问会拖垮整个进程。容器时代还要补一句:Linux 下二者都由 clone 实现,差别只在共享了哪些资源。
7. 创建子进程与判断父子:fork() 是创建子进程的系统调用,它调用一次返回两次:父进程中返回子进程的 PID(大于 0),子进程中返回 0,失败返回 -1 并设置 errno。所以判断方式就是看返回值。子进程拿到的是父进程地址空间的写时拷贝副本,文件描述符表、信号处理等一并继承。fork 之后通常要 exec 换镜像(fork+exec 是 shell 执行命令的经典组合),如果不 exec 必须注意子进程要 _exit 而不是 exit,避免重复刷新父进程的 stdio 缓冲区;vfork 与 clone 是它的变体,后者可以按标志位选择共享哪些资源,也是线程的实现基础。
8. TCP 四次挥手与 CLOSE_WAIT:挥手是四次,因为 TCP 全双工,一方关闭发送方向不影响另一方继续发数据:主动方发 FIN,对方回 ACK(此时主动方进入 FIN_WAIT_2),对方把剩余数据发完后再发 FIN,主动方回 ACK 并进入 TIME_WAIT,等 2MSL 后彻底关闭。CLOSE_WAIT 出现在被动关闭方:它收到 FIN 并回了 ACK,但本端应用还没调用 close(),于是停在这个状态等待本地关闭。线上如果看到大量 CLOSE_WAIT,几乎可以断定是代码里漏了关闭连接(异常分支没走 close、连接池只借不还),属于应用层 bug,而不是内核参数问题;相对地,大量 TIME_WAIT 出现在主动关闭方,一般调 tcp_tw_reuse 或让服务端主动关连接来控制。
9. 粘包拆包是什么、怎么解决:TCP 是字节流协议,只保证字节有序到达,不保留应用层的消息边界,于是多次发送的数据可能被合并(粘包)、一次发送也可能被拆开(拆包/半包)。解决办法是应用层自己定帧:定长消息、特殊分隔符(如 \r\n,注意转义与扫描代价)、或最通用的长度字段方案(TLV:魔数 + 版本 + 消息类型 + 长度 + 数据体 + 校验)。接收端维护累积缓冲区循环解析,够一帧就取走、不够就等待、校验失败就从下一个魔数重新同步。相关系统接口层面可以提:recv/read 本身不保证边界,readv/writev 做分散聚集 IO,MSG_PEEK 预读探边界,TCP_NODELAY 关掉 Nagle 以减少小包合并带来的延迟。
10. send 的语义、数据流转与缓冲区满时的行为:send 返回的是被拷进内核 socket 发送缓冲区的字节数,返回成功只说明内核收下了这段数据,既不代表对方收到、也不代表对方应用读走,而且返回值可能小于请求长度,所以正确写法是循环发送剩余部分并处理 EINTR。数据的完整路径是:TCP 层按 MSS 分段并加 TCP 头(序号、确认号、窗口、校验和),IP 层选路封帧,网卡 DMA 发出,经交换机路由器逐跳转发;对端逐层解封装、校验通过后放进接收缓冲区并回 ACK,乱序或丢包则靠序号缓存与重传处理;直到对端调用 recv/read 把数据拷进用户态,才算真正到达对方程序。发送缓冲区满时,阻塞 socket 会把调用挂起,非阻塞 socket 立刻返回 -1 并置 EAGAIN,此时应记录已发偏移、注册 EPOLLOUT 等可写事件续发,发完再摘掉以免空转。关键指令不能拿 send 的成功当送达确认,要靠应用层 ACK 加序列号去重与超时重发。
11. poll 与 epoll 的区别,以及 epoll 的两种触发模式:poll 每次调用都要把 pollfd 数组全量传进内核并逐个遍历,返回后用户态还要再扫一遍找就绪项,开销随 fd 总数线性增长。epoll 把「注册」和「等待」分开:epoll_ctl 把 fd 挂到内核红黑树并注册回调,epoll_wait 只返回就绪链表里的 fd,开销与就绪数量相关而非总数相关,也没有 select 的 FD_SETSIZE 上限,所以连接数大而活跃比例低的网关场景明显更划算。触发模式上,水平触发(LT,默认)只要有数据就持续上报,允许一次没读完下次接着读;边缘触发(ET)只在状态变化时通知一次,必须配合非阻塞 fd 循环读到 EAGAIN,否则残留的数据不会再有新事件、连接就此假死。担心同一连接被多线程并发处理时,可以加 EPOLLONESHOT,处理完再重新注册。
12. MySQL 与 MongoDB 的选型:关系型数据库适合强一致、需要事务和复杂关系查询的数据:账号与登录、充值与订单流水、道具交易、排行榜结算这类场景对原子性和一致性要求高,MySQL 的 InnoDB 事务、行锁和唯一约束就是干这个的,复杂统计也能靠 SQL 和索引完成。文档型数据库适合结构灵活、读写量大、以「整体读出整体写入」为主的场景:玩家存档、背包与任务状态、行为日志,字段随版本迭代频繁增删,Mongo 的文档模型和水平分片更省事,也少了一层对象关系映射。真实项目里通常是组合:MySQL 存关键账目、Mongo 存存档与日志、Redis 扛热点和会话,按一致性要求和访问模式分工,而不是二选一。
13. 数据包格式与超大包分片:推荐用固定头 + 变长体的 TLV 结构:魔数(用于粘包后重新同步)、版本号(支持协议灰度升级)、消息号(决定反序列化成哪个结构)、序列号(请求响应配对与去重)、包体长度、可选校验位(CRC32)与压缩标志位,包体再用 protobuf 等序列化。头必须定长且长度字段自身位置固定,接收端才知道要读多少。超大包的处理是在头部再加「分片标志 + 总片数 + 当前片序号」,发送端按阈值切分(比如每片 4~16KB),接收端按序列号聚合、全部到齐再交给业务层,同时给聚合表设超时与内存上限,防止恶意或异常客户端只发半截导致内存被打爆;能避免大包就尽量避免,把大对象改成按需分块拉取。
14. 游戏服务端登录流程设计:典型链路是:客户端先通过 HTTP/HTTPS 走账号服务拿到登录票据(token),票据是短期、一次性的;然后与网关建立 TCP 长连接,把票据发过去做鉴权——网关校验签名与有效期,向账号服务换取 uid 和会话上下文,成功后才把这条连接绑定到具体玩家。接着拉取玩家数据(从 Redis 缓存读档、miss 再回 MongoDB/MySQL),进入场景或大厅,并把会话注册到连接管理器供推送使用。要处理的细节包括:票据的一次性消费与防重放(防截获重连)、重复登录踢掉旧连接、断线重连时用会话票据恢复上下文而不是重新登录、以及登录链路的限流与幂等,避免被打号或客户端疯狂重连把数据库打垮。
15. 为什么技术栈偏客户端却投服务端:这类问题的实质是确认你的动机和稳定性,而不是质疑简历。可以按三步回答:先承认事实并给出具体原因(服务端更贴近自己想深入的方向,或者在做客户端项目时发现真正的难点在服务端的同步与状态管理,因而主动转向),再用证据支撑(项目里实际写过的服务端部分、网络协议与压测经历、读过的源码或做过的深度优化),最后说明补齐计划(已经或正在补哪块知识,比如分布式、并发、数据库)。避免两种答法:一是抱怨别的岗位挂了,二是空表态「我学习能力强」。面试官也能接受「更想做服务端但愿意从基础做起」,前提是你能说出为此做过什么。