面灵AI→

腾讯后台开发一面:系统底子从头挖到尾

轮次
一面
时间
2026-09
来源
牛客网

《面试题目》

  1. 手撕:写个快速幂并带取模,要求复杂度 O(log n)。
  2. 一分钟自我介绍,讲清项目和技术栈。
  3. 只有 512M 物理内存的机器,malloc 申请 1G 虚拟空间会怎样?overcommit 策略怎么影响结果?
  4. 一个进程里起了一百个线程,kill 整个进程时怎么保证这百个线程都被回收?信号是怎么派发的?
  5. epoll 的 ET 和 LT 两种模式有什么区别?非阻塞配合 ET 怎么一次把事件读干净?
  6. TCP 四次挥手里 TIME_WAIT 状态存在的意义是什么?MSL 一般取多少?
  7. TIME_WAIT 大概要等多久?给个具体时长。
  8. TCP 靠哪些机制做到可靠传输?序列号、确认、重传、滑动窗口分别管什么?
  9. 弱网下 UDP 会丢包,有没有把 TCP 的可靠和 UDP 的实时结合起来的协议?
  10. 既然已经有了 TCP,为什么还要保留 UDP?两者各自适合什么场景?
  11. 连接复用(keep-alive 或连接池)里怎么区分不同请求各自该拿到哪份响应?
  12. MySQL 用 limit offset 做深分页为什么越往后越慢?什么条件下会变慢?
  13. 深分页在真实项目里怎么优化?延迟游标或子查询改写怎么做?
  14. 你了解哪些缓存组件?KV、TTL、LRU 淘汰分别讲一下。
  15. 更新数据库的同时想更新缓存,Redis 里那份旧数据该怎么处理?
  16. 先更新库再删缓存,如果删缓存这步失败了会怎样?脏数据怎么产生的?
  17. 用消息队列补偿删缓存,如果删除命令滞后到达、库又被回滚成旧值,缓存被误删怎么办?
  18. 进程和线程最本质的区别是什么?上下文切换的代价主要在哪?
  19. 僵尸进程跟孤儿进程差在哪?怎么回收僵尸进程?
  20. 惊群效应是什么?epoll 怎么缓解惊群?
  21. 逻辑题:一百枚硬币三十枚朝上,分成两堆让朝下的数量相等(允许翻面),怎么做?
  22. 逻辑题:二十五匹马五条赛道,最少几场能排出最快的五匹并给出名次?
  23. 哈希冲突怎么解决?开放寻址和链地址两种怎么取舍?
  24. Redis 落盘 RDB 和 AOF 两种机制的区别?混合持久化是什么?
  25. 缓存和数据库双写一致性,用 Canal 订阅 binlog 来同步的思路讲一下。
  26. HTTP 和 HTTPS 的区别?TLS 握手要几个 RTT?
  27. 零拷贝是什么?sendfile 怎么减少内核态到用户态的拷贝次数?
  28. 单台机器 C10K 问题,怎么撑住十万级别的并发连接?
  29. 多线程里的 CAS 是什么?ABA 问题怎么解决?
  30. 死锁是哪四个条件?工程上怎么避开?
  31. 反问业务方向(视频号视频加热)、转正率和 base 地。

《参考解析》

快速幂取模:把指数按二进制拆开,底数不断平方、指数右移一位,遇到当前位为 1 就把结果乘上底数。代码骨架是 r = 1 % m; a %= m; while (b) { if (b & 1) r = r * a % m; a = a * a % m; b >>= 1; }。三个容易翻车的细节:先对底数取模(a %= m),否则第一次平方就溢出了;初始值写 1 % m 而不是 1,否则 m = 1 时结果应为 0 却返回 1;乘法要防溢出,64 位下 r * a 可能超过 long long,用 __int128 或快速乘(把乘法也按二进制拆)兜住。复杂度 O(log b)。

512M 内存申请 1G 虚拟空间:malloc 只分配虚拟地址空间,不分配物理页,所以在默认配置下会返回一个非空指针。物理页在首次写入时才通过缺页中断分配,这时才可能失败。是否允许这种「超额承诺」由 /proc/sys/vm/overcommit_memory 决定:0 是启发式(默认,明显过分的请求会被拒),1 是无条件允许(内核永不因超额拒绝 malloc),2 是严格模式,所有分配都不得超过 CommitLimit(大致是 swap + 物理内存 × overcommit_ratio),此时 1G 的 malloc 会直接返回 NULL。即使 malloc 成功,真去写满 1G 而物理内存加 swap 只有 512M,会触发 OOM killer 杀掉进程——这也是「容器里进程莫名被杀」的常见原因,所以判断可用内存不能只看 malloc 是否成功。

进程内一百个线程怎么整体回收:kill 的信号是发给进程(线程组)而不是单个线程的。发 SIGKILL 时由内核直接终止整个线程组的所有线程,不需要任何应用代码参与,这是最可靠的整体回收方式;发 SIGTERM 时信号的默认动作也是终止整个进程,但内核只会把信号递送给组内任意一个没有阻塞该信号的线程,由它触发进程级退出。问题出在进程自己装了 handler 或多个线程会阻塞该信号时:这时只有拿到信号的那个线程会响应,其余线程如果卡在阻塞调用上就回收不掉。要优雅退出,正确做法是用 signalfd 或自管道把信号统一收敛到一个专用线程处理,该线程收到后置退出标志、pthread_kill 逐个唤醒或通知各工作线程,各线程在自己的循环里检查标志、释放资源后返回,主线程最后 pthread_join 全部回收并 exit。不要指望 kill 一个信号就能让所有线程干净收尾。

epoll 的 ET 与 LT:LT 是电平触发,只要 fd 的读缓冲还有数据,每次 epoll_wait 都会把它报出来,所以可以一次只读一部分、下次接着读,编程简单但系统调用次数多。ET 是边沿触发,只在状态发生变化的瞬间通知一次,之后不再重复通知,所以必须循环读到 EAGAIN 为止,且 fd 必须设成非阻塞——否则最后一次 read 会把整个事件循环阻塞住,这是 ET 最经典的坑。读干净的写法是 while ((n = read(fd, buf, size)) > 0) { 处理 },然后判断 n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK) 就 break,n == 0 表示对端已关闭。ET 下 accept 也要用循环直到 EAGAIN,否则并发连接到来时会漏掉一部分。选 ET 的收益是高并发下显著减少 epoll_wait 返回次数,代价是代码容错空间更小。

TIME_WAIT 的意义与 MSL:TIME_WAIT 由主动关闭方进入,持续 2 倍 MSL。作用有两个:一是保证最后一个 ACK 能到达对端——如果这个 ACK 丢了,对端会超时重传 FIN,此时本端仍处在 TIME_WAIT、可以重发 ACK,否则对端会一直重传到超时并报错;二是让本次连接中迟到的报文在网络中自然消亡,避免它们被复用同一四元组的新连接误收,造成数据错乱。MSL 是报文在网络中的最大生存时间,RFC 建议 2 分钟,而 Linux 实际把 TIME_WAIT 固定成 60 秒(内核常量 TCP_TIMEWAIT_LEN),并不是按 2×MSL 动态计算的,所以答「2 倍 MSL,Linux 上是 60 秒」最准确。TIME_WAIT 大量堆积会耗尽本地端口或占用内存,可用 net.ipv4.tcp_tw_reuse(对出向连接复用)、缩短 tcp_fin_timeout、或让客户端主动关闭改成服务端关闭来缓解。

TCP 的可靠传输机制:序列号让接收端能重排乱序报文并识别重复;累积确认(ACK)告诉发送端「这个序号之前的都收到了」;超时重传与快速重传(连续收到三个重复 ACK 就立刻重发,不等超时)补回丢失的段;滑动窗口做流量控制,防止发送速度压垮接收方的缓冲;拥塞控制(慢启动、拥塞避免、快重传、快恢复)防止发送速度压垮中间网络;此外还有校验和验数据完整性、以及三次握手四次挥手管理连接生命周期。回答时按「数据完整性—不丢—不乱—不过载」四个维度归类,比逐条罗列更有条理。

QUIC 与 KCP:两者都是在 UDP 之上自建可靠传输层,动机一样——TCP 的可靠机制内建在内核里,改不动、升级慢,而且 TCP 的严格有序会造成队头阻塞。QUIC 是标准化的方案:在用户态实现可靠传输与多路复用(每个流独立重传,一个流丢包不阻塞其他流)、内置 TLS 1.3(握手 1‑RTT,会话复用可 0‑RTT)、用 Connection ID 而非四元组标识连接从而支持网络切换时连接迁移,是 HTTP/3 的承载。KCP 是更轻的纯算法库:以牺牲带宽换取低延迟(可选快速重传、不做延迟 ACK、非退让流控),常用于游戏和实时音视频。要强调的是 QUIC 并没有让可靠性消失,只是把重传策略从内核搬到了用户态、并把「有序」的粒度细化到流。

为什么还保留 UDP:因为不是所有场景都值得为可靠性付代价。UDP 无连接、无状态、头部只有 8 字节、不重传不乱序,开销小、延迟稳定可预测,还支持广播与组播。适合的场景是「丢一点没关系,但等不起」:DNS 查询(一次往返、报文小)、DHCP、实时音视频与游戏状态同步(重传带来的延迟尖峰比丢包本身更难忍)、以及作为 QUIC/HTTP3 的承载。反过来,文件传输、普通 HTTP 请求、数据库连接这些数据不能丢的场景仍然用 TCP。判断标准是「延迟敏感 vs 完整性敏感」,不是「新协议一定更好」。

连接复用里怎么区分响应:靠协议层的配对标识,不能靠时间顺序猜。HTTP/1.1 的 pipelining 规定响应必须严格按请求顺序返回,客户端用 FIFO 队列按序取;HTTP/2 用 stream id 做多路复用,每个帧都带所属流的标识,响应可以乱序返回而客户端能正确路由,这正是不再需要「按序」的原因;HTTP/3 沿用 stream 机制。自研的 RPC 协议则是在请求里带 request_id 或递增序列号,响应原样带回,客户端用 map 把响应投递给对应的等待者。

缓存组件与淘汰策略:KV 是一切的基础;TTL 是过期时间,注意 Redis 的过期是惰性删除加定期抽样删除,所以内存不会在过期瞬间立刻释放;LRU 是按最近访问时间淘汰,实现上 Redis 用近似 LRU(采样若干 key 比较空闲时间)而不是严格 LRU,因为严格 LRU 的链表维护成本高。除了 LRU 还有 LFU(按访问频率,适合热点长期稳定的场景)、Random、TTL 三种。选型上要区分本地缓存(Caffeine,纳秒级、容量小、进程内,用 W‑TinyLFU)与分布式缓存(Redis,跨节点共享、有网络开销),生产上常见两级缓存:本地扛热点、Redis 兜底,用消息广播做本地失效。

双写一致性怎么处理:推荐先更新数据库再删缓存,理由是先删缓存再更新库的时间窗里读请求会把旧值捞回缓存,造成长期不一致;先更新库再删缓存即使删除失败也只是短暂旧值,下次读会纠正。删除失败的三条补偿路:消息队列异步重试、订阅 binlog 自动失效、短 TTL 兜底。剩下的极端场景是「读线程在两次删之间把旧值写回」——用延迟双删(更新库后删一次,隔几百毫秒再删一次)或在缓存值里带版本号、写回时校验版本来解决。更麻烦的反向场景是补偿消息滞后:删除命令到达时数据库事务已经回滚,缓存被误删(只是回源一次,无害)或更糟——被误写。要记住这类补偿天然的因果顺序无法只靠重试保证,必须给删除/写回操作带上数据版本,让旧版本的操作在服务端被丢弃。

进程与线程的本质区别:进程是资源分配与隔离的单位,拥有独立地址空间、页表、文件描述符表、信号处理;线程是 CPU 调度的单位,同一进程的线程共享地址空间、堆和全局数据,各自只有栈和寄存器。一句话就是「隔离 vs 共享」。上下文切换的代价分两层:进程切换要切换页表导致 TLB 失效(甚至需要 flush),缓存局部性被打散,代价明显更高;线程切换只需保存恢复寄存器与栈指针,不动地址空间,但要陷入内核、走调度器,同样有代价。所以高并发下「线程不要开太多」的真正原因是切换与缓存失效,而不是线程本身的内存开销(虽然默认 1M 栈也不便宜)。

僵尸进程与孤儿进程:僵尸是子进程已经终止、但父进程还没有调用 wait/waitpid 收取退出状态,于是内核保留其 PCB,占用进程表项,大量堆积会导致无法创建新进程。回收办法是父进程主动 wait、注册 SIGCHLD 处理函数自动回收、或者父进程退出让 init 收养;如果父进程既不想阻塞又想避免僵尸,可以双 fork——中间进程立刻退出,真正干活的进程变成孤儿被 init 收养,由 init 负责 wait。孤儿进程则是父进程先退出而子进程仍在运行,被 init/subreaper 收养后正常运行,本身无害。两者常被一起考,答的时候要先分清因果方向。

惊群效应:多个进程或线程同时阻塞在同一个监听 socket 上,一个新连接到来时内核把全部等待者唤醒,但只有一个能 accept 成功,其余的醒来发现没活干又睡下去,白白付出上下文切换开销。缓解手段分三层:内核层用 EPOLLEXCLUSIVE 标志,让 epoll 只唤醒一个等待者;架构层用 SO_REUSEPORT,每个 worker 各自持有独立的监听 socket,由内核做连接分发,这是目前 nginx 等主流服务器的做法;设计层把 accept 收敛到单个 reactor 线程,再由它把连接分发给工作线程,从根上避免竞争。补充一点:现代 Linux 的 accept 路径本身已经不再惊群,真正还会惊群的是 epoll_wait 层面的多线程等待。

硬币分堆:任取 70 枚作为第一堆,把这 70 枚全部翻面。证明:设取出的 70 枚里原本有 t 枚朝下,那么剩下那堆(30 枚)里朝下的数量是 70 − t(因为总共 70 枚朝下)。翻面之后,第一堆里朝下的数量变成 70 − t——原来那 70 − t 枚朝上的正好被翻成朝下。两堆朝下的数量都是 70 − t,相等。推广到一般情形:n 枚硬币里有 k 枚朝下,就取 k 枚作为一堆并把这堆全部翻面。这类题的关键是意识到「翻面」这个操作可以把一组里朝上的数量直接变成朝下的数量,从而绕开「不知道哪枚是哪面」的信息缺失。

二十五匹马排前五:先把 25 匹分成 5 组各跑一场,组内排出名次(5 场);第 6 场让五组的第一名同场竞技,假设结果是 A1 > B1 > C1 > D1 > E1。此时 A1 已确定是全场第一,而淘汰规则是「已知有 5 匹或更多马比它快,它就不可能进前五」——按这条规则逐匹数一遍:A 组最多留到 A5(它只输给 A1A4 四匹),B 组留到 B4(输给 A1、B1B3 四匹),C 组留到 C3,D 组留到 D2,E 组只剩 E1。所以除去 A1,前五名的剩余候选是 A2、A3、A4、A5、B1、B2、B3、B4、C1、C2、C3、D1、D2、E1 共 14 匹。第 7 场在 A2、A3、B1、B2、C1 之间跑,就能确定第二、第三名(这五匹正是「已知比它们快的不足 3 匹」的集合,也就是广为流传的「7 场求前三」的答案)。要连第 4、第 5 名的名次一起排出来,剩余的候选仍然多于 5 匹,必须在这一步之后继续按淘汰规则收缩、再加赛,所以「7 场」只够给出前三名——面试时能把这层说清比背一个数字更容易拿分,面试官真正想考的是那条「已知更快的达到 5 匹即淘汰」的推理规则。